@crediolabs/policy-synth 0.2.0 → 0.3.1

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 (249) 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/errors.d.ts +1 -1
  5. package/dist/index.d.ts +1 -4
  6. package/dist/index.js +1 -4
  7. package/dist/install/build-add-context-rule.d.ts +3 -19
  8. package/dist/install/build-add-context-rule.js +3 -98
  9. package/dist/install/build-install-policy.d.ts +4 -53
  10. package/dist/install/build-install-policy.js +44 -123
  11. package/dist/install/index.d.ts +0 -2
  12. package/dist/install/index.js +0 -7
  13. package/dist/predicate/decode.d.ts +1 -1
  14. package/dist/predicate/decode.js +2 -70
  15. package/dist/predicate/encode.js +45 -229
  16. package/dist/predicate/from-json.js +1 -42
  17. package/dist/record/decode.js +3 -8
  18. package/dist/record/freshness.d.ts +17 -11
  19. package/dist/record/freshness.js +28 -18
  20. package/dist/review-card/builder.d.ts +4 -3
  21. package/dist/review-card/builder.js +18 -162
  22. package/dist/review-card/cross-check.js +2 -105
  23. package/dist/review-card/render-leaf.d.ts +4 -0
  24. package/dist/review-card/render-leaf.js +47 -0
  25. package/dist/run/index.d.ts +36 -139
  26. package/dist/run/index.js +125 -437
  27. package/dist/run/schemas.d.ts +168 -1929
  28. package/dist/run/schemas.js +40 -269
  29. package/dist/{synth → simulate}/deny-cases.d.ts +4 -8
  30. package/dist/simulate/deny-cases.js +282 -0
  31. package/dist/simulate/evaluate.d.ts +18 -0
  32. package/dist/simulate/evaluate.js +271 -0
  33. package/dist/simulate/index.d.ts +4 -0
  34. package/dist/simulate/index.js +8 -0
  35. package/dist/synth/compose-from-recording.d.ts +24 -51
  36. package/dist/synth/compose-from-recording.js +120 -282
  37. package/dist/synth/index.d.ts +0 -5
  38. package/dist/synth/index.js +0 -5
  39. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  40. package/dist/synth/synthesize-from-recording.js +41 -356
  41. package/dist/types.d.ts +8 -60
  42. package/dist/types.js +5 -2
  43. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  44. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  45. package/dist-cjs/errors.d.ts +1 -1
  46. package/dist-cjs/index.d.ts +1 -4
  47. package/dist-cjs/index.js +1 -4
  48. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  49. package/dist-cjs/install/build-add-context-rule.js +1 -97
  50. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  51. package/dist-cjs/install/build-install-policy.js +44 -122
  52. package/dist-cjs/install/index.d.ts +0 -2
  53. package/dist-cjs/install/index.js +2 -23
  54. package/dist-cjs/predicate/decode.d.ts +1 -1
  55. package/dist-cjs/predicate/decode.js +2 -70
  56. package/dist-cjs/predicate/encode.js +45 -229
  57. package/dist-cjs/predicate/from-json.js +1 -42
  58. package/dist-cjs/record/decode.js +3 -8
  59. package/dist-cjs/record/freshness.d.ts +17 -11
  60. package/dist-cjs/record/freshness.js +28 -18
  61. package/dist-cjs/review-card/builder.d.ts +4 -3
  62. package/dist-cjs/review-card/builder.js +23 -167
  63. package/dist-cjs/review-card/cross-check.js +7 -110
  64. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  65. package/dist-cjs/review-card/render-leaf.js +52 -0
  66. package/dist-cjs/run/index.d.ts +36 -139
  67. package/dist-cjs/run/index.js +125 -444
  68. package/dist-cjs/run/schemas.d.ts +168 -1929
  69. package/dist-cjs/run/schemas.js +41 -270
  70. package/dist-cjs/{synth → simulate}/deny-cases.d.ts +4 -8
  71. package/dist-cjs/simulate/deny-cases.js +286 -0
  72. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  73. package/dist-cjs/simulate/evaluate.js +274 -0
  74. package/dist-cjs/simulate/index.d.ts +4 -0
  75. package/dist-cjs/simulate/index.js +13 -0
  76. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  77. package/dist-cjs/synth/compose-from-recording.js +120 -282
  78. package/dist-cjs/synth/index.d.ts +0 -5
  79. package/dist-cjs/synth/index.js +1 -11
  80. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  81. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  82. package/dist-cjs/types.d.ts +8 -60
  83. package/dist-cjs/types.js +6 -3
  84. package/package.json +5 -5
  85. package/src/adapters/interpreter/adapter.ts +76 -572
  86. package/src/errors.ts +0 -19
  87. package/src/index.ts +1 -4
  88. package/src/install/build-add-context-rule.ts +5 -139
  89. package/src/install/build-install-policy.ts +93 -214
  90. package/src/install/index.ts +0 -34
  91. package/src/predicate/decode.ts +2 -70
  92. package/src/predicate/encode.ts +49 -261
  93. package/src/predicate/from-json.ts +1 -43
  94. package/src/record/decode.ts +3 -8
  95. package/src/record/freshness.ts +29 -18
  96. package/src/review-card/builder.ts +19 -168
  97. package/src/review-card/cross-check.ts +3 -105
  98. package/src/review-card/render-leaf.ts +48 -0
  99. package/src/run/index.ts +144 -572
  100. package/src/run/schemas.ts +42 -291
  101. package/src/simulate/deny-cases.ts +334 -0
  102. package/src/simulate/evaluate.ts +284 -0
  103. package/src/simulate/index.ts +11 -0
  104. package/src/synth/compose-from-recording.ts +148 -347
  105. package/src/synth/index.ts +0 -13
  106. package/src/synth/synthesize-from-recording.ts +43 -456
  107. package/src/types.ts +13 -49
  108. package/dist/adapters/interpreter/index.d.ts +0 -1
  109. package/dist/adapters/interpreter/index.js +0 -2
  110. package/dist/adapters/oz/adapter.d.ts +0 -20
  111. package/dist/adapters/oz/adapter.js +0 -295
  112. package/dist/adapters/oz/index.d.ts +0 -1
  113. package/dist/adapters/oz/index.js +0 -2
  114. package/dist/codegen/compile-gate.d.ts +0 -33
  115. package/dist/codegen/compile-gate.js +0 -124
  116. package/dist/codegen/index.d.ts +0 -2
  117. package/dist/codegen/index.js +0 -8
  118. package/dist/codegen/template.d.ts +0 -18
  119. package/dist/codegen/template.js +0 -148
  120. package/dist/install/authority-overlap.d.ts +0 -134
  121. package/dist/install/authority-overlap.js +0 -0
  122. package/dist/install/build-install-predicate.d.ts +0 -96
  123. package/dist/install/build-install-predicate.js +0 -444
  124. package/dist/install/build-merge-policy.d.ts +0 -70
  125. package/dist/install/build-merge-policy.js +0 -130
  126. package/dist/install/plan-merge-policy.d.ts +0 -49
  127. package/dist/install/plan-merge-policy.js +0 -86
  128. package/dist/install/read-account-rules.d.ts +0 -100
  129. package/dist/install/read-account-rules.js +0 -283
  130. package/dist/ir/index.d.ts +0 -1
  131. package/dist/ir/index.js +0 -2
  132. package/dist/ir/types.d.ts +0 -140
  133. package/dist/ir/types.js +0 -11
  134. package/dist/mandate/index.d.ts +0 -2
  135. package/dist/mandate/index.js +0 -2
  136. package/dist/mandate/to-ir.d.ts +0 -3
  137. package/dist/mandate/to-ir.js +0 -60
  138. package/dist/mandate/types.d.ts +0 -20
  139. package/dist/mandate/types.js +0 -8
  140. package/dist/seams/index.d.ts +0 -1
  141. package/dist/seams/index.js +0 -2
  142. package/dist/seams/types.d.ts +0 -66
  143. package/dist/seams/types.js +0 -11
  144. package/dist/synth/deny-cases.js +0 -562
  145. package/dist/synth/evaluate.d.ts +0 -39
  146. package/dist/synth/evaluate.js +0 -551
  147. package/dist/synth/harness.d.ts +0 -28
  148. package/dist/synth/harness.js +0 -47
  149. package/dist/synth/minimize.d.ts +0 -4
  150. package/dist/synth/minimize.js +0 -38
  151. package/dist/synth/permit-context.d.ts +0 -15
  152. package/dist/synth/permit-context.js +0 -116
  153. package/dist/synth/predicate-literals.d.ts +0 -5
  154. package/dist/synth/predicate-literals.js +0 -25
  155. package/dist/synth/synthesize-from-mandate.d.ts +0 -20
  156. package/dist/synth/synthesize-from-mandate.js +0 -59
  157. package/dist/verify/envelope.d.ts +0 -15
  158. package/dist/verify/envelope.js +0 -22
  159. package/dist/verify/index.d.ts +0 -3
  160. package/dist/verify/index.js +0 -3
  161. package/dist/verify/simulate.d.ts +0 -31
  162. package/dist/verify/simulate.js +0 -258
  163. package/dist/verify/verify.d.ts +0 -21
  164. package/dist/verify/verify.js +0 -189
  165. package/dist-cjs/adapters/interpreter/index.d.ts +0 -1
  166. package/dist-cjs/adapters/interpreter/index.js +0 -8
  167. package/dist-cjs/adapters/oz/adapter.d.ts +0 -20
  168. package/dist-cjs/adapters/oz/adapter.js +0 -300
  169. package/dist-cjs/adapters/oz/index.d.ts +0 -1
  170. package/dist-cjs/adapters/oz/index.js +0 -8
  171. package/dist-cjs/codegen/compile-gate.d.ts +0 -33
  172. package/dist-cjs/codegen/compile-gate.js +0 -128
  173. package/dist-cjs/codegen/index.d.ts +0 -2
  174. package/dist-cjs/codegen/index.js +0 -14
  175. package/dist-cjs/codegen/template.d.ts +0 -18
  176. package/dist-cjs/codegen/template.js +0 -151
  177. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  178. package/dist-cjs/install/authority-overlap.js +0 -0
  179. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  180. package/dist-cjs/install/build-install-predicate.js +0 -479
  181. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  182. package/dist-cjs/install/build-merge-policy.js +0 -134
  183. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  184. package/dist-cjs/install/plan-merge-policy.js +0 -90
  185. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  186. package/dist-cjs/install/read-account-rules.js +0 -296
  187. package/dist-cjs/ir/index.d.ts +0 -1
  188. package/dist-cjs/ir/index.js +0 -3
  189. package/dist-cjs/ir/types.d.ts +0 -140
  190. package/dist-cjs/ir/types.js +0 -12
  191. package/dist-cjs/mandate/index.d.ts +0 -2
  192. package/dist-cjs/mandate/index.js +0 -6
  193. package/dist-cjs/mandate/to-ir.d.ts +0 -3
  194. package/dist-cjs/mandate/to-ir.js +0 -63
  195. package/dist-cjs/mandate/types.d.ts +0 -20
  196. package/dist-cjs/mandate/types.js +0 -9
  197. package/dist-cjs/seams/index.d.ts +0 -1
  198. package/dist-cjs/seams/index.js +0 -3
  199. package/dist-cjs/seams/types.d.ts +0 -66
  200. package/dist-cjs/seams/types.js +0 -12
  201. package/dist-cjs/synth/deny-cases.js +0 -568
  202. package/dist-cjs/synth/evaluate.d.ts +0 -39
  203. package/dist-cjs/synth/evaluate.js +0 -554
  204. package/dist-cjs/synth/harness.d.ts +0 -28
  205. package/dist-cjs/synth/harness.js +0 -50
  206. package/dist-cjs/synth/minimize.d.ts +0 -4
  207. package/dist-cjs/synth/minimize.js +0 -41
  208. package/dist-cjs/synth/permit-context.d.ts +0 -15
  209. package/dist-cjs/synth/permit-context.js +0 -119
  210. package/dist-cjs/synth/predicate-literals.d.ts +0 -5
  211. package/dist-cjs/synth/predicate-literals.js +0 -28
  212. package/dist-cjs/synth/synthesize-from-mandate.d.ts +0 -20
  213. package/dist-cjs/synth/synthesize-from-mandate.js +0 -62
  214. package/dist-cjs/verify/envelope.d.ts +0 -15
  215. package/dist-cjs/verify/envelope.js +0 -23
  216. package/dist-cjs/verify/index.d.ts +0 -3
  217. package/dist-cjs/verify/index.js +0 -8
  218. package/dist-cjs/verify/simulate.d.ts +0 -31
  219. package/dist-cjs/verify/simulate.js +0 -261
  220. package/dist-cjs/verify/verify.d.ts +0 -21
  221. package/dist-cjs/verify/verify.js +0 -192
  222. package/src/adapters/interpreter/index.ts +0 -8
  223. package/src/adapters/oz/adapter.ts +0 -376
  224. package/src/adapters/oz/index.ts +0 -9
  225. package/src/codegen/compile-gate.ts +0 -167
  226. package/src/codegen/index.ts +0 -17
  227. package/src/codegen/template.ts +0 -165
  228. package/src/install/authority-overlap.ts +0 -0
  229. package/src/install/build-merge-policy.ts +0 -219
  230. package/src/install/plan-merge-policy.ts +0 -133
  231. package/src/install/read-account-rules.ts +0 -376
  232. package/src/ir/index.ts +0 -13
  233. package/src/ir/types.ts +0 -132
  234. package/src/mandate/index.ts +0 -4
  235. package/src/mandate/to-ir.ts +0 -71
  236. package/src/mandate/types.ts +0 -21
  237. package/src/seams/index.ts +0 -11
  238. package/src/seams/types.ts +0 -81
  239. package/src/synth/deny-cases.ts +0 -663
  240. package/src/synth/evaluate.ts +0 -613
  241. package/src/synth/harness.ts +0 -68
  242. package/src/synth/minimize.ts +0 -48
  243. package/src/synth/permit-context.ts +0 -136
  244. package/src/synth/predicate-literals.ts +0 -27
  245. package/src/synth/synthesize-from-mandate.ts +0 -82
  246. package/src/verify/envelope.ts +0 -28
  247. package/src/verify/index.ts +0 -5
  248. package/src/verify/simulate.ts +0 -311
  249. 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
- }