@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,85 +1,36 @@
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
 
41
- import type { IRCompOp, IRCondition, IRPolicyRule, PolicyIR } from '../ir/types.ts'
42
20
  import { type IdentifiedProtocol, identifyProtocol } from '../registry/identify.ts'
43
- import type { AmbiguityPrompt, ContractInvocation, Network } from '../types.ts'
21
+ import { getAbi } from '../registry/protocols.ts'
22
+ import type { AmbiguityPrompt, ContractInvocation, Network, PredicateNode } from '../types.ts'
44
23
  import type { IntentFacts } from './lower.ts'
45
24
 
46
- /** Per-asset oracle-price bound supplied by the caller (e.g. swap allowed only
47
- * if oracle_price(XLM) < 5.00 USDC). One entry per asset; the recorder never
48
- * fabricates a price bound from a slippage value (different units). */
49
- export interface OraclePriceBound {
50
- asset: string
51
- operator: IRCompOp
52
- value: string
53
- /** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
54
- * 9 dp, and a threshold silently assumed to share that basis is what let a
55
- * raw 14-dp bound permit everything. The author states it; we convert. */
56
- decimals: number
57
- }
58
-
59
25
  /** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
60
26
  * synth might apply must come from here - the recording supplies observed
61
27
  * amounts (offered only as suggestions), never authorised ceilings. */
62
28
  export interface ComposeUserResponses {
63
- /** Rolling window (seconds) for a spending_limit / invocation_count. */
64
- windowSeconds?: number
65
29
  /** OZ context-rule expiry (ledger sequence). */
66
30
  validUntilLedger?: number
67
- /** Per-window spend ceiling (i128 decimal string). Required to emit a
68
- * spending_limit; absent -> AMOUNT_BOUND_MISSING. */
31
+ /** Per-call ceiling on the amount the call carries (i128 decimal string).
32
+ * Required to bound the amount argument; absent -> AMOUNT_BOUND_MISSING. */
69
33
  limitAmount?: string
70
- /** Max invocations per window for an incoming-only flow. Required to emit an
71
- * invocation_count bound; absent -> FREQUENCY_BOUND_MISSING. */
72
- invocationLimit?: number
73
- /** Per-asset oracle-price bound(s). Each entry lowers to a single
74
- * `oracle_price(asset) OP value` compare in the interpreter IR. */
75
- oraclePriceBound?: OraclePriceBound[]
76
- /** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
77
- * `{num:'95',den:'100'}` = accept losing at most 5%). REQUIRED to be
78
- * supplied by the caller: never derived from the recording (the recorded
79
- * in/out pair is a price at one moment, and freezing it as policy would
80
- * deny ordinary trades as soon as the rate moves). Absent, no floor is
81
- * emitted and the existing unbounded-output warning stands. */
82
- swapMinOutRatio?: { num: string; den: string }
83
34
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
84
35
  * swap_exact_tokens_for_tokens). When supplied, REPLACES the default pin.
85
36
  * Absent -> recipient is pinned to the recorded value (mirroring SEP-41)
