@crediolabs/policy-synth 0.2.0 → 0.3.1

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 (249) 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/errors.d.ts +1 -1
  5. package/dist/index.d.ts +1 -4
  6. package/dist/index.js +1 -4
  7. package/dist/install/build-add-context-rule.d.ts +3 -19
  8. package/dist/install/build-add-context-rule.js +3 -98
  9. package/dist/install/build-install-policy.d.ts +4 -53
  10. package/dist/install/build-install-policy.js +44 -123
  11. package/dist/install/index.d.ts +0 -2
  12. package/dist/install/index.js +0 -7
  13. package/dist/predicate/decode.d.ts +1 -1
  14. package/dist/predicate/decode.js +2 -70
  15. package/dist/predicate/encode.js +45 -229
  16. package/dist/predicate/from-json.js +1 -42
  17. package/dist/record/decode.js +3 -8
  18. package/dist/record/freshness.d.ts +17 -11
  19. package/dist/record/freshness.js +28 -18
  20. package/dist/review-card/builder.d.ts +4 -3
  21. package/dist/review-card/builder.js +18 -162
  22. package/dist/review-card/cross-check.js +2 -105
  23. package/dist/review-card/render-leaf.d.ts +4 -0
  24. package/dist/review-card/render-leaf.js +47 -0
  25. package/dist/run/index.d.ts +36 -139
  26. package/dist/run/index.js +125 -437
  27. package/dist/run/schemas.d.ts +168 -1929
  28. package/dist/run/schemas.js +40 -269
  29. package/dist/{synth → simulate}/deny-cases.d.ts +4 -8
  30. package/dist/simulate/deny-cases.js +282 -0
  31. package/dist/simulate/evaluate.d.ts +18 -0
  32. package/dist/simulate/evaluate.js +271 -0
  33. package/dist/simulate/index.d.ts +4 -0
  34. package/dist/simulate/index.js +8 -0
  35. package/dist/synth/compose-from-recording.d.ts +24 -51
  36. package/dist/synth/compose-from-recording.js +120 -282
  37. package/dist/synth/index.d.ts +0 -5
  38. package/dist/synth/index.js +0 -5
  39. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  40. package/dist/synth/synthesize-from-recording.js +41 -356
  41. package/dist/types.d.ts +8 -60
  42. package/dist/types.js +5 -2
  43. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  44. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  45. package/dist-cjs/errors.d.ts +1 -1
  46. package/dist-cjs/index.d.ts +1 -4
  47. package/dist-cjs/index.js +1 -4
  48. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  49. package/dist-cjs/install/build-add-context-rule.js +1 -97
  50. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  51. package/dist-cjs/install/build-install-policy.js +44 -122
  52. package/dist-cjs/install/index.d.ts +0 -2
  53. package/dist-cjs/install/index.js +2 -23
  54. package/dist-cjs/predicate/decode.d.ts +1 -1
  55. package/dist-cjs/predicate/decode.js +2 -70
  56. package/dist-cjs/predicate/encode.js +45 -229
  57. package/dist-cjs/predicate/from-json.js +1 -42
  58. package/dist-cjs/record/decode.js +3 -8
  59. package/dist-cjs/record/freshness.d.ts +17 -11
  60. package/dist-cjs/record/freshness.js +28 -18
  61. package/dist-cjs/review-card/builder.d.ts +4 -3
  62. package/dist-cjs/review-card/builder.js +23 -167
  63. package/dist-cjs/review-card/cross-check.js +7 -110
  64. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  65. package/dist-cjs/review-card/render-leaf.js +52 -0
  66. package/dist-cjs/run/index.d.ts +36 -139
  67. package/dist-cjs/run/index.js +125 -444
  68. package/dist-cjs/run/schemas.d.ts +168 -1929
  69. package/dist-cjs/run/schemas.js +41 -270
  70. package/dist-cjs/{synth → simulate}/deny-cases.d.ts +4 -8
  71. package/dist-cjs/simulate/deny-cases.js +286 -0
  72. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  73. package/dist-cjs/simulate/evaluate.js +274 -0
  74. package/dist-cjs/simulate/index.d.ts +4 -0
  75. package/dist-cjs/simulate/index.js +13 -0
  76. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  77. package/dist-cjs/synth/compose-from-recording.js +120 -282
  78. package/dist-cjs/synth/index.d.ts +0 -5
  79. package/dist-cjs/synth/index.js +1 -11
  80. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  81. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  82. package/dist-cjs/types.d.ts +8 -60
  83. package/dist-cjs/types.js +6 -3
  84. package/package.json +5 -5
  85. package/src/adapters/interpreter/adapter.ts +76 -572
  86. package/src/errors.ts +0 -19
  87. package/src/index.ts +1 -4
  88. package/src/install/build-add-context-rule.ts +5 -139
  89. package/src/install/build-install-policy.ts +93 -214
  90. package/src/install/index.ts +0 -34
  91. package/src/predicate/decode.ts +2 -70
  92. package/src/predicate/encode.ts +49 -261
  93. package/src/predicate/from-json.ts +1 -43
  94. package/src/record/decode.ts +3 -8
  95. package/src/record/freshness.ts +29 -18
  96. package/src/review-card/builder.ts +19 -168
  97. package/src/review-card/cross-check.ts +3 -105
  98. package/src/review-card/render-leaf.ts +48 -0
  99. package/src/run/index.ts +144 -572
  100. package/src/run/schemas.ts +42 -291
  101. package/src/simulate/deny-cases.ts +334 -0
  102. package/src/simulate/evaluate.ts +284 -0
  103. package/src/simulate/index.ts +11 -0
  104. package/src/synth/compose-from-recording.ts +148 -347
  105. package/src/synth/index.ts +0 -13
  106. package/src/synth/synthesize-from-recording.ts +43 -456
  107. package/src/types.ts +13 -49
  108. package/dist/adapters/interpreter/index.d.ts +0 -1
  109. package/dist/adapters/interpreter/index.js +0 -2
  110. package/dist/adapters/oz/adapter.d.ts +0 -20
  111. package/dist/adapters/oz/adapter.js +0 -295
  112. package/dist/adapters/oz/index.d.ts +0 -1
  113. package/dist/adapters/oz/index.js +0 -2
  114. package/dist/codegen/compile-gate.d.ts +0 -33
  115. package/dist/codegen/compile-gate.js +0 -124
  116. package/dist/codegen/index.d.ts +0 -2
  117. package/dist/codegen/index.js +0 -8
  118. package/dist/codegen/template.d.ts +0 -18
  119. package/dist/codegen/template.js +0 -148
  120. package/dist/install/authority-overlap.d.ts +0 -134
  121. package/dist/install/authority-overlap.js +0 -0
  122. package/dist/install/build-install-predicate.d.ts +0 -96
  123. package/dist/install/build-install-predicate.js +0 -444
  124. package/dist/install/build-merge-policy.d.ts +0 -70
  125. package/dist/install/build-merge-policy.js +0 -130
  126. package/dist/install/plan-merge-policy.d.ts +0 -49
  127. package/dist/install/plan-merge-policy.js +0 -86
  128. package/dist/install/read-account-rules.d.ts +0 -100
  129. package/dist/install/read-account-rules.js +0 -283
  130. package/dist/ir/index.d.ts +0 -1
  131. package/dist/ir/index.js +0 -2
  132. package/dist/ir/types.d.ts +0 -140
  133. package/dist/ir/types.js +0 -11
  134. package/dist/mandate/index.d.ts +0 -2
  135. package/dist/mandate/index.js +0 -2
  136. package/dist/mandate/to-ir.d.ts +0 -3
  137. package/dist/mandate/to-ir.js +0 -60
  138. package/dist/mandate/types.d.ts +0 -20
  139. package/dist/mandate/types.js +0 -8
  140. package/dist/seams/index.d.ts +0 -1
  141. package/dist/seams/index.js +0 -2
  142. package/dist/seams/types.d.ts +0 -66
  143. package/dist/seams/types.js +0 -11
  144. package/dist/synth/deny-cases.js +0 -562
  145. package/dist/synth/evaluate.d.ts +0 -39
  146. package/dist/synth/evaluate.js +0 -551
  147. package/dist/synth/harness.d.ts +0 -28
  148. package/dist/synth/harness.js +0 -47
  149. package/dist/synth/minimize.d.ts +0 -4
  150. package/dist/synth/minimize.js +0 -38
  151. package/dist/synth/permit-context.d.ts +0 -15
  152. package/dist/synth/permit-context.js +0 -116
  153. package/dist/synth/predicate-literals.d.ts +0 -5
  154. package/dist/synth/predicate-literals.js +0 -25
  155. package/dist/synth/synthesize-from-mandate.d.ts +0 -20
  156. package/dist/synth/synthesize-from-mandate.js +0 -59
  157. package/dist/verify/envelope.d.ts +0 -15
  158. package/dist/verify/envelope.js +0 -22
  159. package/dist/verify/index.d.ts +0 -3
  160. package/dist/verify/index.js +0 -3
  161. package/dist/verify/simulate.d.ts +0 -31
  162. package/dist/verify/simulate.js +0 -258
  163. package/dist/verify/verify.d.ts +0 -21
  164. package/dist/verify/verify.js +0 -189
  165. package/dist-cjs/adapters/interpreter/index.d.ts +0 -1
  166. package/dist-cjs/adapters/interpreter/index.js +0 -8
  167. package/dist-cjs/adapters/oz/adapter.d.ts +0 -20
  168. package/dist-cjs/adapters/oz/adapter.js +0 -300
  169. package/dist-cjs/adapters/oz/index.d.ts +0 -1
  170. package/dist-cjs/adapters/oz/index.js +0 -8
  171. package/dist-cjs/codegen/compile-gate.d.ts +0 -33
  172. package/dist-cjs/codegen/compile-gate.js +0 -128
  173. package/dist-cjs/codegen/index.d.ts +0 -2
  174. package/dist-cjs/codegen/index.js +0 -14
  175. package/dist-cjs/codegen/template.d.ts +0 -18
  176. package/dist-cjs/codegen/template.js +0 -151
  177. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  178. package/dist-cjs/install/authority-overlap.js +0 -0
  179. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  180. package/dist-cjs/install/build-install-predicate.js +0 -479
  181. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  182. package/dist-cjs/install/build-merge-policy.js +0 -134
  183. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  184. package/dist-cjs/install/plan-merge-policy.js +0 -90
  185. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  186. package/dist-cjs/install/read-account-rules.js +0 -296
  187. package/dist-cjs/ir/index.d.ts +0 -1
  188. package/dist-cjs/ir/index.js +0 -3
  189. package/dist-cjs/ir/types.d.ts +0 -140
  190. package/dist-cjs/ir/types.js +0 -12
  191. package/dist-cjs/mandate/index.d.ts +0 -2
  192. package/dist-cjs/mandate/index.js +0 -6
  193. package/dist-cjs/mandate/to-ir.d.ts +0 -3
  194. package/dist-cjs/mandate/to-ir.js +0 -63
  195. package/dist-cjs/mandate/types.d.ts +0 -20
  196. package/dist-cjs/mandate/types.js +0 -9
  197. package/dist-cjs/seams/index.d.ts +0 -1
  198. package/dist-cjs/seams/index.js +0 -3
  199. package/dist-cjs/seams/types.d.ts +0 -66
  200. package/dist-cjs/seams/types.js +0 -12
  201. package/dist-cjs/synth/deny-cases.js +0 -568
  202. package/dist-cjs/synth/evaluate.d.ts +0 -39
  203. package/dist-cjs/synth/evaluate.js +0 -554
  204. package/dist-cjs/synth/harness.d.ts +0 -28
  205. package/dist-cjs/synth/harness.js +0 -50
  206. package/dist-cjs/synth/minimize.d.ts +0 -4
  207. package/dist-cjs/synth/minimize.js +0 -41
  208. package/dist-cjs/synth/permit-context.d.ts +0 -15
  209. package/dist-cjs/synth/permit-context.js +0 -119
  210. package/dist-cjs/synth/predicate-literals.d.ts +0 -5
  211. package/dist-cjs/synth/predicate-literals.js +0 -28
  212. package/dist-cjs/synth/synthesize-from-mandate.d.ts +0 -20
  213. package/dist-cjs/synth/synthesize-from-mandate.js +0 -62
  214. package/dist-cjs/verify/envelope.d.ts +0 -15
  215. package/dist-cjs/verify/envelope.js +0 -23
  216. package/dist-cjs/verify/index.d.ts +0 -3
  217. package/dist-cjs/verify/index.js +0 -8
  218. package/dist-cjs/verify/simulate.d.ts +0 -31
  219. package/dist-cjs/verify/simulate.js +0 -261
  220. package/dist-cjs/verify/verify.d.ts +0 -21
  221. package/dist-cjs/verify/verify.js +0 -192
  222. package/src/adapters/interpreter/index.ts +0 -8
  223. package/src/adapters/oz/adapter.ts +0 -376
  224. package/src/adapters/oz/index.ts +0 -9
  225. package/src/codegen/compile-gate.ts +0 -167
  226. package/src/codegen/index.ts +0 -17
  227. package/src/codegen/template.ts +0 -165
  228. package/src/install/authority-overlap.ts +0 -0
  229. package/src/install/build-merge-policy.ts +0 -219
  230. package/src/install/plan-merge-policy.ts +0 -133
  231. package/src/install/read-account-rules.ts +0 -376
  232. package/src/ir/index.ts +0 -13
  233. package/src/ir/types.ts +0 -132
  234. package/src/mandate/index.ts +0 -4
  235. package/src/mandate/to-ir.ts +0 -71
  236. package/src/mandate/types.ts +0 -21
  237. package/src/seams/index.ts +0 -11
  238. package/src/seams/types.ts +0 -81
  239. package/src/synth/deny-cases.ts +0 -663
  240. package/src/synth/evaluate.ts +0 -613
  241. package/src/synth/harness.ts +0 -68
  242. package/src/synth/minimize.ts +0 -48
  243. package/src/synth/permit-context.ts +0 -136
  244. package/src/synth/predicate-literals.ts +0 -27
  245. package/src/synth/synthesize-from-mandate.ts +0 -82
  246. package/src/verify/envelope.ts +0 -28
  247. package/src/verify/index.ts +0 -5
  248. package/src/verify/simulate.ts +0 -311
  249. 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':
