@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,14 +32,30 @@
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
  import { identifyProtocol } from "../registry/identify.js";
37
- /** Compose a PolicyIR from the lowered facts + the resolved scope.
51
+ /** Compose a PolicyIR pair from the lowered facts + the resolved scope.
38
52
  * Pure (no randomness, no clock); same inputs -> byte-identical result. */
39
53
  export function composeFromRecording(facts, scopeContract, topLevel, opts) {
54
+ const interpreterEnabled = opts.interpreterEnabled === true;
40
55
  const ambiguities = [];
41
56
  const warnings = [];
42
- const constraints = [];
57
+ const ozConstraints = [];
58
+ const interpreterConstraints = [];
43
59
  const protocol = topLevel
44
60
  ? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
45
61
  : null;
@@ -50,6 +66,11 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
50
66
  // Outgoing spend -> one spending_limit per spent token. A single caller limit
51
67
  // binds only an unambiguous single-token spend; a multi-token flow needs a
52
68
  // per-token limit, so each unmatched token surfaces AMOUNT_BOUND_MISSING.
69
+ //
70
+ // Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
71
+ // only when token === scope.contract (OZ's spending_limit binds the
72
+ // CallContract target, not a token parameter). Otherwise it goes to the
73
+ // interpreter IR (the interpreter lowers window_spent to a predicate leaf).
53
74
  if (spendTokens.length > 0 && topLevel) {
54
75
  let durationFlagged = false;
55
76
  for (const token of spendTokens) {
@@ -75,33 +96,56 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
75
96
  });
76
97
  continue;
77
98
  }
78
- if (windowSeconds !== undefined) {
79
- constraints.push({
99
+ if (windowSeconds !== undefined && limit !== undefined) {
100
+ const spendCond = {
80
101
  op: 'compare',
81
102
  compare: {
82
103
  selector: { kind: 'window_spent', token, windowSeconds },
83
104
  operator: 'lte',
84
105
  value: limit,
85
106
  },
86
- });
107
+ };
108
+ if (!interpreterEnabled || token === scopeContract) {
109
+ ozConstraints.push(spendCond);
110
+ }
111
+ else {
112
+ interpreterConstraints.push(spendCond);
113
+ }
87
114
  }
88
115
  }
89
116
  }
90
117
  // Incoming-only / frequency intent: an invocation_count bound is emitted ONLY
91
118
  // when the caller supplies the count AND a window - never a fabricated `<= 1`.
119
+ // Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
120
+ // invocation_count); otherwise to the OZ IR (which flags it as uncovered).
121
+ //
122
+ // A recognised swap is NOT an incoming-only flow: it has an outgoing input leg
123
+ // whose spend simply was not attributed to the source account (the
124
+ // fee-sponsored / holder != source case). Its real restrictions - exact path,
125
+ // recipient, and the input-amount cap - come from the protocol-specific pass,
126
+ // so it does NOT get the incoming-only frequency prompt. A caller who wants to
127
+ // rate-limit the swap can still supply an invocationLimit + window, which
128
+ // lowers to an invocation_count for any flow.
92
129
  if (spendTokens.length === 0 && topLevel) {
93
130
  const invocationLimit = opts.userResponses?.invocationLimit;
131
+ const isRecognisedSwap = protocol?.protocol === 'soroswap';
94
132
  if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
95
- constraints.push({
133
+ const icCond = {
96
134
  op: 'compare',
97
135
  compare: {
98
136
  selector: { kind: 'invocation_count', windowSeconds },
99
137
  operator: 'lte',
100
138
  value: String(invocationLimit),
101
139
  },
102
- });
140
+ };
141
+ if (interpreterEnabled) {
142
+ interpreterConstraints.push(icCond);
143
+ }
144
+ else {
145
+ ozConstraints.push(icCond);
146
+ }
103
147
  }