@@ -92,31 +43,31 @@ export interface ComposeUserResponses {
92
43
  export interface ComposeOptions {
93
44
  network: Network
94
45
  userResponses?: ComposeUserResponses
95
- /** When true, constraints the OZ adapter cannot lower are routed to
96
- * `interpreterIr` (the predicate-shape IR) so the orchestrator can compile
97
- * them via the interpreter adapter. When false (the default for callers
98
- * who have not opted in), every constraint goes to `ir` and the OZ
99
- * adapter's `uncovered` machinery generates the descriptive warnings -
100
- * today's behaviour. The orchestrator passes this flag through based on
101
- * whether `opts.interpreter` was supplied. */
102
- interpreterEnabled?: boolean
103
46
  }
104
47
 
105
- /** Result of composition: the OZ-shape PolicyIR, the predicate-shape PolicyIR
106
- * (contains the constraints the OZ adapter cannot lower; empty when
107
- * `interpreterEnabled` is false), any ambiguities surfaced during inference,
108
- * and descriptive warnings for needs that are NOT expressed as an IR node
109
- * (so no fabricated constraint is emitted). The orchestrator carries
110
- * ambiguities into `ProposedPolicy.ambiguities` and merges warnings into
48
+ /** One composed rule: what the call must be scoped to, the constraints on it,
49
+ * and how long the resulting context rule lives. The constraints are already
50
+ * predicate nodes - there is one enforcement backend, so there is nothing to
51
+ * translate between. */
52
+ export interface ComposedRule {
53
+ scope: { contract?: string; method?: string }
54
+ constraints: PredicateNode[]
55
+ expiry?: { validUntilLedger?: number }
56
+ }
57
+
58
+ /** Result of composition: the composed rule the interpreter compiler
59
+ * compiles, the ambiguities the caller must answer, and descriptive warnings
60
+ * for needs that are NOT expressed as an IR node (so no fabricated constraint
61
+ * is emitted). The orchestrator carries ambiguities into
62
+ * `ProposedPolicy.ambiguities` and merges warnings into
111
63
  * `ProposedPolicy.warnings`. */
112
64
  export interface ComposeResult {
113
- ir: PolicyIR
114
- interpreterIr: PolicyIR
65
+ interpreterRule: ComposedRule
115
66
  ambiguities: AmbiguityPrompt[]
116
67
  warnings: string[]
117
68
  }
118
69
 
119
- /** Compose a PolicyIR pair from the lowered facts + the resolved scope.
70
+ /** Compose a rule from the lowered facts + the resolved scope.
120
71
  * Pure (no randomness, no clock); same inputs -> byte-identical result. */
121
72
  export function composeFromRecording(
122
73
  facts: IntentFacts,
@@ -124,185 +75,106 @@ export function composeFromRecording(
124
75
  topLevel: ContractInvocation | null,
125
76
  opts: ComposeOptions
126
77
  ): ComposeResult {
127
- const interpreterEnabled = opts.interpreterEnabled === true
128
78
  const ambiguities: AmbiguityPrompt[] = []
129
79
  const warnings: string[] = []
130
- const ozConstraints: IRCondition[] = []
131
- const interpreterConstraints: IRCondition[] = []
132
- // Route to the matching IR. When the interpreter is enabled, constraints the
133
- // OZ adapter cannot lower go there; otherwise they go to OZ (which flags them
134
- // as uncovered) so today's warning-driven behaviour is preserved.
135
- const routeToAdapter = (cond: IRCondition): void => {
136
- if (interpreterEnabled) interpreterConstraints.push(cond)
137
- else ozConstraints.push(cond)
138
- }
80
+ const interpreterConstraints: PredicateNode[] = []
139
81
 
140
82
  const protocol = topLevel
141
83
  ? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
142
84
  : null
143
85
  const known = protocol !== null
144
86
 
145
- const windowSeconds = opts.userResponses?.windowSeconds
146
87
  const limitAmount = opts.userResponses?.limitAmount
147
88
  const spendTokens = Object.keys(facts.spendByToken)
148
89
 
149
- // Outgoing spend -> one spending_limit per spent token. A single caller
150
- // limit binds only an unambiguous single-token spend; a multi-token flow
151
- // needs a per-token limit, so each unmatched token surfaces
152
- // AMOUNT_BOUND_MISSING.
153
- //
154
- // Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
155
- // only when token === scope.contract (OZ's spending_limit binds the
156
- // CallContract target, not a token parameter). Otherwise it goes to the
157
- // interpreter IR.
90
+ // Outgoing spend -> a cap on the call's own amount ARGUMENT. The interpreter
91
+ // is passed one authorised call, not the transaction's token movements, so a
92
+ // rolling per-window total is not something it can read; the enforceable
93
+ // shape is a bound on the amount the call itself carries. A multi-token flow
94
+ // has no single argument to bind, so each token surfaces AMOUNT_BOUND_MISSING.
158
95
  if (spendTokens.length > 0 && topLevel) {
159
- let durationFlagged = false
160
96
  for (const token of spendTokens) {
161
97
  const observed = facts.spendByToken[token]
162
98
  if (observed === undefined) continue
163
99
 
164
100
  if (!known) {
165
101
  warnings.push(
166
- `spend of ${observed} (token ${token}) not bounded: unrecognised protocol, spend cap needs the interpreter predicate`
102
+ `spend of ${observed} (token ${token}) not bounded: unrecognised protocol, so the amount argument cannot be located`
167
103
  )
168
104
  continue
169
105
  }
170
106
 
171
107
  const limit = spendTokens.length === 1 ? limitAmount : undefined
172
- if (windowSeconds === undefined && !durationFlagged) {
173
- ambiguities.push({
174
- code: 'DURATION_UNSPECIFIED',
175
- question: `Recording shows a ${observed}-${token} spend. What rolling window (seconds) should the spending_limit use?`,
176
- })
177
- durationFlagged = true
178
- }
179
- if (limit === undefined) {
108
+ const amountArgIndex = limitArgumentIndex(protocol, topLevel)
109
+ if (limit === undefined || amountArgIndex === null) {
180
110
  ambiguities.push({
181
111
  code: 'AMOUNT_BOUND_MISSING',
182
- 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})`,
112
+ 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})`,
183
113
  })
184
114
  continue
185
115
  }
186
- if (windowSeconds !== undefined) {
187
- // Rolling spend cap always goes to OZ (`spending_limit` is the audited
188
- // implementation). The interpreter is NOT a fallback for token !=
189
- // scopeContract: on chain it sees one authorized call, not the
190
- // transaction's token movements, so it has no per-call amount to
191
- // accumulate and the counter would never move. OZ reports the case it
192
- // cannot cover (limit pins to the context contract) - the honest
193
- // outcome; the old fallback produced an interpreter predicate that
194
- // silently never bound.
195
- ozConstraints.push({
196
- op: 'compare',
197
- compare: {
198
- selector: { kind: 'window_spent', token, windowSeconds },
199
- operator: 'lte',
200
- value: limit,
201
- },
202
- })
203
- if (interpreterEnabled && token !== scopeContract) {
204
- warnings.push(
205
- `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.`
206
- )
207
- }
208
- }
116
+ interpreterConstraints.push({
117
+ op: 'lte',
118
+ left: { kind: 'call_arg', index: amountArgIndex },
119
+ right: { kind: 'literal_i128', value: limit },
120
+ })
209
121
  }
210
122
  }
211
123
 
212
- // Incoming-only / frequency intent: an invocation_count bound is emitted ONLY
213
- // when the caller supplies the count AND a window - never a fabricated `<= 1`.
214
- // Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
215
- // invocation_count); otherwise to the OZ IR (which flags it as uncovered).
124
+ // Incoming-only / frequency intent: the interpreter is passed one authorised
125
+ // call, so it cannot count prior calls - no frequency bound is emitted and
126
+ // never a fabricated `<= 1`. The need surfaces as a prompt plus a warning.
216
127
  //
217
128
  // A recognised swap is NOT an incoming-only flow: it has an outgoing input
218
129
  // leg whose spend simply was not attributed to the source account
219
130
  // (fee-sponsored / holder != source). Its real restrictions - exact path,
220
131
  // recipient, input-amount cap - come from the protocol-specific pass, so it
221
- // does NOT get the incoming-only frequency prompt. A caller wanting to
222
- // rate-limit the swap can still supply an invocationLimit + window, which
223
- // lowers to an invocation_count for any flow.
132
+ // does NOT get the incoming-only frequency prompt.
224
133
  if (spendTokens.length === 0 && topLevel) {
225
- const invocationLimit = opts.userResponses?.invocationLimit
226
134
  const isRecognisedSwap = protocol?.protocol === 'soroswap'
227
- if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
228
- routeToAdapter({
229
- op: 'compare',
230
- compare: {
231
- selector: { kind: 'invocation_count', windowSeconds },
232
- // `lt`, not `lte`: the leaf reports the calls ALREADY made in the
233
- // window, so `< N` is what permits N of them. With `lte` a limit
234
- // of N let an N+1th call through.
235
- operator: 'lt',
236
- value: String(invocationLimit),
237
- },
238
- })
239
- } else if (!isRecognisedSwap) {
135
+ if (!isRecognisedSwap) {
240
136
  ambiguities.push({
241
137
  code: 'FREQUENCY_BOUND_MISSING',
242
- question: 'Incoming-only flow - what max invocations per window should the policy enforce?',
138
+ question:
139
+ '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.',
243
140
  })
244
141
  warnings.push(
245
- 'frequency bound needed for the incoming-only flow (needs the interpreter predicate); no invocation cap inferred'
142
+ 'incoming-only flow: call frequency is NOT bounded - the interpreter reads only the authorized call, so it cannot count prior calls'
246
143
  )
247
144
  }
248
145
  }
249
146
 
250
- // Per-asset oracle-price bound(s) supplied by the caller -> one
251
- // oracle_price compare per entry. Routed to the interpreter IR when enabled;
252
- // otherwise to the OZ IR (which flags it as uncovered).
253
- const oracleBounds = opts.userResponses?.oraclePriceBound
254
- if (oracleBounds) {
255
- for (const b of oracleBounds) {
256
- routeToAdapter({
257
- op: 'compare',
258
- compare: {
259
- selector: { kind: 'oracle_price', asset: b.asset },
260
- operator: b.operator,
261
- value: b.value,
262
- valueDecimals: b.decimals,
263
- },
264
- })
265
- }
266
- }
267
-
268
- // Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
269
- // adapter flags as not covered; the interpreter adapter lowers it to an `in`
270
- // predicate. Unknown protocols emit nothing here. SoroSwap's swap recipient
271
- // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
147
+ // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
148
+ // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
149
+ // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
150
+ // the swapRecipientAllowlist surface.
272
151
  if (topLevel && protocol !== null) {
273
- // A SoroSwap input-amount cap binds the caller's limitAmount to call_arg[0]
274
- // (the exact amount_in) ONLY when no cumulative outgoing spend was detected
275
- // for the source account - i.e. the input token never moved FROM the source
152
+ // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
153
+ // input-amount argument ONLY when no outgoing spend was detected for the
154
+ // source account - i.e. the input token never moved FROM the source
276
155
  // (fee-sponsored swaps, or a holder != source). When a spend WAS detected,
277
- // the window_spent path above already consumed the limit, so the per-call
278
- // arg cap is skipped to avoid binding one limit to two different semantics.
156
+ // the cap above already consumed the limit, so this one is skipped to avoid
157
+ // binding one limit to two different args.
279
158
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined
280
159
  appendProtocolSpecificConstraints(
281
160
  interpreterConstraints,
282
- routeToAdapter,
283
161
  warnings,
284
162
  ambiguities,
285
163
  facts,
286
164
  topLevel,
287
165
  protocol,
288
166
  opts.userResponses?.swapRecipientAllowlist,
289
- swapInputAmountCap,
290
- opts.userResponses?.swapMinOutRatio,
291
- interpreterEnabled
167
+ swapInputAmountCap
292
168
  )
293
169
  }
294
170
 
295
- // `scope.method` is carried on BOTH IRs so each adapter produces a
296
- // self-consistent rule. The interpreter adapter lowers scope.method into a
297
- // `call_fn == <method>` predicate leaf (a real restriction); the OZ adapter
298
- // flags it as uncovered (CallContract alone permits any method on the
299
- // contract).
300
- const scope: IRPolicyRule['scope'] = { contract: scopeContract }
171
+ // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
172
+ // predicate leaf.
173
+ const scope: ComposedRule['scope'] = { contract: scopeContract }
301
174
  if (topLevel?.fn) scope.method = topLevel.fn
302
175
 
303
- const buildRule = (constraints: IRCondition[]): IRPolicyRule => {
304
- const rule: IRPolicyRule = {
305
- roles: [],
176
+ const buildRule = (constraints: PredicateNode[]): ComposedRule => {
177
+ const rule: ComposedRule = {
306
178
  scope: { ...scope },
307
179
  constraints,
308
180
  }
@@ -312,52 +184,58 @@ export function composeFromRecording(
312
184
  return rule
313
185
  }
314
186
 
315
- const ir: PolicyIR = {
316
- chain: 'stellar',
317
- defaultBehavior: 'deny_all',
318
- rules: [buildRule(ozConstraints)],
319
- }
320
- const interpreterIr: PolicyIR = {
321
- chain: 'stellar',
322
- defaultBehavior: 'deny_all',
323
- rules: [buildRule(interpreterConstraints)],
324
- }
325
- return { ir, interpreterIr, ambiguities, warnings }
187
+ return { interpreterRule: buildRule(interpreterConstraints), ambiguities, warnings }
188
+ }
189
+
190
+ /** Index of the argument a caller-supplied `limitAmount` binds to, or null when
191
+ * the call carries no such argument. The interpreter is passed one authorised
192
+ * call, not the transaction's token movements, so bounding an argument the
193
+ * call itself carries is the only enforceable shape. */
194
+ function limitArgumentIndex(
195
+ protocol: IdentifiedProtocol | null,
196
+ topLevel: ContractInvocation | null
197
+ ): number | null {
198
+ if (!protocol || !topLevel) return null
199
+ const named = amountArgumentIndex(protocol)
200
+ if (named !== null) return named
201
+ // SoroSwap names its input argument `amount_in` / `amount_in_max`, so the
202
+ // lookup by name misses it and the index is function-specific.
203
+ if (protocol.protocol !== 'soroswap') return null
204
+ const i = soroswapInputAmountArgIndex(protocol.fn)
205
+ // Defense in depth: identification's argsMatchAbi already pins it to i128.
206
+ return i !== undefined && topLevel.args[i]?.type === 'i128' ? i : null
326
207
  }
327
208
 
328
- /** Add the constraints justified by the recording that the OZ backend cannot
329
- * express natively. Each constraint is routed to either `ozConstraints` (the
330
- * OZ adapter lowers it) or `interpreterConstraints` (the interpreter adapter
331
- * lowers it). When `interpreterEnabled` is false, every protocol-specific
332
- * constraint is routed to `ozConstraints` (which flags it as uncovered) so
333
- * callers who haven't opted in keep today's warning-driven behaviour.
334
- * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
335
- * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
336
- * call_arg[2]). */
209
+ /** Index of the argument the protocol ABI calls `amount`, or null when the ABI
210
+ * does not name one. */
211
+ function amountArgumentIndex(protocol: IdentifiedProtocol): number | null {
212
+ const abi = getAbi(protocol.protocol)[protocol.fn]
213
+ if (!abi) return null
214
+ const i = abi.args.findIndex((a) => a.name === 'amount')
215
+ return i >= 0 ? i : null
216
+ }
217
+
218
+ /** Add the protocol-specific constraints the recording justifies: recipient
219
+ * allowlists, exact swap paths and per-call argument caps. */
337
220
  function appendProtocolSpecificConstraints(
338
- interpreterConstraints: IRCondition[],
339
- routeToAdapter: (cond: IRCondition) => void,
221
+ interpreterConstraints: PredicateNode[],
340
222
  warnings: string[],
341
223
  ambiguities: AmbiguityPrompt[],
342
224
  facts: IntentFacts,
343
225
  topLevel: ContractInvocation,
344
226
  protocol: IdentifiedProtocol,
345
227
  swapRecipientAllowlist: string[] | undefined,
346
- swapInputAmountCap: string | undefined,
347
- swapMinOutRatio: { num: string; den: string } | undefined,
348
- interpreterEnabled: boolean
228
+ swapInputAmountCap: string | undefined
349
229
  ): void {
350
230
  // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit a
351
231
  // single-element allowlist; the interpreter adapter lowers it to `in`.
352
- // When interpreter is not enabled, route to OZ so the caller sees today's
353
- // `value allowlist on arg 1` warning.
354
232
  if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
355
233
  const toArg = topLevel.args[1]
356
234
  if (toArg && toArg.type === 'address') {
357
- routeToAdapter({
235
+ interpreterConstraints.push({
358
236
  op: 'in',
359
- selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
360
- values: [toArg.value],
237
+ needle: { kind: 'call_arg', index: 1 },
238
+ haystack: [{ kind: 'literal_address', value: toArg.value }],
361
239
  })
362
240
  }
363
241
  }
@@ -373,10 +251,10 @@ function appendProtocolSpecificConstraints(
373
251
  // claim(from, reserve_token_ids, to).
374
252
  const toArg = topLevel.args[2]
375
253
  if (toArg && toArg.type === 'address') {
376
- routeToAdapter({
254
+ interpreterConstraints.push({
377
255
  op: 'in',
378
- selector: { kind: 'arg', argIndex: 2, scalarType: 'address' },
379
- values: [toArg.value],
256
+ needle: { kind: 'call_arg', index: 2 },
257
+ haystack: [{ kind: 'literal_address', value: toArg.value }],
380
258
  })
381
259
  }
382
260
  }
@@ -391,17 +269,14 @@ function appendProtocolSpecificConstraints(
391
269
  // -> Borrow on a different asset, any amount, then auction fills). Length
392
270
  // + per-element pinning is total; a quantifier over elements is not. If we
393
271
  // cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
394
- if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
272
+ if (protocol.protocol === 'blend' && protocol.fn === 'submit') {
395
273
  const requestsArg = topLevel.args[3]
396
274
  if (requestsArg && requestsArg.type === 'vec') {
397
275
  const elements = requestsArg.value
398
276
  interpreterConstraints.push({
399
- op: 'compare',
400
- compare: {
401
- selector: { kind: 'arg_len', argIndex: 3 },
402
- operator: 'eq',
403
- value: String(elements.length),
404
- },
277
+ op: 'eq',
278
+ left: { kind: 'call_arg_len', index: 3 },
279
+ right: { kind: 'literal_u32', value: elements.length },
405
280
  })
406
281
  for (let i = 0; i < elements.length; i++) {
407
282
  const element = elements[i]
@@ -445,46 +320,19 @@ function appendProtocolSpecificConstraints(
445
320
  continue
446
321
  }
447
322
  interpreterConstraints.push({
448
- op: 'compare',
449
- compare: {
450
- selector: {
451
- kind: 'arg_field',
452
- argIndex: 3,
453
- element: i,
454
- field: 'request_type',
455
- scalarType: 'u32',
456
- },
457
- operator: 'eq',
458
- value: requestType,
459
- },
323
+ op: 'eq',
324
+ left: { kind: 'call_arg_field', index: 3, element: i, field: 'request_type' },
325
+ right: { kind: 'literal_u32', value: Number.parseInt(requestType, 10) },
460
326
  })
461
327
  interpreterConstraints.push({
462
- op: 'compare',
463
- compare: {
464
- selector: {
465
- kind: 'arg_field',
466
- argIndex: 3,
467
- element: i,
468
- field: 'address',
469
- scalarType: 'address',
470
- },
471
- operator: 'eq',
472
- value: address,
473
- },
328
+ op: 'eq',
329
+ left: { kind: 'call_arg_field', index: 3, element: i, field: 'address' },
330
+ right: { kind: 'literal_address', value: address },
474
331
  })
475
332
  interpreterConstraints.push({
476
- op: 'compare',
477
- compare: {
478
- selector: {
479
- kind: 'arg_field',
480
- argIndex: 3,
481
- element: i,
482
- field: 'amount',
483
- scalarType: 'i128',
484
- },
485
- operator: 'lte',
486
- value: amount,
487
- },
333
+ op: 'lte',
334
+ left: { kind: 'call_arg_field', index: 3, element: i, field: 'amount' },
335
+ right: { kind: 'literal_i128', value: amount },
488
336
  })
489
337
  }
490
338
  }
@@ -499,18 +347,19 @@ function appendProtocolSpecificConstraints(
499
347
  if (protocol.protocol === 'soroswap') {
500
348
  const paths = facts.allowedPaths?.[topLevel.contract]
501
349
  const route = paths?.length === 1 ? paths[0] : undefined
502
- if (route && route.length > 0 && interpreterEnabled) {
350
+ if (route && route.length > 0) {
503
351
  const pathArgIndex = 2 // SoroSwap swap_exact_tokens_for_tokens: args = [amount_in, amount_out_min, path, to, deadline]
504
352
  interpreterConstraints.push({
505
- op: 'eq_seq',
506
- selector: { kind: 'arg', argIndex: pathArgIndex, scalarType: 'address' },
507
- values: route,
353
+ op: 'eq',
354
+ left: { kind: 'call_arg', index: pathArgIndex },
355
+ right: {
356
+ kind: 'literal_vec',
357
+ elements: route.map((v) => ({ kind: 'literal_address', value: v }) as const),
358
+ },
508
359
  })
509
360
  } else {
510
361
  const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : ''
511
- warnings.push(
512
- `SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate${pathText}`
513
- )
362
+ warnings.push(`SoroSwap swap: the exact hop path needs the interpreter predicate${pathText}`)
514
363
  }
515
364
 
516
365
  // Input-amount cap: bind the caller's limitAmount to the swap's input-amount
@@ -521,12 +370,10 @@ function appendProtocolSpecificConstraints(
521
370
  // as arg[1] (its arg[0] is the exact OUTPUT, so binding arg[0] there would
522
371
  // cap the wrong value and leave the input unbounded). This is a per-call cap
523
372
  // that does NOT depend on attributing a token movement to the source account
524
- // (the fee-sponsored / holder != source case, where the window_spent path
525
- // detects no spend). Fail-closed: it only ever restricts the permitted input.
526
- // Routed to the interpreter predicate (OZ built-ins cannot express a per-arg
527
- // i128 bound); when the interpreter is not enabled it goes to OZ, which flags
528
- // it uncovered (a warning). The `type === 'i128'` check is defense in depth -
529
- // protocol identification's argsMatchAbi already pins the input arg to i128.
373
+ // (the fee-sponsored / holder != source case, where no spend is detected).
374
+ // Fail-closed: it only ever restricts the permitted input. The
375
+ // `type === 'i128'` check is defense in depth - protocol identification's
376
+ // argsMatchAbi already pins the input arg to i128.
530
377
  const inputArgIndex = soroswapInputAmountArgIndex(protocol.fn)
531
378
  const inputAmountArg = inputArgIndex !== undefined ? topLevel.args[inputArgIndex] : undefined
532
379
  if (
@@ -535,34 +382,10 @@ function appendProtocolSpecificConstraints(
535
382
  inputAmountArg &&
536
383
  inputAmountArg.type === 'i128'
537
384
  ) {
538
- routeToAdapter({
539
- op: 'compare',
540
- compare: {
541
- selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
542
- operator: 'lte',
543
- value: swapInputAmountCap,
544
- },
545
- })
546
- }
547
-
548
- // Slippage floor: `out >= in * num/den`. Only when the caller supplied the
549
- // ratio - see `swapMinOutRatio`. Without it the output arg stays free,
550
- // which is the case the unbounded-swap warning above describes.
551
- const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn)
552
- const minOutRatio = swapMinOutRatio
553
- if (
554
- minOutRatio !== undefined &&
555
- inputArgIndex !== undefined &&
556
- outMinArgIndex !== undefined &&
557
- inputAmountArg &&
558
- inputAmountArg.type === 'i128'
559
- ) {
560
- routeToAdapter({
561
- op: 'slippage_floor',
562
- outArgIndex: outMinArgIndex,
563
- inArgIndex: inputArgIndex,
564
- num: minOutRatio.num,
565
- den: minOutRatio.den,
385
+ interpreterConstraints.push({
386
+ op: 'lte',
387
+ left: { kind: 'call_arg', index: inputArgIndex },
388
+ right: { kind: 'literal_i128', value: swapInputAmountCap },
566
389
  })
567
390
  }
568
391
 
@@ -574,22 +397,21 @@ function appendProtocolSpecificConstraints(
574
397
  // it unconstrained would permit an arbitrary recipient (an evil twin with
575
398
  // call_arg[3] = attacker_wallet). RECIPIENT_ALLOWLIST_EMPTY is still
576
399
  // surfaced, but as INFORMATIONAL (the recipient was pinned; here is how to
577
- // widen it), never as a silent free pass. The recipient is only enforceable
578
- // via the interpreter predicate, so the pin (and the ambiguity) apply only
579
- // when the interpreter is enabled - otherwise the swap recipient is ignored
580
- // (today's behaviour, matching the other SoroSwap constraints).
400
+ // widen it), never as a silent free pass.
581
401
  const recipientArg = topLevel.args[3]
582
402
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
583
- routeToAdapter({
403
+ interpreterConstraints.push({
584
404
  op: 'in',
585
- selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
586
- values: [...swapRecipientAllowlist],
405
+ needle: { kind: 'call_arg', index: 3 },
406
+ haystack: swapRecipientAllowlist.map(
407
+ (v) => ({ kind: 'literal_address', value: v }) as const
408
+ ),
587
409
  })
588
- } else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
410
+ } else if (recipientArg && recipientArg.type === 'address') {
589
411
  interpreterConstraints.push({
590
412
  op: 'in',
591
- selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
592
- values: [recipientArg.value],
413
+ needle: { kind: 'call_arg', index: 3 },
414
+ haystack: [{ kind: 'literal_address', value: recipientArg.value }],
593
415
  })
594
416
  ambiguities.push({
595
417
  code: 'RECIPIENT_ALLOWLIST_EMPTY',
@@ -599,27 +421,6 @@ function appendProtocolSpecificConstraints(
599
421
  }
600
422
  }
601
423
 
602
- /** Positional index of the input-amount argument for a recognized SoroSwap swap
603
- * function. `swap_exact_tokens_for_tokens` and `swap_exact_in_for_tokens` take
604
- * the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
605
- * input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
606
- * function has no positional input-amount argument -> undefined (no cap bound). */
607
- /** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
608
- *
609
- * Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
610
- * fixes the output and varies the input, so its output needs no floor (its
611
- * arg[1] is `amount_in_max`, already bounded by the input cap). Returning
612
- * undefined there keeps a floor from being pinned to the wrong argument. */
613
- function soroswapMinOutArgIndex(fn: string): number | undefined {
614
- switch (fn) {
615
- case 'swap_exact_tokens_for_tokens':
616
- case 'swap_exact_in_for_tokens':
617
- return 1
618
- default:
619
- return undefined
620
- }
621
- }
622
-
623
424
  function soroswapInputAmountArgIndex(fn: string): number | undefined {
624
425
  switch (fn) {
625
426
  case 'swap_exact_tokens_for_tokens':