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