@@ -1,10 +1,5 @@
1
1
  export { isStellarAddress } from './address.ts';
2
2
  export { type ComposeOptions, type ComposeResult, composeFromRecording, } from './compose-from-recording.ts';
3
- export { type DenyCase, type GeneratedCases, generateCases, } from './deny-cases.ts';
4
- export { type EvalContext, type EvalResult, evaluate } from './evaluate.ts';
5
- export { type HarnessFailure, type HarnessResult, runHarness, } from './harness.ts';
6
3
  export { type IntentFacts, lower } from './lower.ts';
7
- export { minimize } from './minimize.ts';
8
4
  export { type DecideScopeOptions, decideScope, type ScopeDecision, scopeToContextRuleType, } from './scope.ts';
9
- export { synthesizeFromMandate } from './synthesize-from-mandate.ts';
10
5
  export { type __TestInterpreterAdapterOptions, type SynthesizeFromRecordingOptions, synthesizeFromRecording, } from './synthesize-from-recording.ts';
@@ -4,11 +4,6 @@
4
4
  // and re-deriving it elsewhere means a second address check that can drift.
5
5
  export { isStellarAddress } from "./address.js";
6
6
  export { composeFromRecording, } from "./compose-from-recording.js";
7
- export { generateCases, } from "./deny-cases.js";
8
- export { evaluate } from "./evaluate.js";
9
- export { runHarness, } from "./harness.js";
10
7
  export { lower } from "./lower.js";
11
- export { minimize } from "./minimize.js";
12
8
  export { decideScope, scopeToContextRuleType, } from "./scope.js";
13
- export { synthesizeFromMandate } from "./synthesize-from-mandate.js";
14
9
  export { synthesizeFromRecording, } from "./synthesize-from-recording.js";