@crediolabs/policy-synth 0.4.0 → 0.5.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 (68) hide show
  1. package/dist/adapters/interpreter/adapter.d.ts +2 -2
  2. package/dist/adapters/interpreter/adapter.js +11 -3
  3. package/dist/errors.d.ts +6 -1
  4. package/dist/install/authority-overlap.js +12 -0
  5. package/dist/install/build-add-context-rule.d.ts +1 -1
  6. package/dist/install/read-account-rules.d.ts +79 -0
  7. package/dist/install/read-account-rules.js +241 -0
  8. package/dist/predicate/decode.js +22 -1
  9. package/dist/predicate/encode.js +52 -5
  10. package/dist/predicate/from-json.js +21 -1
  11. package/dist/review-card/builder.js +40 -0
  12. package/dist/review-card/cross-check.js +34 -0
  13. package/dist/review-card/render-leaf.d.ts +1 -1
  14. package/dist/review-card/render-leaf.js +7 -0
  15. package/dist/run/index.d.ts +2 -2
  16. package/dist/run/index.js +52 -9
  17. package/dist/run/schemas.d.ts +64 -14
  18. package/dist/run/schemas.js +70 -6
  19. package/dist/simulate/deny-cases.js +11 -0
  20. package/dist/simulate/evaluate.js +86 -5
  21. package/dist/synth/declare.d.ts +13 -0
  22. package/dist/synth/declare.js +34 -5
  23. package/dist/synth/synthesize-from-recording.js +1 -1
  24. package/dist/types.d.ts +21 -1
  25. package/dist/types.js +1 -1
  26. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  27. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  28. package/dist-cjs/errors.d.ts +6 -1
  29. package/dist-cjs/install/authority-overlap.js +12 -0
  30. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  31. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  32. package/dist-cjs/install/read-account-rules.js +252 -0
  33. package/dist-cjs/predicate/decode.js +22 -1
  34. package/dist-cjs/predicate/encode.js +52 -5
  35. package/dist-cjs/predicate/from-json.js +21 -1
  36. package/dist-cjs/review-card/builder.js +40 -0
  37. package/dist-cjs/review-card/cross-check.js +34 -0
  38. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  39. package/dist-cjs/review-card/render-leaf.js +7 -0
  40. package/dist-cjs/run/index.d.ts +2 -2
  41. package/dist-cjs/run/index.js +54 -9
  42. package/dist-cjs/run/schemas.d.ts +64 -14
  43. package/dist-cjs/run/schemas.js +71 -7
  44. package/dist-cjs/simulate/deny-cases.js +11 -0
  45. package/dist-cjs/simulate/evaluate.js +86 -5
  46. package/dist-cjs/synth/declare.d.ts +13 -0
  47. package/dist-cjs/synth/declare.js +34 -5
  48. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  49. package/dist-cjs/types.d.ts +21 -1
  50. package/dist-cjs/types.js +1 -1
  51. package/package.json +1 -1
  52. package/src/adapters/interpreter/adapter.ts +13 -5
  53. package/src/errors.ts +5 -0
  54. package/src/install/authority-overlap.ts +11 -0
  55. package/src/install/read-account-rules.ts +313 -0
  56. package/src/predicate/decode.ts +22 -1
  57. package/src/predicate/encode.ts +55 -5
  58. package/src/predicate/from-json.ts +21 -1
  59. package/src/review-card/builder.ts +45 -2
  60. package/src/review-card/cross-check.ts +35 -1
  61. package/src/review-card/render-leaf.ts +8 -1
  62. package/src/run/index.ts +57 -8
  63. package/src/run/schemas.ts +83 -6
  64. package/src/simulate/deny-cases.ts +12 -1
  65. package/src/simulate/evaluate.ts +101 -9
  66. package/src/synth/declare.ts +54 -5
  67. package/src/synth/synthesize-from-recording.ts +1 -1
  68. package/src/types.ts +16 -1
@@ -26,9 +26,13 @@ export function jsonToAst(value: unknown): PredicateNode {
26
26
  }
27
27
  switch (v.op) {
28
28
  case 'and':
29
- return { op: 'and', children: arrayOf(v.children, jsonToAst) }
29
+ case 'or':
30
+ return { op: v.op, children: arrayOf(v.children, jsonToAst) }
30
31
  case 'eq':
32
+ case 'lt':
31
33
  case 'lte':
34
+ case 'gt':
35
+ case 'gte':
32
36
  return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) }
33
37
  case 'in':
34
38
  return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) }
