@crediolabs/policy-synth 0.1.3 → 0.1.4

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 (82) hide show
  1. package/README.md +193 -5
  2. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  3. package/dist/adapters/interpreter/adapter.js +522 -0
  4. package/dist/adapters/interpreter/index.d.ts +1 -0
  5. package/dist/adapters/interpreter/index.js +2 -0
  6. package/dist/adapters/oz/adapter.js +2 -0
  7. package/dist/codegen/compile-gate.d.ts +33 -0
  8. package/dist/codegen/compile-gate.js +119 -0
  9. package/dist/codegen/index.d.ts +2 -0
  10. package/dist/codegen/index.js +8 -0
  11. package/dist/codegen/template.d.ts +18 -0
  12. package/dist/codegen/template.js +131 -0
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/ir/types.d.ts +13 -2
  17. package/dist/predicate/encode.d.ts +10 -0
  18. package/dist/predicate/encode.js +249 -0
  19. package/dist/predicate/index.d.ts +1 -0
  20. package/dist/predicate/index.js +2 -0
  21. package/dist/review-card/builder.d.ts +14 -0
  22. package/dist/review-card/builder.js +261 -0
  23. package/dist/review-card/conflict.d.ts +40 -0
  24. package/dist/review-card/conflict.js +111 -0
  25. package/dist/review-card/cross-check.d.ts +11 -0
  26. package/dist/review-card/cross-check.js +148 -0
  27. package/dist/review-card/index.d.ts +3 -0
  28. package/dist/review-card/index.js +4 -0
  29. package/dist/synth/compose-from-recording.d.ts +35 -7
  30. package/dist/synth/compose-from-recording.js +225 -34
  31. package/dist/synth/deny-cases.d.ts +12 -0
  32. package/dist/synth/deny-cases.js +361 -0
  33. package/dist/synth/evaluate.d.ts +39 -0
  34. package/dist/synth/evaluate.js +425 -0
  35. package/dist/synth/harness.d.ts +16 -0
  36. package/dist/synth/harness.js +26 -0
  37. package/dist/synth/index.d.ts +4 -0
  38. package/dist/synth/index.js +4 -0
  39. package/dist/synth/minimize.d.ts +4 -0
  40. package/dist/synth/minimize.js +38 -0
  41. package/dist/synth/predicate-literals.d.ts +5 -0
  42. package/dist/synth/predicate-literals.js +25 -0
  43. package/dist/synth/synthesize-from-recording.d.ts +32 -3
  44. package/dist/synth/synthesize-from-recording.js +488 -14
  45. package/dist/types.d.ts +23 -1
  46. package/dist/verify/envelope.d.ts +15 -0
  47. package/dist/verify/envelope.js +22 -0
  48. package/dist/verify/index.d.ts +3 -0
  49. package/dist/verify/index.js +3 -0
  50. package/dist/verify/simulate.d.ts +31 -0
  51. package/dist/verify/simulate.js +242 -0
  52. package/dist/verify/verify.d.ts +21 -0
  53. package/dist/verify/verify.js +173 -0
  54. package/package.json +1 -1
  55. package/src/adapters/interpreter/adapter.ts +642 -0
  56. package/src/adapters/interpreter/index.ts +8 -0
  57. package/src/adapters/oz/adapter.ts +2 -0
  58. package/src/codegen/compile-gate.ts +162 -0
  59. package/src/codegen/index.ts +17 -0
  60. package/src/codegen/template.ts +148 -0
  61. package/src/errors.ts +2 -0
  62. package/src/index.ts +2 -0
  63. package/src/ir/types.ts +9 -2
  64. package/src/predicate/encode.ts +307 -0
  65. package/src/predicate/index.ts +3 -0
  66. package/src/review-card/builder.ts +303 -0
  67. package/src/review-card/conflict.ts +143 -0
  68. package/src/review-card/cross-check.ts +158 -0
  69. package/src/review-card/index.ts +12 -0
  70. package/src/synth/compose-from-recording.ts +277 -43
  71. package/src/synth/deny-cases.ts +447 -0
  72. package/src/synth/evaluate.ts +493 -0
  73. package/src/synth/harness.ts +40 -0
  74. package/src/synth/index.ts +12 -0
  75. package/src/synth/minimize.ts +44 -0
  76. package/src/synth/predicate-literals.ts +27 -0
  77. package/src/synth/synthesize-from-recording.ts +572 -15
  78. package/src/types.ts +19 -2
  79. package/src/verify/envelope.ts +28 -0
  80. package/src/verify/index.ts +5 -0
  81. package/src/verify/simulate.ts +292 -0
  82. package/src/verify/verify.ts +224 -0
