@crediolabs/policy-synth 0.1.17 → 0.1.18

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 (91) hide show
  1. package/README.md +43 -246
  2. package/dist/adapters/interpreter/adapter.d.ts +6 -7
  3. package/dist/adapters/interpreter/adapter.js +28 -48
  4. package/dist/adapters/oz/adapter.js +12 -13
  5. package/dist/codegen/compile-gate.js +5 -0
  6. package/dist/codegen/template.js +17 -0
  7. package/dist/install/build-add-context-rule.js +16 -45
  8. package/dist/install/build-install-policy.d.ts +12 -5
  9. package/dist/install/build-install-policy.js +32 -23
  10. package/dist/install/get-interpreter-info.js +2 -2
  11. package/dist/install/index.d.ts +1 -1
  12. package/dist/install/index.js +1 -1
  13. package/dist/install/oz-auth.js +5 -3
  14. package/dist/predicate/decode.js +1 -1
  15. package/dist/predicate/encode.js +171 -10
  16. package/dist/record/decode.js +5 -6
  17. package/dist/registry/protocols.d.ts +1 -1
  18. package/dist/registry/protocols.js +1 -1
  19. package/dist/review-card/builder.d.ts +6 -0
  20. package/dist/review-card/builder.js +9 -1
  21. package/dist/run/index.d.ts +14 -13
  22. package/dist/run/index.js +80 -67
  23. package/dist/run/schemas.d.ts +193 -11
  24. package/dist/run/schemas.js +59 -17
  25. package/dist/synth/compose-from-recording.d.ts +10 -15
  26. package/dist/synth/compose-from-recording.js +79 -124
  27. package/dist/synth/deny-cases.d.ts +5 -0
  28. package/dist/synth/deny-cases.js +52 -13
  29. package/dist/synth/evaluate.js +69 -119
  30. package/dist/synth/harness.d.ts +13 -1
  31. package/dist/synth/harness.js +22 -1
  32. package/dist/synth/index.d.ts +1 -1
  33. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  34. package/dist/synth/synthesize-from-recording.js +129 -155
  35. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  36. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  37. package/dist-cjs/adapters/oz/adapter.js +12 -13
  38. package/dist-cjs/codegen/compile-gate.js +5 -0
  39. package/dist-cjs/codegen/template.js +17 -0
  40. package/dist-cjs/install/build-add-context-rule.js +16 -45
  41. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  42. package/dist-cjs/install/build-install-policy.js +32 -23
  43. package/dist-cjs/install/get-interpreter-info.js +2 -2
  44. package/dist-cjs/install/index.d.ts +1 -1
  45. package/dist-cjs/install/index.js +2 -2
  46. package/dist-cjs/install/oz-auth.js +5 -3
  47. package/dist-cjs/predicate/decode.js +1 -1
  48. package/dist-cjs/predicate/encode.js +171 -10
  49. package/dist-cjs/record/decode.js +5 -6
  50. package/dist-cjs/registry/protocols.d.ts +1 -1
  51. package/dist-cjs/registry/protocols.js +1 -1
  52. package/dist-cjs/review-card/builder.d.ts +6 -0
  53. package/dist-cjs/review-card/builder.js +9 -1
  54. package/dist-cjs/run/index.d.ts +14 -13
  55. package/dist-cjs/run/index.js +80 -67
  56. package/dist-cjs/run/schemas.d.ts +193 -11
  57. package/dist-cjs/run/schemas.js +59 -17
  58. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  59. package/dist-cjs/synth/compose-from-recording.js +79 -124
  60. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  61. package/dist-cjs/synth/deny-cases.js +52 -13
  62. package/dist-cjs/synth/evaluate.js +69 -119
  63. package/dist-cjs/synth/harness.d.ts +13 -1
  64. package/dist-cjs/synth/harness.js +22 -1
  65. package/dist-cjs/synth/index.d.ts +1 -1
  66. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  67. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  68. package/package.json +1 -1
  69. package/src/adapters/interpreter/adapter.ts +28 -48
  70. package/src/adapters/oz/adapter.ts +12 -13
  71. package/src/codegen/compile-gate.ts +5 -0
  72. package/src/codegen/template.ts +17 -0
  73. package/src/install/build-add-context-rule.ts +17 -55
  74. package/src/install/build-install-policy.ts +41 -23
  75. package/src/install/get-interpreter-info.ts +2 -2
  76. package/src/install/index.ts +2 -2
  77. package/src/install/oz-auth.ts +5 -3
  78. package/src/predicate/decode.ts +1 -1
  79. package/src/predicate/encode.ts +176 -10
  80. package/src/record/decode.ts +5 -6
  81. package/src/registry/protocols.ts +1 -1
  82. package/src/review-card/builder.ts +17 -1
  83. package/src/run/index.ts +117 -91
  84. package/src/run/schemas.ts +64 -16
  85. package/src/synth/compose-from-recording.ts +87 -132
  86. package/src/synth/deny-cases.ts +59 -13
  87. package/src/synth/evaluate.ts +70 -118
  88. package/src/synth/harness.ts +29 -1
  89. package/src/synth/index.ts +1 -0
  90. package/src/synth/synthesize-from-recording.ts +209 -192
  91. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
