@crediolabs/policy-synth 0.2.0 → 0.3.0

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 (184) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/interpreter/adapter.d.ts +19 -25
  3. package/dist/adapters/interpreter/adapter.js +54 -487
  4. package/dist/adapters/interpreter/index.d.ts +1 -1
  5. package/dist/adapters/interpreter/index.js +1 -1
  6. package/dist/adapters/oz/adapter.js +1 -15
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/index.d.ts +1 -4
  9. package/dist/index.js +1 -4
  10. package/dist/install/build-add-context-rule.d.ts +3 -19
  11. package/dist/install/build-add-context-rule.js +3 -98
  12. package/dist/install/build-install-policy.d.ts +4 -53
  13. package/dist/install/build-install-policy.js +44 -123
  14. package/dist/install/index.d.ts +0 -2
  15. package/dist/install/index.js +0 -7
  16. package/dist/ir/index.d.ts +1 -1
  17. package/dist/ir/types.d.ts +7 -76
  18. package/dist/ir/types.js +0 -2
  19. package/dist/predicate/decode.d.ts +1 -1
  20. package/dist/predicate/decode.js +2 -70
  21. package/dist/predicate/encode.js +45 -229
  22. package/dist/predicate/from-json.js +1 -42
  23. package/dist/record/decode.js +3 -8
  24. package/dist/review-card/builder.d.ts +4 -3
  25. package/dist/review-card/builder.js +18 -162
  26. package/dist/review-card/cross-check.js +2 -105
  27. package/dist/review-card/render-leaf.d.ts +4 -0
  28. package/dist/review-card/render-leaf.js +47 -0
  29. package/dist/run/index.d.ts +36 -139
  30. package/dist/run/index.js +125 -437
  31. package/dist/run/schemas.d.ts +168 -1929
  32. package/dist/run/schemas.js +40 -269
  33. package/dist/seams/types.d.ts +0 -2
  34. package/dist/simulate/deny-cases.d.ts +26 -0
  35. package/dist/simulate/deny-cases.js +282 -0
  36. package/dist/simulate/evaluate.d.ts +18 -0
  37. package/dist/simulate/evaluate.js +271 -0
  38. package/dist/simulate/index.d.ts +4 -0
  39. package/dist/simulate/index.js +8 -0
  40. package/dist/synth/compose-from-recording.d.ts +24 -51
  41. package/dist/synth/compose-from-recording.js +120 -282
  42. package/dist/synth/deny-cases.d.ts +5 -7
  43. package/dist/synth/deny-cases.js +23 -373
  44. package/dist/synth/evaluate.d.ts +0 -21
  45. package/dist/synth/evaluate.js +56 -336
  46. package/dist/synth/index.d.ts +0 -5
  47. package/dist/synth/index.js +0 -5
  48. package/dist/synth/permit-context.d.ts +2 -9
  49. package/dist/synth/permit-context.js +1 -82
  50. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  51. package/dist/synth/synthesize-from-recording.js +41 -356
  52. package/dist/types.d.ts +8 -60
  53. package/dist/types.js +5 -2
  54. package/dist/verify/simulate.d.ts +1 -14
  55. package/dist/verify/simulate.js +2 -88
  56. package/dist/verify/verify.d.ts +1 -7
  57. package/dist/verify/verify.js +0 -8
  58. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  59. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  60. package/dist-cjs/adapters/interpreter/index.d.ts +1 -1
  61. package/dist-cjs/adapters/interpreter/index.js +1 -2
  62. package/dist-cjs/adapters/oz/adapter.js +1 -15
  63. package/dist-cjs/errors.d.ts +1 -1
  64. package/dist-cjs/index.d.ts +1 -4
  65. package/dist-cjs/index.js +1 -4
  66. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  67. package/dist-cjs/install/build-add-context-rule.js +1 -97
  68. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  69. package/dist-cjs/install/build-install-policy.js +44 -122
  70. package/dist-cjs/install/index.d.ts +0 -2
  71. package/dist-cjs/install/index.js +2 -23
  72. package/dist-cjs/ir/index.d.ts +1 -1
  73. package/dist-cjs/ir/types.d.ts +7 -76
  74. package/dist-cjs/ir/types.js +0 -2
  75. package/dist-cjs/predicate/decode.d.ts +1 -1
  76. package/dist-cjs/predicate/decode.js +2 -70
  77. package/dist-cjs/predicate/encode.js +45 -229
  78. package/dist-cjs/predicate/from-json.js +1 -42
  79. package/dist-cjs/record/decode.js +3 -8
  80. package/dist-cjs/review-card/builder.d.ts +4 -3
  81. package/dist-cjs/review-card/builder.js +23 -167
  82. package/dist-cjs/review-card/cross-check.js +7 -110
  83. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  84. package/dist-cjs/review-card/render-leaf.js +52 -0
  85. package/dist-cjs/run/index.d.ts +36 -139
  86. package/dist-cjs/run/index.js +125 -444
  87. package/dist-cjs/run/schemas.d.ts +168 -1929
  88. package/dist-cjs/run/schemas.js +41 -270
  89. package/dist-cjs/seams/types.d.ts +0 -2
  90. package/dist-cjs/simulate/deny-cases.d.ts +26 -0
  91. package/dist-cjs/simulate/deny-cases.js +286 -0
  92. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  93. package/dist-cjs/simulate/evaluate.js +274 -0
  94. package/dist-cjs/simulate/index.d.ts +4 -0
  95. package/dist-cjs/simulate/index.js +13 -0
  96. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  97. package/dist-cjs/synth/compose-from-recording.js +120 -282
  98. package/dist-cjs/synth/deny-cases.d.ts +5 -7
  99. package/dist-cjs/synth/deny-cases.js +23 -374
  100. package/dist-cjs/synth/evaluate.d.ts +0 -21
  101. package/dist-cjs/synth/evaluate.js +57 -337
  102. package/dist-cjs/synth/index.d.ts +0 -5
  103. package/dist-cjs/synth/index.js +1 -11
  104. package/dist-cjs/synth/permit-context.d.ts +2 -9
  105. package/dist-cjs/synth/permit-context.js +1 -82
  106. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  107. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  108. package/dist-cjs/types.d.ts +8 -60
  109. package/dist-cjs/types.js +6 -3
  110. package/dist-cjs/verify/simulate.d.ts +1 -14
  111. package/dist-cjs/verify/simulate.js +2 -88
  112. package/dist-cjs/verify/verify.d.ts +1 -7
  113. package/dist-cjs/verify/verify.js +0 -8
  114. package/package.json +4 -4
  115. package/src/adapters/interpreter/adapter.ts +76 -572
  116. package/src/errors.ts +0 -19
  117. package/src/index.ts +1 -4
  118. package/src/install/build-add-context-rule.ts +5 -139
  119. package/src/install/build-install-policy.ts +93 -214
  120. package/src/install/index.ts +0 -34
  121. package/src/predicate/decode.ts +2 -70
  122. package/src/predicate/encode.ts +49 -261
  123. package/src/predicate/from-json.ts +1 -43
  124. package/src/record/decode.ts +3 -8
  125. package/src/review-card/builder.ts +19 -168
  126. package/src/review-card/cross-check.ts +3 -105
  127. package/src/review-card/render-leaf.ts +48 -0
  128. package/src/run/index.ts +144 -572
  129. package/src/run/schemas.ts +42 -291
  130. package/src/simulate/deny-cases.ts +334 -0
  131. package/src/simulate/evaluate.ts +284 -0
  132. package/src/simulate/index.ts +11 -0
  133. package/src/synth/compose-from-recording.ts +148 -347
  134. package/src/synth/index.ts +0 -13
  135. package/src/synth/synthesize-from-recording.ts +43 -456
  136. package/src/types.ts +13 -49
  137. package/dist/install/authority-overlap.d.ts +0 -134
  138. package/dist/install/authority-overlap.js +0 -0
  139. package/dist/install/build-install-predicate.d.ts +0 -96
  140. package/dist/install/build-install-predicate.js +0 -444
  141. package/dist/install/build-merge-policy.d.ts +0 -70
  142. package/dist/install/build-merge-policy.js +0 -130
  143. package/dist/install/plan-merge-policy.d.ts +0 -49
  144. package/dist/install/plan-merge-policy.js +0 -86
  145. package/dist/install/read-account-rules.d.ts +0 -100
  146. package/dist/install/read-account-rules.js +0 -283
  147. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  148. package/dist-cjs/install/authority-overlap.js +0 -0
  149. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  150. package/dist-cjs/install/build-install-predicate.js +0 -479
  151. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  152. package/dist-cjs/install/build-merge-policy.js +0 -134
  153. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  154. package/dist-cjs/install/plan-merge-policy.js +0 -90
  155. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  156. package/dist-cjs/install/read-account-rules.js +0 -296
  157. package/src/adapters/interpreter/index.ts +0 -8
  158. package/src/adapters/oz/adapter.ts +0 -376
  159. package/src/adapters/oz/index.ts +0 -9
  160. package/src/codegen/compile-gate.ts +0 -167
  161. package/src/codegen/index.ts +0 -17
  162. package/src/codegen/template.ts +0 -165
  163. package/src/install/authority-overlap.ts +0 -0
  164. package/src/install/build-merge-policy.ts +0 -219
  165. package/src/install/plan-merge-policy.ts +0 -133
  166. package/src/install/read-account-rules.ts +0 -376
  167. package/src/ir/index.ts +0 -13
  168. package/src/ir/types.ts +0 -132
  169. package/src/mandate/index.ts +0 -4
  170. package/src/mandate/to-ir.ts +0 -71
  171. package/src/mandate/types.ts +0 -21
  172. package/src/seams/index.ts +0 -11
  173. package/src/seams/types.ts +0 -81
  174. package/src/synth/deny-cases.ts +0 -663
  175. package/src/synth/evaluate.ts +0 -613
  176. package/src/synth/harness.ts +0 -68
  177. package/src/synth/minimize.ts +0 -48
  178. package/src/synth/permit-context.ts +0 -136
  179. package/src/synth/predicate-literals.ts +0 -27
  180. package/src/synth/synthesize-from-mandate.ts +0 -82
  181. package/src/verify/envelope.ts +0 -28
  182. package/src/verify/index.ts +0 -5
  183. package/src/verify/simulate.ts +0 -311
  184. package/src/verify/verify.ts +0 -243