@@ -1,6 +1,6 @@
1
- // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR.
1
+ // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ-shape + interpreter-shape).
2
2
  //
3
- // Composes the canonical IR rule the OZ built-in-primitive backend can compile. Fail-closed
3
+ // Composes the canonical IR rules the two backends compile FROM. Fail-closed
4
4
  // composition rules:
5
5
  //
6
6
  // - identify the protocol of the top-level call (registry.identifyProtocol).
@@ -32,13 +32,36 @@
32
32
  // price fabricated from a slippage bound, no synthetic exact-path compare.
33
33
  // Those needs are surfaced as descriptive warnings instead.
34
34
  //
35
+ // Split rule (P3 wiring): `ComposeResult` carries BOTH `ir` (OZ-shape) and
36
+ // `interpreterIr` (predicate-shape). Each constraint is routed to EXACTLY ONE
37
+ // adapter:
38
+ // - `compare window_spent(token, w) <= limit` where `token === scope.contract`
39
+ // and the protocol is known -> `ir` (OZ lowers to spending_limit).
40
+ // - everything else the compose step emits (recipient allowlists, per-method
41
+ // scoping via scope.method, invocation_count bounds, eq_seq swap paths,
42
+ // oracle_price bounds, AND window_spent where token != scope.contract, i.e.
43
+ // a SoroSwap input-token cap) -> `interpreterIr`.
44
+ //
45
+ // This prevents the interpreter adapter from emitting a duplicate
46
+ // `window_spent` predicate leaf alongside an OZ `spending_limit` primitive
47
+ // covering the same spend semantic - the two adapters never overlap.
48
+ //
35
49
  // The default behavior is `deny_all` (OZ context rules are deny-by-default).
36
50
 
37
- import type { IRCondition, IRPolicyRule, PolicyIR } from '../ir/types.ts'
51
+ import type { IRCompOp, IRCondition, IRPolicyRule, PolicyIR } from '../ir/types.ts'
38
52
  import { type IdentifiedProtocol, identifyProtocol } from '../registry/identify.ts'
39
53
  import type { AmbiguityPrompt, ContractInvocation, Network } from '../types.ts'
40
54
  import type { IntentFacts } from './lower.ts'
41
55
 
56
+ /** Per-asset oracle-price bound supplied by the caller (e.g. swap allowed only
57
+ * if oracle_price(XLM) < 5.00 USDC). One entry per asset; the recorder never
58
+ * fabricates a price bound from a slippage value (different units). */
59
+ export interface OraclePriceBound {
60
+ asset: string
61
+ operator: IRCompOp
62
+ value: string
63
+ }
64
+
42
65
  /** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
43
66
  * synth might apply must come from here - the recording supplies observed
44
67
  * amounts (offered only as suggestions), never authorised ceilings. */
@@ -53,26 +76,46 @@ export interface ComposeUserResponses {
53
76
  /** Max invocations per window for an incoming-only flow. Required to emit an
54
77
  * invocation_count bound; absent -> FREQUENCY_BOUND_MISSING. */
55
78
  invocationLimit?: number
79
+ /** Per-asset oracle-price bound(s). Each entry lowers to a single
80
+ * `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
81
+ * entries on the same asset emit multiple leaves. */
82
+ oraclePriceBound?: OraclePriceBound[]
83
+ /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
84
+ * swap_exact_tokens_for_tokens). Absent -> RECIPIENT_ALLOWLIST_EMPTY
85
+ * ambiguity (the caller is prompted; proceeding without an allowlist leaves
86
+ * the recipient unconstrained). */
87
+ swapRecipientAllowlist?: string[]
56
88
  }
57
89
 
58
90
  /** Composition options. */
59
91
  export interface ComposeOptions {
60
92
  network: Network
61
93
  userResponses?: ComposeUserResponses
94
+ /** When true, constraints the OZ adapter cannot lower are routed to
95
+ * `interpreterIr` (the predicate-shape IR) so the orchestrator can compile
96
+ * them via the interpreter adapter. When false (the default for callers
97
+ * who have not opted in), every constraint goes to `ir` and the OZ
98
+ * adapter's `uncovered` machinery generates the descriptive warnings -
99
+ * today's behaviour. The orchestrator passes this flag through based on
100
+ * whether `opts.interpreter` was supplied. */
101
+ interpreterEnabled?: boolean
62
102
  }