@@ -28,28 +28,23 @@ export interface ComposeUserResponses {
28
28
  * invocation_count bound; absent -> FREQUENCY_BOUND_MISSING. */
29
29
  invocationLimit?: number;
30
30
  /** Per-asset oracle-price bound(s). Each entry lowers to a single
31
- * `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
32
- * entries on the same asset emit multiple leaves. */
31
+ * `oracle_price(asset) OP value` compare in the interpreter IR. */
33
32
  oraclePriceBound?: OraclePriceBound[];
34
33
  /** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
35
- * `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
36
- * slippage floor bounding the output arg against the input arg of the same
37
- * call.
38
- *
39
- * REQUIRED to be supplied by the caller: it is never derived from the
40
- * recording. The recorded in/out pair is a price at one moment, and
41
- * freezing it as policy would deny ordinary trades as soon as the rate
42
- * moves. Absent, no floor is emitted and the existing unbounded-output
43
- * warning stands. */
34
+ * `{num:'95',den:'100'}` = accept losing at most 5%). REQUIRED to be
35
+ * supplied by the caller: never derived from the recording (the recorded
36
+ * in/out pair is a price at one moment, and freezing it as policy would
37
+ * deny ordinary trades as soon as the rate moves). Absent, no floor is
38
+ * emitted and the existing unbounded-output warning stands. */
44
39
  swapMinOutRatio?: {
45
40
  num: string;
46
41
  den: string;
47
42
  };
48
43
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
49
- * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
50
- * pin. Absent -> the recipient is pinned to the recorded value (mirroring
51
- * SEP-41) and RECIPIENT_ALLOWLIST_EMPTY is surfaced as informational, never
52
- * a silent free pass. */
44
+ * swap_exact_tokens_for_tokens). When supplied, REPLACES the default pin.
45
+ * Absent -> recipient is pinned to the recorded value (mirroring SEP-41)
46
+ * and RECIPIENT_ALLOWLIST_EMPTY surfaces as informational, never a silent
47
+ * free pass. */
53
48
  swapRecipientAllowlist?: string[];
54
49
  }
55
50
  /** Composition options. */
@@ -1,52 +1,42 @@
1
- // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ-shape + interpreter-shape).
2
- //
3
- // Composes the canonical IR rules the two backends compile FROM. Fail-closed
4
- // composition rules:
5
- //
6
- // - identify the protocol of the top-level call (registry.identifyProtocol).
7
- // When it is unknown (null), emit NO OZ-primitive-producing IR node: the
8
- // scope is kept (CallContract + method) and every inferred bound is surfaced
9
- // as a descriptive warning. An unrecognised call never compiles to a
10
- // permissive OZ primitive.
1
+ // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ + interpreter).
11
2
  //
