@crediolabs/policy-synth 0.1.10 → 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.
Files changed (84) hide show
  1. package/dist/adapters/interpreter/adapter.js +83 -11
  2. package/dist/adapters/oz/adapter.js +4 -0
  3. package/dist/errors.d.ts +1 -1
  4. package/dist/install/build-add-context-rule.d.ts +48 -0
  5. package/dist/install/build-add-context-rule.js +304 -0
  6. package/dist/ir/types.d.ts +23 -0
  7. package/dist/predicate/decode.d.ts +11 -0
  8. package/dist/predicate/decode.js +234 -0
  9. package/dist/predicate/encode.js +79 -14
  10. package/dist/predicate/from-json.d.ts +4 -0
  11. package/dist/predicate/from-json.js +113 -0
  12. package/dist/predicate/index.d.ts +2 -0
  13. package/dist/predicate/index.js +5 -1
  14. package/dist/registry/identify.js +21 -2
  15. package/dist/registry/protocols.d.ts +50 -1
  16. package/dist/registry/protocols.js +88 -0
  17. package/dist/review-card/builder.d.ts +13 -0
  18. package/dist/review-card/builder.js +63 -5
  19. package/dist/review-card/cross-check.js +32 -3
  20. package/dist/review-card/index.d.ts +1 -1
  21. package/dist/review-card/index.js +1 -1
  22. package/dist/run/schemas.d.ts +4 -4
  23. package/dist/synth/compose-from-recording.d.ts +18 -0
  24. package/dist/synth/compose-from-recording.js +75 -10
  25. package/dist/synth/deny-cases.d.ts +4 -1
  26. package/dist/synth/deny-cases.js +3 -1
  27. package/dist/synth/evaluate.js +130 -3
  28. package/dist/synth/permit-context.d.ts +15 -0
  29. package/dist/synth/permit-context.js +116 -0
  30. package/dist/synth/synthesize-from-recording.js +14 -2
  31. package/dist/types.d.ts +9 -0
  32. package/dist-cjs/adapters/interpreter/adapter.js +83 -11
  33. package/dist-cjs/adapters/oz/adapter.js +4 -0
  34. package/dist-cjs/errors.d.ts +1 -1
  35. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  36. package/dist-cjs/install/build-add-context-rule.js +308 -0
  37. package/dist-cjs/ir/types.d.ts +23 -0
  38. package/dist-cjs/predicate/decode.d.ts +11 -0
  39. package/dist-cjs/predicate/decode.js +239 -0
  40. package/dist-cjs/predicate/encode.js +79 -14
  41. package/dist-cjs/predicate/from-json.d.ts +4 -0
  42. package/dist-cjs/predicate/from-json.js +116 -0
  43. package/dist-cjs/predicate/index.d.ts +2 -0
  44. package/dist-cjs/predicate/index.js +10 -2
  45. package/dist-cjs/registry/identify.js +20 -1
  46. package/dist-cjs/registry/protocols.d.ts +50 -1
  47. package/dist-cjs/registry/protocols.js +89 -1
  48. package/dist-cjs/review-card/builder.d.ts +13 -0
  49. package/dist-cjs/review-card/builder.js +64 -5
  50. package/dist-cjs/review-card/cross-check.js +32 -3
  51. package/dist-cjs/review-card/index.d.ts +1 -1
  52. package/dist-cjs/review-card/index.js +2 -1
  53. package/dist-cjs/run/schemas.d.ts +4 -4
  54. package/dist-cjs/synth/compose-from-recording.d.ts +18 -0
  55. package/dist-cjs/synth/compose-from-recording.js +75 -10
  56. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  57. package/dist-cjs/synth/deny-cases.js +3 -0
  58. package/dist-cjs/synth/evaluate.js +130 -3
  59. package/dist-cjs/synth/permit-context.d.ts +15 -0
  60. package/dist-cjs/synth/permit-context.js +119 -0
  61. package/dist-cjs/synth/synthesize-from-recording.js +14 -2
  62. package/dist-cjs/types.d.ts +9 -0
  63. package/package.json +5 -2
  64. package/src/adapters/interpreter/adapter.ts +93 -11
  65. package/src/adapters/oz/adapter.ts +4 -0
  66. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  67. package/src/errors.ts +13 -0
  68. package/src/install/build-add-context-rule.ts +429 -0
  69. package/src/ir/types.ts +23 -0
  70. package/src/predicate/decode.ts +242 -0
  71. package/src/predicate/encode.ts +110 -13
  72. package/src/predicate/from-json.ts +124 -0
  73. package/src/predicate/index.ts +5 -1
  74. package/src/registry/identify.ts +22 -2
  75. package/src/registry/protocols.ts +90 -1
  76. package/src/review-card/builder.ts +58 -5
  77. package/src/review-card/cross-check.ts +36 -3
  78. package/src/review-card/index.ts +1 -0
  79. package/src/synth/compose-from-recording.ts +101 -7
  80. package/src/synth/deny-cases.ts +3 -1
  81. package/src/synth/evaluate.ts +130 -3
  82. package/src/synth/permit-context.ts +136 -0
  83. package/src/synth/synthesize-from-recording.ts +13 -2
  84. package/src/types.ts +12 -0