63
103
 
64
- /** Result of composition: the PolicyIR, any ambiguities surfaced during
65
- * inference, and descriptive warnings for needs that are NOT expressed
66
- * as an IR node (so no fabricated constraint is emitted). The orchestrator
67
- * carries ambiguities into `ProposedPolicy.ambiguities` and merges warnings
68
- * into `ProposedPolicy.warnings`. */
104
+ /** Result of composition: the OZ-shape PolicyIR, the predicate-shape PolicyIR
105
+ * (contains the constraints the OZ adapter cannot lower; empty when
106
+ * `interpreterEnabled` is false), any ambiguities surfaced during inference,
107
+ * and descriptive warnings for needs that are NOT expressed as an IR node
108
+ * (so no fabricated constraint is emitted). The orchestrator carries
109
+ * ambiguities into `ProposedPolicy.ambiguities` and merges warnings into
110
+ * `ProposedPolicy.warnings`. */
69
111
  export interface ComposeResult {
70
112
  ir: PolicyIR
113
+ interpreterIr: PolicyIR
71
114
  ambiguities: AmbiguityPrompt[]
72
115
  warnings: string[]
73
116
  }
74
117
 
75
- /** Compose a PolicyIR from the lowered facts + the resolved scope.
118
+ /** Compose a PolicyIR pair from the lowered facts + the resolved scope.
76
119
  * Pure (no randomness, no clock); same inputs -> byte-identical result. */
