@crediolabs/policy-synth 0.2.0 → 0.3.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 (184) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/interpreter/adapter.d.ts +19 -25
  3. package/dist/adapters/interpreter/adapter.js +54 -487
  4. package/dist/adapters/interpreter/index.d.ts +1 -1
  5. package/dist/adapters/interpreter/index.js +1 -1
  6. package/dist/adapters/oz/adapter.js +1 -15
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/index.d.ts +1 -4
  9. package/dist/index.js +1 -4
  10. package/dist/install/build-add-context-rule.d.ts +3 -19
  11. package/dist/install/build-add-context-rule.js +3 -98
  12. package/dist/install/build-install-policy.d.ts +4 -53
  13. package/dist/install/build-install-policy.js +44 -123
  14. package/dist/install/index.d.ts +0 -2
  15. package/dist/install/index.js +0 -7
  16. package/dist/ir/index.d.ts +1 -1
  17. package/dist/ir/types.d.ts +7 -76
  18. package/dist/ir/types.js +0 -2
  19. package/dist/predicate/decode.d.ts +1 -1
  20. package/dist/predicate/decode.js +2 -70
  21. package/dist/predicate/encode.js +45 -229
  22. package/dist/predicate/from-json.js +1 -42
  23. package/dist/record/decode.js +3 -8
  24. package/dist/review-card/builder.d.ts +4 -3
  25. package/dist/review-card/builder.js +18 -162
  26. package/dist/review-card/cross-check.js +2 -105
  27. package/dist/review-card/render-leaf.d.ts +4 -0
  28. package/dist/review-card/render-leaf.js +47 -0
  29. package/dist/run/index.d.ts +36 -139
  30. package/dist/run/index.js +125 -437
  31. package/dist/run/schemas.d.ts +168 -1929
  32. package/dist/run/schemas.js +40 -269
  33. package/dist/seams/types.d.ts +0 -2
  34. package/dist/simulate/deny-cases.d.ts +26 -0
  35. package/dist/simulate/deny-cases.js +282 -0
  36. package/dist/simulate/evaluate.d.ts +18 -0
  37. package/dist/simulate/evaluate.js +271 -0
  38. package/dist/simulate/index.d.ts +4 -0
  39. package/dist/simulate/index.js +8 -0
  40. package/dist/synth/compose-from-recording.d.ts +24 -51
  41. package/dist/synth/compose-from-recording.js +120 -282
  42. package/dist/synth/deny-cases.d.ts +5 -7
  43. package/dist/synth/deny-cases.js +23 -373
  44. package/dist/synth/evaluate.d.ts +0 -21
  45. package/dist/synth/evaluate.js +56 -336
  46. package/dist/synth/index.d.ts +0 -5
  47. package/dist/synth/index.js +0 -5
  48. package/dist/synth/permit-context.d.ts +2 -9
  49. package/dist/synth/permit-context.js +1 -82
  50. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  51. package/dist/synth/synthesize-from-recording.js +41 -356
  52. package/dist/types.d.ts +8 -60
  53. package/dist/types.js +5 -2
  54. package/dist/verify/simulate.d.ts +1 -14
  55. package/dist/verify/simulate.js +2 -88
  56. package/dist/verify/verify.d.ts +1 -7
  57. package/dist/verify/verify.js +0 -8
  58. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  59. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  60. package/dist-cjs/adapters/interpreter/index.d.ts +1 -1
  61. package/dist-cjs/adapters/interpreter/index.js +1 -2
  62. package/dist-cjs/adapters/oz/adapter.js +1 -15
  63. package/dist-cjs/errors.d.ts +1 -1
  64. package/dist-cjs/index.d.ts +1 -4
  65. package/dist-cjs/index.js +1 -4
  66. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  67. package/dist-cjs/install/build-add-context-rule.js +1 -97
  68. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  69. package/dist-cjs/install/build-install-policy.js +44 -122
  70. package/dist-cjs/install/index.d.ts +0 -2
  71. package/dist-cjs/install/index.js +2 -23
  72. package/dist-cjs/ir/index.d.ts +1 -1
  73. package/dist-cjs/ir/types.d.ts +7 -76
  74. package/dist-cjs/ir/types.js +0 -2
  75. package/dist-cjs/predicate/decode.d.ts +1 -1
  76. package/dist-cjs/predicate/decode.js +2 -70
  77. package/dist-cjs/predicate/encode.js +45 -229
  78. package/dist-cjs/predicate/from-json.js +1 -42
  79. package/dist-cjs/record/decode.js +3 -8
  80. package/dist-cjs/review-card/builder.d.ts +4 -3
  81. package/dist-cjs/review-card/builder.js +23 -167
  82. package/dist-cjs/review-card/cross-check.js +7 -110
  83. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  84. package/dist-cjs/review-card/render-leaf.js +52 -0
  85. package/dist-cjs/run/index.d.ts +36 -139
  86. package/dist-cjs/run/index.js +125 -444
  87. package/dist-cjs/run/schemas.d.ts +168 -1929
  88. package/dist-cjs/run/schemas.js +41 -270
  89. package/dist-cjs/seams/types.d.ts +0 -2
  90. package/dist-cjs/simulate/deny-cases.d.ts +26 -0
  91. package/dist-cjs/simulate/deny-cases.js +286 -0
  92. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  93. package/dist-cjs/simulate/evaluate.js +274 -0
  94. package/dist-cjs/simulate/index.d.ts +4 -0
  95. package/dist-cjs/simulate/index.js +13 -0
  96. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  97. package/dist-cjs/synth/compose-from-recording.js +120 -282
  98. package/dist-cjs/synth/deny-cases.d.ts +5 -7
  99. package/dist-cjs/synth/deny-cases.js +23 -374
  100. package/dist-cjs/synth/evaluate.d.ts +0 -21
  101. package/dist-cjs/synth/evaluate.js +57 -337
  102. package/dist-cjs/synth/index.d.ts +0 -5
  103. package/dist-cjs/synth/index.js +1 -11
  104. package/dist-cjs/synth/permit-context.d.ts +2 -9
  105. package/dist-cjs/synth/permit-context.js +1 -82
  106. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  107. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  108. package/dist-cjs/types.d.ts +8 -60
  109. package/dist-cjs/types.js +6 -3
  110. package/dist-cjs/verify/simulate.d.ts +1 -14
  111. package/dist-cjs/verify/simulate.js +2 -88
  112. package/dist-cjs/verify/verify.d.ts +1 -7
  113. package/dist-cjs/verify/verify.js +0 -8
  114. package/package.json +4 -4
  115. package/src/adapters/interpreter/adapter.ts +76 -572
  116. package/src/errors.ts +0 -19
  117. package/src/index.ts +1 -4
  118. package/src/install/build-add-context-rule.ts +5 -139
  119. package/src/install/build-install-policy.ts +93 -214
  120. package/src/install/index.ts +0 -34
  121. package/src/predicate/decode.ts +2 -70
  122. package/src/predicate/encode.ts +49 -261
  123. package/src/predicate/from-json.ts +1 -43
  124. package/src/record/decode.ts +3 -8
  125. package/src/review-card/builder.ts +19 -168
  126. package/src/review-card/cross-check.ts +3 -105
  127. package/src/review-card/render-leaf.ts +48 -0
  128. package/src/run/index.ts +144 -572
  129. package/src/run/schemas.ts +42 -291
  130. package/src/simulate/deny-cases.ts +334 -0
  131. package/src/simulate/evaluate.ts +284 -0
  132. package/src/simulate/index.ts +11 -0
  133. package/src/synth/compose-from-recording.ts +148 -347
  134. package/src/synth/index.ts +0 -13
  135. package/src/synth/synthesize-from-recording.ts +43 -456
  136. package/src/types.ts +13 -49
  137. package/dist/install/authority-overlap.d.ts +0 -134
  138. package/dist/install/authority-overlap.js +0 -0
  139. package/dist/install/build-install-predicate.d.ts +0 -96
  140. package/dist/install/build-install-predicate.js +0 -444
  141. package/dist/install/build-merge-policy.d.ts +0 -70
  142. package/dist/install/build-merge-policy.js +0 -130
  143. package/dist/install/plan-merge-policy.d.ts +0 -49
  144. package/dist/install/plan-merge-policy.js +0 -86
  145. package/dist/install/read-account-rules.d.ts +0 -100
  146. package/dist/install/read-account-rules.js +0 -283
  147. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  148. package/dist-cjs/install/authority-overlap.js +0 -0
  149. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  150. package/dist-cjs/install/build-install-predicate.js +0 -479
  151. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  152. package/dist-cjs/install/build-merge-policy.js +0 -134
  153. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  154. package/dist-cjs/install/plan-merge-policy.js +0 -90
  155. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  156. package/dist-cjs/install/read-account-rules.js +0 -296
  157. package/src/adapters/interpreter/index.ts +0 -8
  158. package/src/adapters/oz/adapter.ts +0 -376
  159. package/src/adapters/oz/index.ts +0 -9
  160. package/src/codegen/compile-gate.ts +0 -167
  161. package/src/codegen/index.ts +0 -17
  162. package/src/codegen/template.ts +0 -165
  163. package/src/install/authority-overlap.ts +0 -0
  164. package/src/install/build-merge-policy.ts +0 -219
  165. package/src/install/plan-merge-policy.ts +0 -133
  166. package/src/install/read-account-rules.ts +0 -376
  167. package/src/ir/index.ts +0 -13
  168. package/src/ir/types.ts +0 -132
  169. package/src/mandate/index.ts +0 -4
  170. package/src/mandate/to-ir.ts +0 -71
  171. package/src/mandate/types.ts +0 -21
  172. package/src/seams/index.ts +0 -11
  173. package/src/seams/types.ts +0 -81
  174. package/src/synth/deny-cases.ts +0 -663
  175. package/src/synth/evaluate.ts +0 -613
  176. package/src/synth/harness.ts +0 -68
  177. package/src/synth/minimize.ts +0 -48
  178. package/src/synth/permit-context.ts +0 -136
  179. package/src/synth/predicate-literals.ts +0 -27
  180. package/src/synth/synthesize-from-mandate.ts +0 -82
  181. package/src/verify/envelope.ts +0 -28
  182. package/src/verify/index.ts +0 -5
  183. package/src/verify/simulate.ts +0 -311
  184. package/src/verify/verify.ts +0 -243