@@ -108,16 +108,39 @@ function pushComparison(left, right, op, out) {
108
108
  return;
109
109
  }
110
110
  }
111
+ // Mirrors the builder's wording byte-for-byte: how many calls the rule
112
+ // permits, since the bound counts the calls already made.
111
113
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
112
- out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
114
+ const allowed = op === 'lt' ? right.value : right.value + 1;
115
+ out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
113
116
  return;
114
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
+ }
115
138
  if (left.kind === 'amount' && right.kind === 'literal_i128') {
116
139
  out.push(`Amount <= ${right.value}`);
117
140
  return;
118
141
  }
119
- if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
120
- out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`);
142
+ if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
143
+ out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`);
121
144
  return;
122
145
  }
123
146
  }
@@ -148,6 +171,7 @@ function renderVecElement(leaf) {
148
171
  case 'call_arg':
149
172
  case 'call_arg_len':
150
173
  case 'call_arg_field':
174
+ case 'call_arg_scaled':
151
175
  case 'amount':
152
176
  case 'window_spent':
153
177
  case 'now':
@@ -155,6 +179,11 @@ function renderVecElement(leaf) {
155
179
  case 'invocation_count_in_window':
156
180
  case 'oracle_price':
157
181
  return `<${leaf.kind}>`;
182
+ // Show the declared basis, not just the digits. A threshold on the wrong
183
+ // basis is the one policy error the contract cannot detect, so the review
184
+ // card is where a human has to be able to see it.
185
+ case 'oracle_threshold':
186
+ return `${leaf.value} (${leaf.decimals} dp)`;
158
187
  }
159
188
  }
