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