@@ -53,6 +57,22 @@ function jsonToLeaf(value: unknown): PredicateLeaf {
53
57
  return { kind: 'call_arg', index: numberField(v, 'index') }
54
58
  case 'call_arg_len':
55
59
  return { kind: 'call_arg_len', index: numberField(v, 'index') }
60
+ case 'call_arg_field':
61
+ return {
62
+ kind: 'call_arg_field',
63
+ index: numberField(v, 'index'),
64
+ element: numberField(v, 'element'),
65
+ field: stringField(v, 'field'),
66
+ }
67
+ case 'call_arg_scaled':
68
+ // num/den stay decimal STRINGS: an i128 ratio does not survive a JS
69
+ // number, and silently rounding one would change the floor.
70
+ return {
71
+ kind: 'call_arg_scaled',
72
+ index: numberField(v, 'index'),
73
+ num: stringField(v, 'num'),
74
+ den: stringField(v, 'den'),
75
+ }
56
76
  case 'literal_address':
57
77
  return { kind: 'literal_address', value: stringField(v, 'value') }
58
78
  case 'literal_i128':
@@ -20,7 +20,7 @@
20
20
  // hash. There is no clock; the hash never includes a timestamp.
21
21
 
22
22
  import { createHash } from 'node:crypto'
23
- import type { ContextRuleDraft, PredicateNode } from '../types.ts'
23
+ import type { ContextRuleDraft, PredicateLeaf, PredicateNode } from '../types.ts'
24
24
  import { comparisonOpText, renderHaystackElement, renderVecElement } from './render-leaf.ts'
25
25
 
26
26
  export interface ReviewCardSummary {
@@ -105,16 +105,33 @@ function walkPredicate(node: PredicateNode, visit: (node: PredicateNode) => void
105
105
  case 'and':
106
106
  for (const child of node.children) walkPredicate(child, visit)
107
107
  return
108
+ // NOT descended into. Every line the card emits reads as a requirement,
109
+ // and `and` is what makes that true. Listing an `or`'s branches as
110
+ // separate lines would state the opposite of what the policy means, so
111
+ // the whole disjunction is rendered as ONE line instead.
112
+ case 'or':
113
+ visit(node)
114
+ return
108
115
  case 'in':
109
116
  visit(node)
110
117
  return
111
118
  case 'eq':
119
+ case 'lt':
112
120
  case 'lte':
121
+ case 'gt':
122
+ case 'gte':
113
123
  visit(node)
114
124
  return
115
125
  }
116
126
  }
117
127
 
128
+ /** Argument index of a `call_arg` leaf, for the scaled-comparison line. Any
129
+ * other leaf renders as its kind so the line stays readable rather than
130
+ * claiming an index that does not exist. */
131
+ function leftArgLabel(leaf: PredicateLeaf): string {
132
+ return leaf.kind === 'call_arg' ? String(leaf.index) : `<${leaf.kind}>`
133
+ }
134
+
118
135
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
119
136
  * shape of the output is pinned by Task 7b so the test suite can assert
120
137
  * byte-for-byte equality. Returns `null` when the node is a structural
@@ -123,15 +140,41 @@ function renderConstraint(node: PredicateNode): string | null {
123
140
  switch (node.op) {
124
141
  case 'and':
125
142
  return null
143
+ case 'or': {
144
+ // One line for the whole disjunction. If any branch is a shape the
145
+ // card cannot render, the entire line is withheld rather than shown
146
+ // with a branch missing - a disjunction with a branch dropped reads
147
+ // as STRICTER than it is, which is the dangerous direction.
148
+ const parts = node.children.map(renderConstraint)
149
+ if (parts.some((p) => p === null)) return null
150
+ return `Either: ${parts.join(' OR ')}`
151
+ }
126
152
  case 'eq':
153
+ case 'lt':
127
154
  case 'lte':
155
+ case 'gt':
156
+ case 'gte':
128
157
  return renderComparison(node)
129
158
  case 'in':
130
159
  return renderMembership(node)
131
160
  }
132
161
  }
133
162
 
134
- function renderComparison(node: Extract<PredicateNode, { op: 'eq' | 'lte' }>): string | null {
163
+ function renderComparison(
164
+ node: Extract<PredicateNode, { op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte' }>
165
+ ): string | null {
166
+ // The slippage floor: OP(call_arg[out], call_arg_scaled(in, num, den)).
167
+ // Rendered explicitly because the human approving the signature has to see
168
+ // that the bound is a RATIO of another argument, not a fixed amount.
169
+ if (node.right.kind === 'call_arg_scaled') {
170
+ const s = node.right
171
+ return `arg[${leftArgLabel(node.left)}] ${comparisonOpText(node.op)} arg[${s.index}] * ${s.num}/${s.den}`
172
+ }
173
+ if (node.left.kind === 'call_arg_scaled') {
174
+ const s = node.left
175
+ return `arg[${s.index}] * ${s.num}/${s.den} ${comparisonOpText(node.op)} arg[${leftArgLabel(node.right)}]`
176
+ }
177
+
135
178
  const left = node.left
136
179
  const right = node.right
137
180
 
@@ -46,8 +46,28 @@ function collect(node: PredicateNode, out: string[]): void {
46
46
  case 'and':
47
47
  for (const child of node.children) collect(child, out)
48
48
  return
49
+ // ONE line for the whole disjunction, mirroring the builder. Emitting a
50
+ // line per branch would claim every branch is required, which is the
51
+ // opposite of what `or` means. If any branch renders to nothing the whole
52
+ // line is withheld, again mirroring the builder - a disjunction missing a
53
+ // branch reads STRICTER than it is.
54
+ case 'or': {
55
+ const parts: string[] = []
56
+ for (const child of node.children) {
57
+ const childOut: string[] = []
58
+ collect(child, childOut)
59
+ if (childOut.length !== 1) return
60
+ parts.push(childOut[0] as string)
61
+ }
62
+ if (parts.length === 0) return
63
+ out.push(`Either: ${parts.join(' OR ')}`)
64
+ return
65
+ }
49
66
  case 'eq':
67
+ case 'lt':
50
68
  case 'lte':
69
+ case 'gt':
70
+ case 'gte':
51
71
  pushComparison(node.left, node.right, node.op, out)
52
72
  return
53
73
  case 'in':
@@ -59,9 +79,23 @@ function collect(node: PredicateNode, out: string[]): void {
59
79
  function pushComparison(
60
80
  left: PredicateLeaf,
61
81
  right: PredicateLeaf,
62
- op: 'eq' | 'lte',
82
+ op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte',
63
83
  out: string[]
64
84
  ): void {
85
+ // Slippage floor, mirroring the builder. The human has to see that the
86
+ // bound is a RATIO of another argument, not a fixed amount.
87
+ if (right.kind === 'call_arg_scaled') {
88
+ const label = left.kind === 'call_arg' ? String(left.index) : `<${left.kind}>`
89
+ out.push(
90
+ `arg[${label}] ${comparisonOpText(op)} arg[${right.index}] * ${right.num}/${right.den}`
91
+ )
92
+ return
93
+ }
94
+ if (left.kind === 'call_arg_scaled') {
95
+ const label = right.kind === 'call_arg' ? String(right.index) : `<${right.kind}>`
96
+ out.push(`arg[${left.index}] * ${left.num}/${left.den} ${comparisonOpText(op)} arg[${label}]`)
97
+ return
98
+ }
65
99
  if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
66
100
  out.push(`Contract must be ${right.value}`)
67
101
  return
@@ -23,6 +23,7 @@ export function renderVecElement(leaf: PredicateLeaf): string {
23
23
  case 'call_arg':
24
24
  case 'call_arg_len':
25
25
  case 'call_arg_field':
26
+ case 'call_arg_scaled':
26
27
  return `<${leaf.kind}>`
27
28
  }
28
29
  }
@@ -38,10 +39,16 @@ export function renderHaystackElement(leaf: PredicateLeaf): string {
38
39
  return `<${leaf.kind}>`
39
40
  }
40
41
 
41
- export function comparisonOpText(op: 'eq' | 'lte'): string {
42
+ export function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string {
42
43
  switch (op) {
44
+ case 'lt':
45
+ return '<'
43
46
  case 'lte':
44
47
  return '<='
48
+ case 'gt':
49
+ return '>'
50
+ case 'gte':
51
+ return '>='
45
52
  case 'eq':
46
53
  return '=='
47
54
  }
package/src/run/index.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  rpcClientFromServer,
48
48
  } from '../install/build-install-policy.ts'
49
49
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
50
+ import { accountRuleReaderFromServer, collectObservedRules } from '../install/read-account-rules.ts'
50
51
  import { decodePredicate } from '../predicate/decode.ts'
51
52
  import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
52
53
  import {
@@ -76,6 +77,7 @@ export type {
76
77
  DeclarePolicyInput,
77
78
  GetInterpreterInfoInput,
78
79
  InstallPolicyInput,
80
+ OzBuiltinPolicy,
79
81
  RecordTransactionInput,
80
82
  RevokePolicyInput,
81
83
  SimulatePolicyInput,
@@ -99,6 +101,8 @@ export {
99
101
  PINNED_INTERPRETER_MAINNET_ADDRESS,
100
102
  PINNED_INTERPRETER_TESTNET_ADDRESS,
101
103
  PINNED_INTERPRETER_WASM_SHA256,
104
+ PINNED_OZ_POLICY_ADDRESS_BY_NETWORK,
105
+ PINNED_OZ_POLICY_WASM_SHA256,
102
106
  PredicateLeafSchema,
103
107
  PredicateNodeSchema,
104
108
  RecordedTransactionSchema,
@@ -267,12 +271,19 @@ export async function runInstallPolicy(
267
271
  rpc: rpcClient,
268
272
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
269
273
  })
270
- // Cross-rule scan, when the caller supplied what else is on the account.
271
- // ABSENT is reported as `null` rather than an empty list: "we did not
272
- // look" and "we looked and found nothing" are different answers, and
273
- // collapsing them would let a caller read silence as safety.
274
+ // Cross-rule scan. The caller may supply `existingRules` (useful offline,
275
+ // and for testing); otherwise the account is READ, so the answer describes
276
+ // what is actually installed rather than what the caller happened to
277
+ // mention.
278
+ //
279
+ // `null` means NOT CHECKED and is returned whenever the scan cannot be
280
+ // trusted to be complete - the read failed, or it stopped before
281
+ // accounting for every live rule. An empty list would say "checked,
282
+ // nothing found", and a partial scan that reported `[]` would be claiming
283
+ // a safety it never established.
284
+ const observed = await resolveExistingRules(input, network, expectedInterpreter)
274
285
  const authorityScan =
275
- input.existingRules === undefined
286
+ observed === null
276
287
  ? null
277
288
  : findAuthorityOverlaps({
278
289
  intended: {
@@ -284,9 +295,7 @@ export async function runInstallPolicy(
284
295
  signers: input.rule.signers,
285
296
  predicate: decodePredicate(encodedPredicate),
286
297
  },
287
- // The schema types `predicate` loosely (it is the shared
288
- // PredicateNodeSchema); the shape is already validated.
289
- existing: input.existingRules as ObservedRule[],
298
+ existing: observed,
290
299
  })
291
300
  return { ok: true, data: { ...result, authorityScan } }
292
301
  } catch (e) {
@@ -453,6 +462,7 @@ export function runDeclarePolicy(raw: unknown): ToolResponse<{
453
462
  ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
454
463
  ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
455
464
  ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
465
+ ...(d.minOutputRatio !== undefined ? { minOutputRatio: d.minOutputRatio } : {}),
456
466
  })
457
467
  const { encodedPredicate, predicateHash } = encodePredicate(predicate)
458
468
  return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } }
@@ -560,6 +570,45 @@ export async function runGetInterpreterInfo(
560
570
  * network, falling back to the pinned RPC for the network. The caller
561
571
  * has already been gated against the pinned URL elsewhere, so the
562
572
  * fallback here only ever picks from a finite, audited pair. */
573
+ /** The account's other context rules, or `null` when they could not be
574
+ * established completely.
575
+ *
576
+ * Caller-supplied `existingRules` win: they let the scan run offline, and a
577
+ * caller who passes them has said what to compare against. Otherwise the
578
+ * account is read over RPC.
579
+ *
580
+ * Every failure path returns `null` rather than a short list. A read that
581
+ * threw, or one that stopped before accounting for every live rule, has not
582
+ * ruled anything out - and reporting `[]` there would turn "we could not
583
+ * check" into "there is nothing to worry about". */
584
+ async function resolveExistingRules(
585
+ input: InstallPolicyInput,
586
+ network: Network,
587
+ interpreterAddress: string
588
+ ): Promise<ObservedRule[] | null> {
589
+ if (input.existingRules !== undefined) {
590
+ // The schema types `predicate` loosely (it is the shared
591
+ // PredicateNodeSchema); the shape is already validated.
592
+ return input.existingRules as ObservedRule[]
593
+ }
594
+ try {
595
+ const url = input.rpcUrl ?? RPC_URL_BY_NETWORK[network]
596
+ const server = new rpc.Server(url, { allowHttp: false })
597
+ const collected = await collectObservedRules({
598
+ reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
599
+ smartAccount: input.smartAccount,
600
+ interpreterAddress,
601
+ })
602
+ if (collected.incomplete) return null
603
+ return collected.rules
604
+ } catch {
605
+ // The install itself is unaffected: the scan is advisory, so a failed
606
+ // read must not block a policy the user asked for. It just cannot be
607
+ // reported as a clean scan.
608
+ return null
609
+ }
610
+ }
611
+
563
612
  function buildRpcClientFromInput(
564
613
  urlOverride: string | undefined,
565
614
  network: Network
@@ -201,6 +201,15 @@ export const PredicateLeafSchema: z.ZodType<unknown> = z.lazy(() =>
201
201
  element: z.number().int().nonnegative(),
202
202
  field: z.string(),
203
203
  }),
204
+ // num/den are i128 decimal strings, matching `literal_i128`. The regex
205
+ // is the boundary guard; the ratio's SIGN is checked at encode, where
206
+ // the message can explain that a negative ratio inverts the comparison.
207
+ z.object({
208
+ kind: z.literal('call_arg_scaled'),
209
+ index: z.number().int().nonnegative(),
210
+ num: z.string().regex(/^-?[0-9]+$/),
211
+ den: z.string().regex(/^-?[0-9]+$/),
212
+ }),
204
213
  z.object({ kind: z.literal('literal_address'), value: z.string() }),
205
214
  z.object({ kind: z.literal('literal_i128'), value: z.string().regex(/^-?[0-9]+$/) }),
206
215
  z.object({ kind: z.literal('literal_symbol'), value: z.string() }),
@@ -222,16 +231,32 @@ export const PredicateLeafSchema: z.ZodType<unknown> = z.lazy(() =>
222
231
  export const PredicateNodeSchema: z.ZodType<unknown> = z.lazy(() =>
223
232
  z.union([
224
233
  z.object({ op: z.literal('and'), children: z.array(PredicateNodeSchema) }),
234
+ z.object({ op: z.literal('or'), children: z.array(PredicateNodeSchema) }),
225
235
  z.object({
226
236
  op: z.literal('eq'),
227
237
  left: PredicateLeafSchema,
228
238
  right: PredicateLeafSchema,
229
239
  }),
240
+ z.object({
241
+ op: z.literal('lt'),
242
+ left: PredicateLeafSchema,
243
+ right: PredicateLeafSchema,
244
+ }),
230
245
  z.object({
231
246
  op: z.literal('lte'),
232
247
  left: PredicateLeafSchema,
233
248
  right: PredicateLeafSchema,
234
249
  }),
250
+ z.object({
251
+ op: z.literal('gt'),
252
+ left: PredicateLeafSchema,
253
+ right: PredicateLeafSchema,
254
+ }),
255
+ z.object({
256
+ op: z.literal('gte'),
257
+ left: PredicateLeafSchema,
258
+ right: PredicateLeafSchema,
259
+ }),
235
260
  z.object({
236
261
  op: z.literal('in'),
237
262
  needle: PredicateLeafSchema,
@@ -348,12 +373,12 @@ const ContextRuleDraftSchema = z
348
373
  /** Pinned interpreter address (testnet).
349
374
  * Single source for the MCP layer; do not embed elsewhere. */
350
375
  export const PINNED_INTERPRETER_TESTNET_ADDRESS =
351
- 'CCL336TCK2Y5OFNRCMN2M3HVPBCEX4PW5H6EQ5VW5NPMXOCP4ESB5XR4'
376
+ 'CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5'
352
377
 
353
- /** Pinned interpreter address (mainnet), redeployed 2026-08-22 from a reproducible build. The mainnet
378
+ /** Pinned interpreter address (mainnet), redeployed 2026-08-22 for grammar 4, from a reproducible build. The mainnet
354
379
  * interpreter IS the binary exercised on testnet - both instances were created
355
380
  * from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
356
- * both were read back with `grammar_version()` returning 3. The address differs
381
+ * both were read back with `grammar_version()` returning 4. The address differs
357
382
  * because instance ids are network-scoped. UNAUDITED at the time of writing.
358
383
  *
359
384
  * These four constants move together or not at all. The grammar version and
@@ -361,15 +386,15 @@ export const PINNED_INTERPRETER_TESTNET_ADDRESS =
361
386
  * alone would have the builder emit a version the other network refuses - with
362
387
  * a green test run, since `grammar-version-parity.test.ts` would then pass. */
363
388
  export const PINNED_INTERPRETER_MAINNET_ADDRESS =
364
- 'CBZXLSTQUITBFZHQH6XRXF3XIVRQR4RHRI64Q5WELS5KGY3ZKJPFWDPF'
389
+ 'CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52'
365
390
 
366
391
  /** Pinned interpreter wasm sha256 (hex). */
367
392
  export const PINNED_INTERPRETER_WASM_SHA256 =
368
- 'a2b36e8ac5a61caf3757af26aa79e83f2995b451099f44772383806a55fe3414'
393
+ 'b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae'
369
394
 
370
395
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
371
396
  * contracts/policy-interpreter/src/version.rs). */
372
- export const PINNED_INTERPRETER_GRAMMAR_VERSION = 3
397
+ export const PINNED_INTERPRETER_GRAMMAR_VERSION = 4
373
398
 
374
399
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
375
400
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
@@ -396,6 +421,46 @@ export const RPC_URL_BY_NETWORK: Record<Network, string> = {
396
421
  mainnet: MAINNET_RPC_URL,
397
422
  }
398
423
 
424
+ /** The OpenZeppelin built-in policies we deployed instances of. These are OZ
425
+ * EXAMPLE contracts, built by us from `OpenZeppelin/stellar-contracts` at tag
426
+ * v0.7.2 and deployed by us. We have NOT audited them and upstream ships an
427
+ * "experimental software ... as is" disclaimer; anything surfacing one of
428
+ * these to a user must say so rather than implying we vouch for the code.
429
+ * Provenance detail in `docs/audit/README.md` finding 7. */
430
+ export type OzBuiltinPolicy = 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
431
+
432
+ /** Instance addresses per network. Exported so consumers import the pin
433
+ * instead of copying a literal - a copied address is how a testnet id ends up
434
+ * being queried against mainnet, which returns `Error(Storage, MissingValue)`
435
+ * and reads exactly like "nothing is deployed there".
436
+ *
437
+ * Instance ids are network-scoped, so the addresses differ while the wasm
438
+ * hash does not: each pair below was created from the same uploaded wasm (see
439
+ * `PINNED_OZ_POLICY_WASM_SHA256`), verified by fetching the deployed bytes
440
+ * back from both networks. */
441
+ export const PINNED_OZ_POLICY_ADDRESS_BY_NETWORK: Record<
442
+ Network,
443
+ Record<OzBuiltinPolicy, string>
444
+ > = {
445
+ testnet: {
446
+ spending_limit: 'CDH4KOBRUEZI6TTZ72YXR5YUIODB6RH3AF75KX56Z73DELRCA5TWFISP',
447
+ simple_threshold: 'CAYTIVQOEZDOQI4GC3XBXEEYHQUANQQJHPJVMXVRBREGSAP6TCN3DID6',
448
+ weighted_threshold: 'CCTNRFZCL45GTJICA3Z2KFQO3VEGBHGCVBLHQ3GLJKAGACQIJMYJS7T2',
449
+ },
450
+ mainnet: {
451
+ spending_limit: 'CA7IBD266HIHFDUIBZLPIAITJUA3DVY4JAG6K3QMGBKLZCXXLP5E2F7A',
452
+ simple_threshold: 'CDOGPGUFGGUDG25P3TG6XIXJKRRYOZ3PXUZIEPVH74KXRZIDKZ5HYEOS',
453
+ weighted_threshold: 'CDWPZ4YZ3YIJ64XSHRMERRF2L2H7XD6SPUZDP3KI56T7QXQCICC25V3J',
454
+ },
455
+ }
456
+
457
+ /** sha256 of each deployed policy wasm, identical across both networks. */
458
+ export const PINNED_OZ_POLICY_WASM_SHA256: Record<OzBuiltinPolicy, string> = {
459
+ spending_limit: '9ce30ea1fe5c2dc5c9c49cf3462adb32e2c11d7dfadb15ef43a51ba56568de2b',
460
+ simple_threshold: '01c0be09eb6fb288cab2e878b4e890f7a38f75afab99aeb197861f44e2e2dfe6',
461
+ weighted_threshold: '78030272b06afb09d2949ab8877c9a8ae1ab9025b48f4edafd5816cc44f76eaa',
462
+ }
463
+
399
464
  /** Stellar network passphrases. Pinned here so the XDR envelope uses the
400
465
  * matching passphrase when the wallet signs (a mismatch yields invalid
401
466
  * hashes). */
@@ -438,6 +503,18 @@ export const DeclarePolicyInputSchema = z
438
503
  recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
439
504
  recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
440
505
  allowZeroCap: z.boolean().optional(),
506
+ /** Minimum output as a ratio of the call's own input. num/den are decimal
507
+ * STRINGS for the same reason maxAmount is: an i128 ratio does not
508
+ * survive a JS number. */
509
+ minOutputRatio: z
510
+ .object({
511
+ num: z.string().regex(/^[0-9]+$/, 'num must be an unsigned integer'),
512
+ den: z.string().regex(/^[0-9]+$/, 'den must be an unsigned integer'),
513
+ inputArgIndex: z.number().int().nonnegative().max(U32_MAX),
514
+ outputArgIndex: z.number().int().nonnegative().max(U32_MAX),
515
+ })
516
+ .strict()
517
+ .optional(),
441
518
  })
442
519
  .strict()
443
520
  export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>
@@ -24,7 +24,7 @@ export interface GeneratedCases {
24
24
  * the wrong reason. */
25
25
  const OTHER_CONTRACT = Address.contract(Buffer.alloc(32, 0x5a)).toString()
26
26
 
27
- type ComparisonOperator = 'eq' | 'lte'
27
+ type ComparisonOperator = 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
28
28
 
29
29
  type ComparisonNode = {
30
30
  op: ComparisonOperator
@@ -248,11 +248,22 @@ function visit(node: PredicateNode, facts: PredicateFacts): void {
248
248
  case 'and':
249
249
  for (const child of node.children) visit(child, facts)
250
250
  return
251
+ // NOT descended into. A deny case works by violating ONE constraint and
252
+ // asserting the predicate refuses the call. Violating one branch of an
253
+ // `or` proves nothing, because another branch can still permit, so the
254
+ // generated case would either fail or pass for the wrong reason. A sound
255
+ // deny case for a disjunction must violate EVERY branch at once, which
256
+ // this generator does not construct - so it emits none.
257
+ case 'or':
258
+ return
251
259
  case 'in':
252
260
  facts.memberships.push(node)
253
261
  return
254
262
  case 'eq':
263
+ case 'lt':
255
264
  case 'lte':
265
+ case 'gt':
266
+ case 'gte':
256
267
  facts.comparisons.push(node)
257
268
  }
258
269
  }
@@ -1,4 +1,4 @@
1
- // src/simulate/evaluate.ts - TypeScript reference evaluator for grammar version 3.
1
+ // src/simulate/evaluate.ts - TypeScript reference evaluator for grammar version 4.
2
2
  //
3
3
  // Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
4
4
  // no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
@@ -8,12 +8,13 @@
8
8
  // 3. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
9
9
  // 4. otherwise permit.
10
10
  //
11
- // Grammar version 3 nodes: and, eq, lte
12
- // Grammar version 3 leaves: call_contract, call_fn, call_arg(i),
11
+ // Grammar version 4 nodes: and, or, eq, lt, lte, gt, gte, in
12
+ // Grammar version 4 leaves: call_contract, call_fn, call_arg(i),
13
13
  // call_arg_len(i), call_arg_field(i, element, field),
14
+ // call_arg_scaled(i, num, den),
14
15
  // literal_address, literal_i128, literal_symbol, literal_u32, literal_vec
15
- // Grammar version 3 deny reasons: ARG_MISMATCH, CONTRACT_SCOPE,
16
- // UNSUPPORTED_NODE, NOT_IN_ALLOWLIST
16
+ // Grammar version 4 deny reasons: ARG_MISMATCH, CONTRACT_SCOPE,
17
+ // ARITHMETIC_OVERFLOW, UNSUPPORTED_NODE, NOT_IN_ALLOWLIST, SLIPPAGE_FLOOR
17
18
 
18
19
  import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
19
20
 
@@ -28,6 +29,9 @@ export interface EvalContext {
28
29
 
29
30
  export type EvalResult = { permit: true } | { permit: false; reason: string }
30
31
 
32
+ /** The comparison operators grammar 4 carries. */
33
+ type CompareOpName = 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
34
+
31
35
  /** Evaluate a `PredicateNode` against the candidate call described by `ctx`.
32
36
  * Pure function. Returns `{ permit: true }` or `{ permit: false; reason }`. */
33
37
  export function evaluate(predicate: PredicateNode, ctx: EvalContext): EvalResult {
@@ -47,8 +51,23 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
47
51
  }
48
52
  return lastDeny ?? { permit: true }
49
53
  }
54
+ // Permits on the first branch that holds; when none does, reports the
55
+ // FIRST branch's reason. Mirrors `Node::Or` in the Rust evaluator, which
56
+ // the conformance suite pins.
57
+ case 'or': {
58
+ let firstDeny: EvalResult | null = null
59
+ for (const child of node.children) {
60
+ const r = walk(child, ctx)
61
+ if (r.permit) return r
62
+ if (firstDeny === null) firstDeny = r
63
+ }
64
+ return firstDeny ?? { permit: false, reason: 'UNSUPPORTED_NODE' }
65
+ }
50
66
  case 'eq':
67
+ case 'lt':
51
68
  case 'lte':
69
+ case 'gt':
70
+ case 'gte':
52
71
  return evalCompare(node.op, node.left, node.right, ctx)
53
72
  case 'in':
54
73
  return evalIn(node.needle, node.haystack, ctx)
@@ -57,11 +76,22 @@ function walk(node: PredicateNode, ctx: EvalContext): EvalResult {
57
76
 
58
77
  /** Comparison leaf evaluation. */
59
78
  function evalCompare(
60
- op: 'eq' | 'lte',
79
+ op: CompareOpName,
61
80
  left: PredicateLeaf,
62
81
  right: PredicateLeaf,
63
82
  ctx: EvalContext
64
83
  ): EvalResult {
84
+ // Scaled operands first, so the dedicated reasons reach the caller instead
85
+ // of a generic mismatch. Right-hand dispatch leads because
86
+ // `out >= in * num / den` is the canonical swap form. Mirrors the order in
87
+ // `eval_compare` on the Rust side.
88
+ if (right.kind === 'call_arg_scaled') {
89
+ return evalScaledCompare(op, left, right, true, ctx)
90
+ }
91
+ if (left.kind === 'call_arg_scaled') {
92
+ return evalScaledCompare(op, right, left, false, ctx)
93
+ }
94
+
65
95
  // CONTRACT_SCOPE on call_contract eq
66
96
  if (left.kind === 'call_contract' && op === 'eq') {
67
97
  if (right.kind !== 'literal_address') return { permit: false, reason: 'CONTRACT_SCOPE' }
@@ -112,10 +142,66 @@ function evalCompare(
112
142
  return { permit: false, reason: 'UNSUPPORTED_NODE' }
113
143
  }
114
144
 
145
+ /** i128 bounds. The contract computes in i128 and denies on overflow, so the
146
+ * reference has to draw the same line or the two layers disagree on inputs
147
+ * near the boundary. */
148
+ const I128_MIN = -(2n ** 127n)
149
+ const I128_MAX = 2n ** 127n - 1n
150
+
151
+ /** Comparison where one side is `call_arg_scaled`. Mirrors
152
+ * `eval_scaled_arg_compare`: `args[index] * num / den` truncating toward
153
+ * zero, ARITHMETIC_OVERFLOW on arithmetic that does not fit or a zero
154
+ * denominator, SLIPPAGE_FLOOR on a comparison that simply fails. */
155
+ function evalScaledCompare(
156
+ op: CompareOpName,
157
+ other: PredicateLeaf,
158
+ scaled: Extract<PredicateLeaf, { kind: 'call_arg_scaled' }>,
159
+ scaledOnRight: boolean,
160
+ ctx: EvalContext
161
+ ): EvalResult {
162
+ // Chaining two computed operands has no meaning a review card could state.
163
+ if (other.kind === 'call_arg_scaled') return { permit: false, reason: 'UNSUPPORTED_NODE' }
164
+
165
+ // Could not READ the operand is a different failure from read-and-missed.
166
+ const input = argNumericBigInt(ctx.args[scaled.index])
167
+ if (input === null) return { permit: false, reason: 'ARG_MISMATCH' }
168
+
169
+ let num: bigint
170
+ let den: bigint
171
+ try {
172
+ num = BigInt(scaled.num)
173
+ den = BigInt(scaled.den)
174
+ } catch {
175
+ return { permit: false, reason: 'ARG_MISMATCH' }
176
+ }
177
+ if (den === 0n) return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
178
+
179
+ const product = input * num
180
+ if (product < I128_MIN || product > I128_MAX) {
181
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
182
+ }
183
+ // BigInt division already truncates toward zero, matching i128 semantics.
184
+ const quotient = product / den
185
+ if (quotient < I128_MIN || quotient > I128_MAX) {
186
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' }
187
+ }
188
+
189
+ const otherVal =
190
+ other.kind === 'call_arg'
191
+ ? argNumericBigInt(ctx.args[other.index])
192
+ : literalNumericBigInt(other)
193
+ if (otherVal === null) return { permit: false, reason: 'ARG_MISMATCH' }
194
+
195
+ const [a, b] = scaledOnRight ? [otherVal, quotient] : [quotient, otherVal]
196
+ return bigintCmp(op, a.toString(), b.toString())
197
+ ? { permit: true }
198
+ : { permit: false, reason: 'SLIPPAGE_FLOOR' }
199
+ }
200
+
115
201
  /** Per-ScVal equality. Handles literal_vec as an EXACT ordered sequence:
116
202
  * compare element-by-element in order; deny if length or any element differs.
117
203
  * Opaque args (`type: 'other'`) fail closed. */
118
- function evalArgEq(op: 'eq' | 'lte', actual: ScVal | undefined, right: PredicateLeaf): EvalResult {
204
+ function evalArgEq(op: CompareOpName, actual: ScVal | undefined, right: PredicateLeaf): EvalResult {
119
205
  // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality.
120
206
  if (op === 'eq' && right.kind === 'literal_vec') {
121
207
  if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
@@ -163,7 +249,7 @@ function evalArgEq(op: 'eq' | 'lte', actual: ScVal | undefined, right: Predicate
163
249
  * to a numeric literal via BigInt. A non-numeric arg or a non-numeric literal
164
250
  * fails closed (ARG_MISMATCH) rather than permitting an undecidable bound. */
165
251
  function evalArgOrderedCompare(
166
- op: 'eq' | 'lte',
252
+ op: CompareOpName,
167
253
  actual: ScVal | undefined,
168
254
  right: PredicateLeaf
169
255
  ): EvalResult {
@@ -272,13 +358,19 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
272
358
  }
273
359
 
274
360
  /** BigInt compare helper. */
275
- function bigintCmp(op: 'eq' | 'lte', aStr: string, bStr: string): boolean {
361
+ function bigintCmp(op: CompareOpName, aStr: string, bStr: string): boolean {
276
362
  const a = BigInt(aStr)
277
363
  const b = BigInt(bStr)
278
364
  switch (op) {
279
365
  case 'eq':
280
366
  return a === b
367
+ case 'lt':
368
+ return a < b
281
369
  case 'lte':
282
370
  return a <= b
371
+ case 'gt':
372
+ return a > b
373
+ case 'gte':
374
+ return a >= b
283
375
  }
284
376
  }