104
- else {
148
+ else if (!isRecognisedSwap) {
105
149
  ambiguities.push({
106
150
  code: 'FREQUENCY_BOUND_MISSING',
107
151
  question: 'Incoming-only flow - what max invocations per window should the policy enforce?',
@@ -109,54 +153,201 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
109
153
  warnings.push('frequency bound needed for the incoming-only flow (needs the interpreter predicate); no invocation cap inferred');
110
154
  }
111
155
  }
156
+ // Per-asset oracle-price bound(s) supplied by the caller -> one
157
+ // oracle_price compare per entry. Routed to the interpreter IR when enabled;
158
+ // otherwise to the OZ IR (which flags it as uncovered).
159
+ const oracleBounds = opts.userResponses?.oraclePriceBound;
160
+ if (oracleBounds) {
161
+ for (const b of oracleBounds) {
162
+ const oracleCond = {
163
+ op: 'compare',
164
+ compare: {
165
+ selector: { kind: 'oracle_price', asset: b.asset },
166
+ operator: b.operator,
167
+ value: b.value,
168
+ },
169
+ };
170
+ if (interpreterEnabled) {
171
+ interpreterConstraints.push(oracleCond);
172
+ }
173
+ else {
174
+ ozConstraints.push(oracleCond);
175
+ }
176
+ }
177
+ }
112
178
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
113
- // adapter flags as not covered. Unknown protocols emit nothing here.
179
+ // adapter flags as not covered; the interpreter adapter lowers it to an `in`
180
+ // predicate. Unknown protocols emit nothing here. SoroSwap's swap recipient
181
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
114
182
  if (topLevel && protocol !== null) {
115
- appendProtocolSpecificConstraints(constraints, warnings, facts, topLevel, protocol);
183
+ // A SoroSwap input-amount cap binds the caller's limitAmount to call_arg[0]
184
+ // (the exact amount_in) ONLY when no cumulative outgoing spend was detected
185
+ // for the source account - i.e. the input token never moved FROM the source
186
+ // (fee-sponsored swaps, or a holder != source). When a spend WAS detected,
187
+ // the window_spent path above already consumed the limit, so the per-call
188
+ // arg cap is skipped to avoid binding one limit to two different semantics.
189
+ const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
190
+ appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled);
116
191
  }
192
+ // `scope.method` is carried on BOTH IRs so each adapter produces a
193
+ // self-consistent rule. The interpreter adapter lowers scope.method into a
194
+ // `call_fn == <method>` predicate leaf (a real restriction); the OZ adapter
195
+ // flags it as uncovered (CallContract alone permits any method on the
196
+ // contract).
117
197
  const scope = { contract: scopeContract };
118
198
  if (topLevel?.fn)
119
199
  scope.method = topLevel.fn;
120
- const rule = {
121
- roles: [],
122
- scope,
123
- constraints,
200
+ const buildRule = (constraints) => {
201
+ const rule = {
202
+ roles: [],
203
+ scope: { ...scope },
204
+ constraints,
205
+ };
206
+ if (opts.userResponses?.validUntilLedger !== undefined) {
207
+ rule.expiry = { validUntilLedger: opts.userResponses.validUntilLedger };
208
+ }
209
+ return rule;
124
210
  };
125
- if (opts.userResponses?.validUntilLedger !== undefined) {
126
- rule.expiry = { validUntilLedger: opts.userResponses.validUntilLedger };
127
- }
128
211
  const ir = {
129
212
  chain: 'stellar',
130
213
  defaultBehavior: 'deny_all',
131
- rules: [rule],
214
+ rules: [buildRule(ozConstraints)],
132
215
  };
133
- return { ir, ambiguities, warnings };
216
+ const interpreterIr = {
217
+ chain: 'stellar',
218
+ defaultBehavior: 'deny_all',
219
+ rules: [buildRule(interpreterConstraints)],
220
+ };
221
+ return { ir, interpreterIr, ambiguities, warnings };
134
222
  }
135
223
  /** Add the constraints justified by the recording that the OZ backend cannot
136
- * express natively (the SEP-41 recipient the transfer moved to). The OZ adapter
137
- * flags them as not covered. SoroSwap's slippage / oracle / exact-path needs are
138
- * surfaced as descriptive warnings, NOT as fabricated comparison nodes. */
139
- function appendProtocolSpecificConstraints(constraints, warnings, facts, topLevel, protocol) {
224
+ * express natively. Each constraint is routed to either `ozConstraints` (the
225
+ * OZ adapter lowers it) or `interpreterConstraints` (the interpreter adapter
226
+ * lowers it). When `interpreterEnabled` is false, every protocol-specific
227
+ * constraint is routed to `ozConstraints` (which flags it as uncovered) so
228
+ * callers who haven't opted in keep today's warning-driven behaviour.
229
+ * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
230
+ * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
231
+ * call_arg[2]). */
232
+ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled) {
140
233
  // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
141
- // a single-element allowlist; the OZ adapter flags it as not covered.
234
+ // a single-element allowlist; the interpreter adapter lowers it to `in`.
235
+ // When interpreter is not enabled, route to OZ so the caller sees today's
236
+ // `value allowlist on arg 1 (arg allowlist)` warning.
142
237
  if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
143
238
  const toArg = topLevel.args[1];
144
239
  if (toArg && toArg.type === 'address') {
145
- constraints.push({
240
+ const cond = {
146
241
  op: 'in',
147
242
  selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
148
243
  values: [toArg.value],
149
- });
244
+ };
245
+ if (interpreterEnabled) {
246
+ interpreterConstraints.push(cond);
247
+ }
248
+ else {
249
+ ozConstraints.push(cond);
250
+ }
150
251
  }
151
252
  }