77
120
  export function composeFromRecording(
78
121
  facts: IntentFacts,
@@ -80,9 +123,11 @@ export function composeFromRecording(
80
123
  topLevel: ContractInvocation | null,
81
124
  opts: ComposeOptions
82
125
  ): ComposeResult {
126
+ const interpreterEnabled = opts.interpreterEnabled === true
83
127
  const ambiguities: AmbiguityPrompt[] = []
84
128
  const warnings: string[] = []
85
- const constraints: IRCondition[] = []
129
+ const ozConstraints: IRCondition[] = []
130
+ const interpreterConstraints: IRCondition[] = []
86
131
 
87
132
  const protocol = topLevel
88
133
  ? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
@@ -96,6 +141,11 @@ export function composeFromRecording(
96
141
  // Outgoing spend -> one spending_limit per spent token. A single caller limit
97
142
  // binds only an unambiguous single-token spend; a multi-token flow needs a
98
143
  // per-token limit, so each unmatched token surfaces AMOUNT_BOUND_MISSING.
144
+ //
145
+ // Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
146
+ // only when token === scope.contract (OZ's spending_limit binds the
147
+ // CallContract target, not a token parameter). Otherwise it goes to the
148
+ // interpreter IR (the interpreter lowers window_spent to a predicate leaf).
99
149
  if (spendTokens.length > 0 && topLevel) {
100
150
  let durationFlagged = false
101
151
  for (const token of spendTokens) {
@@ -124,33 +174,54 @@ export function composeFromRecording(
124
174
  })
125
175
  continue
126
176
  }
127
- if (windowSeconds !== undefined) {
128
- constraints.push({
177
+ if (windowSeconds !== undefined && limit !== undefined) {
178
+ const spendCond: IRCondition = {
129
179
  op: 'compare',
130
180
  compare: {
131
181
  selector: { kind: 'window_spent', token, windowSeconds },
132
182
  operator: 'lte',
133
183
  value: limit,
134
184
  },
135
- })
185
+ }
186
+ if (!interpreterEnabled || token === scopeContract) {
187
+ ozConstraints.push(spendCond)
188
+ } else {
189
+ interpreterConstraints.push(spendCond)
190
+ }
136
191
  }
137
192
  }
138
193
  }
139
194
 
140
195
  // Incoming-only / frequency intent: an invocation_count bound is emitted ONLY
141
196
  // when the caller supplies the count AND a window - never a fabricated `<= 1`.
197
+ // Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
198
+ // invocation_count); otherwise to the OZ IR (which flags it as uncovered).
199
+ //
200
+ // A recognised swap is NOT an incoming-only flow: it has an outgoing input leg
201
+ // whose spend simply was not attributed to the source account (the
202
+ // fee-sponsored / holder != source case). Its real restrictions - exact path,
203
+ // recipient, and the input-amount cap - come from the protocol-specific pass,
204
+ // so it does NOT get the incoming-only frequency prompt. A caller who wants to
205
+ // rate-limit the swap can still supply an invocationLimit + window, which
206
+ // lowers to an invocation_count for any flow.
142
207
  if (spendTokens.length === 0 && topLevel) {
143
208
  const invocationLimit = opts.userResponses?.invocationLimit
209
+ const isRecognisedSwap = protocol?.protocol === 'soroswap'
144
210
  if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
145
- constraints.push({
211
+ const icCond: IRCondition = {
146
212
  op: 'compare',
147
213
  compare: {
148
214
  selector: { kind: 'invocation_count', windowSeconds },
149
215
  operator: 'lte',
150
216
  value: String(invocationLimit),
151
217
  },
152
- })
153
- } else {
218
+ }
219
+ if (interpreterEnabled) {
220
+ interpreterConstraints.push(icCond)
221
+ } else {
222
+ ozConstraints.push(icCond)
223
+ }
224
+ } else if (!isRecognisedSwap) {
154
225
  ambiguities.push({
155
226
  code: 'FREQUENCY_BOUND_MISSING',
156
227
  question: 'Incoming-only flow - what max invocations per window should the policy enforce?',
@@ -161,66 +232,229 @@ export function composeFromRecording(
161
232
  }
162
233
  }
163
234
 
235
+ // Per-asset oracle-price bound(s) supplied by the caller -> one
236
+ // oracle_price compare per entry. Routed to the interpreter IR when enabled;
237
+ // otherwise to the OZ IR (which flags it as uncovered).
238
+ const oracleBounds = opts.userResponses?.oraclePriceBound
239
+ if (oracleBounds) {
240
+ for (const b of oracleBounds) {
241
+ const oracleCond: IRCondition = {
242
+ op: 'compare',
243
+ compare: {
244
+ selector: { kind: 'oracle_price', asset: b.asset },
245
+ operator: b.operator,
246
+ value: b.value,
247
+ },
248
+ }
249
+ if (interpreterEnabled) {
250
+ interpreterConstraints.push(oracleCond)
251
+ } else {
252
+ ozConstraints.push(oracleCond)
253
+ }
254
+ }
255
+ }
256
+
164
257
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
165
- // adapter flags as not covered. Unknown protocols emit nothing here.
258
+ // adapter flags as not covered; the interpreter adapter lowers it to an `in`
259
+ // predicate. Unknown protocols emit nothing here. SoroSwap's swap recipient
260
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
166
261
  if (topLevel && protocol !== null) {
167
- appendProtocolSpecificConstraints(constraints, warnings, facts, topLevel, protocol)
262
+ // A SoroSwap input-amount cap binds the caller's limitAmount to call_arg[0]
263
+ // (the exact amount_in) ONLY when no cumulative outgoing spend was detected
264
+ // for the source account - i.e. the input token never moved FROM the source
265
+ // (fee-sponsored swaps, or a holder != source). When a spend WAS detected,
266
+ // the window_spent path above already consumed the limit, so the per-call
267
+ // arg cap is skipped to avoid binding one limit to two different semantics.
268
+ const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined
269
+ appendProtocolSpecificConstraints(
270
+ ozConstraints,
271
+ interpreterConstraints,
272
+ warnings,
273
+ ambiguities,
274
+ facts,
275
+ topLevel,
276
+ protocol,
277
+ opts.userResponses?.swapRecipientAllowlist,
278
+ swapInputAmountCap,
279
+ interpreterEnabled
280
+ )
168
281
  }
169
282
 
283
+ // `scope.method` is carried on BOTH IRs so each adapter produces a
284
+ // self-consistent rule. The interpreter adapter lowers scope.method into a
285
+ // `call_fn == <method>` predicate leaf (a real restriction); the OZ adapter
286
+ // flags it as uncovered (CallContract alone permits any method on the
287
+ // contract).
170
288
  const scope: IRPolicyRule['scope'] = { contract: scopeContract }
171
289
  if (topLevel?.fn) scope.method = topLevel.fn
172
290
 
173
- const rule: IRPolicyRule = {
174
- roles: [],
175
- scope,
176
- constraints,
177
- }
178
- if (opts.userResponses?.validUntilLedger !== undefined) {
179
- rule.expiry = { validUntilLedger: opts.userResponses.validUntilLedger }
291
+ const buildRule = (constraints: IRCondition[]): IRPolicyRule => {
292
+ const rule: IRPolicyRule = {
293
+ roles: [],
294
+ scope: { ...scope },
295
+ constraints,
296
+ }
297
+ if (opts.userResponses?.validUntilLedger !== undefined) {
298
+ rule.expiry = { validUntilLedger: opts.userResponses.validUntilLedger }
299
+ }
300
+ return rule
180
301
  }
181
302
 
182
303
  const ir: PolicyIR = {
183
304
  chain: 'stellar',
184
305
  defaultBehavior: 'deny_all',
185
- rules: [rule],
306
+ rules: [buildRule(ozConstraints)],
307
+ }
308
+ const interpreterIr: PolicyIR = {
309
+ chain: 'stellar',
310
+ defaultBehavior: 'deny_all',
311
+ rules: [buildRule(interpreterConstraints)],
186
312
  }
187
- return { ir, ambiguities, warnings }
313
+ return { ir, interpreterIr, ambiguities, warnings }
188
314
  }
189
315
 
190
316
  /** Add the constraints justified by the recording that the OZ backend cannot
191
- * express natively (the SEP-41 recipient the transfer moved to). The OZ adapter
192
- * flags them as not covered. SoroSwap's slippage / oracle / exact-path needs are
193
- * surfaced as descriptive warnings, NOT as fabricated comparison nodes. */
317
+ * express natively. Each constraint is routed to either `ozConstraints` (the
318
+ * OZ adapter lowers it) or `interpreterConstraints` (the interpreter adapter
319
+ * lowers it). When `interpreterEnabled` is false, every protocol-specific
320
+ * constraint is routed to `ozConstraints` (which flags it as uncovered) so
321
+ * callers who haven't opted in keep today's warning-driven behaviour.
322
+ * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
323
+ * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
324
+ * call_arg[2]). */
194
325
  function appendProtocolSpecificConstraints(
195
- constraints: IRCondition[],
326
+ ozConstraints: IRCondition[],
327
+ interpreterConstraints: IRCondition[],
196
328
  warnings: string[],
329
+ ambiguities: AmbiguityPrompt[],
197
330
  facts: IntentFacts,
198
331
  topLevel: ContractInvocation,
199
- protocol: IdentifiedProtocol
332
+ protocol: IdentifiedProtocol,
333
+ swapRecipientAllowlist: string[] | undefined,
334
+ swapInputAmountCap: string | undefined,
335
+ interpreterEnabled: boolean
200
336
  ): void {
201
337
  // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
202
- // a single-element allowlist; the OZ adapter flags it as not covered.
338
+ // a single-element allowlist; the interpreter adapter lowers it to `in`.
339
+ // When interpreter is not enabled, route to OZ so the caller sees today's
340
+ // `value allowlist on arg 1 (arg allowlist)` warning.
203
341
  if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
204
342
  const toArg = topLevel.args[1]
205
343
  if (toArg && toArg.type === 'address') {
206
- constraints.push({
344
+ const cond: IRCondition = {
207
345
  op: 'in',
208
346
  selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
209
347
  values: [toArg.value],
210
- })
348
+ }
349
+ if (interpreterEnabled) {
350
+ interpreterConstraints.push(cond)
351
+ } else {
352
+ ozConstraints.push(cond)
353
+ }
211
354
  }
212
355
  }
213
356
 
214
- // SoroSwap: the swap's slippage / oracle-price bound and its exact hop path
215
- // need the interpreter predicate. We do NOT fabricate a comparison node
216
- // (oracle price and amountOutMin are different units; a vec path is not a
217
- // scalar compare). Surface the observed path descriptively instead.
357
+ // SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
358
+ // The interpreter adapter is the ONLY way to express an exact ordered
359
+ // sequence (OZ built-ins cannot). Element order is preserved verbatim.
360
+ // When multiple paths were recorded, the intersection is emitted as the
361
+ // canonical single path only if all observations agree; otherwise each is
362
+ // surfaced descriptively.
218
363
  if (protocol.protocol === 'soroswap') {
219
364
  const paths = facts.allowedPaths?.[topLevel.contract]
220
365
  const route = paths?.length === 1 ? paths[0] : undefined
221
- const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : ''
222
- warnings.push(
223
- `SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate${pathText}`
224
- )
366
+ if (route && route.length > 0 && interpreterEnabled) {
367
+ const pathArgIndex = 2 // SoroSwap swap_exact_tokens_for_tokens: args = [amount_in, amount_out_min, path, to, deadline]
368
+ interpreterConstraints.push({
369
+ op: 'eq_seq',
370
+ selector: { kind: 'arg', argIndex: pathArgIndex, scalarType: 'address' },
371
+ values: route,
372
+ })
373
+ } else {
374
+ const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : ''
375
+ warnings.push(
376
+ `SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate${pathText}`
377
+ )
378
+ }
379
+
380
+ // Input-amount cap: bind the caller's limitAmount to the swap's input-amount
381
+ // argument as `call_arg[i] <= limit`. The index is function-specific -
382
+ // SoroSwap has three swap entrypoints: `swap_exact_tokens_for_tokens` and
383
+ // `swap_exact_in_for_tokens` take the exact input as arg[0], while
384
+ // `swap_tokens_for_exact_tokens` takes the MAXIMUM input (`amount_in_max`)
385
+ // as arg[1] (its arg[0] is the exact OUTPUT, so binding arg[0] there would
386
+ // cap the wrong value and leave the input unbounded). This is a per-call cap
387
+ // that does NOT depend on attributing a token movement to the source account
388
+ // (the fee-sponsored / holder != source case, where the window_spent path
389
+ // detects no spend). Fail-closed: it only ever restricts the permitted input.
390
+ // Routed to the interpreter predicate (OZ built-ins cannot express a per-arg
391
+ // i128 bound); when the interpreter is not enabled it goes to OZ, which flags
392
+ // it uncovered (a warning). The `type === 'i128'` check is defense in depth -
393
+ // protocol identification's argsMatchAbi already pins the input arg to i128.
394
+ const inputArgIndex = soroswapInputAmountArgIndex(protocol.fn)
395
+ const inputAmountArg = inputArgIndex !== undefined ? topLevel.args[inputArgIndex] : undefined
396
+ if (
397
+ swapInputAmountCap !== undefined &&
398
+ inputArgIndex !== undefined &&
399
+ inputAmountArg &&
400
+ inputAmountArg.type === 'i128'
401
+ ) {
402
+ const cond: IRCondition = {
403
+ op: 'compare',
404
+ compare: {
405
+ selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
406
+ operator: 'lte',
407
+ value: swapInputAmountCap,
408
+ },
409
+ }
410
+ if (interpreterEnabled) {
411
+ interpreterConstraints.push(cond)
412
+ } else {
413
+ ozConstraints.push(cond)
414
+ }
415
+ }
416
+
417
+ // Swap recipient (call_arg[3]): when the caller supplies
418
+ // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
419
+ // arg. When absent, surface RECIPIENT_ALLOWLIST_EMPTY so the caller is
420
+ // prompted; proceeding without an allowlist leaves the recipient
421
+ // unconstrained in the predicate. The ambiguity is only surfaced when
422
+ // the interpreter is enabled - if it isn't, the swap recipient is just
423
+ // ignored (today's behaviour).
424
+ const recipientArg = topLevel.args[3]
425
+ if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
426
+ const cond: IRCondition = {
427
+ op: 'in',
428
+ selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
429
+ values: [...swapRecipientAllowlist],
430
+ }
431
+ if (interpreterEnabled) {
432
+ interpreterConstraints.push(cond)
433
+ } else {
434
+ ozConstraints.push(cond)
435
+ }
436
+ } else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
437
+ ambiguities.push({
438
+ code: 'RECIPIENT_ALLOWLIST_EMPTY',
439
+ question: `Recording shows the swap sending to ${recipientArg.value}. What recipient allowlist should the interpreter predicate bind? (omit to leave recipient unconstrained)`,
440
+ })
441
+ }
442
+ }
443
+ }
444
+
445
+ /** Positional index of the input-amount argument for a recognized SoroSwap swap
446
+ * function. `swap_exact_tokens_for_tokens` and `swap_exact_in_for_tokens` take
447
+ * the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
448
+ * input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
449
+ * function has no positional input-amount argument -> undefined (no cap bound). */
450
+ function soroswapInputAmountArgIndex(fn: string): number | undefined {
451
+ switch (fn) {
452
+ case 'swap_exact_tokens_for_tokens':
453
+ case 'swap_exact_in_for_tokens':
454
+ return 0
455
+ case 'swap_tokens_for_exact_tokens':
456
+ return 1
457
+ default:
458
+ return undefined
225
459
  }
226
460
  }