@crediolabs/policy-synth 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) 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.d.ts +101 -0
  5. package/dist/install/authority-overlap.js +239 -0
  6. package/dist/install/build-add-context-rule.d.ts +1 -1
  7. package/dist/install/index.d.ts +1 -0
  8. package/dist/install/index.js +4 -0
  9. package/dist/install/read-account-rules.d.ts +79 -0
  10. package/dist/install/read-account-rules.js +241 -0
  11. package/dist/predicate/decode.js +22 -1
  12. package/dist/predicate/encode.js +52 -5
  13. package/dist/predicate/from-json.js +14 -1
  14. package/dist/record/index.d.ts +10 -0
  15. package/dist/record/index.js +32 -1
  16. package/dist/record/rpc.d.ts +4 -0
  17. package/dist/record/rpc.js +4 -1
  18. package/dist/registry/identify.d.ts +10 -1
  19. package/dist/registry/identify.js +4 -1
  20. package/dist/registry/on-chain-spec.d.ts +37 -0
  21. package/dist/registry/on-chain-spec.js +152 -0
  22. package/dist/review-card/builder.js +40 -0
  23. package/dist/review-card/cross-check.js +34 -0
  24. package/dist/review-card/render-leaf.d.ts +1 -1
  25. package/dist/review-card/render-leaf.js +7 -0
  26. package/dist/run/index.d.ts +25 -4
  27. package/dist/run/index.js +108 -4
  28. package/dist/run/schemas.d.ts +350 -6
  29. package/dist/run/schemas.js +95 -6
  30. package/dist/simulate/deny-cases.js +11 -0
  31. package/dist/simulate/evaluate.js +86 -5
  32. package/dist/synth/declare.d.ts +43 -0
  33. package/dist/synth/declare.js +127 -0
  34. package/dist/synth/index.d.ts +1 -0
  35. package/dist/synth/index.js +1 -0
  36. package/dist/synth/synthesize-from-recording.js +1 -1
  37. package/dist/types.d.ts +21 -1
  38. package/dist/types.js +1 -1
  39. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  40. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  41. package/dist-cjs/errors.d.ts +6 -1
  42. package/dist-cjs/install/authority-overlap.d.ts +101 -0
  43. package/dist-cjs/install/authority-overlap.js +248 -0
  44. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  45. package/dist-cjs/install/index.d.ts +1 -0
  46. package/dist-cjs/install/index.js +13 -2
  47. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  48. package/dist-cjs/install/read-account-rules.js +252 -0
  49. package/dist-cjs/predicate/decode.js +22 -1
  50. package/dist-cjs/predicate/encode.js +52 -5
  51. package/dist-cjs/predicate/from-json.js +14 -1
  52. package/dist-cjs/record/index.d.ts +10 -0
  53. package/dist-cjs/record/index.js +31 -0
  54. package/dist-cjs/record/rpc.d.ts +4 -0
  55. package/dist-cjs/record/rpc.js +7 -3
  56. package/dist-cjs/registry/identify.d.ts +10 -1
  57. package/dist-cjs/registry/identify.js +4 -0
  58. package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
  59. package/dist-cjs/registry/on-chain-spec.js +159 -0
  60. package/dist-cjs/review-card/builder.js +40 -0
  61. package/dist-cjs/review-card/cross-check.js +34 -0
  62. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  63. package/dist-cjs/review-card/render-leaf.js +7 -0
  64. package/dist-cjs/run/index.d.ts +25 -4
  65. package/dist-cjs/run/index.js +108 -2
  66. package/dist-cjs/run/schemas.d.ts +350 -6
  67. package/dist-cjs/run/schemas.js +96 -7
  68. package/dist-cjs/simulate/deny-cases.js +11 -0
  69. package/dist-cjs/simulate/evaluate.js +86 -5
  70. package/dist-cjs/synth/declare.d.ts +43 -0
  71. package/dist-cjs/synth/declare.js +130 -0
  72. package/dist-cjs/synth/index.d.ts +1 -0
  73. package/dist-cjs/synth/index.js +3 -1
  74. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  75. package/dist-cjs/types.d.ts +21 -1
  76. package/dist-cjs/types.js +1 -1
  77. package/package.json +1 -1
  78. package/src/adapters/interpreter/adapter.ts +13 -5
  79. package/src/errors.ts +5 -0
  80. package/src/install/authority-overlap.ts +323 -0
  81. package/src/install/index.ts +20 -0
  82. package/src/install/read-account-rules.ts +313 -0
  83. package/src/predicate/decode.ts +22 -1
  84. package/src/predicate/encode.ts +55 -5
  85. package/src/predicate/from-json.ts +14 -1
  86. package/src/record/index.ts +59 -2
  87. package/src/record/rpc.ts +4 -1
  88. package/src/registry/identify.ts +4 -1
  89. package/src/registry/on-chain-spec.ts +168 -0
  90. package/src/review-card/builder.ts +45 -2
  91. package/src/review-card/cross-check.ts +35 -1
  92. package/src/review-card/render-leaf.ts +8 -1
  93. package/src/run/index.ts +125 -2
  94. package/src/run/schemas.ts +100 -6
  95. package/src/simulate/deny-cases.ts +12 -1
  96. package/src/simulate/evaluate.ts +101 -9
  97. package/src/synth/declare.ts +206 -0
  98. package/src/synth/index.ts +5 -0
  99. package/src/synth/synthesize-from-recording.ts +1 -1
  100. package/src/types.ts +16 -1