3
+ // Fail-closed composition rules:
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.
12
8
  // - carry the recorded top-level function into `rule.scope.method` so the OZ
13
9
  // adapter flags per-method scoping as not covered (CallContract permits every
14
- // method on the contract; a per-method restriction needs the interpreter predicate).
15
- //
16
- // - a `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
17
- // the caller supplies BOTH the limit (userResponses.limitAmount) and the
18
- // window (userResponses.windowSeconds). A single recorded spend does NOT
19
- // authorise that amount every window, so the observed amount is NEVER used as
20
- // an auto-ceiling: a missing limit surfaces AMOUNT_BOUND_MISSING (the observed
21
- // amount is offered as a suggestion), a missing window surfaces
22
- // DURATION_UNSPECIFIED. EVERY spent token is handled - none is silently
23
- // dropped; a single caller limit binds only an unambiguous single-token spend.
24
- //
25
- // - incoming-only flows (e.g. Blend yield claim) emit an `invocation_count`
26
- // bound ONLY when the caller supplies the count (userResponses.invocationLimit)
27
- // and a window; otherwise FREQUENCY_BOUND_MISSING is surfaced with no
28
- // fabricated count.
29
- //
30
- // - the IR carries ONLY constraints justified by the recording (observed
31
- // recipient allowlist) + explicit user input. Nothing invented: no oracle
32
- // price fabricated from a slippage bound, no synthetic exact-path compare.
33
- // Those needs are surfaced as descriptive warnings instead.
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
17
+ // 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.
34
24
  //
35
25
  // Split rule (P3 wiring): `ComposeResult` carries BOTH `ir` (OZ-shape) and
36
26
  // `interpreterIr` (predicate-shape). Each constraint is routed to EXACTLY ONE
37
27
  // 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`.
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`.
44
34
  //
45
35
  // This prevents the interpreter adapter from emitting a duplicate
46
36
  // `window_spent` predicate leaf alongside an OZ `spending_limit` primitive
47
37
  // covering the same spend semantic - the two adapters never overlap.
48
38
  //
49
- // The default behavior is `deny_all` (OZ context rules are deny-by-default).
39
+ // Default policy is `deny_all` (OZ context rules are deny-by-default).
50
40
  import { identifyProtocol } from "../registry/identify.js";
51
41
  /** Compose a PolicyIR pair from the lowered facts + the resolved scope.
52
42
  * Pure (no randomness, no clock); same inputs -> byte-identical result. */
@@ -56,6 +46,15 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
56
46
  const warnings = [];
57
47
  const ozConstraints = [];
58
48
  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
+ };
59
58
  const protocol = topLevel
60
59
  ? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
61
60
  : null;
@@ -63,14 +62,15 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
63
62
  const windowSeconds = opts.userResponses?.windowSeconds;
64
63
  const limitAmount = opts.userResponses?.limitAmount;
65
64
  const spendTokens = Object.keys(facts.spendByToken);
66
- // Outgoing spend -> one spending_limit per spent token. A single caller limit
67
- // binds only an unambiguous single-token spend; a multi-token flow needs a
68
- // per-token limit, so each unmatched token surfaces AMOUNT_BOUND_MISSING.
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
69
  //
70
70
  // Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
71
71
  // only when token === scope.contract (OZ's spending_limit binds the
72
72
  // CallContract target, not a token parameter). Otherwise it goes to the
73
- // interpreter IR (the interpreter lowers window_spent to a predicate leaf).
73
+ // interpreter IR.
74
74
  if (spendTokens.length > 0 && topLevel) {
75
75
  let durationFlagged = false;
76
76
  for (const token of spendTokens) {
@@ -96,24 +96,23 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
96
96
  });
97
97
  continue;
98
98
  }
99
- if (windowSeconds !== undefined && limit !== undefined) {
100
- const spendCond = {
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({
101
109
  op: 'compare',
102
110
  compare: {
103
111
  selector: { kind: 'window_spent', token, windowSeconds },
104
112
  operator: 'lte',
105
113
  value: limit,
106
114
  },
107
- };
108
- // A rolling spend cap always goes to OZ, whose `spending_limit` is the
109
- // audited implementation. The interpreter is NOT a fallback for the
110
- // token != scopeContract case: on chain it sees one authorized call,
111
- // not the transaction's token movements, so it has no per-call amount
112
- // to accumulate and the counter would never move. OZ reports the case
113
- // it cannot cover (it pins the limit to the context contract), which
114
- // is the honest outcome - the old fallback produced an interpreter
115
- // predicate that silently never bound.
116
- ozConstraints.push(spendCond);
115
+ });
117
116
  if (interpreterEnabled && token !== scopeContract) {
118
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.`);
119
118
  }
