@crediolabs/policy-synth 0.1.3 → 0.1.5

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 (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -5
  3. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  4. package/dist/adapters/interpreter/adapter.js +522 -0
  5. package/dist/adapters/interpreter/index.d.ts +1 -0
  6. package/dist/adapters/interpreter/index.js +2 -0
  7. package/dist/adapters/oz/adapter.js +2 -0
  8. package/dist/codegen/compile-gate.d.ts +33 -0
  9. package/dist/codegen/compile-gate.js +119 -0
  10. package/dist/codegen/index.d.ts +2 -0
  11. package/dist/codegen/index.js +8 -0
  12. package/dist/codegen/template.d.ts +18 -0
  13. package/dist/codegen/template.js +131 -0
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/ir/types.d.ts +13 -2
  18. package/dist/predicate/encode.d.ts +10 -0
  19. package/dist/predicate/encode.js +249 -0
  20. package/dist/predicate/index.d.ts +1 -0
  21. package/dist/predicate/index.js +2 -0
  22. package/dist/record/decode.d.ts +6 -3
  23. package/dist/record/decode.js +13 -3
  24. package/dist/review-card/builder.d.ts +14 -0
  25. package/dist/review-card/builder.js +261 -0
  26. package/dist/review-card/conflict.d.ts +40 -0
  27. package/dist/review-card/conflict.js +111 -0
  28. package/dist/review-card/cross-check.d.ts +11 -0
  29. package/dist/review-card/cross-check.js +148 -0
  30. package/dist/review-card/index.d.ts +3 -0
  31. package/dist/review-card/index.js +4 -0
  32. package/dist/run/index.d.ts +31 -0
  33. package/dist/run/index.js +164 -0
  34. package/dist/run/schemas.d.ts +2003 -0
  35. package/dist/run/schemas.js +215 -0
  36. package/dist/synth/compose-from-recording.d.ts +35 -7
  37. package/dist/synth/compose-from-recording.js +225 -34
  38. package/dist/synth/deny-cases.d.ts +12 -0
  39. package/dist/synth/deny-cases.js +377 -0
  40. package/dist/synth/evaluate.d.ts +39 -0
  41. package/dist/synth/evaluate.js +425 -0
  42. package/dist/synth/harness.d.ts +16 -0
  43. package/dist/synth/harness.js +26 -0
  44. package/dist/synth/index.d.ts +4 -0
  45. package/dist/synth/index.js +4 -0
  46. package/dist/synth/minimize.d.ts +4 -0
  47. package/dist/synth/minimize.js +38 -0
  48. package/dist/synth/predicate-literals.d.ts +5 -0
  49. package/dist/synth/predicate-literals.js +25 -0
  50. package/dist/synth/synthesize-from-recording.d.ts +37 -5
  51. package/dist/synth/synthesize-from-recording.js +550 -18
  52. package/dist/types.d.ts +40 -1
  53. package/dist/types.js +17 -0
  54. package/dist/verify/envelope.d.ts +15 -0
  55. package/dist/verify/envelope.js +22 -0
  56. package/dist/verify/index.d.ts +3 -0
  57. package/dist/verify/index.js +3 -0
  58. package/dist/verify/simulate.d.ts +31 -0
  59. package/dist/verify/simulate.js +258 -0
  60. package/dist/verify/verify.d.ts +21 -0
  61. package/dist/verify/verify.js +189 -0
  62. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  63. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  64. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  65. package/dist-cjs/adapters/interpreter/index.js +8 -0
  66. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  67. package/dist-cjs/adapters/oz/adapter.js +289 -0
  68. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  69. package/dist-cjs/adapters/oz/index.js +8 -0
  70. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  71. package/dist-cjs/codegen/compile-gate.js +123 -0
  72. package/dist-cjs/codegen/index.d.ts +2 -0
  73. package/dist-cjs/codegen/index.js +14 -0
  74. package/dist-cjs/codegen/template.d.ts +18 -0
  75. package/dist-cjs/codegen/template.js +134 -0
  76. package/dist-cjs/errors.d.ts +37 -0
  77. package/dist-cjs/errors.js +3 -0
  78. package/dist-cjs/index.d.ts +11 -0
  79. package/dist-cjs/index.js +27 -0
  80. package/dist-cjs/ir/index.d.ts +1 -0
  81. package/dist-cjs/ir/index.js +3 -0
  82. package/dist-cjs/ir/types.d.ts +108 -0
  83. package/dist-cjs/ir/types.js +12 -0
  84. package/dist-cjs/mandate/index.d.ts +2 -0
  85. package/dist-cjs/mandate/index.js +6 -0
  86. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  87. package/dist-cjs/mandate/to-ir.js +63 -0
  88. package/dist-cjs/mandate/types.d.ts +20 -0
  89. package/dist-cjs/mandate/types.js +9 -0
  90. package/dist-cjs/package.json +3 -0
  91. package/dist-cjs/predicate/encode.d.ts +10 -0
  92. package/dist-cjs/predicate/encode.js +252 -0
  93. package/dist-cjs/predicate/index.d.ts +1 -0
  94. package/dist-cjs/predicate/index.js +6 -0
  95. package/dist-cjs/record/decode.d.ts +79 -0
  96. package/dist-cjs/record/decode.js +398 -0
  97. package/dist-cjs/record/freshness.d.ts +17 -0
  98. package/dist-cjs/record/freshness.js +55 -0
  99. package/dist-cjs/record/index.d.ts +21 -0
  100. package/dist-cjs/record/index.js +166 -0
  101. package/dist-cjs/record/movements.d.ts +20 -0
  102. package/dist-cjs/record/movements.js +192 -0
  103. package/dist-cjs/record/rpc.d.ts +22 -0
  104. package/dist-cjs/record/rpc.js +74 -0
  105. package/dist-cjs/record/validate.d.ts +22 -0
  106. package/dist-cjs/record/validate.js +63 -0
  107. package/dist-cjs/registry/identify.d.ts +11 -0
  108. package/dist-cjs/registry/identify.js +87 -0
  109. package/dist-cjs/registry/index.d.ts +3 -0
  110. package/dist-cjs/registry/index.js +15 -0
  111. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  112. package/dist-cjs/registry/known-addresses.js +53 -0
  113. package/dist-cjs/registry/protocols.d.ts +38 -0
  114. package/dist-cjs/registry/protocols.js +153 -0
  115. package/dist-cjs/review-card/builder.d.ts +14 -0
  116. package/dist-cjs/review-card/builder.js +264 -0
  117. package/dist-cjs/review-card/conflict.d.ts +40 -0
  118. package/dist-cjs/review-card/conflict.js +114 -0
  119. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  120. package/dist-cjs/review-card/cross-check.js +151 -0
  121. package/dist-cjs/review-card/index.d.ts +3 -0
  122. package/dist-cjs/review-card/index.js +10 -0
  123. package/dist-cjs/run/index.d.ts +31 -0
  124. package/dist-cjs/run/index.js +178 -0
  125. package/dist-cjs/run/schemas.d.ts +2003 -0
  126. package/dist-cjs/run/schemas.js +218 -0
  127. package/dist-cjs/seams/index.d.ts +1 -0
  128. package/dist-cjs/seams/index.js +3 -0
  129. package/dist-cjs/seams/types.d.ts +66 -0
  130. package/dist-cjs/seams/types.js +12 -0
  131. package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
  132. package/dist-cjs/synth/compose-from-recording.js +356 -0
  133. package/dist-cjs/synth/deny-cases.d.ts +12 -0
  134. package/dist-cjs/synth/deny-cases.js +380 -0
  135. package/dist-cjs/synth/evaluate.d.ts +39 -0
  136. package/dist-cjs/synth/evaluate.js +428 -0
  137. package/dist-cjs/synth/harness.d.ts +16 -0
  138. package/dist-cjs/synth/harness.js +29 -0
  139. package/dist-cjs/synth/index.d.ts +9 -0
  140. package/dist-cjs/synth/index.js +23 -0
  141. package/dist-cjs/synth/lower.d.ts +23 -0
  142. package/dist-cjs/synth/lower.js +119 -0
  143. package/dist-cjs/synth/minimize.d.ts +4 -0
  144. package/dist-cjs/synth/minimize.js +41 -0
  145. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  146. package/dist-cjs/synth/predicate-literals.js +28 -0
  147. package/dist-cjs/synth/scope.d.ts +26 -0
  148. package/dist-cjs/synth/scope.js +81 -0
  149. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  150. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  151. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  152. package/dist-cjs/synth/synthesize-from-recording.js +711 -0
  153. package/dist-cjs/types.d.ts +288 -0
  154. package/dist-cjs/types.js +55 -0
  155. package/dist-cjs/verify/envelope.d.ts +15 -0
  156. package/dist-cjs/verify/envelope.js +23 -0
  157. package/dist-cjs/verify/index.d.ts +3 -0
  158. package/dist-cjs/verify/index.js +8 -0
  159. package/dist-cjs/verify/simulate.d.ts +31 -0
  160. package/dist-cjs/verify/simulate.js +261 -0
  161. package/dist-cjs/verify/verify.d.ts +21 -0
  162. package/dist-cjs/verify/verify.js +192 -0
  163. package/package.json +43 -3
  164. package/src/adapters/interpreter/adapter.ts +642 -0
  165. package/src/adapters/interpreter/index.ts +8 -0
  166. package/src/adapters/oz/adapter.ts +2 -0
  167. package/src/codegen/compile-gate.ts +162 -0
  168. package/src/codegen/index.ts +17 -0
  169. package/src/codegen/template.ts +148 -0
  170. package/src/errors.ts +2 -0
  171. package/src/index.ts +2 -0
  172. package/src/ir/types.ts +9 -2
  173. package/src/predicate/encode.ts +307 -0
  174. package/src/predicate/index.ts +3 -0
  175. package/src/record/decode.ts +21 -10
  176. package/src/review-card/builder.ts +303 -0
  177. package/src/review-card/conflict.ts +143 -0
  178. package/src/review-card/cross-check.ts +158 -0
  179. package/src/review-card/index.ts +12 -0
  180. package/src/run/index.ts +213 -0
  181. package/src/run/schemas.ts +242 -0
  182. package/src/synth/compose-from-recording.ts +277 -43
  183. package/src/synth/deny-cases.ts +466 -0
  184. package/src/synth/evaluate.ts +493 -0
  185. package/src/synth/harness.ts +40 -0
  186. package/src/synth/index.ts +12 -0
  187. package/src/synth/minimize.ts +44 -0
  188. package/src/synth/predicate-literals.ts +27 -0
  189. package/src/synth/synthesize-from-recording.ts +655 -19
  190. package/src/types.ts +38 -2
  191. package/src/verify/envelope.ts +28 -0
  192. package/src/verify/index.ts +5 -0
  193. package/src/verify/simulate.ts +311 -0
  194. package/src/verify/verify.ts +243 -0
@@ -0,0 +1,493 @@
1
+ // src/synth/evaluate.ts - the model evaluator.
2
+ //
3
+ // Pure function. The single semantic evaluator every deny-case runs through
4
+ // (TS model of the interpreter's semantics; real-semantics wiring is Phase 03).
5
+ // Determinism: same `(predicate, ctx)` -> byte-identical result, no clock, no
6
+ // randomness.
7
+ //
8
+ // Deny order (deny on FIRST violation, stable `reason` string):
9
+ // 1. `atLedger > validUntilLedger` (when set) -> 'EXPIRED'
10
+ // 2. a `now` leaf compared past `valid_until` semantics -> 'EXPIRED'
11
+ // 3. a `call_contract` eq whose literal != ctx.contract -> 'CONTRACT_SCOPE'
12
+ // 4. per-ScVal equality on `call_fn` / `call_arg[i]`;
13
+ // `eq(selector, literal_vec)` = EXACT ordered vector equality
14
+ // (compare element-by-element, deny if length or any differs);
15
+ // fail-closed on opaque/undecodable args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
16
+ // 5. `in` membership: needle NOT in haystack; an EMPTY
17
+ // haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
18
+ // 6. amount / window_spent comparisons via BigInt -> 'AMOUNT_BOUND'
19
+ // 7. `invocation_count_in_window` compare -> 'FREQUENCY'
20
+ // 8. `oracle_price`: missing/stale/deviation/etc. is FATAL -
21
+ // throw `OracleError`, catch at the top, deny with the
22
+ // underlying `ORACLE_*` reason -> 'ORACLE_*'
23
+ // 9. boolean nodes: `and` = all children permit;
24
+ // `or` = any child permits; `not` = invert a non-oracle child
25
+ // 10. signer threshold gate (when `signerWeights` provided,
26
+ // no positive-weight signer -> deny 'THRESHOLD_NOT_MET')
27
+ // 11. otherwise `{ permit: true }`.
28
+ //
29
+ // Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
30
+ // modelled by throwing (never a boolean-false that `not`/`or` could mask).
31
+
32
+ import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
33
+ import { literalNumericBigInt } from './predicate-literals.ts'
34
+
35
+ export interface EvalContext {
36
+ /** Contract the interpreter is asked to enforce against. */
37
+ contract: string
38
+ /** Function name on that contract. */
39
+ fn: string
40
+ /** Decoded `ScVal[]` of the top-level authorized call. */
41
+ args: ScVal[]
42
+ /** Ledger sequence at which the interpreter is invoked. */
43
+ atLedger: number
44
+ /** Optional policy expiry; absent -> step 1 is skipped. */
45
+ validUntilLedger?: number
46
+ /** Unix seconds; used by `now` / `valid_until` leaves. */
47
+ nowSeconds: number
48
+ /** Per-token amount moved by the current call (i128 decimal string). */
49
+ amountByToken: Record<string, string>
50
+ /** Per-token window-rolling spend prior to this call (i128 decimal string). */
51
+ windowSpentByToken: Record<string, string>
52
+ /** Recorded invocation counts keyed by window seconds. */
53
+ invocationCountByWindow: Record<number, number>
54
+ /** Optional signer-weight map for the threshold gate. Absent -> skip. */
55
+ signerWeights?: Record<string, number>
56
+ /** Per-asset oracle snapshot. Missing keys default to ORACLE_STALE. */
57
+ oraclePriceByAsset: Record<
58
+ string,
59
+ | { price: string; timestampSeconds: number }
60
+ | { error: 'stale' | 'missing' | 'deviation' | 'paused' | 'decimals' | 'fingerprint' }
61
+ >
62
+ }
63
+
64
+ export type EvalResult = { permit: true } | { permit: false; reason: string }
65
+
66
+ /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
67
+ * and converted to the matching `ORACLE_*` deny reason. NOT a
68
+ * boolean-false that `not` / `or` could mask. */
69
+ class OracleError extends Error {
70
+ readonly code: string
71
+ constructor(code: string) {
72
+ super(`oracle fatal: ${code}`)
73
+ this.code = code
74
+ }
75
+ }
76
+
77
+ const ORACLE_ERROR_CODES: Readonly<Record<string, string>> = {
78
+ stale: 'ORACLE_STALE',
79
+ missing: 'ORACLE_MISSING',
80
+ deviation: 'ORACLE_DEVIATION_EXCEEDED',
81
+ paused: 'ORACLE_PAUSED',
82
+ decimals: 'ORACLE_DECIMALS_MISMATCH',
83
+ fingerprint: 'ORACLE_FINGERPRINT_DRIFT',
84
+ }
85
+
86
+ /** Evaluate a `PredicateNode` against the candidate call described by `ctx`.
87
+ * Pure function. Returns `{ permit: true }` or `{ permit: false; reason }`. */
88
+ export function evaluate(predicate: PredicateNode, ctx: EvalContext): EvalResult {
89
+ try {
90
+ // --- step 1: ledger-time expiry ---
91
+ if (ctx.validUntilLedger !== undefined && ctx.atLedger > ctx.validUntilLedger) {
92
+ return { permit: false, reason: 'EXPIRED' }
93
+ }
94
+
95
+ // --- step 2..9: predicate tree ---
96
+ const decision = walk(predicate, ctx)
97
+
98
+ // --- step 10: signer threshold gate (only when the predicate permitted) ---
99
+ if (decision.permit && ctx.signerWeights !== undefined) {
100
+ let totalWeight = 0n
101
+ for (const w of Object.values(ctx.signerWeights)) {
102
+ // weights are non-negative integers; BigInt keeps the gate bounded
103
+ totalWeight += BigInt(w)
104
+ }
105
+ if (totalWeight === 0n) return { permit: false, reason: 'THRESHOLD_NOT_MET' }
106
+ }
107
+
108
+ return decision
109
+ } catch (e) {
110
+ if (e instanceof OracleError) {
111
+ return { permit: false, reason: e.code }
112
+ }
113
+ throw e
114
+ }
115
+ }
116
+
117
+ /** Walk the predicate tree. Returns the FIRST deny reason encountered on
118
+ * the active branch (so `and` fails-fast; `or` accepts the first permit). */
119
+ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
120
+ switch (node.op) {
121
+ case 'and': {
122
+ let lastDeny: EvalResult | null = null
123
+ for (const child of node.children) {
124
+ const r = walk(child, ctx)
125
+ if (!r.permit) {
126
+ // deny-on-first: short-circuit. The "active branch" is the failing
127
+ // child, so the reason we surface is from that child.
128
+ return r
129
+ }
130
+ // keep a reference to the last permit so unused-variable analysis
131
+ // doesn't complain; this is purely structural.
132
+ lastDeny = r
133
+ }
134
+ return lastDeny ?? { permit: true }
135
+ }
136
+ case 'or': {
137
+ let lastDeny: EvalResult | null = null
138
+ for (const child of node.children) {
139
+ const r = walk(child, ctx)
140
+ if (r.permit) return r
141
+ lastDeny = r
142
+ }
143
+ return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' }
144
+ }
145
+ case 'not': {
146
+ // `not` structurally inverts the child UNLESS the child contains an
147
+ // oracle leaf (compile-time rule: no oracle leaf under not/or). Here we
148
+ // walk the child; if it throws `OracleError`, the catch at the top of
149
+ // `evaluate` re-throws and surfaces the ORACLE_* deny. A non-oracle
150
+ // deny is inverted to a permit, and a permit is inverted to a deny.
151
+ const r = walk(node.child, ctx)
152
+ if (r.permit) return { permit: false, reason: 'FN_MISMATCH' }
153
+ return { permit: true }
154
+ }
155
+ case 'eq':
156
+ case 'lt':
157
+ case 'lte':
158
+ case 'gt':
159
+ case 'gte':
160
+ return evalCompare(node.op, node.left, node.right, ctx)
161
+ case 'in':
162
+ return evalIn(node.needle, node.haystack, ctx)
163
+ }
164
+ }
165
+
166
+ /** Step 2..8: comparison leaf evaluation. */
167
+ function evalCompare(
168
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
169
+ left: PredicateLeaf,
170
+ right: PredicateLeaf,
171
+ ctx: EvalContext
172
+ ): EvalResult {
173
+ // --- step 2: `now` vs `valid_until` semantics ---
174
+ if (left.kind === 'now' && right.kind === 'valid_until') {
175
+ const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0
176
+ // only `gt` / `gte` / `lt` / `lte` are meaningful here. Treat `eq` /
177
+ // any other combo as: "expired iff nowSeconds > validUntilSeconds".
178
+ // We model `valid_until` as a synthetic future timestamp far past
179
+ // `nowSeconds` so the only true-positive expired path is the
180
+ // `gt`/`gte` shapes callers actually write.
181
+ if (op === 'gt' || op === 'gte') {
182
+ if (ctx.nowSeconds > 0) return { permit: false, reason: 'EXPIRED' }
183
+ } else if (op === 'lt' || op === 'lte') {
184
+ // permits when valid_until is in the future
185
+ return { permit: true }
186
+ }
187
+ return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true }
188
+ }
189
+ if (right.kind === 'now' && left.kind === 'valid_until') {
190
+ return evalCompare(op, right, left, ctx)
191
+ }
192
+
193
+ // --- step 3: CONTRACT_SCOPE on call_contract eq ---
194
+ if (left.kind === 'call_contract' && op === 'eq') {
195
+ if (right.kind !== 'literal_address') return { permit: false, reason: 'CONTRACT_SCOPE' }
196
+ return right.value === ctx.contract
197
+ ? { permit: true }
198
+ : { permit: false, reason: 'CONTRACT_SCOPE' }
199
+ }
200
+
201
+ // --- step 4a: call_fn equality ---
202
+ if (left.kind === 'call_fn' && op === 'eq') {
203
+ if (right.kind !== 'literal_symbol') return { permit: false, reason: 'FN_MISMATCH' }
204
+ return right.value === ctx.fn ? { permit: true } : { permit: false, reason: 'FN_MISMATCH' }
205
+ }
206
+
207
+ // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
208
+ if (left.kind === 'call_arg') {
209
+ const actual = ctx.args[left.index]
210
+ // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
211
+ // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
212
+ // cap `call_arg[0] <= limit`). The per-ScVal-type equality semantics live
213
+ // in evalArgEq (`eq` only); ordered ops are numeric-only and fail closed on
214
+ // a non-numeric arg or literal.
215
+ if (op !== 'eq') return evalArgOrderedCompare(op, actual, right)
216
+ return evalArgEq(op, actual, right, ctx)
217
+ }
218
+
219
+ // --- step 6: AMOUNT_BOUND ---
220
+ if (left.kind === 'amount' && op !== 'eq') {
221
+ return evalAmountCompare(op, left.token, right, ctx)
222
+ }
223
+ if (left.kind === 'window_spent' && op !== 'eq') {
224
+ return evalWindowSpentCompare(op, left.token, right, ctx)
225
+ }
226
+ // `eq` on amount / window_spent not defined as a bound - fall through.
227
+
228
+ // --- step 7: FREQUENCY ---
229
+ if (left.kind === 'invocation_count_in_window') {
230
+ return evalFrequencyCompare(op, left.windowSecs, right, ctx)
231
+ }
232
+
233
+ // --- step 8: oracle_price (FATAL via throw) ---
234
+ if (left.kind === 'oracle_price') {
235
+ return evalOracleCompare(op, left.asset, right, ctx)
236
+ }
237
+
238
+ // Unknown leaf/op combination - structural fail-closed.
239
+ return { permit: false, reason: 'FN_MISMATCH' }
240
+ }
241
+
242
+ /** Step 4b: per-ScVal equality. Handles literal_vec as an EXACT ordered
243
+ * sequence: compare element-by-element in order; deny if length or any
244
+ * element differs. Opaque args (`type: 'other'`) fail closed. */
245
+ function evalArgEq(
246
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
247
+ actual: ScVal | undefined,
248
+ right: PredicateLeaf,
249
+ ctx: EvalContext
250
+ ): EvalResult {
251
+ // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
252
+ if (op === 'eq' && right.kind === 'literal_vec') {
253
+ if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
254
+ return compareVecExact(actual.value, right.elements, ctx)
255
+ ? { permit: true }
256
+ : { permit: false, reason: 'ARG_MISMATCH' }
257
+ }
258
+
259
+ // eq(call_arg[i], literal_address) -> address compare
260
+ if (op === 'eq' && right.kind === 'literal_address') {
261
+ if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
262
+ if (actual.type === 'other') return { permit: false, reason: 'ARG_MISMATCH' }
263
+ return actual.type === 'address' && actual.value === right.value
264
+ ? { permit: true }
265
+ : { permit: false, reason: 'ARG_MISMATCH' }
266
+ }
267
+
268
+ // eq(call_arg[i], literal_i128) -> BigInt compare
269
+ if (op === 'eq' && right.kind === 'literal_i128') {
270
+ if (actual?.type !== 'i128') return { permit: false, reason: 'ARG_MISMATCH' }
271
+ return BigInt(actual.value) === BigInt(right.value)
272
+ ? { permit: true }
273
+ : { permit: false, reason: 'ARG_MISMATCH' }
274
+ }
275
+
276
+ // eq(call_arg[i], literal_symbol)
277
+ if (op === 'eq' && right.kind === 'literal_symbol') {
278
+ if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
279
+ return actual.type === 'symbol' && actual.value === right.value
280
+ ? { permit: true }
281
+ : { permit: false, reason: 'ARG_MISMATCH' }
282
+ }
283
+
284
+ // eq(call_arg[i], literal_u32)
285
+ if (op === 'eq' && right.kind === 'literal_u32') {
286
+ if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
287
+ return actual.type === 'u32' && actual.value === String(right.value)
288
+ ? { permit: true }
289
+ : { permit: false, reason: 'ARG_MISMATCH' }
290
+ }
291
+
292
+ // eq(call_arg[i], literal_u64)
293
+ if (op === 'eq' && right.kind === 'literal_u64') {
294
+ if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
295
+ return actual.type === 'u64' && actual.value === right.value
296
+ ? { permit: true }
297
+ : { permit: false, reason: 'ARG_MISMATCH' }
298
+ }
299
+
300
+ // eq(call_arg[i], literal_bytes)
301
+ if (op === 'eq' && right.kind === 'literal_bytes') {
302
+ if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
303
+ return actual.type === 'bytes' && actual.value === right.value
304
+ ? { permit: true }
305
+ : { permit: false, reason: 'ARG_MISMATCH' }
306
+ }
307
+
308
+ // Anything else: fail closed on opacity (cannot decode the arg reliably).
309
+ if (!actual || actual.type === 'other') return { permit: false, reason: 'ARG_MISMATCH' }
310
+ return { permit: false, reason: 'ARG_MISMATCH' }
311
+ }
312
+
313
+ /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
314
+ * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
315
+ * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
316
+ * or a non-numeric literal fails closed (ARG_MISMATCH) rather than permitting
317
+ * an undecidable bound. Backs the SoroSwap input-amount cap
318
+ * (`call_arg[0] <= limit`). */
319
+ function evalArgOrderedCompare(
320
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
321
+ actual: ScVal | undefined,
322
+ right: PredicateLeaf
323
+ ): EvalResult {
324
+ const actualInt = argNumericBigInt(actual)
325
+ const literalInt = literalNumericBigInt(right)
326
+ if (actualInt === null || literalInt === null) {
327
+ return { permit: false, reason: 'ARG_MISMATCH' }
328
+ }
329
+ return bigintCmp(op, actualInt.toString(), literalInt.toString())
330
+ ? { permit: true }
331
+ : { permit: false, reason: 'ARG_MISMATCH' }
332
+ }
333
+
334
+ /** BigInt value of a numeric-integer ScVal arg (i128 / u64 / u32), or null when
335
+ * the arg is absent, opaque, or a non-numeric type. */
336
+ function argNumericBigInt(actual: ScVal | undefined): bigint | null {
337
+ if (!actual) return null
338
+ if (actual.type === 'i128' || actual.type === 'u64' || actual.type === 'u32') {
339
+ try {
340
+ return BigInt(actual.value)
341
+ } catch {
342
+ return null
343
+ }
344
+ }
345
+ return null
346
+ }
347
+
348
+ /** Element-by-element ordered comparison of an `ScVal[]` against a
349
+ * `PredicateLeaf[]`. Equal-length and equal at every index => permit.
350
+ * Length mismatch OR any element mismatch => deny ARG_MISMATCH. */
351
+ function compareVecExact(actual: ScVal[], expected: PredicateLeaf[], ctx: EvalContext): boolean {
352
+ if (actual.length !== expected.length) return false
353
+ for (let i = 0; i < expected.length; i++) {
354
+ const e = expected[i]
355
+ const a = actual[i]
356
+ if (!e || !a) return false
357
+ const r = evalArgEq('eq', a, e, ctx)
358
+ if (!r.permit) return false
359
+ }
360
+ return true
361
+ }
362
+
363
+ /** Step 5: `in` membership. Empty haystack ALWAYS denies. Opaque needle
364
+ * fails closed. */
365
+ function evalIn(needle: PredicateLeaf, haystack: PredicateLeaf[], ctx: EvalContext): EvalResult {
366
+ if (haystack.length === 0) return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
367
+ // Resolve the needle's ScVal against the candidate call.
368
+ const actual = resolveLeaf(needle, ctx)
369
+ if (!actual || actual.type === 'other') return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
370
+ for (const h of haystack) {
371
+ const r = evalArgEq('eq', actual, h, ctx)
372
+ if (r.permit) return { permit: true }
373
+ }
374
+ return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
375
+ }
376
+
377
+ /** Resolve a selector leaf to its current ScVal against the candidate call. */
378
+ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
379
+ switch (leaf.kind) {
380
+ case 'call_contract':
381
+ return { type: 'address', value: ctx.contract }
382
+ case 'call_fn':
383
+ return { type: 'symbol', value: ctx.fn }
384
+ case 'call_arg':
385
+ return ctx.args[leaf.index]
386
+ case 'amount':
387
+ case 'window_spent':
388
+ case 'oracle_price':
389
+ case 'invocation_count_in_window':
390
+ case 'now':
391
+ case 'valid_until':
392
+ return undefined // selector leaves with no ScVal projection
393
+ case 'literal_address':
394
+ return { type: 'address', value: leaf.value }
395
+ case 'literal_i128':
396
+ return { type: 'i128', value: leaf.value }
397
+ case 'literal_symbol':
398
+ return { type: 'symbol', value: leaf.value }
399
+ case 'literal_u32':
400
+ return { type: 'u32', value: String(leaf.value) }
401
+ case 'literal_u64':
402
+ return { type: 'u64', value: leaf.value }
403
+ case 'literal_bytes':
404
+ return { type: 'bytes', value: leaf.value }
405
+ case 'literal_vec':
406
+ return undefined
407
+ }
408
+ }
409
+
410
+ /** Step 6: amount compare on BigInt. */
411
+ function evalAmountCompare(
412
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
413
+ token: string,
414
+ right: PredicateLeaf,
415
+ ctx: EvalContext
416
+ ): EvalResult {
417
+ const literal = right.kind === 'literal_i128' ? right.value : null
418
+ if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
419
+ const actual = ctx.amountByToken[token] ?? '0'
420
+ return bigintCmp(op, actual, literal)
421
+ ? { permit: true }
422
+ : { permit: false, reason: 'AMOUNT_BOUND' }
423
+ }
424
+
425
+ /** Step 6: window_spent compare on BigInt. */
426
+ function evalWindowSpentCompare(
427
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
428
+ token: string,
429
+ right: PredicateLeaf,
430
+ ctx: EvalContext
431
+ ): EvalResult {
432
+ const literal = right.kind === 'literal_i128' ? right.value : null
433
+ if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
434
+ const actual = ctx.windowSpentByToken[token] ?? '0'
435
+ return bigintCmp(op, actual, literal)
436
+ ? { permit: true }
437
+ : { permit: false, reason: 'AMOUNT_BOUND' }
438
+ }
439
+
440
+ /** Step 7: invocation_count_in_window compare. */
441
+ function evalFrequencyCompare(
442
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
443
+ windowSecs: number,
444
+ right: PredicateLeaf,
445
+ ctx: EvalContext
446
+ ): EvalResult {
447
+ const literal = right.kind === 'literal_u32' ? String(right.value) : null
448
+ if (literal === null) return { permit: false, reason: 'FREQUENCY' }
449
+ const actual = String(ctx.invocationCountByWindow[windowSecs] ?? 0)
450
+ return bigintCmp(op, actual, literal) ? { permit: true } : { permit: false, reason: 'FREQUENCY' }
451
+ }
452
+
453
+ /** Step 8: oracle_price compare. Reads `ctx.oraclePriceByAsset[asset]`. Any
454
+ * error entry OR a missing key throws `OracleError` (FATAL). A satisfied
455
+ * compare permits. */
456
+ function evalOracleCompare(
457
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
458
+ asset: string,
459
+ right: PredicateLeaf,
460
+ ctx: EvalContext
461
+ ): EvalResult {
462
+ const entry = ctx.oraclePriceByAsset[asset]
463
+ if (!entry) throw new OracleError('ORACLE_STALE')
464
+ if ('error' in entry) {
465
+ const mapped = ORACLE_ERROR_CODES[entry.error]
466
+ if (!mapped) throw new OracleError('ORACLE_STALE')
467
+ throw new OracleError(mapped)
468
+ }
469
+ const literal = right.kind === 'literal_i128' ? right.value : null
470
+ if (literal === null) throw new OracleError('ORACLE_DECIMALS_MISMATCH')
471
+ return bigintCmp(op, entry.price, literal)
472
+ ? { permit: true }
473
+ : { permit: false, reason: 'FN_MISMATCH' }
474
+ }
475
+
476
+ /** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
477
+ * equal checks), but the dedicated arg path uses it via `BigInt(actual) === BigInt(right)`. */
478
+ function bigintCmp(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', aStr: string, bStr: string): boolean {
479
+ const a = BigInt(aStr)
480
+ const b = BigInt(bStr)
481
+ switch (op) {
482
+ case 'eq':
483
+ return a === b
484
+ case 'lt':
485
+ return a < b
486
+ case 'lte':
487
+ return a <= b
488
+ case 'gt':
489
+ return a > b
490
+ case 'gte':
491
+ return a >= b
492
+ }
493
+ }
@@ -0,0 +1,40 @@
1
+ import type { PredicateNode } from '../types.ts'
2
+ import type { GeneratedCases } from './deny-cases.ts'
3
+ import { evaluate } from './evaluate.ts'
4
+
5
+ export interface HarnessFailure {
6
+ dimension: string
7
+ expected: 'permit' | 'deny'
8
+ got: 'permit' | 'deny'
9
+ reason?: string
10
+ }
11
+
12
+ export type HarnessResult = { ok: true } | { ok: false; failures: HarnessFailure[] }
13
+
14
+ /** Evaluate the intended call and every generated denial without side effects. */
15
+ export function runHarness(predicate: PredicateNode, cases: GeneratedCases): HarnessResult {
16
+ const failures: HarnessFailure[] = []
17
+ const permitResult = evaluate(predicate, cases.permit)
18
+ if (!permitResult.permit) {
19
+ failures.push({
20
+ dimension: 'PERMIT_CASE_FAILED',
21
+ expected: 'permit',
22
+ got: 'deny',
23
+ reason: permitResult.reason,
24
+ })
25
+ }
26
+
27
+ for (const deny of cases.denies) {
28
+ const result = evaluate(predicate, deny.ctx)
29
+ if (result.permit) {
30
+ failures.push({
31
+ dimension: deny.dimension,
32
+ expected: 'deny',
33
+ got: 'permit',
34
+ reason: 'DENY_CASE_FAILURE',
35
+ })
36
+ }
37
+ }
38
+
39
+ return failures.length === 0 ? { ok: true } : { ok: false, failures }
40
+ }
@@ -5,7 +5,19 @@ export {
5
5
  type ComposeResult,
6
6
  composeFromRecording,
7
7
  } from './compose-from-recording.ts'