package/src/run/index.ts CHANGED
@@ -20,7 +20,9 @@
20
20
  import { createHash } from 'node:crypto'
21
21
  import { rpc } from '@stellar/stellar-sdk'
22
22
  import {
23
+ declarePredicate,
23
24
  type ErrorCode,
25
+ encodePredicate,
24
26
  type Network,
25
27
  type PredicateNode,
26
28
  type ProposedPolicy,
@@ -31,6 +33,11 @@ import {
31
33
  type ToolError,
32
34
  type ToolResponse,
33
35
  } from '../index.ts'
36
+ import {
37
+ type AuthorityOverlap,
38
+ findAuthorityOverlaps,
39
+ type ObservedRule,
40
+ } from '../install/authority-overlap.ts'
34
41
  import {
35
42
  type BuildInstallPolicyResult,
36
43
  type BuildRevokePolicyResult,
@@ -40,8 +47,11 @@ import {
40
47
  rpcClientFromServer,
41
48
  } from '../install/build-install-policy.ts'
42
49
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
50
+ import { accountRuleReaderFromServer, collectObservedRules } from '../install/read-account-rules.ts'
51
+ import { decodePredicate } from '../predicate/decode.ts'
43
52
  import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
44
53
  import {
54
+ DeclarePolicyInputSchema,
45
55
  type GetInterpreterInfoInput,
46
56
  GetInterpreterInfoInputSchema,
47
57
  type InstallPolicyInput,
@@ -64,6 +74,7 @@ import {
64
74
  } from './schemas.ts'
65
75
 
66
76
  export type {
77
+ DeclarePolicyInput,
67
78
  GetInterpreterInfoInput,
68
79
  InstallPolicyInput,
69
80
  RecordTransactionInput,
@@ -78,6 +89,7 @@ export type {
78
89
  // truth - MCP tool shapes are derived from them.
79
90
  export {
80
91
  ComposeUserResponsesSchema,
92
+ DeclarePolicyInputSchema,
81
93
  GetInterpreterInfoInputSchema,
82
94
  InstallPolicyInputSchema,
83
95
  InterpreterOptionsSchema,
@@ -107,6 +119,7 @@ export type RunVerifyPolicyInput = VerifyPolicyInput
107
119
  type RunToolName =
108
120
  | 'record_transaction'
109
121
  | 'synthesize_policy'
122
+ | 'declare_policy'
110
123
  | 'simulate_policy'
111
124
  | 'verify_policy'
112
125
  | 'install_policy'
@@ -119,6 +132,7 @@ type RunToolName =
119
132
  const TOOL_ERROR_CODE: Record<RunToolName, ErrorCode> = {
120
133
  record_transaction: 'RECORDING_FAILED',
121
134
  synthesize_policy: 'SYNTHESIS_ERROR',
135
+ declare_policy: 'SYNTHESIS_ERROR',
122
136
  simulate_policy: 'SIMULATION_ERROR',
123
137
  verify_policy: 'VERIFICATION_FAILED',
124
138
  install_policy: 'INSTALL_BUILD_FAILED',
@@ -203,7 +217,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
203
217
 
204
218
  export async function runInstallPolicy(
205
219
  raw: unknown
206
- ): Promise<ToolResponse<BuildInstallPolicyResult>> {
220
+ ): Promise<ToolResponse<BuildInstallPolicyResult & { authorityScan: AuthorityOverlap[] | null }>> {
207
221
  const parsed = InstallPolicyInputSchema.safeParse(raw)
208
222
  if (!parsed.success) {
209
223
  return { ok: false, error: validationError('install_policy', parsed.error.issues) }
@@ -254,7 +268,33 @@ export async function runInstallPolicy(
254
268
  rpc: rpcClient,
255
269
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
256
270
  })
257
- return { ok: true, data: result }
271
+ // Cross-rule scan. The caller may supply `existingRules` (useful offline,
272
+ // and for testing); otherwise the account is READ, so the answer describes
273
+ // what is actually installed rather than what the caller happened to
274
+ // mention.
275
+ //
276
+ // `null` means NOT CHECKED and is returned whenever the scan cannot be
277
+ // trusted to be complete - the read failed, or it stopped before
278
+ // accounting for every live rule. An empty list would say "checked,
279
+ // nothing found", and a partial scan that reported `[]` would be claiming
280
+ // a safety it never established.
281
+ const observed = await resolveExistingRules(input, network, expectedInterpreter)
282
+ const authorityScan =
283
+ observed === null
284
+ ? null
285
+ : findAuthorityOverlaps({
286
+ intended: {
287
+ // `add_context_rule` gets its id FROM the account, so there is
288
+ // no existing rule this install replaces. A sentinel no real id
289
+ // can equal keeps every observed rule in scope.
290
+ ruleId: -1,
291
+ contextType: input.rule.contextRuleType,
292
+ signers: input.rule.signers,
293
+ predicate: decodePredicate(encodedPredicate),
294
+ },
295
+ existing: observed,
296
+ })
297
+ return { ok: true, data: { ...result, authorityScan } }
258
298
  } catch (e) {
259
299
  return toolFailure('install_policy', e)
260
300
  }
@@ -384,6 +424,50 @@ export function runSimulatePolicy(raw: unknown): ToolResponse<{
384
424
  }
385
425
  }
386
426
 
427
+ /** `declare_policy` body - the DECLARATIVE front-end.
428
+ *
429
+ * `synthesize_policy` infers a predicate from a transaction that happened;
430
+ * this takes the constraint stated outright. No RPC, no decoding and no
431
+ * parseConfidence, so nothing here can be refused for a contract the registry
432
+ * does not recognise - which is most of the point of having it.
433
+ *
434
+ * The returned `warnings` are load-bearing, not decoration. An argument index
435
+ * the caller did not supply is DEFAULTED to the SEP-41 position, and a bound
436
+ * on the wrong argument constrains something the caller did not mean without
437
+ * ever announcing itself, so a caller that ignores warnings can install a
438
+ * predicate that reads correctly and binds nothing. */
439
+ export function runDeclarePolicy(raw: unknown): ToolResponse<{
440
+ predicate: PredicateNode
441
+ encodedPredicate: string
442
+ predicateHash: string
443
+ warnings: string[]
444
+ }> {
445
+ const parsed = DeclarePolicyInputSchema.safeParse(raw)
446
+ if (!parsed.success) {
447
+ return { ok: false, error: validationError('declare_policy', parsed.error.issues) }
448
+ }
449
+ try {
450
+ // Rebuilt field-by-field rather than passed through: the schema's
451
+ // optionals are `T | undefined` and `PolicyDeclaration`'s are absent-or-T,
452
+ // which `exactOptionalPropertyTypes` treats as different.
453
+ const d = parsed.data
454
+ const { predicate, warnings } = declarePredicate({
455
+ fn: d.fn,
456
+ ...(d.contract !== undefined ? { contract: d.contract } : {}),
457
+ ...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
458
+ ...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
459
+ ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
460
+ ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
461
+ ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
462
+ ...(d.minOutputRatio !== undefined ? { minOutputRatio: d.minOutputRatio } : {}),
463
+ })
464
+ const { encodedPredicate, predicateHash } = encodePredicate(predicate)
465
+ return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } }
466
+ } catch (e) {
467
+ return toolFailure('declare_policy', e)
468
+ }
469
+ }
470
+
387
471
  /** `verify_policy` body - the permit case plus a generated deny case per
388
472
  * dimension.
389
473
  *
@@ -483,6 +567,45 @@ export async function runGetInterpreterInfo(
483
567
  * network, falling back to the pinned RPC for the network. The caller
484
568
  * has already been gated against the pinned URL elsewhere, so the
485
569
  * fallback here only ever picks from a finite, audited pair. */
570
+ /** The account's other context rules, or `null` when they could not be
571
+ * established completely.
572
+ *
573
+ * Caller-supplied `existingRules` win: they let the scan run offline, and a
574
+ * caller who passes them has said what to compare against. Otherwise the
575
+ * account is read over RPC.
576
+ *
577
+ * Every failure path returns `null` rather than a short list. A read that
578
+ * threw, or one that stopped before accounting for every live rule, has not
579
+ * ruled anything out - and reporting `[]` there would turn "we could not
580
+ * check" into "there is nothing to worry about". */
581
+ async function resolveExistingRules(
582
+ input: InstallPolicyInput,
583
+ network: Network,
584
+ interpreterAddress: string
585
+ ): Promise<ObservedRule[] | null> {
586
+ if (input.existingRules !== undefined) {
587
+ // The schema types `predicate` loosely (it is the shared
588
+ // PredicateNodeSchema); the shape is already validated.
589
+ return input.existingRules as ObservedRule[]
590
+ }
591
+ try {
592
+ const url = input.rpcUrl ?? RPC_URL_BY_NETWORK[network]
593
+ const server = new rpc.Server(url, { allowHttp: false })
594
+ const collected = await collectObservedRules({
595
+ reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
596
+ smartAccount: input.smartAccount,
597
+ interpreterAddress,
598
+ })
599
+ if (collected.incomplete) return null
600
+ return collected.rules
601
+ } catch {
602
+ // The install itself is unaffected: the scan is advisory, so a failed
603
+ // read must not block a policy the user asked for. It just cannot be
604
+ // reported as a clean scan.
605
+ return null
606
+ }
607
+ }
608
+
486
609
  function buildRpcClientFromInput(
487
610
  urlOverride: string | undefined,
488
611
  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,
@@ -281,6 +306,29 @@ export type VerifyPolicyInput = z.infer<typeof VerifyPolicyInputSchema>
281
306
  const MAX_SIGNERS_PER_RULE = 15
282
307
  const MAX_POLICIES_PER_RULE = 5
283
308
 
309
+ const SignerDraftSchema = z.discriminatedUnion('kind', [
310
+ z.object({ kind: z.literal('delegated'), address: z.string() }),
311
+ z.object({ kind: z.literal('external'), verifier: z.string(), keyBytes: z.string() }),
312
+ ])
313
+
314
+ const ContextTypeSchema = z.discriminatedUnion('kind', [
315
+ z.object({ kind: z.literal('default') }),
316
+ z.object({ kind: z.literal('call_contract'), contract: z.string() }),
317
+ z.object({ kind: z.literal('create_contract'), wasmHash: z.string() }),
318
+ ])
319
+
320
+ /** A rule ALREADY on the account, as the caller observed it. Supplying these
321
+ * turns on the cross-rule authority scan: a signer belonging to several rules
322
+ * picks which one applies, so a predicate only constrains a key when the
323
+ * policed rule is the only rule that key is on. */
324
+ export const ObservedRuleSchema = z.object({
325
+ id: z.number().int().nonnegative(),
326
+ contextType: ContextTypeSchema,
327
+ signers: z.array(SignerDraftSchema),
328
+ policyAddresses: z.array(z.string()),
329
+ predicate: PredicateNodeSchema.optional(),
330
+ })
331
+
284
332
  const ContextRuleDraftSchema = z
285
333
  .object({
286
334
  contextRuleType: z.discriminatedUnion('kind', [
@@ -325,12 +373,12 @@ const ContextRuleDraftSchema = z
325
373
  /** Pinned interpreter address (testnet).
326
374
  * Single source for the MCP layer; do not embed elsewhere. */
327
375
  export const PINNED_INTERPRETER_TESTNET_ADDRESS =
328
- 'CCL336TCK2Y5OFNRCMN2M3HVPBCEX4PW5H6EQ5VW5NPMXOCP4ESB5XR4'
376
+ 'CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5'
329
377
 
330
- /** 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
331
379
  * interpreter IS the binary exercised on testnet - both instances were created
332
380
  * from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
333
- * 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
334
382
  * because instance ids are network-scoped. UNAUDITED at the time of writing.
335
383
  *
336
384
  * These four constants move together or not at all. The grammar version and
@@ -338,15 +386,15 @@ export const PINNED_INTERPRETER_TESTNET_ADDRESS =
338
386
  * alone would have the builder emit a version the other network refuses - with
339
387
  * a green test run, since `grammar-version-parity.test.ts` would then pass. */
340
388
  export const PINNED_INTERPRETER_MAINNET_ADDRESS =
341
- 'CBZXLSTQUITBFZHQH6XRXF3XIVRQR4RHRI64Q5WELS5KGY3ZKJPFWDPF'
389
+ 'CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52'
342
390
 
343
391
  /** Pinned interpreter wasm sha256 (hex). */
344
392
  export const PINNED_INTERPRETER_WASM_SHA256 =
345
- 'a2b36e8ac5a61caf3757af26aa79e83f2995b451099f44772383806a55fe3414'
393
+ 'b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae'
346
394
 
347
395
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
348
396
  * contracts/policy-interpreter/src/version.rs). */
349
- export const PINNED_INTERPRETER_GRAMMAR_VERSION = 3
397
+ export const PINNED_INTERPRETER_GRAMMAR_VERSION = 4
350
398
 
351
399
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
352
400
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
@@ -391,8 +439,54 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
391
439
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
392
440
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
393
441
 
442
+ // ===== declare_policy =====
443
+ //
444
+ // The declarative front-end: the constraint stated outright, with no
445
+ // transaction to decode. Deliberately NOT a revival of the removed
446
+ // `MandateSpec` - that carried a rolling `spendingLimit` the interpreter
447
+ // cannot evaluate and an `approvalThreshold` needing OZ primitives nobody
448
+ // deployed. Only fields grammar 3 can actually enforce appear here.
449
+ export const DeclarePolicyInputSchema = z
450
+ .object({
451
+ fn: z.string().min(1, 'fn must name the method to pin'),
452
+ contract: z
453
+ .string()
454
+ .regex(STELLAR_CONTRACT_ADDRESS, 'contract must be a Stellar contract address (C...)')
455
+ .optional(),
456
+ /** Smallest unit, unsigned decimal STRING - an i128 is wider than
457
+ * Number.MAX_SAFE_INTEGER, so a number here would silently round. */
458
+ maxAmount: z
459
+ .string()
460
+ .regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
461
+ .optional(),
462
+ amountArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
463
+ recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
464
+ recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
465
+ allowZeroCap: z.boolean().optional(),
466
+ /** Minimum output as a ratio of the call's own input. num/den are decimal
467
+ * STRINGS for the same reason maxAmount is: an i128 ratio does not
468
+ * survive a JS number. */
469
+ minOutputRatio: z
470
+ .object({
471
+ num: z.string().regex(/^[0-9]+$/, 'num must be an unsigned integer'),
472
+ den: z.string().regex(/^[0-9]+$/, 'den must be an unsigned integer'),
473
+ inputArgIndex: z.number().int().nonnegative().max(U32_MAX),
474
+ outputArgIndex: z.number().int().nonnegative().max(U32_MAX),
475
+ })
476
+ .strict()
477
+ .optional(),
478
+ })
479
+ .strict()
480
+ export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>
481
+
394
482
  export const InstallPolicyInputSchema = z
395
483
  .object({
484
+ /** Rules already on the account. Supplying them turns on the cross-rule
485
+ * authority scan, which reports every existing rule a signer of this
486
+ * install could name INSTEAD - including an unpoliced one, against which
487
+ * the predicate never runs. Absent means the scan is skipped, and the
488
+ * result says so rather than reporting "no overlaps found". */
489
+ existingRules: z.array(ObservedRuleSchema).optional(),
396
490
  /** The smart account contract address (C...) that will receive the rule. */
397
491
  smartAccount: z
398
492
  .string()
@@ -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
  }