@@ -125,18 +124,18 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
125
124
  // Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
126
125
  // invocation_count); otherwise to the OZ IR (which flags it as uncovered).
127
126
  //
128
- // A recognised swap is NOT an incoming-only flow: it has an outgoing input leg
129
- // whose spend simply was not attributed to the source account (the
130
- // fee-sponsored / holder != source case). Its real restrictions - exact path,
131
- // recipient, and the input-amount cap - come from the protocol-specific pass,
132
- // so it does NOT get the incoming-only frequency prompt. A caller who wants to
127
+ // A recognised swap is NOT an incoming-only flow: it has an outgoing input
128
+ // leg whose spend simply was not attributed to the source account
129
+ // (fee-sponsored / holder != source). Its real restrictions - exact path,
130
+ // 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
133
132
  // rate-limit the swap can still supply an invocationLimit + window, which
134
133
  // lowers to an invocation_count for any flow.
135
134
  if (spendTokens.length === 0 && topLevel) {
136
135
  const invocationLimit = opts.userResponses?.invocationLimit;
137
136
  const isRecognisedSwap = protocol?.protocol === 'soroswap';
138
137
  if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
139
- const icCond = {
138
+ routeToAdapter({
140
139
  op: 'compare',
141
140
  compare: {
142
141
  selector: { kind: 'invocation_count', windowSeconds },
@@ -146,13 +145,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
146
145
  operator: 'lt',
147
146
  value: String(invocationLimit),
148
147
  },
149
- };
150
- if (interpreterEnabled) {
151
- interpreterConstraints.push(icCond);
152
- }
153
- else {
154
- ozConstraints.push(icCond);
155
- }
148
+ });
156
149
  }