160
189
  function renderHaystackElement(leaf) {
@@ -1,3 +1,3 @@
1
- export { buildReviewCardSummary, type ReviewCardSummary, } from './builder.ts';
1
+ export { buildReviewCardSummary, describePredicate, type ReviewCardSummary, } from './builder.ts';
2
2
  export { type ConflictAnnotation, classifyConflict, type RuleRef, } from './conflict.ts';
3
3
  export { summaryCrossCheck } from './cross-check.ts';
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  // src/review-card/index.ts - re-export the deterministic review-card surface.
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.summaryCrossCheck = exports.classifyConflict = exports.buildReviewCardSummary = void 0;
4
+ exports.summaryCrossCheck = exports.classifyConflict = exports.describePredicate = exports.buildReviewCardSummary = void 0;
5
5
  var builder_ts_1 = require("./builder.js");
6
6
  Object.defineProperty(exports, "buildReviewCardSummary", { enumerable: true, get: function () { return builder_ts_1.buildReviewCardSummary; } });
7
+ Object.defineProperty(exports, "describePredicate", { enumerable: true, get: function () { return builder_ts_1.describePredicate; } });
7
8
  var conflict_ts_1 = require("./conflict.js");
8
9
  Object.defineProperty(exports, "classifyConflict", { enumerable: true, get: function () { return conflict_ts_1.classifyConflict; } });
9
10
  var cross_check_ts_1 = require("./cross-check.js");
@@ -1093,13 +1093,13 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1093
1093
  network: "mainnet" | "testnet";
1094
1094
  source: "recording";
1095
1095
  recordedTx: {
1096
+ signers: string[];
1096
1097
  events: {
1097
1098
  contract: string;
1098
1099
  topics: string[];
1099
1100
  data?: unknown;
1100
1101
  }[];
1101
1102
  network: "mainnet" | "testnet";
1102
- signers: string[];
1103
1103
  invocations: unknown[];
1104
1104
  tokenMovements: {
1105
1105
  amount: string;
@@ -1158,13 +1158,13 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1158
1158
  network: "mainnet" | "testnet";
1159
1159
  source: "recording";
1160
1160
  recordedTx: {
1161
+ signers: string[];
1161
1162
  events: {
1162
1163
  contract: string;
1163
1164
  topics: string[];
1164
1165
  data?: unknown;
1165
1166
  }[];
1166
1167
  network: "mainnet" | "testnet";
1167
- signers: string[];
1168
1168
  invocations: unknown[];
1169
1169
  tokenMovements: {
1170
1170
  amount: string;
@@ -1750,13 +1750,13 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1750
1750
  network: "mainnet" | "testnet";
1751
1751
  source: "recording";
1752
1752
  recordedTx: {
1753
+ signers: string[];
1753
1754
  events: {
1754
1755
  contract: string;
1755
1756
  topics: string[];
1756
1757
  data?: unknown;
1757
1758
  }[];
1758
1759
  network: "mainnet" | "testnet";
1759
- signers: string[];
1760
1760
  invocations: unknown[];
1761
1761
  tokenMovements: {
1762
1762
  amount: string;
@@ -1815,13 +1815,13 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1815
1815
  network: "mainnet" | "testnet";
1816
1816
  source: "recording";
1817
1817
  recordedTx: {
1818
+ signers: string[];
1818
1819
  events: {
1819
1820
  contract: string;
1820
1821
  topics: string[];
1821
1822
  data?: unknown;
1822
1823
  }[];
1823
1824
  network: "mainnet" | "testnet";
1824
- signers: string[];
1825
1825
  invocations: unknown[];
1826
1826
  tokenMovements: {
1827
1827
  amount: string;
@@ -8,6 +8,10 @@ export interface OraclePriceBound {
8
8
  asset: string;
9
9
  operator: IRCompOp;
10
10
  value: string;
11
+ /** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
12
+ * 9 dp, and a threshold silently assumed to share that basis is what let a
13
+ * raw 14-dp bound permit everything. The author states it; we convert. */
14
+ decimals: number;
11
15
  }
12
16
  /** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
13
17
  * synth might apply must come from here - the recording supplies observed
@@ -27,6 +31,20 @@ export interface ComposeUserResponses {
27
31
  * `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
28
32
  * entries on the same asset emit multiple leaves. */
29
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
+ };
30
48
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
31
49
  * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
32
50
  * pin. Absent -> the recipient is pinned to the recorded value (mirroring
@@ -108,11 +108,17 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
108
108
  value: limit,
109
109
  },
110
110
  };
111
- if (!interpreterEnabled || token === scopeContract) {
112
- ozConstraints.push(spendCond);
113
- }
114
- else {
115
- interpreterConstraints.push(spendCond);
111
+ // A rolling spend cap always goes to OZ, whose `spending_limit` is the
112
+ // audited implementation. The interpreter is NOT a fallback for the
113
+ // token != scopeContract case: on chain it sees one authorized call,
114
+ // not the transaction's token movements, so it has no per-call amount
115
+ // to accumulate and the counter would never move. OZ reports the case
116
+ // it cannot cover (it pins the limit to the context contract), which
117
+ // is the honest outcome - the old fallback produced an interpreter
118
+ // predicate that silently never bound.
119
+ ozConstraints.push(spendCond);
120
+ if (interpreterEnabled && token !== scopeContract) {
121
+ warnings.push(`rolling spend cap on ${token} cannot be enforced on chain: OZ spending_limit pins the limit to the context contract, and the interpreter cannot observe token movements. Bound the per-call value with an argument cap plus an invocation-count limit instead.`);
116
122
  }
117
123
  }
118
124
  }
@@ -137,7 +143,10 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
137
143
  op: 'compare',
138
144
  compare: {
139
145
  selector: { kind: 'invocation_count', windowSeconds },
140
- operator: 'lte',
146
+ // `lt`, not `lte`: the leaf reports the calls ALREADY made in the
147
+ // window, so `< N` is what permits N of them. With `lte` a limit
148
+ // of N let an N+1th call through.
149
+ operator: 'lt',
141
150
  value: String(invocationLimit),
142
151
  },
143
152
  };
@@ -168,6 +177,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
168
177
  selector: { kind: 'oracle_price', asset: b.asset },
169
178
  operator: b.operator,
170
179
  value: b.value,
180
+ valueDecimals: b.decimals,
171
181
  },
172
182
  };
173
183
  if (interpreterEnabled) {
@@ -190,7 +200,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
190
200
  // the window_spent path above already consumed the limit, so the per-call
191
201
  // arg cap is skipped to avoid binding one limit to two different semantics.
192
202
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
193
- 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);
194
204
  }
195
205
  // `scope.method` is carried on BOTH IRs so each adapter produces a
196
206
  // self-consistent rule. The interpreter adapter lowers scope.method into a
@@ -232,7 +242,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
232
242
  * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
233
243
  * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
234
244
  * call_arg[2]). */
235
- 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) {
236
246
  // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
237
247
  // a single-element allowlist; the interpreter adapter lowers it to `in`.
238
248
  // When interpreter is not enabled, route to OZ so the caller sees today's
@@ -303,8 +313,15 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
303
313
  });
