@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
@@ -0,0 +1,447 @@
1
+ import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
2
+ import type { EvalContext } from './evaluate.ts'
3
+ import { literalNumericBigInt } from './predicate-literals.ts'
4
+
5
+ export interface DenyCase {
6
+ dimension: string
7
+ ctx: EvalContext
8
+ }
9
+
10
+ export interface GeneratedCases {
11
+ permit: EvalContext
12
+ denies: DenyCase[]
13
+ }
14
+
15
+ type ComparisonOperator = 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
16
+
17
+ type ComparisonNode = {
18
+ op: ComparisonOperator
19
+ left: PredicateLeaf
20
+ right: PredicateLeaf
21
+ }
22
+
23
+ type MembershipNode = {
24
+ op: 'in'
25
+ needle: PredicateLeaf
26
+ haystack: PredicateLeaf[]
27
+ }
28
+
29
+ interface PredicateFacts {
30
+ comparisons: ComparisonNode[]
31
+ memberships: MembershipNode[]
32
+ }
33
+
34
+ const ORACLE_CASES = [
35
+ ['oracle_stale', 'stale'],
36
+ ['oracle_missing', 'missing'],
37
+ ['oracle_deviation_exceeded', 'deviation'],
38
+ ['oracle_paused', 'paused'],
39
+ ] as const
40
+
41
+ // Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
42
+ const ADJACENT_ASSETS = [
43
+ 'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
44
+ 'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
45
+ ] as const
46
+
47
+ /** Build deterministic model-evaluated alternatives without mutating the intended call. */
48
+ export function generateCases(predicate: PredicateNode, permitCtx: EvalContext): GeneratedCases {
49
+ const facts = inspectPredicate(predicate)
50
+ const denies: DenyCase[] = []
51
+
52
+ for (const comparison of facts.comparisons) {
53
+ if (comparison.left.kind !== 'amount') continue
54
+ const mutated = mutateBigIntRecord(
55
+ permitCtx,
56
+ 'amountByToken',
57
+ comparison.left.token,
58
+ comparison
59
+ )
60
+ if (mutated) denies.push({ dimension: 'amount', ctx: mutated })
61
+ }
62
+
63
+ const movedTokens = new Set<string>()
64
+ for (const comparison of facts.comparisons) {
65
+ if (comparison.left.kind === 'amount' || comparison.left.kind === 'window_spent') {
66
+ movedTokens.add(comparison.left.token)
67
+ }
68
+ }
69
+ for (const token of movedTokens) {
70
+ denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) })
71
+ }
72
+
73
+ const contractConstraints = [
74
+ ...facts.comparisons.filter(
75
+ (node) =>
76
+ node.op === 'eq' &&
77
+ node.left.kind === 'call_contract' &&
78
+ node.right.kind === 'literal_address'
79
+ ),
80
+ ...facts.memberships.filter((node) => node.needle.kind === 'call_contract'),
81
+ ]
82
+ for (const _constraint of contractConstraints) {
83
+ const ctx = cloneContext(permitCtx)
84
+ ctx.contract = distinctText(permitCtx.contract, 'contract')
85
+ denies.push({ dimension: 'contract', ctx })
86
+ }
87
+
88
+ const functionConstraints = [
89
+ ...facts.comparisons.filter(
90
+ (node) =>
91
+ node.op === 'eq' && node.left.kind === 'call_fn' && node.right.kind === 'literal_symbol'
92
+ ),
93
+ ...facts.memberships.filter((node) => node.needle.kind === 'call_fn'),
94
+ ]
95
+ for (const _constraint of functionConstraints) {
96
+ const ctx = cloneContext(permitCtx)
97
+ ctx.fn = distinctText(permitCtx.fn, 'function')
98
+ denies.push({ dimension: 'function', ctx })
99
+ }
100
+
101
+ if (permitCtx.validUntilLedger !== undefined) {
102
+ const ctx = cloneContext(permitCtx)
103
+ ctx.atLedger = permitCtx.validUntilLedger + 1
104
+ denies.push({ dimension: 'timing', ctx })
105
+ }
106
+
107
+ for (const comparison of facts.comparisons) {
108
+ if (comparison.left.kind !== 'window_spent') continue
109
+ const mutated = mutateBigIntRecord(
110
+ permitCtx,
111
+ 'windowSpentByToken',
112
+ comparison.left.token,
113
+ comparison,
114
+ false
115
+ )
116
+ if (mutated) denies.push({ dimension: 'time_window', ctx: mutated })
117
+ }
118
+
119
+ for (const comparison of facts.comparisons) {
120
+ if (comparison.left.kind !== 'invocation_count_in_window') continue
121
+ if (comparison.right.kind !== 'literal_u32') continue
122
+ const ctx = cloneContext(permitCtx)
123
+ ctx.invocationCountByWindow[comparison.left.windowSecs] = violatingNumber(
124
+ comparison.op,
125
+ comparison.right.value
126
+ )
127
+ denies.push({ dimension: 'invocation_count', ctx })
128
+ }
129
+
130
+ // Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
131
+ // `call_arg[0] <= limit`). A violating deny case pushes the arg past the
132
+ // bound so the leaf is exercised - and, critically, so `minimize` keeps it:
133
+ // a conjunct that no deny case needs is pruned as redundant, which would
134
+ // silently drop a caller-requested restriction.
135
+ for (const comparison of facts.comparisons) {
136
+ if (comparison.op === 'eq' || comparison.left.kind !== 'call_arg') continue
137
+ const violating = violatingArgScVal(comparison.op, comparison.right)
138
+ if (!violating) continue
139
+ const ctx = cloneContext(permitCtx)
140
+ ctx.args[comparison.left.index] = violating
141
+ denies.push({ dimension: 'arg_amount_bound', ctx })
142
+ }
143
+
144
+ const argumentConstraints: Array<{ index: number }> = []
145
+ for (const comparison of facts.comparisons) {
146
+ if (
147
+ comparison.op === 'eq' &&
148
+ comparison.left.kind === 'call_arg' &&
149
+ comparison.right.kind !== 'literal_vec'
150
+ ) {
151
+ argumentConstraints.push({ index: comparison.left.index })
152
+ }
153
+ }
154
+ for (const membership of facts.memberships) {
155
+ if (membership.needle.kind === 'call_arg') {
156
+ argumentConstraints.push({ index: membership.needle.index })
157
+ }
158
+ }
159
+ for (const constraint of argumentConstraints) {
160
+ const ctx = cloneContext(permitCtx)
161
+ ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' }
162
+ denies.push({ dimension: 'arg_bound', ctx })
163
+ }
164
+
165
+ const scopedArgumentIndices = new Set<number>(argumentConstraints.map(({ index }) => index))
166
+ for (const comparison of facts.comparisons) {
167
+ if (
168
+ comparison.op === 'eq' &&
169
+ comparison.left.kind === 'call_arg' &&
170
+ comparison.right.kind === 'literal_vec'
171
+ ) {
172
+ scopedArgumentIndices.add(comparison.left.index)
173
+ }
174
+ }
175
+ if (
176
+ contractConstraints.length > 0 &&
177
+ functionConstraints.length > 0 &&
178
+ scopedArgumentIndices.size > 0
179
+ ) {
180
+ const ctx = cloneContext(permitCtx)
181
+ ctx.contract = distinctText(permitCtx.contract, 'authorized-call-contract')
182
+ ctx.fn = distinctText(permitCtx.fn, 'authorized-call-function')
183
+ for (const index of scopedArgumentIndices) {
184
+ ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' }
185
+ }
186
+ denies.push({ dimension: 'scope_contract_fn_arg', ctx })
187
+ }
188
+
189
+ const oracleComparisons = facts.comparisons.filter((node) => node.left.kind === 'oracle_price')
190
+ for (const [dimension, error] of ORACLE_CASES) {
191
+ for (const comparison of oracleComparisons) {
192
+ if (comparison.left.kind !== 'oracle_price') continue
193
+ const ctx = cloneContext(permitCtx)
194
+ ctx.oraclePriceByAsset[comparison.left.asset] = { error }
195
+ denies.push({ dimension, ctx })
196
+ }
197
+ }
198
+
199
+ for (const comparison of facts.comparisons) {
200
+ if (
201
+ comparison.op !== 'eq' ||
202
+ comparison.left.kind !== 'call_arg' ||
203
+ comparison.right.kind !== 'literal_vec'
204
+ ) {
205
+ continue
206
+ }
207
+ const ctx = cloneContext(permitCtx)
208
+ ctx.args[comparison.left.index] = differentVector(ctx.args[comparison.left.index])
209
+ denies.push({ dimension: 'soroswap_allowed_path', ctx })
210
+ }
211
+
212
+ // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
213
+ return { permit: cloneContext(permitCtx), denies }
214
+ }
215
+
216
+ function inspectPredicate(predicate: PredicateNode): PredicateFacts {
217
+ const facts: PredicateFacts = { comparisons: [], memberships: [] }
218
+ visit(predicate, facts)
219
+ return facts
220
+ }
221
+
222
+ function visit(node: PredicateNode, facts: PredicateFacts): void {
223
+ switch (node.op) {
224
+ case 'and':
225
+ case 'or':
226
+ for (const child of node.children) visit(child, facts)
227
+ return
228
+ case 'not':
229
+ visit(node.child, facts)
230
+ return
231
+ case 'in':
232
+ facts.memberships.push(node)
233
+ return
234
+ case 'eq':
235
+ case 'lt':
236
+ case 'lte':
237
+ case 'gt':
238
+ case 'gte':
239
+ facts.comparisons.push(node)
240
+ }
241
+ }
242
+
243
+ function mutateBigIntRecord(
244
+ permitCtx: EvalContext,
245
+ recordKey: 'amountByToken' | 'windowSpentByToken',
246
+ token: string,
247
+ comparison: ComparisonNode,
248
+ preferScaledAmount = true
249
+ ): EvalContext | null {
250
+ if (comparison.right.kind !== 'literal_i128') return null
251
+
252
+ try {
253
+ const current = BigInt(permitCtx[recordKey][token] ?? '0')
254
+ const bound = BigInt(comparison.right.value)
255
+ const value = violatingBigInt(comparison.op, current, bound, preferScaledAmount)
256
+ const ctx = cloneContext(permitCtx)
257
+ ctx[recordKey][token] = value.toString()
258
+ return ctx
259
+ } catch {
260
+ return null
261
+ }
262
+ }
263
+
264
+ function violatingBigInt(
265
+ op: ComparisonOperator,
266
+ current: bigint,
267
+ bound: bigint,
268
+ preferScaledAmount: boolean
269
+ ): bigint {
270
+ if (preferScaledAmount) {
271
+ const scaledCandidates = [(current * 101n) / 100n, current * 10n]
272
+ for (const candidate of scaledCandidates) {
273
+ if (!bigIntComparison(op, candidate, bound)) return candidate
274
+ }
275
+ }
276
+
277
+ return boundaryViolatingBigInt(op, bound)
278
+ }
279
+
280
+ /** The single value at the boundary that violates `<selector> op bound`:
281
+ * bound+1 for `lte`/`eq`, bound-1 for `gte`, and bound itself for the strict
282
+ * `lt`/`gt`. */
283
+ function boundaryViolatingBigInt(op: ComparisonOperator, bound: bigint): bigint {
284
+ switch (op) {
285
+ case 'lt':
286
+ case 'gt':
287
+ return bound
288
+ case 'lte':
289
+ case 'eq':
290
+ return bound + 1n
291
+ case 'gte':
292
+ return bound - 1n
293
+ }
294
+ }
295
+
296
+ function violatingNumber(op: ComparisonOperator, bound: number): number {
297
+ switch (op) {
298
+ case 'lt':
299
+ case 'gt':
300
+ return bound
301
+ case 'lte':
302
+ case 'eq':
303
+ return bound + 1
304
+ case 'gte':
305
+ return bound - 1
306
+ }
307
+ }
308
+
309
+ /** Build an ScVal that VIOLATES an ordered numeric bound on a call_arg, given
310
+ * the comparison op and its numeric-literal right-hand side. Returns null when
311
+ * the literal is not an integer (the bound is not a numeric compare). The arg
312
+ * is emitted as an i128 - the recorder's numeric args are read via BigInt, so
313
+ * the wire type only needs to be a numeric ScVal to exercise the bound. */
314
+ function violatingArgScVal(op: ComparisonOperator, right: PredicateLeaf): ScVal | null {
315
+ const bound = literalNumericBigInt(right)
316
+ if (bound === null) return null
317
+ return { type: 'i128', value: boundaryViolatingBigInt(op, bound).toString() }
318
+ }
319
+
320
+ function bigIntComparison(op: ComparisonOperator, value: bigint, bound: bigint): boolean {
321
+ switch (op) {
322
+ case 'eq':
323
+ return value === bound
324
+ case 'lt':
325
+ return value < bound
326
+ case 'lte':
327
+ return value <= bound
328
+ case 'gt':
329
+ return value > bound
330
+ case 'gte':
331
+ return value >= bound
332
+ }
333
+ }
334
+
335
+ function mutateAsset(predicate: PredicateNode, permitCtx: EvalContext, token: string): EvalContext {
336
+ const binding = findAddressBinding(predicate, token)
337
+ const adjacent = adjacentAsset(token)
338
+ const ctx = cloneContext(permitCtx)
339
+ moveRecordEntry(ctx.amountByToken, token, adjacent)
340
+ moveRecordEntry(ctx.windowSpentByToken, token, adjacent)
341
+ if (binding.contract && ctx.contract === token) ctx.contract = adjacent
342
+ for (const index of binding.argumentIndices) {
343
+ const value = ctx.args[index]
344
+ if (value) ctx.args[index] = replaceAddress(value, token, adjacent)
345
+ }
346
+ return ctx
347
+ }
348
+
349
+ function findAddressBinding(
350
+ predicate: PredicateNode,
351
+ address: string
352
+ ): { contract: boolean; argumentIndices: Set<number> } {
353
+ const facts = inspectPredicate(predicate)
354
+ let contract = false
355
+ const argumentIndices = new Set<number>()
356
+
357
+ for (const comparison of facts.comparisons) {
358
+ if (comparison.op !== 'eq' || !leafContainsAddress(comparison.right, address)) continue
359
+ if (comparison.left.kind === 'call_contract') contract = true
360
+ if (comparison.left.kind === 'call_arg') argumentIndices.add(comparison.left.index)
361
+ }
362
+ for (const membership of facts.memberships) {
363
+ if (!membership.haystack.some((leaf) => leafContainsAddress(leaf, address))) continue
364
+ if (membership.needle.kind === 'call_contract') contract = true
365
+ if (membership.needle.kind === 'call_arg') argumentIndices.add(membership.needle.index)
366
+ }
367
+
368
+ return { contract, argumentIndices }
369
+ }
370
+
371
+ function leafContainsAddress(leaf: PredicateLeaf, address: string): boolean {
372
+ if (leaf.kind === 'literal_address') return leaf.value === address
373
+ if (leaf.kind === 'literal_vec') {
374
+ return leaf.elements.some((element) => leafContainsAddress(element, address))
375
+ }
376
+ return false
377
+ }
378
+
379
+ function moveRecordEntry(record: Record<string, string>, from: string, to: string): void {
380
+ const value = record[from]
381
+ if (value === undefined) return
382
+ delete record[from]
383
+ record[to] = value
384
+ }
385
+
386
+ function replaceAddress(value: ScVal, from: string, to: string): ScVal {
387
+ if (value.type === 'address') {
388
+ return value.value === from ? { type: 'address', value: to } : { ...value }
389
+ }
390
+ if (value.type === 'vec') {
391
+ return { type: 'vec', value: value.value.map((item) => replaceAddress(item, from, to)) }
392
+ }
393
+ return { ...value }
394
+ }
395
+
396
+ function differentVector(actual: ScVal | undefined): ScVal {
397
+ if (actual?.type !== 'vec') return { type: 'vec', value: [] }
398
+ if (actual.value.length === 0) {
399
+ return { type: 'vec', value: [{ type: 'other', value: 'deny-case-extra-hop' }] }
400
+ }
401
+ if (actual.value.length > 1) {
402
+ const reversed = actual.value.map(cloneScVal).reverse()
403
+ if (JSON.stringify(reversed) !== JSON.stringify(actual.value)) {
404
+ return { type: 'vec', value: reversed }
405
+ }
406
+ }
407
+ const value = actual.value.map(cloneScVal)
408
+ value[0] = { type: 'other', value: 'deny-case-different-hop' }
409
+ return { type: 'vec', value }
410
+ }
411
+
412
+ function adjacentAsset(asset: string): string {
413
+ return asset === ADJACENT_ASSETS[0] ? ADJACENT_ASSETS[1] : ADJACENT_ASSETS[0]
414
+ }
415
+
416
+ function distinctText(value: string, label: string): string {
417
+ return `${value}#${label}`
418
+ }
419
+
420
+ function cloneContext(ctx: EvalContext): EvalContext {
421
+ const cloned: EvalContext = {
422
+ contract: ctx.contract,
423
+ fn: ctx.fn,
424
+ args: ctx.args.map(cloneScVal),
425
+ atLedger: ctx.atLedger,
426
+ nowSeconds: ctx.nowSeconds,
427
+ amountByToken: { ...ctx.amountByToken },
428
+ windowSpentByToken: { ...ctx.windowSpentByToken },
429
+ invocationCountByWindow: { ...ctx.invocationCountByWindow },
430
+ oraclePriceByAsset: Object.fromEntries(
431
+ Object.entries(ctx.oraclePriceByAsset).map(([asset, entry]) => [
432
+ asset,
433
+ 'error' in entry ? { error: entry.error } : { ...entry },
434
+ ])
435
+ ),
436
+ }
437
+ if (ctx.validUntilLedger !== undefined) cloned.validUntilLedger = ctx.validUntilLedger
438
+ if (ctx.signerWeights !== undefined) cloned.signerWeights = { ...ctx.signerWeights }
439
+ return cloned
440
+ }
441
+
442
+ function cloneScVal(value: ScVal): ScVal {
443
+ if (value.type === 'vec') {
444
+ return { type: 'vec', value: value.value.map(cloneScVal) }
445
+ }
446
+ return { ...value }
447
+ }