@@ -1,205 +1,108 @@
1
1
  "use strict";
2
- // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ + interpreter).
2
+ // src/synth/compose-from-recording.ts - facts + scope -> a composed rule.
3
3
  //
4
4
  // Fail-closed composition rules:
5
5
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
6
- // emit no OZ-primitive-producing IR node; scope is kept (CallContract + method)
7
- // and every inferred bound surfaces as a descriptive warning. An unrecognised
8
- // call never compiles to a permissive OZ primitive.
9
- // - carry the recorded top-level function into `rule.scope.method` so the OZ
10
- // adapter flags per-method scoping as not covered (CallContract permits every
11
- // method; a per-method restriction needs the interpreter predicate).
12
- // - `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
13
- // the caller supplies BOTH limit (userResponses.limitAmount) AND window
14
- // (userResponses.windowSeconds). A single recorded spend does NOT authorise
15
- // that amount every window, so the observed amount is NEVER used as an
16
- // auto-ceiling: missing limit -> AMOUNT_BOUND_MISSING (observed amount
17
- // offered as a suggestion); missing window -> DURATION_UNSPECIFIED. EVERY
6
+ // emit no constraint from the spend; scope is kept (contract + method) and
7
+ // every inferred bound surfaces as a descriptive warning. An unrecognised
8
+ // call never compiles to a permissive policy.
9
+ // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
10
+ // the call carries an amount argument to bind it to. A single recorded
11
+ // spend does NOT authorise that amount on every call, so the observed
12
+ // amount is NEVER used as an auto-ceiling: without both,
13
+ // AMOUNT_BOUND_MISSING carries the observed amount as a suggestion. EVERY
18
14
  // spent token is handled - none is silently dropped.