152
- // SoroSwap: the swap's slippage / oracle-price bound and its exact hop path
153
- // need the interpreter predicate. We do NOT fabricate a comparison node
154
- // (oracle price and amountOutMin are different units; a vec path is not a
155
- // scalar compare). Surface the observed path descriptively instead.
253
+ // SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
254
+ // The interpreter adapter is the ONLY way to express an exact ordered
255
+ // sequence (OZ built-ins cannot). Element order is preserved verbatim.
256
+ // When multiple paths were recorded, the intersection is emitted as the
257
+ // canonical single path only if all observations agree; otherwise each is
258
+ // surfaced descriptively.
156
259
  if (protocol.protocol === 'soroswap') {
157
260
  const paths = facts.allowedPaths?.[topLevel.contract];
158
261
  const route = paths?.length === 1 ? paths[0] : undefined;
159
- const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : '';
160
- warnings.push(`SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate${pathText}`);
262
+ if (route && route.length > 0 && interpreterEnabled) {
263
+ const pathArgIndex = 2; // SoroSwap swap_exact_tokens_for_tokens: args = [amount_in, amount_out_min, path, to, deadline]
264
+ interpreterConstraints.push({
265
+ op: 'eq_seq',
266
+ selector: { kind: 'arg', argIndex: pathArgIndex, scalarType: 'address' },
267
+ values: route,
268
+ });
269
+ }
270
+ else {
271
+ const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : '';
272
+ warnings.push(`SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate${pathText}`);
273
+ }
274
+ // Input-amount cap: bind the caller's limitAmount to the swap's input-amount
275
+ // argument as `call_arg[i] <= limit`. The index is function-specific -
276
+ // SoroSwap has three swap entrypoints: `swap_exact_tokens_for_tokens` and
277
+ // `swap_exact_in_for_tokens` take the exact input as arg[0], while
278
+ // `swap_tokens_for_exact_tokens` takes the MAXIMUM input (`amount_in_max`)
279
+ // as arg[1] (its arg[0] is the exact OUTPUT, so binding arg[0] there would
280
+ // cap the wrong value and leave the input unbounded). This is a per-call cap
281
+ // that does NOT depend on attributing a token movement to the source account
282
+ // (the fee-sponsored / holder != source case, where the window_spent path
283
+ // detects no spend). Fail-closed: it only ever restricts the permitted input.
284
+ // Routed to the interpreter predicate (OZ built-ins cannot express a per-arg
285
+ // i128 bound); when the interpreter is not enabled it goes to OZ, which flags
286
+ // it uncovered (a warning). The `type === 'i128'` check is defense in depth -
287
+ // protocol identification's argsMatchAbi already pins the input arg to i128.
288
+ const inputArgIndex = soroswapInputAmountArgIndex(protocol.fn);
289
+ const inputAmountArg = inputArgIndex !== undefined ? topLevel.args[inputArgIndex] : undefined;
290
+ if (swapInputAmountCap !== undefined &&
291
+ inputArgIndex !== undefined &&
292
+ inputAmountArg &&
293
+ inputAmountArg.type === 'i128') {
294
+ const cond = {
295
+ op: 'compare',
296
+ compare: {
297
+ selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
298
+ operator: 'lte',
299
+ value: swapInputAmountCap,
300
+ },
301
+ };
302
+ if (interpreterEnabled) {
303
+ interpreterConstraints.push(cond);
304
+ }
305
+ else {
306
+ ozConstraints.push(cond);
307
+ }
308
+ }
309
+ // Swap recipient (call_arg[3]): when the caller supplies
310
+ // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
311
+ // arg. When absent, surface RECIPIENT_ALLOWLIST_EMPTY so the caller is
312
+ // prompted; proceeding without an allowlist leaves the recipient
313
+ // unconstrained in the predicate. The ambiguity is only surfaced when
314
+ // the interpreter is enabled - if it isn't, the swap recipient is just
315
+ // ignored (today's behaviour).
316
+ const recipientArg = topLevel.args[3];
317
+ if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
318
+ const cond = {
319
+ op: 'in',
320
+ selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
321
+ values: [...swapRecipientAllowlist],
322
+ };
323
+ if (interpreterEnabled) {
324
+ interpreterConstraints.push(cond);
325
+ }
326
+ else {
327
+ ozConstraints.push(cond);
328
+ }
329
+ }
330
+ else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
331
+ ambiguities.push({
332
+ code: 'RECIPIENT_ALLOWLIST_EMPTY',
333
+ question: `Recording shows the swap sending to ${recipientArg.value}. What recipient allowlist should the interpreter predicate bind? (omit to leave recipient unconstrained)`,
334
+ });
335
+ }
336
+ }
337
+ }
338
+ /** Positional index of the input-amount argument for a recognized SoroSwap swap
339
+ * function. `swap_exact_tokens_for_tokens` and `swap_exact_in_for_tokens` take
340
+ * the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
341
+ * input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
342
+ * function has no positional input-amount argument -> undefined (no cap bound). */
343
+ function soroswapInputAmountArgIndex(fn) {
344
+ switch (fn) {
345
+ case 'swap_exact_tokens_for_tokens':
346
+ case 'swap_exact_in_for_tokens':
347
+ return 0;
348
+ case 'swap_tokens_for_exact_tokens':
349
+ return 1;
350
+ default:
351
+ return undefined;
161
352
  }
162
353
  }
@@ -0,0 +1,12 @@
1
+ import type { PredicateNode } from '../types.ts';
2
+ import type { EvalContext } from './evaluate.ts';
3
+ export interface DenyCase {
4
+ dimension: string;
5
+ ctx: EvalContext;
6
+ }
7
+ export interface GeneratedCases {
8
+ permit: EvalContext;
9
+ denies: DenyCase[];
10
+ }
11
+ /** Build deterministic model-evaluated alternatives without mutating the intended call. */
12
+ export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext): GeneratedCases;