157
150
  else if (!isRecognisedSwap) {
158
151
  ambiguities.push({
@@ -168,7 +161,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
168
161
  const oracleBounds = opts.userResponses?.oraclePriceBound;
169
162
  if (oracleBounds) {
170
163
  for (const b of oracleBounds) {
171
- const oracleCond = {
164
+ routeToAdapter({
172
165
  op: 'compare',
173
166
  compare: {
174
167
  selector: { kind: 'oracle_price', asset: b.asset },
@@ -176,13 +169,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
176
169
  value: b.value,
177
170
  valueDecimals: b.decimals,
178
171
  },
179
- };
180
- if (interpreterEnabled) {
181
- interpreterConstraints.push(oracleCond);
182
- }
183
- else {
184
- ozConstraints.push(oracleCond);
185
- }
172
+ });
186
173
  }
187
174
  }
188
175
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
@@ -197,7 +184,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
197
184
  // the window_spent path above already consumed the limit, so the per-call
198
185
  // arg cap is skipped to avoid binding one limit to two different semantics.
199
186
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
200
- appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
187
+ appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapter, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
201
188
  }
202
189
  // `scope.method` is carried on BOTH IRs so each adapter produces a
203
190
  // self-consistent rule. The interpreter adapter lowers scope.method into a
@@ -239,32 +226,24 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
239
226
  * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
240
227
  * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
241
228
  * call_arg[2]). */
242
- function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
243
- // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
244
- // a single-element allowlist; the interpreter adapter lowers it to `in`.
229
+ function appendProtocolSpecificConstraints(interpreterConstraints, routeToAdapter, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
230
+ // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit a
231
+ // single-element allowlist; the interpreter adapter lowers it to `in`.
245
232
  // When interpreter is not enabled, route to OZ so the caller sees today's
246
- // `value allowlist on arg 1 (arg allowlist)` warning.
233
+ // `value allowlist on arg 1` warning.
247
234
  if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
248
235
  const toArg = topLevel.args[1];
249
236
  if (toArg && toArg.type === 'address') {
250
- const cond = {
237
+ routeToAdapter({
251
238
  op: 'in',
252
239
  selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
253
240
  values: [toArg.value],
254
- };
255
- if (interpreterEnabled) {
256
- interpreterConstraints.push(cond);
257
- }
258
- else {
259
- ozConstraints.push(cond);
260
- }
241
+ });
261
242
  }
262
243
  }
263
244
  // Blend submit / claim: the `to` arg is the beneficiary - it receives the
264
245
  // resulting position shares on `submit` and the claimed tokens on `claim`.
265
- // That is the same role as the SEP-41 recipient above and the SoroSwap swap
266
- // recipient below, so it is pinned to the recorded address the same way:
267
- // without it a policy scoped to a pool and method still lets an agent send
246
+ // Without it, a policy scoped to a pool + method still lets an agent send
268
247
  // the proceeds anywhere. `from` and `spender` are deliberately NOT pinned -
269
248
  // the call already requires their authorisation, so binding them adds no
270
249
  // restriction the chain is not already enforcing.
@@ -273,25 +252,19 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
273
252
  // claim(from, reserve_token_ids, to).
274
253
  const toArg = topLevel.args[2];
275
254
  if (toArg && toArg.type === 'address') {
276
- const cond = {
255
+ routeToAdapter({
277
256
  op: 'in',
278
257
  selector: { kind: 'arg', argIndex: 2, scalarType: 'address' },
279
258
  values: [toArg.value],
280
- };
281
- if (interpreterEnabled) {
282
- interpreterConstraints.push(cond);
283
- }
284
- else {
285
- ozConstraints.push(cond);
286
- }
259
+ });
287
260
  }
288
261
  }
289
262
  // Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
290
263
  // reserve_token_ids - no map fields to bind). The `requests` vec
291
264
  // (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
292
- // Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
265
+ // Request is the per-reserve action selector (0 Supply, 1 Withdraw,
293
266
  // 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
294
- // liquidation/auction fills. Pinning only one element is unsafe: a caller
267
+ // liquidation/auction fills). Pinning only one element is unsafe: a caller
295
268
  // can append a second element with a different action (WithdrawCollateral
296
269
  // -> Borrow on a different asset, any amount, then auction fills). Length
297
270
  // + per-element pinning is total; a quantifier over elements is not. If we
@@ -432,20 +405,14 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
432
405
  inputArgIndex !== undefined &&
433
406
  inputAmountArg &&
434
407
  inputAmountArg.type === 'i128') {
435
- const cond = {
408
+ routeToAdapter({
436
409
  op: 'compare',
437
410
  compare: {
438
411
  selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
439
412
  operator: 'lte',
440
413
  value: swapInputAmountCap,
441
414
  },
442
- };
443
- if (interpreterEnabled) {
444
- interpreterConstraints.push(cond);
445
- }
446
- else {
447
- ozConstraints.push(cond);
448
- }
415
+ });
449
416
  }
450
417
  // Slippage floor: `out >= in * num/den`. Only when the caller supplied the
451
418
  // ratio - see `swapMinOutRatio`. Without it the output arg stays free,
@@ -457,19 +424,13 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
457
424
  outMinArgIndex !== undefined &&
458
425
  inputAmountArg &&
