@crediolabs/policy-synth 0.1.9 → 0.1.11

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 (96) hide show
  1. package/dist/adapters/interpreter/adapter.js +62 -11
  2. package/dist/errors.d.ts +1 -1
  3. package/dist/install/build-add-context-rule.d.ts +48 -0
  4. package/dist/install/build-add-context-rule.js +304 -0
  5. package/dist/ir/types.d.ts +4 -0
  6. package/dist/predicate/decode.d.ts +11 -0
  7. package/dist/predicate/decode.js +234 -0
  8. package/dist/predicate/encode.js +79 -14
  9. package/dist/predicate/from-json.d.ts +4 -0
  10. package/dist/predicate/from-json.js +113 -0
  11. package/dist/predicate/index.d.ts +2 -0
  12. package/dist/predicate/index.js +5 -1
  13. package/dist/registry/identify.js +21 -2
  14. package/dist/registry/protocols.d.ts +50 -1
  15. package/dist/registry/protocols.js +88 -0
  16. package/dist/review-card/builder.d.ts +13 -0
  17. package/dist/review-card/builder.js +61 -5
  18. package/dist/review-card/cross-check.js +50 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/index.d.ts +8 -2
  22. package/dist/run/index.js +2 -1
  23. package/dist/run/schemas.d.ts +16 -4
  24. package/dist/run/schemas.js +14 -0
  25. package/dist/synth/compose-from-recording.d.ts +4 -0
  26. package/dist/synth/compose-from-recording.js +34 -8
  27. package/dist/synth/deny-cases.d.ts +4 -1
  28. package/dist/synth/deny-cases.js +3 -1
  29. package/dist/synth/evaluate.js +130 -3
  30. package/dist/synth/permit-context.d.ts +15 -0
  31. package/dist/synth/permit-context.js +116 -0
  32. package/dist/synth/synthesize-from-mandate.d.ts +17 -2
  33. package/dist/synth/synthesize-from-mandate.js +27 -2
  34. package/dist/synth/synthesize-from-recording.d.ts +25 -1
  35. package/dist/synth/synthesize-from-recording.js +80 -3
  36. package/dist/types.d.ts +9 -0
  37. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  38. package/dist-cjs/errors.d.ts +1 -1
  39. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  40. package/dist-cjs/install/build-add-context-rule.js +308 -0
  41. package/dist-cjs/ir/types.d.ts +4 -0
  42. package/dist-cjs/predicate/decode.d.ts +11 -0
  43. package/dist-cjs/predicate/decode.js +239 -0
  44. package/dist-cjs/predicate/encode.js +79 -14
  45. package/dist-cjs/predicate/from-json.d.ts +4 -0
  46. package/dist-cjs/predicate/from-json.js +116 -0
  47. package/dist-cjs/predicate/index.d.ts +2 -0
  48. package/dist-cjs/predicate/index.js +10 -2
  49. package/dist-cjs/registry/identify.js +20 -1
  50. package/dist-cjs/registry/protocols.d.ts +50 -1
  51. package/dist-cjs/registry/protocols.js +89 -1
  52. package/dist-cjs/review-card/builder.d.ts +13 -0
  53. package/dist-cjs/review-card/builder.js +62 -5
  54. package/dist-cjs/review-card/cross-check.js +50 -3
  55. package/dist-cjs/review-card/index.d.ts +1 -1
  56. package/dist-cjs/review-card/index.js +2 -1
  57. package/dist-cjs/run/index.d.ts +8 -2
  58. package/dist-cjs/run/index.js +2 -1
  59. package/dist-cjs/run/schemas.d.ts +16 -4
  60. package/dist-cjs/run/schemas.js +14 -0
  61. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  62. package/dist-cjs/synth/compose-from-recording.js +34 -8
  63. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  64. package/dist-cjs/synth/deny-cases.js +3 -0
  65. package/dist-cjs/synth/evaluate.js +130 -3
  66. package/dist-cjs/synth/permit-context.d.ts +15 -0
  67. package/dist-cjs/synth/permit-context.js +119 -0
  68. package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
  69. package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
  70. package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
  71. package/dist-cjs/synth/synthesize-from-recording.js +80 -3
  72. package/dist-cjs/types.d.ts +9 -0
  73. package/package.json +5 -2
  74. package/src/adapters/interpreter/adapter.ts +72 -11
  75. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  76. package/src/errors.ts +13 -0
  77. package/src/install/build-add-context-rule.ts +429 -0
  78. package/src/ir/types.ts +4 -0
  79. package/src/predicate/decode.ts +242 -0
  80. package/src/predicate/encode.ts +110 -13
  81. package/src/predicate/from-json.ts +124 -0
  82. package/src/predicate/index.ts +5 -1
  83. package/src/registry/identify.ts +22 -2
  84. package/src/registry/protocols.ts +90 -1
  85. package/src/review-card/builder.ts +57 -5
  86. package/src/review-card/cross-check.ts +50 -3
  87. package/src/review-card/index.ts +1 -0
  88. package/src/run/index.ts +16 -2
  89. package/src/run/schemas.ts +14 -0
  90. package/src/synth/compose-from-recording.ts +46 -7
  91. package/src/synth/deny-cases.ts +3 -1
  92. package/src/synth/evaluate.ts +131 -3
  93. package/src/synth/permit-context.ts +137 -0
  94. package/src/synth/synthesize-from-mandate.ts +40 -4
  95. package/src/synth/synthesize-from-recording.ts +116 -6
  96. package/src/types.ts +12 -0