@@ -1,613 +0,0 @@
1
- // src/synth/evaluate.ts - the model evaluator (TS model of interpreter semantics).
2
- //
3
- // Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
4
- // no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
5
- // 1. ledger expiry -> 'EXPIRED'
6
- // 2. `now` past valid_until -> 'EXPIRED'
7
- // 3. contract mismatch -> 'CONTRACT_SCOPE'
8
- // 4. per-ScVal equality on fn/args; EXACT ordered vector
9
- // equality; fail-closed on opaque args -> 'ARG_MISMATCH' / 'FN_MISMATCH'
10
- // 5. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
11
- // 6. amount / window_spent via BigInt -> 'AMOUNT_BOUND'
12
- // 7. invocation_count_in_window -> 'FREQUENCY'
13
- // 8. oracle_price: fatal -> throw OracleError, deny with
14
- // underlying ORACLE_* reason -> 'ORACLE_*'
15
- // 9. boolean nodes (and/or/not)
16
- // 10. signer threshold gate -> 'THRESHOLD_NOT_MET'
17
- // 11. otherwise permit.
18
- //
19
- // Amounts: BigInt on decimal strings. Oracle errors thrown (never
20
- // boolean-false `not`/`or` could mask).
21
-
22
- import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
23
- import { literalNumericBigInt } from './predicate-literals.ts'
24
-
25
- /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
26
- * in oracle.rs. A threshold on any other basis must say so. */
27
- const NORMALISED_DECIMALS = 9
28
- /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
29
- const MAX_ORACLE_THRESHOLD_DECIMALS = 18
30
-
31
- export interface EvalContext {
32
- /** Contract the interpreter is asked to enforce against. */
33
- contract: string
34
- /** Function name on that contract. */
35
- fn: string
36
- /** Decoded `ScVal[]` of the top-level authorized call. */
37
- args: ScVal[]
38
- /** Ledger sequence at which the interpreter is invoked. */
39
- atLedger: number
40
- /** Optional policy expiry; absent -> step 1 is skipped. */
41
- validUntilLedger?: number
42
- /** Unix seconds; used by `now` / `valid_until` leaves. */
43
- nowSeconds: number
44
- /** Per-token amount moved by the current call (i128 decimal string). */
45
- amountByToken: Record<string, string>
46
- /** Per-token window-rolling spend prior to this call (i128 decimal string). */
47
- windowSpentByToken: Record<string, string>
48
- /** Recorded invocation counts keyed by window seconds. */
49
- invocationCountByWindow: Record<number, number>
50
- /** Optional signer-weight map for the threshold gate. Absent -> skip. */
51
- signerWeights?: Record<string, number>
52
- /** Per-asset oracle snapshot. Missing keys default to ORACLE_STALE. */
53
- oraclePriceByAsset: Record<
54
- string,
55
- | { price: string; timestampSeconds: number }
56
- | { error: 'stale' | 'missing' | 'deviation' | 'paused' | 'decimals' | 'fingerprint' }
57
- >
58
- }
59
-
60
- export type EvalResult = { permit: true } | { permit: false; reason: string }
61
-
62
- /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
63
- * and converted to the matching `ORACLE_*` deny reason. NOT a boolean-false
64
- * that `not` / `or` could mask. */
65
- class OracleError extends Error {
66
- readonly code: string
67
- constructor(code: string) {
68
- super(`oracle fatal: ${code}`)
69
- this.code = code
70
- }
71
- }
72
-
73
- const ORACLE_ERROR_CODES: Readonly<Record<string, string>> = {
74
- stale: 'ORACLE_STALE',
75
- missing: 'ORACLE_MISSING',
76
- deviation: 'ORACLE_DEVIATION_EXCEEDED',
77
- paused: 'ORACLE_PAUSED',
78
- decimals: 'ORACLE_DECIMALS_MISMATCH',
79
- fingerprint: 'ORACLE_FINGERPRINT_DRIFT',
80
- }
81
-
82
- /** Evaluate a `PredicateNode` against the candidate call described by `ctx`.
83
- * Pure function. Returns `{ permit: true }` or `{ permit: false; reason }`. */
84
- export function evaluate(predicate: PredicateNode, ctx: EvalContext): EvalResult {
85
- try {
86
- // --- step 1: ledger-time expiry ---
87
- if (ctx.validUntilLedger !== undefined && ctx.atLedger > ctx.validUntilLedger) {
88
- return { permit: false, reason: 'EXPIRED' }
89
- }
90
-
91
- // --- step 2..9: predicate tree ---
92
- const decision = walk(predicate, ctx)
93
-
94
- // --- step 10: signer threshold gate (only when the predicate permitted) ---
95
- if (decision.permit && ctx.signerWeights !== undefined) {
96
- let totalWeight = 0n
97
- for (const w of Object.values(ctx.signerWeights)) {
98
- // weights are non-negative integers; BigInt keeps the gate bounded
99
- totalWeight += BigInt(w)
100
- }
101
- if (totalWeight === 0n) return { permit: false, reason: 'THRESHOLD_NOT_MET' }
102
- }
103
-
104
- return decision
105
- } catch (e) {
106
- if (e instanceof OracleError) {
107
- return { permit: false, reason: e.code }
108
- }
109
- throw e
110
- }
111
- }
112
-
113
- /** Walk the predicate tree. Returns the FIRST deny reason encountered on
114
- * the active branch (so `and` fails-fast; `or` accepts the first permit). */
115
- function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
116
- switch (node.op) {
117
- case 'and': {
118
- let lastDeny: EvalResult | null = null
119
- for (const child of node.children) {
120
- const r = walk(child, ctx)
121
- if (!r.permit) {
122
- // deny-on-first: short-circuit on the failing child.
123
- return r
124
- }
125
- lastDeny = r
126
- }
127
- return lastDeny ?? { permit: true }
128
- }
129
- case 'or': {
130
- let lastDeny: EvalResult | null = null
131
- for (const child of node.children) {
132
- const r = walk(child, ctx)
133
- if (r.permit) return r
134
- lastDeny = r
135
- }
136
- return lastDeny ?? { permit: false, reason: 'NOT_IN_ALLOWLIST' }
137
- }
138
- case 'not': {
139
- // `not` structurally inverts the child unless the child contains an
140
- // oracle leaf (compile-time rule: no oracle leaf under not/or). A
141
- // child `OracleError` is re-thrown to the catch at the top of `evaluate`.
142
- const r = walk(node.child, ctx)
143
- if (r.permit) return { permit: false, reason: 'FN_MISMATCH' }
144
- return { permit: true }
145
- }
146
- case 'eq':
147
- case 'lt':
148
- case 'lte':
149
- case 'gt':
150
- case 'gte':
151
- return evalCompare(node.op, node.left, node.right, ctx)
152
- case 'in':
153
- return evalIn(node.needle, node.haystack, ctx)
154
- }
155
- }
156
-
157
- /** Step 2..8: comparison leaf evaluation. */
158
- function evalCompare(
159
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
160
- left: PredicateLeaf,
161
- right: PredicateLeaf,
162
- ctx: EvalContext
163
- ): EvalResult {
164
- // --- step 2: `now` vs `valid_until` semantics ---
165
- if (left.kind === 'now' && right.kind === 'valid_until') {
166
- const expired = op === 'gt' || op === 'gte' ? ctx.nowSeconds >= 0 : ctx.nowSeconds < 0
167
- // `valid_until` is modelled as a synthetic future timestamp far past
168
- // `nowSeconds` so the only true-positive expired path is the `gt`/`gte`
169
- // shapes callers actually write.
170
- if (op === 'gt' || op === 'gte') {
171
- if (ctx.nowSeconds > 0) return { permit: false, reason: 'EXPIRED' }
172
- } else if (op === 'lt' || op === 'lte') {
173
- return { permit: true }
174
- }
175
- return expired ? { permit: false, reason: 'EXPIRED' } : { permit: true }
176
- }
177
- if (right.kind === 'now' && left.kind === 'valid_until') {
178
- return evalCompare(op, right, left, ctx)
179
- }
180
-
181
- // --- step 3: CONTRACT_SCOPE on call_contract eq ---
182
- if (left.kind === 'call_contract' && op === 'eq') {
183
- if (right.kind !== 'literal_address') return { permit: false, reason: 'CONTRACT_SCOPE' }
184
- return right.value === ctx.contract
185
- ? { permit: true }
186
- : { permit: false, reason: 'CONTRACT_SCOPE' }
187
- }
188
-
189
- // --- step 4a: call_fn equality ---
190
- if (left.kind === 'call_fn' && op === 'eq') {
191
- if (right.kind !== 'literal_symbol') return { permit: false, reason: 'FN_MISMATCH' }
192
- return right.value === ctx.fn ? { permit: true } : { permit: false, reason: 'FN_MISMATCH' }
193
- }
194
-
195
- // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
196
- if (left.kind === 'call_arg') {
197
- // The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
198
- // Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
199
- // -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
200
- // generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
201
- if (right.kind === 'call_arg_scaled') {
202
- return evalScaledArgCompare(op, left, right, ctx)
203
- }
204
- const actual = ctx.args[left.index]
205
- if (op !== 'eq') return evalArgOrderedCompare(op, actual, right)
206
- return evalArgEq(op, actual, right, ctx)
207
- }
208
-
209
- // --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
210
- // Symmetric form so a policy that phrases the floor either way works.
211
- if (left.kind === 'call_arg_scaled') {
212
- return evalScaledArgCompare(op, right, left, ctx)
213
- }
214
-
215
- // --- step 4c: call_arg_len: length of a vec-typed argument as u32.
216
- // Fails closed on a non-vec / absent arg or a non-u32 literal.
217
- if (left.kind === 'call_arg_len') {
218
- const actual = ctx.args[left.index]
219
- if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
220
- if (right.kind !== 'literal_u32') return { permit: false, reason: 'ARG_MISMATCH' }
221
- return actual.value.length === right.value
222
- ? { permit: true }
223
- : { permit: false, reason: 'ARG_MISMATCH' }
224
- }
225
-
226
- // --- step 4d: call_arg_field: value of a field in the map at element i of
227
- // the vec at argument index. Fails closed on shape / type / range issues.
228
- if (left.kind === 'call_arg_field') {
229
- const actual = ctx.args[left.index]
230
- if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
231
- const element = actual.value[left.element]
232
- if (element?.type !== 'map') return { permit: false, reason: 'ARG_MISMATCH' }
233
- if (!Array.isArray(element.value)) return { permit: false, reason: 'ARG_MISMATCH' }
234
- const entry = element.value.find((e) => e.key === left.field)
235
- if (!entry) return { permit: false, reason: 'ARG_MISMATCH' }
236
- if (op === 'eq') return evalArgEq(op, entry.val, right, ctx)
237
- return evalArgOrderedCompare(op, entry.val, right)
238
- }
239
-
240
- // --- step 6: AMOUNT_BOUND ---
241
- if (left.kind === 'amount' && op !== 'eq') {
242
- return evalAmountCompare(op, left.token, right, ctx, 'amountByToken')
243
- }
244
- if (left.kind === 'window_spent' && op !== 'eq') {
245
- return evalWindowSpentCompare(op, left.token, right, ctx)
246
- }
247
- // `eq` on amount / window_spent not defined as a bound - fall through.
248
-
249
- // --- step 7: FREQUENCY ---
250
- if (left.kind === 'invocation_count_in_window') {
251
- return evalFrequencyCompare(op, left.windowSecs, right, ctx)
252
- }
253
-
254
- // --- step 8: oracle_price (FATAL via throw) ---
255
- if (left.kind === 'oracle_price') {
256
- return evalOracleCompare(op, left.asset, right, ctx)
257
- }
258
-
259
- // Unknown leaf/op combination - structural fail-closed.
260
- return { permit: false, reason: 'FN_MISMATCH' }
261
- }
262
-
263
- /** Step 4b: per-ScVal equality. Handles literal_vec as an EXACT ordered
264
- * sequence: compare element-by-element in order; deny if length or any
265
- * element differs. Opaque args (`type: 'other'`) fail closed. */
266
- function evalArgEq(
267
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
268
- actual: ScVal | undefined,
269
- right: PredicateLeaf,
270
- ctx: EvalContext
271
- ): EvalResult {
272
- // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
273
- if (op === 'eq' && right.kind === 'literal_vec') {
274
- if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
275
- return compareVecExact(actual.value, right.elements, ctx)
276
- ? { permit: true }
277
- : { permit: false, reason: 'ARG_MISMATCH' }
278
- }
279
-
280
- if (op === 'eq' && right.kind === 'literal_address') {
281
- if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
282
- if (actual.type === 'other') return { permit: false, reason: 'ARG_MISMATCH' }
283
- return actual.type === 'address' && actual.value === right.value
284
- ? { permit: true }
285
- : { permit: false, reason: 'ARG_MISMATCH' }
286
- }
287
-
288
- if (op === 'eq' && right.kind === 'literal_i128') {
289
- if (actual?.type !== 'i128') return { permit: false, reason: 'ARG_MISMATCH' }
290
- return BigInt(actual.value) === BigInt(right.value)
291
- ? { permit: true }
292
- : { permit: false, reason: 'ARG_MISMATCH' }
293
- }
294
-
295
- if (op === 'eq' && right.kind === 'literal_symbol') {
296
- if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
297
- return actual.type === 'symbol' && actual.value === right.value
298
- ? { permit: true }
299
- : { permit: false, reason: 'ARG_MISMATCH' }
300
- }
301
-
302
- if (op === 'eq' && right.kind === 'literal_u32') {
303
- if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
304
- return actual.type === 'u32' && actual.value === String(right.value)
305
- ? { permit: true }
306
- : { permit: false, reason: 'ARG_MISMATCH' }
307
- }
308
-
309
- if (op === 'eq' && right.kind === 'literal_u64') {
310
- if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
311
- return actual.type === 'u64' && actual.value === right.value
312
- ? { permit: true }
313
- : { permit: false, reason: 'ARG_MISMATCH' }
314
- }
315
-
316
- if (op === 'eq' && right.kind === 'literal_bytes') {
317
- if (!actual) return { permit: false, reason: 'ARG_MISMATCH' }
318
- return actual.type === 'bytes' && actual.value === right.value
319
- ? { permit: true }
320
- : { permit: false, reason: 'ARG_MISMATCH' }
321
- }
322
-
323
- // Anything else: fail closed on opacity (cannot decode the arg reliably).
324
- if (!actual || actual.type === 'other') return { permit: false, reason: 'ARG_MISMATCH' }
325
- return { permit: false, reason: 'ARG_MISMATCH' }
326
- }
327
-
328
- /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`:
329
- * the scaled leaf is `args[index] * num / den` (truncating toward zero). On
330
- * overflow or divide-by-zero deny with `ARITHMETIC_OVERFLOW`; a failed bound
331
- * denies with `SLIPPAGE_FLOOR` (the dedicated reason) rather than the generic
332
- * `ARG_MISMATCH`. A scaled-on-scaled compare denies `ARG_MISMATCH`
333
- * (no definable semantics). The non-scaled operand must be numeric
334
- * (`call_arg` carrying a number, or a numeric literal); else `ARG_MISMATCH`. */
335
- function evalScaledArgCompare(
336
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
337
- left: PredicateLeaf,
338
- right: PredicateLeaf,
339
- ctx: EvalContext
340
- ): EvalResult {
341
- let scaled: { index: number; num: string; den: string }
342
- let other: PredicateLeaf
343
- let scaledOnRight: boolean
344
- if (left.kind === 'call_arg_scaled') {
345
- scaled = left
346
- other = right
347
- scaledOnRight = false
348
- } else if (right.kind === 'call_arg_scaled') {
349
- scaled = right
350
- other = left
351
- scaledOnRight = true
352
- } else {
353
- // Programming error in the dispatcher; fail closed.
354
- return { permit: false, reason: 'ARG_MISMATCH' }
355
- }
356
- // Out-of-bounds or non-numeric arg fails closed as ARG_MISMATCH (not
357
- // SLIPPAGE_FLOOR) - a violated floor is the wrong code when the operand
358
- // itself could not be read.
359
- if (scaled.index >= ctx.args.length) {
360
- return { permit: false, reason: 'ARG_MISMATCH' }
361
- }
362
- const input = argNumericBigInt(ctx.args[scaled.index])
363
- if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
364
- let num: bigint
365
- let den: bigint
366
- try {
367
- num = BigInt(scaled.num)
368
- den = BigInt(scaled.den)
369
- } catch {
370
- return { permit: false, reason: 'ARG_MISMATCH' }
371
- }
372
- // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
373
- // check is defensive belt-and-braces so a validator regression cannot
374
- // panic the frame on a divide-by-zero.
375
- if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
376
- const product = input * num
377
- let scaledValue: bigint
378
- try {
379
- // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
380
- scaledValue = product / den
381
- } catch {
382
- return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
383
- }
384
- // i128 range check: the contract would have wrapped on i128 arithmetic;
385
- // surface the same deny.
386
- const I128_MAX = (1n << 127n) - 1n
387
- const I128_MIN = -(1n << 127n)
388
- if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
389
- return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
390
- }
391
- let otherVal: bigint | null
392
- if (other.kind === 'call_arg') {
393
- otherVal = argNumericBigInt(ctx.args[other.index])
394
- } else {
395
- otherVal = literalNumericBigInt(other)
396
- }
397
- if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
398
- const pass = scaledOnRight
399
- ? bigintCmp(op, otherVal.toString(), scaledValue.toString())
400
- : bigintCmp(op, scaledValue.toString(), otherVal.toString())
401
- return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' }
402
- }
403
-
404
- /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
405
- * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
406
- * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
407
- * or a non-numeric literal fails closed (ARG_MISMATCH) rather than permitting
408
- * an undecidable bound. Backs the SoroSwap input-amount cap
409
- * (`call_arg[0] <= limit`). */
410
- function evalArgOrderedCompare(
411
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
412
- actual: ScVal | undefined,
413
- right: PredicateLeaf
414
- ): EvalResult {
415
- const actualInt = argNumericBigInt(actual)
416
- const literalInt = literalNumericBigInt(right)
417
- if (actualInt === null || literalInt === null) {
418
- return { permit: false, reason: 'ARG_MISMATCH' }
419
- }
420
- return bigintCmp(op, actualInt.toString(), literalInt.toString())
421
- ? { permit: true }
422
- : { permit: false, reason: 'ARG_MISMATCH' }
423
- }
424
-
425
- /** BigInt value of a numeric-integer ScVal arg (i128 / u64 / u32), or null when
426
- * the arg is absent, opaque, or a non-numeric type. */
427
- function argNumericBigInt(actual: ScVal | undefined): bigint | null {
428
- if (!actual) return null
429
- if (actual.type === 'i128' || actual.type === 'u64' || actual.type === 'u32') {
430
- try {
431
- return BigInt(actual.value)
432
- } catch {
433
- return null
434
- }
435
- }
436
- return null
437
- }
438
-
439
- /** Element-by-element ordered comparison of an `ScVal[]` against a
440
- * `PredicateLeaf[]`. Equal-length and equal at every index => permit.
441
- * Length mismatch OR any element mismatch => deny ARG_MISMATCH. */
442
- function compareVecExact(actual: ScVal[], expected: PredicateLeaf[], ctx: EvalContext): boolean {
443
- if (actual.length !== expected.length) return false
444
- for (let i = 0; i < expected.length; i++) {
445
- const e = expected[i]
446
- const a = actual[i]
447
- if (!e || !a) return false
448
- const r = evalArgEq('eq', a, e, ctx)
449
- if (!r.permit) return false
450
- }
451
- return true
452
- }
453
-
454
- /** Step 5: `in` membership. Empty haystack ALWAYS denies. Opaque needle
455
- * fails closed. */
456
- function evalIn(needle: PredicateLeaf, haystack: PredicateLeaf[], ctx: EvalContext): EvalResult {
457
- if (haystack.length === 0) return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
458
- const actual = resolveLeaf(needle, ctx)
459
- if (!actual || actual.type === 'other') return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
460
- for (const h of haystack) {
461
- const r = evalArgEq('eq', actual, h, ctx)
462
- if (r.permit) return { permit: true }
463
- }
464
- return { permit: false, reason: 'NOT_IN_ALLOWLIST' }
465
- }
466
-
467
- /** Resolve a selector leaf to its current ScVal against the candidate call. */
468
- function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
469
- switch (leaf.kind) {
470
- case 'call_contract':
471
- return { type: 'address', value: ctx.contract }
472
- case 'call_fn':
473
- return { type: 'symbol', value: ctx.fn }
474
- case 'call_arg':
475
- return ctx.args[leaf.index]
476
- case 'call_arg_len':
477
- // No direct ScVal projection: the length is an integer the comparator
478
- // resolves against the right-hand literal. Returning undefined keeps
479
- // the `in` membership path structurally informed (no haystack match).
480
- return undefined
481
- case 'call_arg_field': {
482
- const actual = ctx.args[leaf.index]
483
- if (actual?.type !== 'vec') return undefined
484
- const element = actual.value[leaf.element]
485
- if (element?.type !== 'map') return undefined
486
- if (!Array.isArray(element.value)) return undefined
487
- const entry = element.value.find((e) => e.key === leaf.field)
488
- return entry ? entry.val : undefined
489
- }
490
- case 'amount':
491
- case 'window_spent':
492
- case 'oracle_price':
493
- case 'invocation_count_in_window':
494
- case 'now':
495
- case 'valid_until':
496
- case 'call_arg_scaled':
497
- return undefined // selector leaves with no ScVal projection
498
- case 'literal_address':
499
- return { type: 'address', value: leaf.value }
500
- case 'literal_i128':
501
- return { type: 'i128', value: leaf.value }
502
- case 'literal_symbol':
503
- return { type: 'symbol', value: leaf.value }
504
- case 'literal_u32':
505
- return { type: 'u32', value: String(leaf.value) }
506
- case 'literal_u64':
507
- return { type: 'u64', value: leaf.value }
508
- case 'literal_bytes':
509
- return { type: 'bytes', value: leaf.value }
510
- case 'literal_vec':
511
- return undefined
512
- }
513
- }
514
-
515
- /** Step 6: amount / window_spent compare on BigInt. Both leaves compare a
516
- * per-token BigInt record (current vs. rolling) to an i128 literal; the
517
- * compare + denial reason are identical, so a single helper handles both. */
518
- function evalAmountCompare(
519
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
520
- token: string,
521
- right: PredicateLeaf,
522
- ctx: EvalContext,
523
- record: 'amountByToken' | 'windowSpentByToken'
524
- ): EvalResult {
525
- const literal = right.kind === 'literal_i128' ? right.value : null
526
- if (literal === null) return { permit: false, reason: 'AMOUNT_BOUND' }
527
- const actual = ctx[record][token] ?? '0'
528
- return bigintCmp(op, actual, literal)
529
- ? { permit: true }
530
- : { permit: false, reason: 'AMOUNT_BOUND' }
531
- }
532
-
533
- /** Step 6: window_spent compare on BigInt. */
534
- function evalWindowSpentCompare(
535
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
536
- token: string,
537
- right: PredicateLeaf,
538
- ctx: EvalContext
539
- ): EvalResult {
540
- return evalAmountCompare(op, token, right, ctx, 'windowSpentByToken')
541
- }
542
-
543
- /** Step 7: invocation_count_in_window compare. */
544
- function evalFrequencyCompare(
545
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
546
- windowSecs: number,
547
- right: PredicateLeaf,
548
- ctx: EvalContext
549
- ): EvalResult {
550
- const literal = right.kind === 'literal_u32' ? String(right.value) : null
551
- if (literal === null) return { permit: false, reason: 'FREQUENCY' }
552
- const actual = String(ctx.invocationCountByWindow[windowSecs] ?? 0)
553
- return bigintCmp(op, actual, literal) ? { permit: true } : { permit: false, reason: 'FREQUENCY' }
554
- }
555
-
556
- /** Step 8: oracle_price compare. Reads `ctx.oraclePriceByAsset[asset]`. Any
557
- * error entry OR a missing key throws `OracleError` (FATAL). A satisfied
558
- * compare permits. */
559
- function evalOracleCompare(
560
- op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
561
- asset: string,
562
- right: PredicateLeaf,
563
- ctx: EvalContext
564
- ): EvalResult {
565
- const entry = ctx.oraclePriceByAsset[asset]
566
- if (!entry) throw new OracleError('ORACLE_STALE')
567
- if ('error' in entry) {
568
- const mapped = ORACLE_ERROR_CODES[entry.error]
569
- if (!mapped) throw new OracleError('ORACLE_STALE')
570
- throw new OracleError(mapped)
571
- }
572
- // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
573
- // decimal basis: prices are on the normalised 9-dp basis, and a bare literal
574
- // is refused (not assumed) because a raw 14-dp threshold that assumed the
575
- // 9-dp basis would permit everything.
576
- if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
577
- if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
578
- throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
579
- }
580
- // Scale BOTH sides up to the wider basis; dividing the threshold down
581
- // truncates and moves the permit boundary.
582
- const decimals = BigInt(right.decimals)
583
- const normalised = BigInt(NORMALISED_DECIMALS)
584
- const common = decimals > normalised ? decimals : normalised
585
- const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
586
- const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
587
- // A violated oracle threshold is a stateful bound (Rust dsl.rs:276 returns
588
- // `DenyReason::StatefulBound`, deny code #104). The TS evaluator must mirror
589
- // the same reason so a future TS/Rust reason divergence fails CI; the
590
- // cross-layer harness in this file now asserts reason codes match.
591
- return bigintCmp(op, String(priceScaled), String(literalScaled))
592
- ? { permit: true }
593
- : { permit: false, reason: 'STATEFUL_BOUND' }
594
- }
595
-
596
- /** BigInt compare helper. `eq` is also supported by callers (selector-vs-literal
597
- * equal checks), but the dedicated arg path uses it via `BigInt(actual) === BigInt(right)`. */
598
- function bigintCmp(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', aStr: string, bStr: string): boolean {
599
- const a = BigInt(aStr)
600
- const b = BigInt(bStr)
601
- switch (op) {
602
- case 'eq':
603
- return a === b
604
- case 'lt':
605
- return a < b
606
- case 'lte':
607
- return a <= b
608
- case 'gt':
609
- return a > b
610
- case 'gte':
611
- return a >= b
612
- }
613
- }
@@ -1,68 +0,0 @@
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
- /** When `expectedReason` is set on the deny case and the case actually
11
- * denies, this carries the canonical reason the TS evaluator MUST emit
12
- * to stay in lockstep with the Rust interpreter (the cross-layer
13
- * reason-code contract). A future TS/Rust reason divergence surfaces
14
- * here in CI. */
15
- expectedReason?: string
16
- actualReason?: string
17
- }
18
-
19
- export type HarnessResult = { ok: true } | { ok: false; failures: HarnessFailure[] }
20
-
21
- /** Evaluate the intended call and every generated denial without side effects.
22
- * Asserts both the boolean decision AND, for deny cases that carry an
23
- * `expectedReason`, the concrete reason the TS evaluator emits. The reason
24
- * is what the Rust interpreter returns; the cross-layer conformance test in
25
- * `contracts/policy-interpreter/tests/conformance` mirrors the same contract
26
- * for the verifyLive path. */
27
- export function runHarness(predicate: PredicateNode, cases: GeneratedCases): HarnessResult {
28
- const failures: HarnessFailure[] = []
29
- const permitResult = evaluate(predicate, cases.permit)
30
- if (!permitResult.permit) {
31
- failures.push({
32
- dimension: 'PERMIT_CASE_FAILED',
33
- expected: 'permit',
34
- got: 'deny',
35
- reason: permitResult.reason,
36
- })
37
- }
38
-
39
- for (const deny of cases.denies) {
40
- const result = evaluate(predicate, deny.ctx)
41
- if (result.permit) {
42
- failures.push({
43
- dimension: deny.dimension,
44
- expected: 'deny',
45
- got: 'permit',
46
- reason: 'DENY_CASE_FAILURE',
47
- })
48
- continue
49
- }
50
- // Reason-code assertion (cross-layer contract). When a deny case carries
51
- // an expected reason, the TS evaluator MUST emit that exact string; a
52
- // divergence means the TS model and the Rust interpreter disagree on
53
- // what the user sees in CI - the harness fails so the diff is visible
54
- // here, not at runtime in production.
55
- if (deny.expectedReason !== undefined && result.reason !== deny.expectedReason) {
56
- failures.push({
57
- dimension: deny.dimension,
58
- expected: 'deny',
59
- got: 'deny',
60
- reason: 'REASON_MISMATCH',
61
- expectedReason: deny.expectedReason,
62
- actualReason: result.reason,
63
- })
64
- }
65
- }
66
-
67
- return failures.length === 0 ? { ok: true } : { ok: false, failures }
68
- }