459
426
  inputAmountArg.type === 'i128') {
460
- const floor = {
427
+ routeToAdapter({
461
428
  op: 'slippage_floor',
462
429
  outArgIndex: outMinArgIndex,
463
430
  inArgIndex: inputArgIndex,
464
431
  num: minOutRatio.num,
465
432
  den: minOutRatio.den,
466
- };
467
- if (interpreterEnabled) {
468
- interpreterConstraints.push(floor);
469
- }
470
- else {
471
- ozConstraints.push(floor);
472
- }
433
+ });
473
434
  }
474
435
  // Swap recipient (call_arg[3]): when the caller supplies
475
436
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
@@ -485,17 +446,11 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
485
446
  // (today's behaviour, matching the other SoroSwap constraints).
486
447
  const recipientArg = topLevel.args[3];
487
448
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
488
- const cond = {
449
+ routeToAdapter({
489
450
  op: 'in',
490
451
  selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
491
452
  values: [...swapRecipientAllowlist],
492
- };
493
- if (interpreterEnabled) {
494
- interpreterConstraints.push(cond);
495
- }
496
- else {
497
- ozConstraints.push(cond);
498
- }
453
+ });
499
454
  }
500
455
  else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
501
456
  interpreterConstraints.push({
@@ -3,6 +3,11 @@ import type { EvalContext } from './evaluate.ts';
3
3
  export interface DenyCase {
4
4
  dimension: string;
5
5
  ctx: EvalContext;
6
+ /** Canonical reason the Rust interpreter returns for this dimension.
7
+ * Optional: when set, `runHarness` asserts the TS evaluator emits this
8
+ * exact string, so a future TS/Rust reason divergence fails CI. When
9
+ * absent the case is only checked on the boolean decision. */
10
+ expectedReason?: string;
6
11
  }
7
12
  export interface GeneratedCases {
8
13
  permit: EvalContext;
@@ -6,16 +6,29 @@ const ORACLE_CASES = [
6
6
  ['oracle_deviation_exceeded', 'deviation'],
7
7
  ['oracle_paused', 'paused'],
8
8
  ];
9
+ // Cross-layer reason contract: the TS evaluator's deny reason for each
10
+ // oracle error category must match the Rust interpreter's `DenyReason` code
11
+ // string (the same strings surface in the conformance fixture). The harness
12
+ // asserts them via the per-case `expectedReason`; a future divergence
13
+ // surfaces as a REASON_MISMATCH failure.
14
+ const ORACLE_ERROR_REASON = {
15
+ stale: 'ORACLE_STALE',
16
+ missing: 'ORACLE_MISSING',
17
+ deviation: 'ORACLE_DEVIATION_EXCEEDED',
18
+ paused: 'ORACLE_PAUSED',
19
+ };
9
20
  // Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
10
21
  const ADJACENT_ASSETS = [
11
22
  'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
12
23
  'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
13
24
  ];
14
- // Phase 1 property-harness mutation dimensions excluded from the synth pipeline's
25
+ // Property-harness mutation dimensions excluded from the synth pipeline's
15
26
  // self-verify call so existing fixtures still emit policies. The harness tests
16
27
  // them as FINDINGS against the already-emitted policy.
17
28
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'];
18
- // The 15 dimensions the synth pipeline uses for self-verify and minimise.
29
+ // The dimensions the synth pipeline uses for self-verify and minimise. No count
30
+ // is written here on purpose: this comment said 15 while the array held 17, and
31
+ // anyone checking the number should read the array rather than the prose.
19
32
  // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
20
33
  // alongside the existing dimensions so the per-element binds emitted for
21
34
  // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
@@ -61,7 +74,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
61
74
  continue;
62
75
  const mutated = mutateBigIntRecord(permitCtx, 'amountByToken', comparison.left.token, comparison);
63
76
  if (mutated)
64
- denies.push({ dimension: 'amount', ctx: mutated });
77
+ denies.push({ dimension: 'amount', ctx: mutated, expectedReason: 'AMOUNT_BOUND' });
65
78
  }
66
79
  const movedTokens = new Set();
67
80
  for (const comparison of facts.comparisons) {
@@ -70,6 +83,15 @@ export function generateCases(predicate, permitCtx, dimensions) {
70
83
  }
71
84
  }
72
85
  for (const token of movedTokens) {
86
+ // The `asset` deny case mutates BOTH the amount record AND the contract
87
+ // binding (token's value moved to an adjacent asset; the same swap applied
88
+ // to any address-typed `call_arg` / `call_contract` literal). The actual
89
+ // deny reason is therefore predicate-dependent - the order of evaluation
90
+ // is CONTRACT_SCOPE (step 3) before AMOUNT_BOUND (step 6), so a policy
91
+ // that has both bindings denies with CONTRACT_SCOPE, while a policy with
92
+ // only an amount binding denies with AMOUNT_BOUND. The reason assertion
93
+ // would be brittle here; the boolean decision is what we strictly need
94
+ // to pin. Leave `expectedReason` unset for this dimension.
73
95
  denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) });