@@ -80,16 +80,57 @@ function pushComparison(
80
80
  out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`)
81
81
  return
82
82
  }
83
+ if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
84
+ out.push(`Length of arg[${left.index}] is ${right.value}`)
85
+ return
86
+ }
87
+ // call_arg_field: must mirror the builder's templates byte-for-byte so
88
+ // the cross-check can assert a faithful summary.
89
+ if (left.kind === 'call_arg_field') {
90
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`
91
+ const sep = op === 'eq' ? '=' : comparisonOpText(op)
92
+ if (right.kind === 'literal_address') {
93
+ out.push(`${head} ${sep} ${right.value}`)
94
+ return
95
+ }
96
+ if (right.kind === 'literal_symbol') {
97
+ out.push(`${head} ${sep} ${right.value}`)
98
+ return
99
+ }
100
+ if (right.kind === 'literal_bytes') {
101
+ out.push(`${head} ${sep} ${right.value}`)
102
+ return
103
+ }
104
+ if (right.kind === 'literal_u64') {
105
+ out.push(`${head} ${sep} ${right.value}`)
106
+ return
107
+ }
108
+ if (right.kind === 'literal_i128') {
109
+ out.push(`${head} ${sep} ${right.value}`)
110
+ return
111
+ }
112
+ if (right.kind === 'literal_u32') {
113
+ out.push(`${head} ${sep} ${right.value}`)
114
+ return
115
+ }
116
+ if (right.kind === 'literal_vec') {
117
+ out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`)
118
+ return
119
+ }
120
+ }
121
+ // Mirrors the builder's wording byte-for-byte: how many calls the rule
122
+ // permits, since the bound counts the calls already made.
83
123
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
84
- out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`)
124
+ const allowed = op === 'lt' ? right.value : right.value + 1
125
+ out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`)
85
126
  return
86
127
  }
87
128
  if (left.kind === 'amount' && right.kind === 'literal_i128') {
88
129
  out.push(`Amount <= ${right.value}`)
89
130
  return
90
131
  }
91
- if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
92
- out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`)
132
+ if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
133
+ out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`)
93
134
  return
94
135
  }
95
136
  }
@@ -121,6 +162,7 @@ function renderVecElement(leaf: PredicateLeaf): string {
121
162
  case 'call_arg':
122
163
  case 'call_arg_len':
123
164
  case 'call_arg_field':
165
+ case 'call_arg_scaled':
124
166
  case 'amount':
125
167
  case 'window_spent':
126
168
  case 'now':
@@ -128,6 +170,11 @@ function renderVecElement(leaf: PredicateLeaf): string {
128
170
  case 'invocation_count_in_window':
129
171
  case 'oracle_price':
130
172
  return `<${leaf.kind}>`
173
+ // Show the declared basis, not just the digits. A threshold on the wrong
174
+ // basis is the one policy error the contract cannot detect, so the review
175
+ // card is where a human has to be able to see it.
176
+ case 'oracle_threshold':
177
+ return `${leaf.value} (${leaf.decimals} dp)`
131
178
  }
132
179
  }
133
180
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  export {
4
4
  buildReviewCardSummary,
5
+ describePredicate,
5
6
  type ReviewCardSummary,
6
7
  } from './builder.ts'
7
8
  export {
package/src/run/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type ErrorCode,
22
22
  type MandateSpec,
23
23
  type OzAdapterConfig,
24
+ type PredicateNode,
24
25
  type ProposedPolicy,
25
26
  placeholderOzConfig,
26
27
  type RecordedTransaction,
@@ -31,6 +32,7 @@ import {
31
32
  type ToolError,
32
33
  type ToolResponse,
33
34
  } from '../index.ts'
35
+ import type { SimulationResult } from '../verify/envelope.ts'
34
36
  import {
35
37
  type RecordTransactionInput,
36
38
  RecordTransactionInputSchema,
@@ -112,7 +114,14 @@ export async function runRecordTransaction(
112
114
  * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
113
115
  * XDR decode error in the adapter) would otherwise surface as
114
116
  * "[object Object]" in the MCP transport. */
115
- export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>> {
117
+ export async function runSynthesizePolicy(raw: unknown): Promise<
118
+ ToolResponse<ProposedPolicy> & {
119
+ explain?: {
120
+ predicateTree: PredicateNode | null
121
+ simulation: SimulationResult
122
+ }
123
+ }
124
+ > {
116
125
  const parsed = SynthesizePolicyInputSchema.safeParse(raw)
117
126
  if (!parsed.success) {
118
127
  return {
@@ -128,7 +137,11 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
128
137
  // Zod's optional fields widen to `T | undefined`, which the core's
129
138
  // exact-optional MandateSpec rejects; the schema already validated the
130
139
  // shape, so assert it (same pattern as the recordedTx cast below).
131
- return await synthesizeFromMandate(input.mandate as MandateSpec, ozConfig)
140
+ return await synthesizeFromMandate(
141
+ input.mandate as MandateSpec,
142
+ ozConfig,
143
+ input.explain === true ? { explain: true } : {}
144
+ )
132
145
  }
133
146
  // recording source
134
147
  const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
@@ -141,6 +154,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
141
154
  ? { confidenceOverride: input.confidenceOverride }
142
155
  : {}),
143
156
  ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
157
+ ...(input.explain === true ? { explain: true } : {}),
144
158
  } as SynthesizeFromRecordingOptions,
145
159
  ozConfig
146
160
  )
@@ -197,6 +197,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
197
197
  source: z.literal('mandate'),
198
198
  mandate: MandateSpecSchema,
199
199
  ozConfig: OzAdapterConfigSchema.optional(),
200
+ // --explain opt-in. When true, the orchestrator attaches the
201
+ // in-memory predicate tree (null for the mandate path) + a minimal
202
+ // honest SimulationResult to the success envelope. Absent or false
203
+ // -> the success envelope is unchanged (byte-identical to today).
204
+ explain: z.boolean().optional(),
200
205
  })
201
206
 
202
207
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
@@ -224,6 +229,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
224
229
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
225
230
  interpreter: InterpreterOptionsSchema.optional(),
226
231
  ozConfig: OzAdapterConfigSchema.optional(),
232
+ // --explain opt-in. When true, the orchestrator attaches the
233
+ // in-memory PredicateNode + the corresponding SimulationResult
234
+ // (real one from the self-verify pipeline when the interpreter is
235
+ // engaged, minimal honest value otherwise) to the success envelope.
236
+ // Absent or false -> the success envelope is unchanged (byte-identical
237
+ // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
238
+ // (encodedPredicate, predicateHash, etc.) are never altered by enabling
239
+ // explain.
240
+ explain: z.boolean().optional(),
227
241
  })
228
242
 
229
243
  export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
@@ -60,6 +60,10 @@ export interface OraclePriceBound {
60
60
  asset: string
61
61
  operator: IRCompOp
62
62
  value: string
63
+ /** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
64
+ * 9 dp, and a threshold silently assumed to share that basis is what let a
65
+ * raw 14-dp bound permit everything. The author states it; we convert. */
66
+ decimals: number
63
67
  }
64
68
 
65
69
  /** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
@@ -184,10 +188,19 @@ export function composeFromRecording(
184
188
  value: limit,
185
189
  },
186
190
  }
187
- if (!interpreterEnabled || token === scopeContract) {
188
- ozConstraints.push(spendCond)
189
- } else {
190
- interpreterConstraints.push(spendCond)
191
+ // A rolling spend cap always goes to OZ, whose `spending_limit` is the
192
+ // audited implementation. The interpreter is NOT a fallback for the
193
+ // token != scopeContract case: on chain it sees one authorized call,
194
+ // not the transaction's token movements, so it has no per-call amount
195
+ // to accumulate and the counter would never move. OZ reports the case
196
+ // it cannot cover (it pins the limit to the context contract), which
197
+ // is the honest outcome - the old fallback produced an interpreter
198
+ // predicate that silently never bound.
199
+ ozConstraints.push(spendCond)
200
+ if (interpreterEnabled && token !== scopeContract) {
201
+ warnings.push(
202
+ `rolling spend cap on ${token} cannot be enforced on chain: OZ spending_limit pins the limit to the context contract, and the interpreter cannot observe token movements. Bound the per-call value with an argument cap plus an invocation-count limit instead.`
203
+ )
191
204
  }
192
205
  }
193
206
  }
@@ -213,7 +226,10 @@ export function composeFromRecording(
213
226
  op: 'compare',
214
227
  compare: {
215
228
  selector: { kind: 'invocation_count', windowSeconds },
216
- operator: 'lte',
229
+ // `lt`, not `lte`: the leaf reports the calls ALREADY made in the
230
+ // window, so `< N` is what permits N of them. With `lte` a limit
231
+ // of N let an N+1th call through.
232
+ operator: 'lt',
217
233
  value: String(invocationLimit),
218
234
  },
219
235
  }
@@ -245,6 +261,7 @@ export function composeFromRecording(
245
261
  selector: { kind: 'oracle_price', asset: b.asset },
246
262
  operator: b.operator,
247
263
  value: b.value,
264
+ valueDecimals: b.decimals,
248
265
  },
249
266
  }
250
267
  if (interpreterEnabled) {
@@ -405,7 +422,17 @@ function appendProtocolSpecificConstraints(
405
422
  })
406
423
  for (let i = 0; i < elements.length; i++) {
407
424
  const element = elements[i]
408
- if (!element || element.type !== 'map') continue
425
+ // An element we cannot bind leaves that request's action, asset and
426
+ // amount free while the length pin makes the policy LOOK total. The
427
+ // length pin still stays (it is a real restriction, and dropping it
428
+ // would only widen the policy) - what must not happen is dropping the
429
+ // element quietly.
430
+ if (element?.type !== 'map') {
431
+ warnings.push(
432
+ `Blend submit requests element ${i} is not a Request map (recorded as ${element?.type ?? 'nothing'}): its request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`
433
+ )
434
+ continue
435
+ }
409
436
  const fields = element.value
410
437
  const fieldValue = (
411
438
  name: string,
@@ -421,7 +448,19 @@ function appendProtocolSpecificConstraints(
421
448
  const address = fieldValue('address', 'address')
422
449
  const amount = fieldValue('amount', 'i128')
423
450
  const requestType = fieldValue('request_type', 'u32')
424
- if (address === null || amount === null || requestType === null) continue
451
+ if (address === null || amount === null || requestType === null) {
452
+ const missing = [
453
+ requestType === null ? 'request_type (u32)' : null,
454
+ address === null ? 'address' : null,
455
+ amount === null ? 'amount (i128)' : null,
456
+ ].filter((f): f is string => f !== null)
457
+ // Partial pins are deliberately NOT emitted (see the note above): a
458
+ // half-pinned request reads as a bound one on the review card.
459
+ warnings.push(
460
+ `Blend submit requests element ${i} could not be bound: ${missing.join(', ')} missing or of an unexpected type. That request's request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`
461
+ )
462
+ continue
463
+ }
425
464
  interpreterConstraints.push({
426
465
  op: 'compare',
427
466
  compare: {
@@ -566,7 +566,9 @@ function cloneContext(ctx: EvalContext): EvalContext {
566
566
  return cloned
567
567
  }
568
568
 
569
- function cloneScVal(value: ScVal, depth = 0): ScVal {
569
+ /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
570
+ * Exported so the permit-context builder shares this one implementation. */
571
+ export function cloneScVal(value: ScVal, depth = 0): ScVal {
570
572
  // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
571
573
  // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
572
574
  // throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
@@ -32,6 +32,13 @@
32
32
  import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
33
33
  import { literalNumericBigInt } from './predicate-literals.ts'
34
34
 
35
+ /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
36
+ * in oracle.rs. A threshold on any other basis must say so. */
37
+ const NORMALISED_DECIMALS = 9
38
+ /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
39
+ const MAX_ORACLE_THRESHOLD_DECIMALS = 18
40
+
41
+
35
42
  export interface EvalContext {
36
43
  /** Contract the interpreter is asked to enforce against. */
37
44
  contract: string
@@ -206,6 +213,13 @@ function evalCompare(
206
213
 
207
214
  // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
208
215
  if (left.kind === 'call_arg') {
216
+ // The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
217
+ // Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
218
+ // -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
219
+ // generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
220
+ if (right.kind === 'call_arg_scaled') {
221
+ return evalScaledArgCompare(op, left, right, ctx)
222
+ }
209
223
  const actual = ctx.args[left.index]
210
224
  // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
211
225
  // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
@@ -216,6 +230,12 @@ function evalCompare(
216
230
  return evalArgEq(op, actual, right, ctx)
217
231
  }
218
232
 
233
+ // --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
234
+ // Symmetric form so a policy that phrases the floor either way works.
235
+ if (left.kind === 'call_arg_scaled') {
236
+ return evalScaledArgCompare(op, right, left, ctx)
237
+ }
238
+
219
239
  // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
220
240
  // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
221
241
  if (left.kind === 'call_arg_len') {
@@ -337,6 +357,100 @@ function evalArgEq(
337
357
  return { permit: false, reason: 'ARG_MISMATCH' }
338
358
  }
339
359
 
360
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
361
+ * path: the scaled leaf is `args[index] * num / den` (truncating toward
362
+ * zero). On `checked_mul` / `checked_div` failure (overflow or
363
+ * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
364
+ * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
365
+ * rather than the generic `ARG_MISMATCH`.
366
+ *
367
+ * `left` is whichever side of the compare is NOT the scaled leaf. A
368
+ * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
369
+ * leaves has no definable semantics). The other operand must be a
370
+ * numeric shape (`call_arg` carrying a number, or a numeric literal);
371
+ * anything else is `ARG_MISMATCH`. */
372
+ function evalScaledArgCompare(
373
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
374
+ left: PredicateLeaf,
375
+ right: PredicateLeaf,
376
+ ctx: EvalContext
377
+ ): EvalResult {
378
+ // Identify which side is scaled, and pull the other side as a number.
379
+ let scaled: { index: number; num: string; den: string }
380
+ let other: PredicateLeaf
381
+ let scaledOnRight: boolean
382
+ if (left.kind === 'call_arg_scaled') {
383
+ scaled = left
384
+ other = right
385
+ scaledOnRight = false
386
+ } else if (right.kind === 'call_arg_scaled') {
387
+ scaled = right
388
+ other = left
389
+ scaledOnRight = true
390
+ } else {
391
+ // Neither side is scaled - this is a programming error in the
392
+ // dispatcher; the contract returns ARG_MISMATCH to fail closed.
393
+ return { permit: false, reason: 'ARG_MISMATCH' }
394
+ }
395
+ // Source `args[index]` -> BigInt. An out-of-bounds index or a
396
+ // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
397
+ // a violated floor is the wrong code when the operand itself could
398
+ // not be read.
399
+ if (scaled.index >= ctx.args.length) {
400
+ return { permit: false, reason: 'ARG_MISMATCH' }
401
+ }
402
+ const input = argNumericBigInt(ctx.args[scaled.index])
403
+ if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
404
+ let num: bigint
405
+ let den: bigint
406
+ try {
407
+ num = BigInt(scaled.num)
408
+ den = BigInt(scaled.den)
409
+ } catch {
410
+ return { permit: false, reason: 'ARG_MISMATCH' }
411
+ }
412
+ // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
413
+ // check is a defensive belt-and-braces - a future validator
414
+ // regression cannot panic the frame on a divide-by-zero.
415
+ if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
416
+ const product = input * num
417
+ // BigInt overflow is silent in JS (it does not throw on multiplication).
418
+ // We can detect over-range by dividing and checking the result against
419
+ // i128 bounds, but the more practical check is whether the result
420
+ // divides cleanly. We rely on BigInt's arbitrary precision and then
421
+ // surface ARITHMETIC_OVERFLOW if the result is outside i128.
422
+ let scaledValue: bigint
423
+ try {
424
+ // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
425
+ scaledValue = product / den
426
+ } catch {
427
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
428
+ }
429
+ // i128 range check: if the scaled value is outside i128, the contract
430
+ // would have wrapped on i128 arithmetic; surface the same deny.
431
+ const I128_MAX = (1n << 127n) - 1n
432
+ const I128_MIN = -(1n << 127n)
433
+ if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
434
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
435
+ }
436
+ // Resolving the operand when the scaled side is on the right: the
437
+ // operand is `left`; the scaled value is the RHS. The AST order is
438
+ // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
439
+ // When the scaled side is on the left, the order is `scaled <op> other`,
440
+ // i.e. `scaled <op> other_val`.
441
+ let otherVal: bigint | null
442
+ if (other.kind === 'call_arg') {
443
+ otherVal = argNumericBigInt(ctx.args[other.index])
444
+ } else {
445
+ otherVal = literalNumericBigInt(other)
446
+ }
447
+ if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
448
+ const pass = scaledOnRight
449
+ ? bigintCmp(op, otherVal.toString(), scaledValue.toString())
450
+ : bigintCmp(op, scaledValue.toString(), otherVal.toString())
451
+ return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' }
452
+ }
453
+
340
454
  /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
341
455
  * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
342
456
  * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
@@ -430,6 +544,7 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
430
544
  case 'invocation_count_in_window':
431
545
  case 'now':
432
546
  case 'valid_until':
547
+ case 'call_arg_scaled':
433
548
  return undefined // selector leaves with no ScVal projection
434
549
  case 'literal_address':
435
550
  return { type: 'address', value: leaf.value }
@@ -507,9 +622,22 @@ function evalOracleCompare(
507
622
  if (!mapped) throw new OracleError('ORACLE_STALE')
508
623
  throw new OracleError(mapped)
509
624
  }
510
- const literal = right.kind === 'literal_i128' ? right.value : null
511
- if (literal === null) throw new OracleError('ORACLE_DECIMALS_MISMATCH')
512
- return bigintCmp(op, entry.price, literal)
625
+ // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
626
+ // decimal basis: prices are on the normalised 9-dp basis, and assuming a
627
+ // bare literal shares it is what let a raw 14-dp threshold permit
628
+ // everything. A bare literal is refused rather than assumed.
629
+ if (right.kind !== 'oracle_threshold') throw new OracleError('ORACLE_DECIMALS_MISMATCH')
630
+ if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
631
+ throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE')
632
+ }
633
+ // Scale BOTH sides up to the wider basis rather than dividing the threshold
634
+ // down: dividing truncates, and a truncated bound moves the permit boundary.
635
+ const decimals = BigInt(right.decimals)
636
+ const normalised = BigInt(NORMALISED_DECIMALS)
637
+ const common = decimals > normalised ? decimals : normalised
638
+ const priceScaled = BigInt(entry.price) * 10n ** (common - normalised)
639
+ const literalScaled = BigInt(right.value) * 10n ** (common - decimals)
640
+ return bigintCmp(op, String(priceScaled), String(literalScaled))
513
641
  ? { permit: true }
514
642
  : { permit: false, reason: 'FN_MISMATCH' }
515
643
  }
@@ -0,0 +1,137 @@
1
+ // src/synth/permit-context.ts - build the EvalContext that represents the
2
+ // recorded call being replayed.
3
+ //
4
+ // Extracted from the over-permissiveness harness so the same construction
5
+ // feeds both the in-process battery and the on-chain replay
6
+ // (scripts/verify-mutations-testnet.ts). Two callers building this
7
+ // separately would be two chances to disagree about what "the recorded
8
+ // call" means, which is the one thing both must share.
9
+
10
+ import type { PredicateLeaf, PredicateNode, RecordedTransaction } from '../types.ts'
11
+ import { cloneScVal } from './deny-cases.ts'
12
+ import type { EvalContext } from './evaluate.ts'
13
+ import { literalNumericBigInt } from './predicate-literals.ts'
14
+
15
+ export interface PermitContextResponses {
16
+ windowSeconds: number
17
+ invocationLimit?: number
18
+ limitAmount?: string
19
+ validUntilLedger: number
20
+ oraclePriceBound?: Array<{ asset: string; operator: string; value: string; decimals: number }>
21
+ }
22
+
23
+ export function buildPermitContext(
24
+ tx: RecordedTransaction,
25
+ responses: PermitContextResponses,
26
+ predicate: PredicateNode
27
+ ): EvalContext {
28
+ const amountByToken: Record<string, string> = {}
29
+ const totals = new Map<string, bigint>()
30
+ for (const m of tx.tokenMovements) {
31
+ const current = totals.get(m.token) ?? 0n
32
+ totals.set(m.token, current + BigInt(m.amount))
33
+ }
34
+ for (const [token, total] of totals) {
35
+ amountByToken[token] = total.toString()
36
+ }
37
+
38
+ const topLevel = tx.invocations[0]
39
+ const scopeContract = topLevel?.contract ?? ''
40
+
41
+ const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = {}
42
+ visitOracleLeaves(predicate, (asset, op, bound) => {
43
+ let price: bigint
44
+ switch (op) {
45
+ case 'lt':
46
+ price = bound - 1n
47
+ break
48
+ case 'gt':
49
+ price = bound + 1n
50
+ break
51
+ default:
52
+ price = bound
53
+ }
54
+ if (price < 0n) price = 0n
55
+ oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt }
56
+ })
57
+
58
+ const ctx: EvalContext = {
59
+ contract: scopeContract,
60
+ fn: topLevel?.fn ?? '',
61
+ args: (topLevel?.args ?? []).map(cloneScVal),
62
+ atLedger: tx.ledgerSequence,
63
+ nowSeconds: tx.fetchedAt,
64
+ amountByToken,
65
+ windowSpentByToken: {},
66
+ invocationCountByWindow: {},
67
+ oraclePriceByAsset,
68
+ }
69
+ if (responses.validUntilLedger !== undefined) {
70
+ ctx.validUntilLedger = responses.validUntilLedger
71
+ }
72
+ return ctx
73
+ }
74
+
75
+ function visitOracleLeaves(
76
+ node: PredicateNode,
77
+ visit: (asset: string, op: string, bound: bigint) => void
78
+ ): void {
79
+ switch (node.op) {
80
+ case 'and':
81
+ case 'or':
82
+ for (const child of node.children) visitOracleLeaves(child, visit)
83
+ return
84
+ case 'not':
85
+ visitOracleLeaves(node.child, visit)
86
+ return
87
+ case 'eq':
88
+ case 'lt':
89
+ case 'lte':
90
+ case 'gt':
91
+ case 'gte': {
92
+ const leftIsOracle = node.left.kind === 'oracle_price'
93
+ const rightIsOracle = node.right.kind === 'oracle_price'
94
+ let asset: string | undefined
95
+ let literal: bigint | undefined
96
+ if (leftIsOracle) {
97
+ asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined
98
+ literal = oracleThresholdNormalised(node.right)
99
+ } else if (rightIsOracle) {
100
+ asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined
101
+ literal = oracleThresholdNormalised(node.left)
102
+ }
103
+ if (asset !== undefined && literal !== undefined) {
104
+ visit(asset, node.op, literal)
105
+ }
106
+ return
107
+ }
108
+ case 'in':
109
+ return
110
+ }
111
+ }
112
+
113
+ /** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
114
+ * oracle.rs. */
115
+ const NORMALISED_DECIMALS = 9n
116
+
117
+ /** A threshold restated on the normalised basis, so a derived permit price is
118
+ * comparable to it. Thresholds carry their own basis, so the conversion has
119
+ * to happen here - reading the digits raw would build a context off by a
120
+ * factor of 10^(decimals-9). Returns undefined for any other leaf. */
121
+ function oracleThresholdNormalised(leaf: PredicateLeaf): bigint | undefined {
122
+ if (leaf.kind !== 'oracle_threshold') return undefined
123
+ let value: bigint
124
+ try {
125
+ value = BigInt(leaf.value)
126
+ } catch {
127
+ return undefined
128
+ }
129
+ const decimals = BigInt(leaf.decimals)
130
+ if (decimals <= NORMALISED_DECIMALS) {
131
+ return value * 10n ** (NORMALISED_DECIMALS - decimals)
132
+ }
133
+ // Floor: a finer-grained threshold has no exact 9-dp representation. The
134
+ // caller only needs a price that lands on the right side of the bound, and
135
+ // it offsets by one from here.
136
+ return value / 10n ** (decimals - NORMALISED_DECIMALS)
137
+ }
@@ -13,14 +13,27 @@ import { createOzAdapter } from '../adapters/oz/adapter.ts'
13
13
  import type { ToolResponse } from '../errors.ts'
14
14
  import { mandateToPolicyIR } from '../mandate/to-ir.ts'
15
15
  import type { MandateSpec } from '../mandate/types.ts'
16
- import type { ProposedPolicy } from '../types.ts'
16
+ import type { PredicateNode, ProposedPolicy } from '../types.ts'
17
+ import type { SimulationResult } from '../verify/envelope.ts'
17
18
 
18
19
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
19
20
 
20
21
  export function synthesizeFromMandate(
21
22
  spec: MandateSpec,
22
- ozConfig: OzAdapterConfig
23
- ): ToolResponse<ProposedPolicy> {
23
+ ozConfig: OzAdapterConfig,
24
+ /** --explain opt-in. When true, the success envelope carries the
25
+ * in-memory predicate tree (always null for the mandate path - the
26
+ * declarative MandateSpec lowers to OZ built-ins, not to an
27
+ * interpreter predicate) + a minimal honest SimulationResult. The
28
+ * flag is ADDITIVE: the existing ProposedPolicy fields are never
29
+ * altered. */
30
+ opts?: { explain?: true }
31
+ ): ToolResponse<ProposedPolicy> & {
32
+ explain?: {
33
+ predicateTree: PredicateNode | null
34
+ simulation: SimulationResult
35
+ }
36
+ } {
24
37
  const ir = mandateToPolicyIR(spec)
25
38
  const adapter = createOzAdapter(ozConfig)
26
39
  const result = adapter.compile(ir)
@@ -42,5 +55,28 @@ export function synthesizeFromMandate(
42
55
  ...result.proposed,
43
56
  warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
44
57
  }
45
- return { ok: true, data: proposed }
58
+ // Same --explain envelope pattern as the recording path. The mandate path
59
+ // never produces an interpreter predicate, so predicateTree is null and
60
+ // the simulation is a minimal honest deny (no self-verify was performed).
61
+ const envelope: ToolResponse<ProposedPolicy> & {
62
+ explain?: {
63
+ predicateTree: PredicateNode | null
64
+ simulation: SimulationResult
65
+ }
66
+ } = { ok: true, data: proposed }
67
+ if (opts?.explain) {
68
+ envelope.explain = {
69
+ predicateTree: null,
70
+ simulation: {
71
+ permit: {
72
+ tx: 'deny',
73
+ reason: 'No self-verification was performed (mandate path is OZ-only)',
74
+ },
75
+ evaluatedCases: [],
76
+ backend: 'ts-model',
77
+ simulatorVersion: 'not-run',
78
+ },
79
+ }
80
+ }
81
+ return envelope
46
82
  }