304
314
  for (let i = 0; i < elements.length; i++) {
305
315
  const element = elements[i];
306
- if (!element || element.type !== 'map')
316
+ // An element we cannot bind leaves that request's action, asset and
317
+ // amount free while the length pin makes the policy LOOK total. The
318
+ // length pin still stays (it is a real restriction, and dropping it
319
+ // would only widen the policy) - what must not happen is dropping the
320
+ // element quietly.
321
+ if (element?.type !== 'map') {
322
+ warnings.push(`Blend submit requests element ${i} is not a Request map (recorded as ${element?.type ?? 'nothing'}): its request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`);
307
323
  continue;
324
+ }
308
325
  const fields = element.value;
309
326
  const fieldValue = (name, scalarType) => {
310
327
  const entry = fields.find((e) => e.key === name);
@@ -321,8 +338,17 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
321
338
  const address = fieldValue('address', 'address');
322
339
  const amount = fieldValue('amount', 'i128');
323
340
  const requestType = fieldValue('request_type', 'u32');
324
- if (address === null || amount === null || requestType === null)
341
+ if (address === null || amount === null || requestType === null) {
342
+ const missing = [
343
+ requestType === null ? 'request_type (u32)' : null,
344
+ address === null ? 'address' : null,
345
+ amount === null ? 'amount (i128)' : null,
346
+ ].filter((f) => f !== null);
347
+ // Partial pins are deliberately NOT emitted (see the note above): a
348
+ // half-pinned request reads as a bound one on the review card.
349
+ warnings.push(`Blend submit requests element ${i} could not be bound: ${missing.join(', ')} missing or of an unexpected type. That request's request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`);
325
350
  continue;
351
+ }
326
352
  interpreterConstraints.push({
327
353
  op: 'compare',
328
354
  compare: {
@@ -424,6 +450,30 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
424
450
  ozConstraints.push(cond);
425
451
  }
426
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
+ }
427
477
  // Swap recipient (call_arg[3]): when the caller supplies
428
478
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
429
479
  // arg. When absent, PIN the recipient to the recorded value - mirroring the
@@ -468,6 +518,21 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
468
518
  * the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
469
519
  * input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
470
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
+ }
471
536
  function soroswapInputAmountArgIndex(fn) {
472
537
  switch (fn) {
473
538
  case 'swap_exact_tokens_for_tokens':
@@ -1,4 +1,4 @@
1
- import type { PredicateNode } from '../types.ts';
1
+ import type { PredicateNode, ScVal } from '../types.ts';
2
2
  import type { EvalContext } from './evaluate.ts';
3
3
  export interface DenyCase {
4
4
  dimension: string;
@@ -20,3 +20,6 @@ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
20
20
  * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
21
21
  * existing fixtures do not regress. */
22
22
  export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
23
+ /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
24
+ * Exported so the permit-context builder shares this one implementation. */
25
+ export declare function cloneScVal(value: ScVal, depth?: number): ScVal;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OVERPERMISSIVE_DIMENSIONS = exports.ORIGINAL_DIMENSIONS = void 0;
4
4
  exports.generateCases = generateCases;
5
+ exports.cloneScVal = cloneScVal;
5
6
  const types_ts_1 = require("../types.js");
6
7
  const predicate_literals_ts_1 = require("./predicate-literals.js");
7
8
  const ORACLE_CASES = [
@@ -481,6 +482,8 @@ function cloneContext(ctx) {
481
482
  cloned.signerWeights = { ...ctx.signerWeights };
482
483
  return cloned;
483
484
  }
485
+ /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
486
+ * Exported so the permit-context builder shares this one implementation. */
484
487
  function cloneScVal(value, depth = 0) {
485
488
  // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
486
489
  // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
@@ -32,6 +32,11 @@
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.evaluate = evaluate;
34
34
  const predicate_literals_ts_1 = require("./predicate-literals.js");
35
+ /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
36
+ * in oracle.rs. A threshold on any other basis must say so. */
37
+ const NORMALISED_DECIMALS = 9;
38
+ /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
39
+ const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
35
40
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
36
41
  * and converted to the matching `ORACLE_*` deny reason. NOT a
37
42
  * boolean-false that `not` / `or` could mask. */
@@ -168,6 +173,13 @@ function evalCompare(op, left, right, ctx) {
168
173
  }
169
174
  // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
170
175
  if (left.kind === 'call_arg') {
176
+ // The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
177
+ // Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
178
+ // -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
179
+ // generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
180
+ if (right.kind === 'call_arg_scaled') {
181
+ return evalScaledArgCompare(op, left, right, ctx);
182
+ }
171
183
  const actual = ctx.args[left.index];
172
184
  // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
173
185
  // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
@@ -178,6 +190,11 @@ function evalCompare(op, left, right, ctx) {
178
190
  return evalArgOrderedCompare(op, actual, right);
179
191
  return evalArgEq(op, actual, right, ctx);
180
192
  }
193
+ // --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
194
+ // Symmetric form so a policy that phrases the floor either way works.
195
+ if (left.kind === 'call_arg_scaled') {
196
+ return evalScaledArgCompare(op, right, left, ctx);
197
+ }
181
198
  // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
182
199
  // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
183
200
  if (left.kind === 'call_arg_len') {
@@ -296,6 +313,102 @@ function evalArgEq(op, actual, right, ctx) {
296
313
  return { permit: false, reason: 'ARG_MISMATCH' };
297
314
  return { permit: false, reason: 'ARG_MISMATCH' };
298
315
  }
316
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
317
+ * path: the scaled leaf is `args[index] * num / den` (truncating toward
318
+ * zero). On `checked_mul` / `checked_div` failure (overflow or
319
+ * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
320
+ * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
321
+ * rather than the generic `ARG_MISMATCH`.
322
+ *
323
+ * `left` is whichever side of the compare is NOT the scaled leaf. A
324
+ * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
325
+ * leaves has no definable semantics). The other operand must be a
326
+ * numeric shape (`call_arg` carrying a number, or a numeric literal);
327
+ * anything else is `ARG_MISMATCH`. */
328
+ function evalScaledArgCompare(op, left, right, ctx) {
329
+ // Identify which side is scaled, and pull the other side as a number.
330
+ let scaled;
331
+ let other;
332
+ let scaledOnRight;
333
+ if (left.kind === 'call_arg_scaled') {
334
+ scaled = left;
335
+ other = right;
336
+ scaledOnRight = false;
337
+ }
338
+ else if (right.kind === 'call_arg_scaled') {
339
+ scaled = right;
340
+ other = left;
341
+ scaledOnRight = true;
342
+ }
343
+ else {
344
+ // Neither side is scaled - this is a programming error in the
345
+ // dispatcher; the contract returns ARG_MISMATCH to fail closed.
346
+ return { permit: false, reason: 'ARG_MISMATCH' };
347
+ }
348
+ // Source `args[index]` -> BigInt. An out-of-bounds index or a
349
+ // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
350
+ // a violated floor is the wrong code when the operand itself could
351
+ // not be read.
352
+ if (scaled.index >= ctx.args.length) {
353
+ return { permit: false, reason: 'ARG_MISMATCH' };
354
+ }
355
+ const input = argNumericBigInt(ctx.args[scaled.index]);
356
+ if (input === null)
357
+ return { permit: false, reason: 'ARG_MISMATCH' };
358
+ let num;
359
+ let den;
360
+ try {
361
+ num = BigInt(scaled.num);
362
+ den = BigInt(scaled.den);
363
+ }
364
+ catch {
365
+ return { permit: false, reason: 'ARG_MISMATCH' };
366
+ }
367
+ // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
368
+ // check is a defensive belt-and-braces - a future validator
369
+ // regression cannot panic the frame on a divide-by-zero.
370
+ if (den === 0n)
371
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
372
+ const product = input * num;
373
+ // BigInt overflow is silent in JS (it does not throw on multiplication).
374
+ // We can detect over-range by dividing and checking the result against
375
+ // i128 bounds, but the more practical check is whether the result
376
+ // divides cleanly. We rely on BigInt's arbitrary precision and then
377
+ // surface ARITHMETIC_OVERFLOW if the result is outside i128.
378
+ let scaledValue;
379
+ try {
380
+ // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
381
+ scaledValue = product / den;
382
+ }
383
+ catch {
384
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
385
+ }
386
+ // i128 range check: if the scaled value is outside i128, the contract
387
+ // would have wrapped on i128 arithmetic; surface the same deny.
388
+ const I128_MAX = (1n << 127n) - 1n;
389
+ const I128_MIN = -(1n << 127n);
390
+ if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
391
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
392
+ }
393
+ // Resolving the operand when the scaled side is on the right: the
394
+ // operand is `left`; the scaled value is the RHS. The AST order is
395
+ // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
396
+ // When the scaled side is on the left, the order is `scaled <op> other`,
397
+ // i.e. `scaled <op> other_val`.
398
+ let otherVal;
399
+ if (other.kind === 'call_arg') {
400
+ otherVal = argNumericBigInt(ctx.args[other.index]);
401
+ }
402
+ else {
403
+ otherVal = (0, predicate_literals_ts_1.literalNumericBigInt)(other);
404
+ }
405
+ if (otherVal === null)
406
+ return { permit: false, reason: 'ARG_MISMATCH' };
407
+ const pass = scaledOnRight
408
+ ? bigintCmp(op, otherVal.toString(), scaledValue.toString())
409
+ : bigintCmp(op, scaledValue.toString(), otherVal.toString());
410
+ return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' };
411
+ }
299
412
  /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
300
413
  * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
301
414
  * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
@@ -392,6 +505,7 @@ function resolveLeaf(leaf, ctx) {
392
505
  case 'invocation_count_in_window':
393
506
  case 'now':
394
507
  case 'valid_until':
508
+ case 'call_arg_scaled':
395
509
  return undefined; // selector leaves with no ScVal projection
396
510
  case 'literal_address':
397
511
  return { type: 'address', value: leaf.value };
@@ -450,10 +564,23 @@ function evalOracleCompare(op, asset, right, ctx) {
450
564
  throw new OracleError('ORACLE_STALE');
451
565
  throw new OracleError(mapped);
452
566
  }
453
- const literal = right.kind === 'literal_i128' ? right.value : null;
454
- if (literal === null)
567
+ // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
568
+ // decimal basis: prices are on the normalised 9-dp basis, and assuming a
569
+ // bare literal shares it is what let a raw 14-dp threshold permit
570
+ // everything. A bare literal is refused rather than assumed.
571
+ if (right.kind !== 'oracle_threshold')
455
572
  throw new OracleError('ORACLE_DECIMALS_MISMATCH');
456
- return bigintCmp(op, entry.price, literal)
573
+ if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
574
+ throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
575
+ }
576
+ // Scale BOTH sides up to the wider basis rather than dividing the threshold
577
+ // down: dividing truncates, and a truncated bound moves the permit boundary.
578
+ const decimals = BigInt(right.decimals);
579
+ const normalised = BigInt(NORMALISED_DECIMALS);
580
+ const common = decimals > normalised ? decimals : normalised;
581
+ const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
582
+ const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
583
+ return bigintCmp(op, String(priceScaled), String(literalScaled))
457
584
  ? { permit: true }
458
585
  : { permit: false, reason: 'FN_MISMATCH' };
459
586
  }
@@ -0,0 +1,15 @@
1
+ import type { PredicateNode, RecordedTransaction } from '../types.ts';
2
+ import type { EvalContext } from './evaluate.ts';
3
+ export interface PermitContextResponses {
4
+ windowSeconds: number;
5
+ invocationLimit?: number;
6
+ limitAmount?: string;
7
+ validUntilLedger: number;
8
+ oraclePriceBound?: Array<{
9
+ asset: string;
10
+ operator: string;
11
+ value: string;
12
+ decimals: number;
13
+ }>;
14
+ }
15
+ export declare function buildPermitContext(tx: RecordedTransaction, responses: PermitContextResponses, predicate: PredicateNode): EvalContext;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ // src/synth/permit-context.ts - build the EvalContext that represents the
3
+ // recorded call being replayed.
4
+ //
5
+ // Extracted from the over-permissiveness harness so the same construction
6
+ // feeds both the in-process battery and the on-chain replay
7
+ // (scripts/verify-mutations-testnet.ts). Two callers building this
8
+ // separately would be two chances to disagree about what "the recorded
9
+ // call" means, which is the one thing both must share.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.buildPermitContext = buildPermitContext;
12
+ const deny_cases_ts_1 = require("./deny-cases.js");
13
+ function buildPermitContext(tx, responses, predicate) {
14
+ const amountByToken = {};
15
+ const totals = new Map();
16
+ for (const m of tx.tokenMovements) {
17
+ const current = totals.get(m.token) ?? 0n;
18
+ totals.set(m.token, current + BigInt(m.amount));
19
+ }
20
+ for (const [token, total] of totals) {
21
+ amountByToken[token] = total.toString();
22
+ }
23
+ const topLevel = tx.invocations[0];
24
+ const scopeContract = topLevel?.contract ?? '';
25
+ const oraclePriceByAsset = {};
26
+ visitOracleLeaves(predicate, (asset, op, bound) => {
27
+ let price;
28
+ switch (op) {
29
+ case 'lt':
30
+ price = bound - 1n;
31
+ break;
32
+ case 'gt':
33
+ price = bound + 1n;
34
+ break;
35
+ default:
36
+ price = bound;
37
+ }
38
+ if (price < 0n)
39
+ price = 0n;
40
+ oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt };
41
+ });
42
+ const ctx = {
43
+ contract: scopeContract,
44
+ fn: topLevel?.fn ?? '',
45
+ args: (topLevel?.args ?? []).map(deny_cases_ts_1.cloneScVal),
46
+ atLedger: tx.ledgerSequence,
47
+ nowSeconds: tx.fetchedAt,
48
+ amountByToken,
49
+ windowSpentByToken: {},
50
+ invocationCountByWindow: {},
51
+ oraclePriceByAsset,
52
+ };
53
+ if (responses.validUntilLedger !== undefined) {
54
+ ctx.validUntilLedger = responses.validUntilLedger;
55
+ }
56
+ return ctx;
57
+ }
58
+ function visitOracleLeaves(node, visit) {
59
+ switch (node.op) {
60
+ case 'and':
61
+ case 'or':
62
+ for (const child of node.children)
63
+ visitOracleLeaves(child, visit);
64
+ return;
65
+ case 'not':
66
+ visitOracleLeaves(node.child, visit);
67
+ return;
68
+ case 'eq':
69
+ case 'lt':
70
+ case 'lte':
71
+ case 'gt':
72
+ case 'gte': {
73
+ const leftIsOracle = node.left.kind === 'oracle_price';
74
+ const rightIsOracle = node.right.kind === 'oracle_price';
75
+ let asset;
76
+ let literal;
77
+ if (leftIsOracle) {
78
+ asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined;
79
+ literal = oracleThresholdNormalised(node.right);
80
+ }
81
+ else if (rightIsOracle) {
82
+ asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined;
83
+ literal = oracleThresholdNormalised(node.left);
84
+ }
85
+ if (asset !== undefined && literal !== undefined) {
86
+ visit(asset, node.op, literal);
87
+ }
88
+ return;
89
+ }
90
+ case 'in':
91
+ return;
92
+ }
93
+ }
94
+ /** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
95
+ * oracle.rs. */
96
+ const NORMALISED_DECIMALS = 9n;
97
+ /** A threshold restated on the normalised basis, so a derived permit price is
98
+ * comparable to it. Thresholds carry their own basis, so the conversion has
99
+ * to happen here - reading the digits raw would build a context off by a
100
+ * factor of 10^(decimals-9). Returns undefined for any other leaf. */
101
+ function oracleThresholdNormalised(leaf) {
102
+ if (leaf.kind !== 'oracle_threshold')
103
+ return undefined;
104
+ let value;
105
+ try {
106
+ value = BigInt(leaf.value);
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ const decimals = BigInt(leaf.decimals);
112
+ if (decimals <= NORMALISED_DECIMALS) {
113
+ return value * 10n ** (NORMALISED_DECIMALS - decimals);
114
+ }
115
+ // Floor: a finer-grained threshold has no exact 9-dp representation. The
116
+ // caller only needs a price that lands on the right side of the bound, and
117
+ // it offsets by one from here.
118
+ return value / 10n ** (decimals - NORMALISED_DECIMALS);
119
+ }