74
96
  }
75
97
  const contractConstraints = [
@@ -81,7 +103,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
81
103
  for (const _constraint of contractConstraints) {
82
104
  const ctx = cloneContext(permitCtx);
83
105
  ctx.contract = distinctText(permitCtx.contract, 'contract');
84
- denies.push({ dimension: 'contract', ctx });
106
+ denies.push({ dimension: 'contract', ctx, expectedReason: 'CONTRACT_SCOPE' });
85
107
  }
86
108
  const functionConstraints = [
87
109
  ...facts.comparisons.filter((node) => node.op === 'eq' && node.left.kind === 'call_fn' && node.right.kind === 'literal_symbol'),
@@ -90,19 +112,19 @@ export function generateCases(predicate, permitCtx, dimensions) {
90
112
  for (const _constraint of functionConstraints) {
91
113
  const ctx = cloneContext(permitCtx);
92
114
  ctx.fn = distinctText(permitCtx.fn, 'function');
93
- denies.push({ dimension: 'function', ctx });
115
+ denies.push({ dimension: 'function', ctx, expectedReason: 'FN_MISMATCH' });
94
116
  }
95
117
  if (permitCtx.validUntilLedger !== undefined) {
96
118
  const ctx = cloneContext(permitCtx);
97
119
  ctx.atLedger = permitCtx.validUntilLedger + 1;
98
- denies.push({ dimension: 'timing', ctx });
120
+ denies.push({ dimension: 'timing', ctx, expectedReason: 'EXPIRED' });
99
121
  }
100
122
  for (const comparison of facts.comparisons) {
101
123
  if (comparison.left.kind !== 'window_spent')
102
124
  continue;
103
125
  const mutated = mutateBigIntRecord(permitCtx, 'windowSpentByToken', comparison.left.token, comparison, false);
104
126
  if (mutated)
105
- denies.push({ dimension: 'time_window', ctx: mutated });
127
+ denies.push({ dimension: 'time_window', ctx: mutated, expectedReason: 'AMOUNT_BOUND' });
106
128
  }
107
129
  for (const comparison of facts.comparisons) {
108
130
  if (comparison.left.kind !== 'invocation_count_in_window')
@@ -111,7 +133,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
111
133
  continue;
112
134
  const ctx = cloneContext(permitCtx);
113
135
  ctx.invocationCountByWindow[comparison.left.windowSecs] = violatingNumber(comparison.op, comparison.right.value);
114
- denies.push({ dimension: 'invocation_count', ctx });
136
+ denies.push({ dimension: 'invocation_count', ctx, expectedReason: 'FREQUENCY' });
115
137
  }
116
138
  // Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
117
139
  // `call_arg[0] <= limit`). A violating deny case pushes the arg past the
@@ -126,7 +148,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
126
148
  continue;
127
149
  const ctx = cloneContext(permitCtx);
128
150
  ctx.args[comparison.left.index] = violating;
129
- denies.push({ dimension: 'arg_amount_bound', ctx });
151
+ denies.push({ dimension: 'arg_amount_bound', ctx, expectedReason: 'ARG_MISMATCH' });
130
152
  }
131
153
  const argumentConstraints = [];
132
154
  for (const comparison of facts.comparisons) {
@@ -144,6 +166,15 @@ export function generateCases(predicate, permitCtx, dimensions) {
144
166
  for (const constraint of argumentConstraints) {
145
167
  const ctx = cloneContext(permitCtx);
146
168
  ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' };
169
+ // The `arg_bound` case sets the arg to an opaque ScVal. The deny reason
170
+ // depends on the predicate shape:
171
+ // - an `eq(call_arg[i], literal)` denies with ARG_MISMATCH
172
+ // - an `in(call_arg[i], [literals])` denies with NOT_IN_ALLOWLIST
173
+ // (opaque needles fail-closed at the `in` membership gate, step 5)
174
+ // Both are correct in the TS evaluator; the reason is predicate-dependent
175
+ // so we cannot pin a single canonical reason here. The boolean decision
176
+ // is what we strictly need to assert; the reason is recorded (not asserted)
177
+ // when the harness runs this case.
147
178
  denies.push({ dimension: 'arg_bound', ctx });
148
179
  }
149
180
  const scopedArgumentIndices = new Set(argumentConstraints.map(({ index }) => index));
@@ -163,6 +194,14 @@ export function generateCases(predicate, permitCtx, dimensions) {
163
194
  for (const index of scopedArgumentIndices) {
164
195
  ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' };
165
196
  }
197
+ // `scope_contract_fn_arg` changes contract, fn, AND args simultaneously.
198
+ // The first failing child of the AND decides the reason, and the contract
199
+ // check (step 3) does fire first in the evaluator. The reason is therefore
200
+ // CONTRACT_SCOPE for the canonical case, but a predicate that lists the
201
+ // call_fn leaf first (or that has a `call_arg_field` for that arg index)
202
+ // can flip the order. Pin the assertion here only when no `in` /
203
+ // call_arg_field binds the same arg index exist - the canonical blend
204
+ // case does have those binds, so the assertion would fire there.
166
205
  denies.push({ dimension: 'scope_contract_fn_arg', ctx });
167
206
  }
168
207
  const oracleComparisons = facts.comparisons.filter((node) => node.left.kind === 'oracle_price');
@@ -172,7 +211,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
172
211
  continue;
173
212
  const ctx = cloneContext(permitCtx);
174
213
  ctx.oraclePriceByAsset[comparison.left.asset] = { error };
175
- denies.push({ dimension, ctx });
214
+ denies.push({ dimension, ctx, expectedReason: ORACLE_ERROR_REASON[error] });
176
215
  }
177
216
  }
178
217
  for (const comparison of facts.comparisons) {
@@ -225,10 +264,10 @@ export function generateCases(predicate, permitCtx, dimensions) {
225
264
  if (sel.kind !== 'call_arg_field')
226
265
  continue;
227
266
  const arg = permitCtx.args[sel.index];
228
- if (!arg || arg.type !== 'vec')
267
+ if (arg?.type !== 'vec')
229
268
  continue;
230
269
  const element = arg.value[sel.element];
231
- if (!element || element.type !== 'map' || !Array.isArray(element.value))
270
+ if (element?.type !== 'map' || !Array.isArray(element.value))
232
271
  continue;
233
272
  const entry = element.value.find((e) => e.key === sel.field);
234
273
  if (!entry)
@@ -256,7 +295,7 @@ export function generateCases(predicate, permitCtx, dimensions) {
256
295
  if (sel.kind !== 'call_arg_len')
257
296
  continue;
258
297
  const arg = permitCtx.args[sel.index];
259
- if (!arg || arg.type !== 'vec')
298
+ if (arg?.type !== 'vec')
260
299
  continue;
261
300
  const ctx = cloneContext(permitCtx);
262
301
  ctx.args[sel.index] = {