19
- // - incoming-only flows emit an `invocation_count` bound ONLY when the caller
20
- // supplies both the count and the window; otherwise FREQUENCY_BOUND_MISSING
21
- // with no fabricated count.
22
- // - the IR carries ONLY constraints justified by the recording + explicit
23
- // user input. Nothing invented: no oracle price fabricated from a slippage
24
- // bound, no synthetic exact-path compare. Those needs surface as warnings.
25
- //
26
- // Split rule (P3 wiring): `ComposeResult` carries BOTH `ir` (OZ-shape) and
27
- // `interpreterIr` (predicate-shape). Each constraint is routed to EXACTLY ONE
28
- // adapter:
29
- // - `window_spent(token, w) <= limit` where `token === scope.contract` and
30
- // protocol is known -> `ir` (OZ lowers to spending_limit).
31
- // - everything else (recipient allowlists, per-method scope.method,
32
- // invocation_count, eq_seq swap paths, oracle_price, AND window_spent
33
- // where token != scope.contract i.e. SoroSwap input-token cap) ->
34
- // `interpreterIr`.
35
- //
36
- // This prevents the interpreter adapter from emitting a duplicate
37
- // `window_spent` predicate leaf alongside an OZ `spending_limit` primitive
38
- // covering the same spend semantic - the two adapters never overlap.
15
+ // - the IR carries ONLY constraints justified by the recording plus an
16
+ // explicit bound, no synthetic exact-path compare. Other needs surface as
17
+ // warnings.
39
18
  //
40
19
  // Default policy is `deny_all` (OZ context rules are deny-by-default).
41
20
  Object.defineProperty(exports, "__esModule", { value: true });
42
21
  exports.composeFromRecording = composeFromRecording;
43
22
  const identify_ts_1 = require("../registry/identify.js");
44
- /** Compose a PolicyIR pair from the lowered facts + the resolved scope.
23
+ const protocols_ts_1 = require("../registry/protocols.js");
24
+ /** Compose a rule from the lowered facts + the resolved scope.
45
25
  * Pure (no randomness, no clock); same inputs -> byte-identical result. */
