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