8
+ export {
9
+ type DenyCase,
10
+ type GeneratedCases,
11
+ generateCases,
12
+ } from './deny-cases.ts'
13
+ export { type EvalContext, type EvalResult, evaluate } from './evaluate.ts'
14
+ export {
15
+ type HarnessFailure,
16
+ type HarnessResult,
17
+ runHarness,
18
+ } from './harness.ts'
8
19
  export { type IntentFacts, lower } from './lower.ts'
20
+ export { minimize } from './minimize.ts'
9
21
  export {
10
22
  type DecideScopeOptions,
11
23
  decideScope,
@@ -0,0 +1,44 @@
1
+ import type { PredicateNode } from '../types.ts'
2
+ import { type DenyCase, generateCases } from './deny-cases.ts'
3
+ import type { EvalContext } from './evaluate.ts'
4
+ import { runHarness } from './harness.ts'
5
+
6
+ /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
7
+ export function minimize(predicate: PredicateNode, permitCtx: EvalContext): PredicateNode {
8
+ if (predicate.op !== 'and') return predicate
9
+
10
+ let children = [...predicate.children]
11
+ let index = 0
12
+
13
+ while (index < children.length) {
14
+ const current: PredicateNode = { op: 'and', children }
15
+ const currentCases = generateCases(current, permitCtx)
16
+ const candidateChildren = children.filter((_, childIndex) => childIndex !== index)
17
+ const candidate: PredicateNode = { op: 'and', children: candidateChildren }
18
+ const candidateCases = generateCases(candidate, permitCtx)
19
+ const verificationCases = {
20
+ permit: candidateCases.permit,
21
+ denies: mergeDenyCases(currentCases.denies, candidateCases.denies),
22
+ }
23
+
24
+ if (runHarness(candidate, verificationCases).ok) {
25
+ children = candidateChildren
26
+ continue
27
+ }
28
+ index += 1
29
+ }
30
+
31
+ return { op: 'and', children }
32
+ }
33
+
34
+ function mergeDenyCases(current: DenyCase[], candidate: DenyCase[]): DenyCase[] {
35
+ const merged: DenyCase[] = []
36
+ const seen = new Set<string>()
37
+ for (const deny of [...current, ...candidate]) {
38
+ const key = `${deny.dimension}:${JSON.stringify(deny.ctx)}`
39
+ if (seen.has(key)) continue
40
+ seen.add(key)
41
+ merged.push(deny)
42
+ }
43
+ return merged
44
+ }
@@ -0,0 +1,27 @@
1
+ // src/synth/predicate-literals.ts - read scalar values out of predicate literal leaves.
2
+ //
3
+ // Small shared helpers for interpreting a `PredicateLeaf` literal. Kept out of
4
+ // `evaluate.ts` so the evaluator stays focused on the deny-order semantics; the
5
+ // deny-case generator uses the same reader so both sides agree on what a numeric
6
+ // literal means.
7
+
8
+ import type { PredicateLeaf } from '../types.ts'
9
+
10
+ /** BigInt value of a numeric-integer literal leaf (`literal_i128` / `literal_u64`
11
+ * / `literal_u32`), or null for a non-numeric literal (address / symbol / bytes
12
+ * / vec). Callers fail closed on null. */
13
+ export function literalNumericBigInt(leaf: PredicateLeaf): bigint | null {
14
+ switch (leaf.kind) {
15
+ case 'literal_i128':
16
+ case 'literal_u64':
17
+ try {
18
+ return BigInt(leaf.value)
19
+ } catch {
20
+ return null
21
+ }
22
+ case 'literal_u32':
23
+ return BigInt(leaf.value)
24
+ default:
25
+ return null
26
+ }
27
+ }