46
26
  function composeFromRecording(facts, scopeContract, topLevel, opts) {
47
- const interpreterEnabled = opts.interpreterEnabled === true;
48
27
  const ambiguities = [];
49
28
  const warnings = [];
50
- const ozConstraints = [];
51
29
  const interpreterConstraints = [];
52
- // Route to the matching IR. When the interpreter is enabled, constraints the
53
- // OZ adapter cannot lower go there; otherwise they go to OZ (which flags them
54
- // as uncovered) so today's warning-driven behaviour is preserved.
55
- const routeToAdapter = (cond) => {
56
- if (interpreterEnabled)
57
- interpreterConstraints.push(cond);
58
- else
59
- ozConstraints.push(cond);
60
- };
61
30
  const protocol = topLevel
62
31
  ? (0, identify_ts_1.identifyProtocol)(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
63
32
  : null;
64
33
  const known = protocol !== null;
65
- const windowSeconds = opts.userResponses?.windowSeconds;
66
34
  const limitAmount = opts.userResponses?.limitAmount;
67
35
  const spendTokens = Object.keys(facts.spendByToken);
68
- // Outgoing spend -> one spending_limit per spent token. A single caller
69
- // limit binds only an unambiguous single-token spend; a multi-token flow
70
- // needs a per-token limit, so each unmatched token surfaces
71
- // AMOUNT_BOUND_MISSING.
72
- //
73
- // Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
74
- // only when token === scope.contract (OZ's spending_limit binds the
75
- // CallContract target, not a token parameter). Otherwise it goes to the
76
- // interpreter IR.
36
+ // Outgoing spend -> a cap on the call's own amount ARGUMENT. The interpreter
37
+ // is passed one authorised call, not the transaction's token movements, so a
38
+ // rolling per-window total is not something it can read; the enforceable
39
+ // shape is a bound on the amount the call itself carries. A multi-token flow
40
+ // has no single argument to bind, so each token surfaces AMOUNT_BOUND_MISSING.
77
41
  if (spendTokens.length > 0 && topLevel) {
78
- let durationFlagged = false;
79
42
  for (const token of spendTokens) {
80
43
  const observed = facts.spendByToken[token];
81
44
  if (observed === undefined)
82
45
  continue;
83
46
  if (!known) {
84
- warnings.push(`spend of ${observed} (token ${token}) not bounded: unrecognised protocol, spend cap needs the interpreter predicate`);
47
+ warnings.push(`spend of ${observed} (token ${token}) not bounded: unrecognised protocol, so the amount argument cannot be located`);
85
48
  continue;
86
49
  }
87
50
  const limit = spendTokens.length === 1 ? limitAmount : undefined;
88
- if (windowSeconds === undefined && !durationFlagged) {
89
- ambiguities.push({
90
- code: 'DURATION_UNSPECIFIED',
91
- question: `Recording shows a ${observed}-${token} spend. What rolling window (seconds) should the spending_limit use?`,
92
- });
93
- durationFlagged = true;
94
- }
95
- if (limit === undefined) {
51
+ const amountArgIndex = limitArgumentIndex(protocol, topLevel);
52
+ if (limit === undefined || amountArgIndex === null) {
96
53
  ambiguities.push({
97
54
  code: 'AMOUNT_BOUND_MISSING',
98
- question: `Recording shows a ${observed}-${token} spend, but a single spend does not authorise that amount every window. What per-window spending_limit should apply? (observed amount, suggestion only: ${observed})`,
55
+ question: `Recording shows a ${observed}-${token} spend, but a single spend does not authorise that amount on every call. What per-call cap should apply? (observed amount, suggestion only: ${observed})`,
99
56
  });
100
57
  continue;
101
58
  }
102
- if (windowSeconds !== undefined) {
103
- // Rolling spend cap always goes to OZ (`spending_limit` is the audited
104
- // implementation). The interpreter is NOT a fallback for token !=
105
- // scopeContract: on chain it sees one authorized call, not the
106
- // transaction's token movements, so it has no per-call amount to
107
- // accumulate and the counter would never move. OZ reports the case it
108
- // cannot cover (limit pins to the context contract) - the honest
109
- // outcome; the old fallback produced an interpreter predicate that
110
- // silently never bound.
111
- ozConstraints.push({
112
- op: 'compare',
113
- compare: {
114
- selector: { kind: 'window_spent', token, windowSeconds },
115
- operator: 'lte',
116
- value: limit,
117
- },
118
- });
119
- if (interpreterEnabled && token !== scopeContract) {
120
- 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.`);
121
- }
122
- }
59
+ interpreterConstraints.push({
60
+ op: 'lte',
61
+ left: { kind: 'call_arg', index: amountArgIndex },
62
+ right: { kind: 'literal_i128', value: limit },
63
+ });
123
64
  }
124
65
  }
125
- // Incoming-only / frequency intent: an invocation_count bound is emitted ONLY
126
- // when the caller supplies the count AND a window - never a fabricated `<= 1`.
127
- // Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
128
- // invocation_count); otherwise to the OZ IR (which flags it as uncovered).
66
+ // Incoming-only / frequency intent: the interpreter is passed one authorised
67
+ // call, so it cannot count prior calls - no frequency bound is emitted and
68
+ // never a fabricated `<= 1`. The need surfaces as a prompt plus a warning.
129
69
  //
130
70
  // A recognised swap is NOT an incoming-only flow: it has an outgoing input
131
71
  // leg whose spend simply was not attributed to the source account
132
72
  // (fee-sponsored / holder != source). Its real restrictions - exact path,
133
73
  // recipient, input-amount cap - come from the protocol-specific pass, so it
134
- // does NOT get the incoming-only frequency prompt. A caller wanting to
135
- // rate-limit the swap can still supply an invocationLimit + window, which
136
- // lowers to an invocation_count for any flow.
74
+ // does NOT get the incoming-only frequency prompt.
137
75
  if (spendTokens.length === 0 && topLevel) {
138
- const invocationLimit = opts.userResponses?.invocationLimit;
139
76
  const isRecognisedSwap = protocol?.protocol === 'soroswap';
140
- if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
141
- routeToAdapter({
142
- op: 'compare',
143
- compare: {
144
- selector: { kind: 'invocation_count', windowSeconds },
145
- // `lt`, not `lte`: the leaf reports the calls ALREADY made in the
146
- // window, so `< N` is what permits N of them. With `lte` a limit
147
- // of N let an N+1th call through.
148
- operator: 'lt',
149
- value: String(invocationLimit),
150
- },
151
- });
152
- }
153
- else if (!isRecognisedSwap) {
77
+ if (!isRecognisedSwap) {
154
78
  ambiguities.push({
155
79
  code: 'FREQUENCY_BOUND_MISSING',
156
- question: 'Incoming-only flow - what max invocations per window should the policy enforce?',
80
+ question: 'Incoming-only flow - the policy does not bound how often this call may be made. Bound it outside the policy, or scope the rule more tightly.',
157
81
  });
158
- warnings.push('frequency bound needed for the incoming-only flow (needs the interpreter predicate); no invocation cap inferred');
82
+ warnings.push('incoming-only flow: call frequency is NOT bounded - the interpreter reads only the authorized call, so it cannot count prior calls');
159
83
  }
160
84
  }
161
- // Per-asset oracle-price bound(s) supplied by the caller -> one
162
- // oracle_price compare per entry. Routed to the interpreter IR when enabled;
163
- // otherwise to the OZ IR (which flags it as uncovered).
164
- const oracleBounds = opts.userResponses?.oraclePriceBound;
165
- if (oracleBounds) {
166
- for (const b of oracleBounds) {
167
- routeToAdapter({
168
- op: 'compare',
169
- compare: {
170
- selector: { kind: 'oracle_price', asset: b.asset },
171
- operator: b.operator,
172
- value: b.value,
173
- valueDecimals: b.decimals,
174
- },
175
- });
176
- }
177
- }
178
- // Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
179
- // adapter flags as not covered; the interpreter adapter lowers it to an `in`
180
- // predicate. Unknown protocols emit nothing here. SoroSwap's swap recipient
181
- // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
85
+ // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
86
+ // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
87
+ // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
88
+ // the swapRecipientAllowlist surface.
182
89
  if (topLevel && protocol !== null) {
183
- // A SoroSwap input-amount cap binds the caller's limitAmount to call_arg[0]
184
- // (the exact amount_in) ONLY when no cumulative outgoing spend was detected
185
- // for the source account - i.e. the input token never moved FROM the source
90
+ // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
91
+ // input-amount argument ONLY when no outgoing spend was detected for the
92
+ // source account - i.e. the input token never moved FROM the source
186
93
  // (fee-sponsored swaps, or a holder != source). When a spend WAS detected,
187
- // the window_spent path above already consumed the limit, so the per-call
188
- // arg cap is skipped to avoid binding one limit to two different semantics.
94
+ // the cap above already consumed the limit, so this one is skipped to avoid
95
+ // binding one limit to two different args.
189
96
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
190
- appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapter, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
97
+ appendProtocolSpecificConstraints(interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap);
191
98
  }
192
- // `scope.method` is carried on BOTH IRs so each adapter produces a
193
- // self-consistent rule. The interpreter adapter lowers scope.method into a
194
- // `call_fn == <method>` predicate leaf (a real restriction); the OZ adapter
195
- // flags it as uncovered (CallContract alone permits any method on the
196
- // contract).
99
+ // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
100
+ // predicate leaf.
197
101
  const scope = { contract: scopeContract };
198
102
  if (topLevel?.fn)
199
103
  scope.method = topLevel.fn;
200
104
  const buildRule = (constraints) => {
201
105
  const rule = {
202
- roles: [],
203
106
  scope: { ...scope },
204
107
  constraints,
205
108
  };
@@ -208,39 +111,47 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
208
111
  }
209
112
  return rule;
210
113
  };
211
- const ir = {
212
- chain: 'stellar',
213
- defaultBehavior: 'deny_all',
214
- rules: [buildRule(ozConstraints)],
215
- };
216
- const interpreterIr = {
217
- chain: 'stellar',
218
- defaultBehavior: 'deny_all',
219
- rules: [buildRule(interpreterConstraints)],
220
- };
221
- return { ir, interpreterIr, ambiguities, warnings };
114
+ return { interpreterRule: buildRule(interpreterConstraints), ambiguities, warnings };
115
+ }
116
+ /** Index of the argument a caller-supplied `limitAmount` binds to, or null when
117
+ * the call carries no such argument. The interpreter is passed one authorised
118
+ * call, not the transaction's token movements, so bounding an argument the
119
+ * call itself carries is the only enforceable shape. */
120
+ function limitArgumentIndex(protocol, topLevel) {
121
+ if (!protocol || !topLevel)
122
+ return null;
123
+ const named = amountArgumentIndex(protocol);
124
+ if (named !== null)
125
+ return named;
126
+ // SoroSwap names its input argument `amount_in` / `amount_in_max`, so the
127
+ // lookup by name misses it and the index is function-specific.
128
+ if (protocol.protocol !== 'soroswap')
129
+ return null;
130
+ const i = soroswapInputAmountArgIndex(protocol.fn);
131
+ // Defense in depth: identification's argsMatchAbi already pins it to i128.
132
+ return i !== undefined && topLevel.args[i]?.type === 'i128' ? i : null;
222
133
  }
223
- /** Add the constraints justified by the recording that the OZ backend cannot
224
- * express natively. Each constraint is routed to either `ozConstraints` (the
225
- * OZ adapter lowers it) or `interpreterConstraints` (the interpreter adapter
226
- * lowers it). When `interpreterEnabled` is false, every protocol-specific
227
- * constraint is routed to `ozConstraints` (which flags it as uncovered) so
228
- * callers who haven't opted in keep today's warning-driven behaviour.
229
- * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
230
- * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
231
- * call_arg[2]). */
232
- function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapter, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
134
+ /** Index of the argument the protocol ABI calls `amount`, or null when the ABI
135
+ * does not name one. */
136
+ function amountArgumentIndex(protocol) {
137
+ const abi = (0, protocols_ts_1.getAbi)(protocol.protocol)[protocol.fn];
138
+ if (!abi)
139
+ return null;
140
+ const i = abi.args.findIndex((a) => a.name === 'amount');
141
+ return i >= 0 ? i : null;
142
+ }
143
+ /** Add the protocol-specific constraints the recording justifies: recipient
144
+ * allowlists, exact swap paths and per-call argument caps. */
145
+ function appendProtocolSpecificConstraints(interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap) {
233
146
  // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit a
234
147
  // single-element allowlist; the interpreter adapter lowers it to `in`.
235
- // When interpreter is not enabled, route to OZ so the caller sees today's
236
- // `value allowlist on arg 1` warning.
237
148
  if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
238
149
  const toArg = topLevel.args[1];
239
150
  if (toArg && toArg.type === 'address') {
240
- routeToAdapter({
151
+ interpreterConstraints.push({
241
152
  op: 'in',
242
- selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
243
- values: [toArg.value],
153
+ needle: { kind: 'call_arg', index: 1 },
154
+ haystack: [{ kind: 'literal_address', value: toArg.value }],
244
155
  });
245
156
  }
246
157
  }
@@ -255,10 +166,10 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
255
166
  // claim(from, reserve_token_ids, to).
256
167
  const toArg = topLevel.args[2];
257
168
  if (toArg && toArg.type === 'address') {
258
- routeToAdapter({
169
+ interpreterConstraints.push({
259
170
  op: 'in',
260
- selector: { kind: 'arg', argIndex: 2, scalarType: 'address' },
261
- values: [toArg.value],
171
+ needle: { kind: 'call_arg', index: 2 },
172
+ haystack: [{ kind: 'literal_address', value: toArg.value }],
262
173
  });
263
174
  }
264
175
  }
@@ -272,17 +183,14 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
272
183
  // -> Borrow on a different asset, any amount, then auction fills). Length
273
184
  // + per-element pinning is total; a quantifier over elements is not. If we
274
185
  // cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
275
- if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
186
+ if (protocol.protocol === 'blend' && protocol.fn === 'submit') {
276
187
  const requestsArg = topLevel.args[3];
277
188
  if (requestsArg && requestsArg.type === 'vec') {
278
189
  const elements = requestsArg.value;
279
190
  interpreterConstraints.push({
280
- op: 'compare',
281
- compare: {
282
- selector: { kind: 'arg_len', argIndex: 3 },
283
- operator: 'eq',
284
- value: String(elements.length),
285
- },
191
+ op: 'eq',
192
+ left: { kind: 'call_arg_len', index: 3 },
193
+ right: { kind: 'literal_u32', value: elements.length },
286
194
  });
287
195
  for (let i = 0; i < elements.length; i++) {
288
196
  const element = elements[i];
@@ -323,46 +231,19 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
323
231
  continue;
324
232
  }
325
233
  interpreterConstraints.push({
326
- op: 'compare',
327
- compare: {
328
- selector: {
329
- kind: 'arg_field',
330
- argIndex: 3,
331
- element: i,
332
- field: 'request_type',
333
- scalarType: 'u32',
334
- },
335
- operator: 'eq',
336
- value: requestType,
337
- },
234
+ op: 'eq',
235
+ left: { kind: 'call_arg_field', index: 3, element: i, field: 'request_type' },
236
+ right: { kind: 'literal_u32', value: Number.parseInt(requestType, 10) },
338
237
  });
339
238
  interpreterConstraints.push({
340
- op: 'compare',
341
- compare: {
342
- selector: {
343
- kind: 'arg_field',
344
- argIndex: 3,
345
- element: i,
346
- field: 'address',
347
- scalarType: 'address',
348
- },
349
- operator: 'eq',
350
- value: address,
351
- },
239
+ op: 'eq',
240
+ left: { kind: 'call_arg_field', index: 3, element: i, field: 'address' },
241
+ right: { kind: 'literal_address', value: address },
352
242
  });
353
243
  interpreterConstraints.push({
354
- op: 'compare',
355
- compare: {
356
- selector: {
357
- kind: 'arg_field',
358
- argIndex: 3,
359
- element: i,
360
- field: 'amount',
361
- scalarType: 'i128',
362
- },
363
- operator: 'lte',
364
- value: amount,
365
- },
244
+ op: 'lte',
245
+ left: { kind: 'call_arg_field', index: 3, element: i, field: 'amount' },
246
+ right: { kind: 'literal_i128', value: amount },
366
247
  });
367
248
  }
368
249
  }
@@ -376,17 +257,20 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
376
257
  if (protocol.protocol === 'soroswap') {
377
258
  const paths = facts.allowedPaths?.[topLevel.contract];
378
259
  const route = paths?.length === 1 ? paths[0] : undefined;
379
- if (route && route.length > 0 && interpreterEnabled) {
260
+ if (route && route.length > 0) {
380
261
  const pathArgIndex = 2; // SoroSwap swap_exact_tokens_for_tokens: args = [amount_in, amount_out_min, path, to, deadline]
381
262
  interpreterConstraints.push({
382
- op: 'eq_seq',
383
- selector: { kind: 'arg', argIndex: pathArgIndex, scalarType: 'address' },
384
- values: route,
263
+ op: 'eq',
264
+ left: { kind: 'call_arg', index: pathArgIndex },
265
+ right: {
266
+ kind: 'literal_vec',
267
+ elements: route.map((v) => ({ kind: 'literal_address', value: v })),
268
+ },
385
269
  });
386
270
  }
387
271
  else {
388
272
  const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : '';
389
- warnings.push(`SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate${pathText}`);
273
+ warnings.push(`SoroSwap swap: the exact hop path needs the interpreter predicate${pathText}`);
390
274
  }
391
275
  // Input-amount cap: bind the caller's limitAmount to the swap's input-amount
392
276
  // argument as `call_arg[i] <= limit`. The index is function-specific -
@@ -396,43 +280,20 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
396
280
  // as arg[1] (its arg[0] is the exact OUTPUT, so binding arg[0] there would
397
281
  // cap the wrong value and leave the input unbounded). This is a per-call cap
398
282
  // that does NOT depend on attributing a token movement to the source account
399
- // (the fee-sponsored / holder != source case, where the window_spent path
400
- // detects no spend). Fail-closed: it only ever restricts the permitted input.
401
- // Routed to the interpreter predicate (OZ built-ins cannot express a per-arg
402
- // i128 bound); when the interpreter is not enabled it goes to OZ, which flags
403
- // it uncovered (a warning). The `type === 'i128'` check is defense in depth -
404
- // protocol identification's argsMatchAbi already pins the input arg to i128.
283
+ // (the fee-sponsored / holder != source case, where no spend is detected).
284
+ // Fail-closed: it only ever restricts the permitted input. The
285
+ // `type === 'i128'` check is defense in depth - protocol identification's
286
+ // argsMatchAbi already pins the input arg to i128.
405
287
  const inputArgIndex = soroswapInputAmountArgIndex(protocol.fn);
406
288
  const inputAmountArg = inputArgIndex !== undefined ? topLevel.args[inputArgIndex] : undefined;
407
289
  if (swapInputAmountCap !== undefined &&
408
290
  inputArgIndex !== undefined &&
409
291
  inputAmountArg &&
410
292
  inputAmountArg.type === 'i128') {
411
- routeToAdapter({
412
- op: 'compare',
413
- compare: {
414
- selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
415
- operator: 'lte',
416
- value: swapInputAmountCap,
417
- },
418
- });
419
- }
420
- // Slippage floor: `out >= in * num/den`. Only when the caller supplied the
421
- // ratio - see `swapMinOutRatio`. Without it the output arg stays free,
422
- // which is the case the unbounded-swap warning above describes.
423
- const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn);
424
- const minOutRatio = swapMinOutRatio;
425
- if (minOutRatio !== undefined &&
426
- inputArgIndex !== undefined &&
427
- outMinArgIndex !== undefined &&
428
- inputAmountArg &&
429
- inputAmountArg.type === 'i128') {
430
- routeToAdapter({
431
- op: 'slippage_floor',
432
- outArgIndex: outMinArgIndex,
433
- inArgIndex: inputArgIndex,
434
- num: minOutRatio.num,
435
- den: minOutRatio.den,
293
+ interpreterConstraints.push({
294
+ op: 'lte',
295
+ left: { kind: 'call_arg', index: inputArgIndex },
296
+ right: { kind: 'literal_i128', value: swapInputAmountCap },
436
297
  });
437
298
  }
438
299
  // Swap recipient (call_arg[3]): when the caller supplies
@@ -443,23 +304,20 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
443
304
  // it unconstrained would permit an arbitrary recipient (an evil twin with
444
305
  // call_arg[3] = attacker_wallet). RECIPIENT_ALLOWLIST_EMPTY is still
445
306
  // surfaced, but as INFORMATIONAL (the recipient was pinned; here is how to
446
- // widen it), never as a silent free pass. The recipient is only enforceable
447
- // via the interpreter predicate, so the pin (and the ambiguity) apply only
448
- // when the interpreter is enabled - otherwise the swap recipient is ignored
449
- // (today's behaviour, matching the other SoroSwap constraints).
307
+ // widen it), never as a silent free pass.
450
308
  const recipientArg = topLevel.args[3];
451
309
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
452
- routeToAdapter({
310
+ interpreterConstraints.push({
453
311
  op: 'in',
454
- selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
455
- values: [...swapRecipientAllowlist],
312
+ needle: { kind: 'call_arg', index: 3 },
313
+ haystack: swapRecipientAllowlist.map((v) => ({ kind: 'literal_address', value: v })),
456
314
  });
457
315
  }
458
- else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
316
+ else if (recipientArg && recipientArg.type === 'address') {
459
317
  interpreterConstraints.push({
460
318
  op: 'in',
461
- selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
462
- values: [recipientArg.value],
319
+ needle: { kind: 'call_arg', index: 3 },
320
+ haystack: [{ kind: 'literal_address', value: recipientArg.value }],
463
321
  });
464
322
  ambiguities.push({
465
323
  code: 'RECIPIENT_ALLOWLIST_EMPTY',
@@ -468,26 +326,6 @@ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapte
468
326
  }
469
327
  }
470
328
  }
471
- /** Positional index of the input-amount argument for a recognized SoroSwap swap
472
- * function. `swap_exact_tokens_for_tokens` and `swap_exact_in_for_tokens` take
473
- * the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
474
- * input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
475
- * function has no positional input-amount argument -> undefined (no cap bound). */
476
- /** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
477
- *
478
- * Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
479
- * fixes the output and varies the input, so its output needs no floor (its
480
- * arg[1] is `amount_in_max`, already bounded by the input cap). Returning
481
- * undefined there keeps a floor from being pinned to the wrong argument. */
482
- function soroswapMinOutArgIndex(fn) {
483
- switch (fn) {
484
- case 'swap_exact_tokens_for_tokens':
485
- case 'swap_exact_in_for_tokens':
486
- return 1;
487
- default:
488
- return undefined;
489
- }
490
- }
491
329
  function soroswapInputAmountArgIndex(fn) {
492
330
  switch (fn) {
493
331
  case 'swap_exact_tokens_for_tokens':
@@ -14,16 +14,14 @@ export interface GeneratedCases {
14
14
  denies: DenyCase[];
15
15
  }
16
16
  declare const OVERPERMISSIVE_DIMENSIONS: readonly ["argument_reorder"];
17
- declare const ORIGINAL_DIMENSIONS: string[];
18
- export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
17
+ export { OVERPERMISSIVE_DIMENSIONS };
19
18
  /** Build deterministic model-evaluated alternatives without mutating the intended call.
20
19
  *
21
20
  * @param predicate - the synthesized predicate
22
- * @param permitCtx - EvalContext for the intended (permitted) call
23
- * @param dimensions - optional whitelist of dimension names to emit; when omitted
24
- * all known dimensions (including over-permissiveness mutations)
25
- * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
26
- * existing fixtures do not regress. */
21
+ * @param permitCtx - EvalContext for the intended (permitted) call
22
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
23
+ * all known dimensions (including over-permissiveness mutations)
24
+ * are emitted. */
27
25
  export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
28
26
  /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
29
27
  * Exported so the permit-context builder shares this one implementation. */