@crediolabs/policy-synth 0.1.17 → 0.2.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 (113) hide show
  1. package/README.md +44 -246
  2. package/dist/adapters/interpreter/adapter.d.ts +6 -7
  3. package/dist/adapters/interpreter/adapter.js +28 -48
  4. package/dist/adapters/oz/adapter.js +12 -13
  5. package/dist/codegen/compile-gate.js +5 -0
  6. package/dist/codegen/template.js +17 -0
  7. package/dist/install/authority-overlap.d.ts +134 -0
  8. package/dist/install/authority-overlap.js +0 -0
  9. package/dist/install/build-add-context-rule.d.ts +8 -0
  10. package/dist/install/build-add-context-rule.js +17 -46
  11. package/dist/install/build-install-policy.d.ts +12 -5
  12. package/dist/install/build-install-policy.js +32 -23
  13. package/dist/install/build-merge-policy.d.ts +70 -0
  14. package/dist/install/build-merge-policy.js +130 -0
  15. package/dist/install/get-interpreter-info.js +2 -2
  16. package/dist/install/index.d.ts +3 -1
  17. package/dist/install/index.js +8 -1
  18. package/dist/install/oz-auth.js +5 -3
  19. package/dist/install/plan-merge-policy.d.ts +49 -0
  20. package/dist/install/plan-merge-policy.js +86 -0
  21. package/dist/install/read-account-rules.d.ts +100 -0
  22. package/dist/install/read-account-rules.js +283 -0
  23. package/dist/predicate/decode.js +1 -1
  24. package/dist/predicate/encode.js +171 -10
  25. package/dist/record/decode.js +5 -6
  26. package/dist/registry/protocols.d.ts +1 -1
  27. package/dist/registry/protocols.js +1 -1
  28. package/dist/review-card/builder.d.ts +6 -0
  29. package/dist/review-card/builder.js +9 -1
  30. package/dist/run/index.d.ts +104 -18
  31. package/dist/run/index.js +359 -75
  32. package/dist/run/schemas.d.ts +479 -18
  33. package/dist/run/schemas.js +132 -24
  34. package/dist/synth/compose-from-recording.d.ts +10 -15
  35. package/dist/synth/compose-from-recording.js +79 -124
  36. package/dist/synth/deny-cases.d.ts +5 -0
  37. package/dist/synth/deny-cases.js +52 -13
  38. package/dist/synth/evaluate.js +69 -119
  39. package/dist/synth/harness.d.ts +13 -1
  40. package/dist/synth/harness.js +22 -1
  41. package/dist/synth/index.d.ts +1 -1
  42. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  43. package/dist/synth/synthesize-from-recording.js +129 -155
  44. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  45. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  46. package/dist-cjs/adapters/oz/adapter.js +12 -13
  47. package/dist-cjs/codegen/compile-gate.js +5 -0
  48. package/dist-cjs/codegen/template.js +17 -0
  49. package/dist-cjs/install/authority-overlap.d.ts +134 -0
  50. package/dist-cjs/install/authority-overlap.js +0 -0
  51. package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
  52. package/dist-cjs/install/build-add-context-rule.js +17 -45
  53. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  54. package/dist-cjs/install/build-install-policy.js +32 -23
  55. package/dist-cjs/install/build-merge-policy.d.ts +70 -0
  56. package/dist-cjs/install/build-merge-policy.js +134 -0
  57. package/dist-cjs/install/get-interpreter-info.js +2 -2
  58. package/dist-cjs/install/index.d.ts +3 -1
  59. package/dist-cjs/install/index.js +24 -3
  60. package/dist-cjs/install/oz-auth.js +5 -3
  61. package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
  62. package/dist-cjs/install/plan-merge-policy.js +90 -0
  63. package/dist-cjs/install/read-account-rules.d.ts +100 -0
  64. package/dist-cjs/install/read-account-rules.js +296 -0
  65. package/dist-cjs/predicate/decode.js +1 -1
  66. package/dist-cjs/predicate/encode.js +171 -10
  67. package/dist-cjs/record/decode.js +5 -6
  68. package/dist-cjs/registry/protocols.d.ts +1 -1
  69. package/dist-cjs/registry/protocols.js +1 -1
  70. package/dist-cjs/review-card/builder.d.ts +6 -0
  71. package/dist-cjs/review-card/builder.js +9 -1
  72. package/dist-cjs/run/index.d.ts +104 -18
  73. package/dist-cjs/run/index.js +360 -74
  74. package/dist-cjs/run/schemas.d.ts +479 -18
  75. package/dist-cjs/run/schemas.js +133 -25
  76. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  77. package/dist-cjs/synth/compose-from-recording.js +79 -124
  78. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  79. package/dist-cjs/synth/deny-cases.js +52 -13
  80. package/dist-cjs/synth/evaluate.js +69 -119
  81. package/dist-cjs/synth/harness.d.ts +13 -1
  82. package/dist-cjs/synth/harness.js +22 -1
  83. package/dist-cjs/synth/index.d.ts +1 -1
  84. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  85. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  86. package/package.json +1 -1
  87. package/src/adapters/interpreter/adapter.ts +28 -48
  88. package/src/adapters/oz/adapter.ts +12 -13
  89. package/src/codegen/compile-gate.ts +5 -0
  90. package/src/codegen/template.ts +17 -0
  91. package/src/install/authority-overlap.ts +0 -0
  92. package/src/install/build-add-context-rule.ts +29 -56
  93. package/src/install/build-install-policy.ts +41 -23
  94. package/src/install/build-merge-policy.ts +219 -0
  95. package/src/install/get-interpreter-info.ts +2 -2
  96. package/src/install/index.ts +36 -2
  97. package/src/install/oz-auth.ts +5 -3
  98. package/src/install/plan-merge-policy.ts +133 -0
  99. package/src/install/read-account-rules.ts +376 -0
  100. package/src/predicate/decode.ts +1 -1
  101. package/src/predicate/encode.ts +176 -10
  102. package/src/record/decode.ts +5 -6
  103. package/src/registry/protocols.ts +1 -1
  104. package/src/review-card/builder.ts +17 -1
  105. package/src/run/index.ts +500 -102
  106. package/src/run/schemas.ts +145 -24
  107. package/src/synth/compose-from-recording.ts +87 -132
  108. package/src/synth/deny-cases.ts +59 -13
  109. package/src/synth/evaluate.ts +70 -118
  110. package/src/synth/harness.ts +29 -1
  111. package/src/synth/index.ts +1 -0
  112. package/src/synth/synthesize-from-recording.ts +209 -192
  113. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
@@ -7,9 +7,9 @@
7
7
  // step with the TS source of truth.
8
8
  //
9
9
  // i128 amounts and other large integers are carried as base-10 decimal strings
10
- // end-to-end (no JS number coercion). Networks are pinned to the same closed
11
- // set the core defines. The discriminated union on `source` exposes BOTH
12
- // synthesize_policy front-ends through a single tool input.
10
+ // end-to-end. Networks are pinned to the same closed set the core defines.
11
+ // The discriminated union on `source` exposes BOTH synthesize_policy
12
+ // front-ends through a single tool input.
13
13
  //
14
14
  // This module is the SINGLE source of truth for these shapes. The MCP package
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
@@ -139,6 +139,25 @@ export const MandateSpecSchema = z
139
139
  .optional(),
140
140
  })
141
141
  .passthrough()
142
+ // TS-F4/F6: a `recipients` allowlist is only meaningful against a SEP-41
143
+ // method whose arg 1 IS the recipient (SAC/SEP-41 `transfer(from, to,
144
+ // amount)` and SEP-41 `mint(to, amount)`). Without this gate the
145
+ // `to-ir.ts` lowering pins the allowlist to `RECIPIENT_ARG_INDEX = 1`
146
+ // for any contract+method, which would let a non-SEP-41 method's
147
+ // arg[1] (e.g. an amount, an op type, an arbitrary address payload)
148
+ // be silently constrained as if it were a recipient. Refusing
149
+ // non-SEP-41 methods at the boundary is the fail-closed shape.
150
+ .refine(
151
+ (v) =>
152
+ v.recipients === undefined ||
153
+ v.recipients.length === 0 ||
154
+ v.method === 'transfer' ||
155
+ v.method === 'mint',
156
+ {
157
+ message:
158
+ 'recipients is only valid when method is a SEP-41 method (transfer or mint); other methods do not have a recipient at arg[1]',
159
+ }
160
+ )
142
161
 
143
162
  /** ComposeUserResponses mirrors the core. */
144
163
  export const ComposeUserResponsesSchema = z
@@ -157,6 +176,23 @@ export const ComposeUserResponsesSchema = z
157
176
  swapRecipientAllowlist: z
158
177
  .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
159
178
  .optional(),
179
+ // Per-asset oracle-price bound, one entry per asset. Reaches
180
+ // `compose-from-recording` and emits an `oracle_price` compare. Declared
181
+ // here because the object is `.passthrough()`: the field already worked
182
+ // undeclared, which left the accepted surface wider than the documented
183
+ // one. `decimals` is REQUIRED - oracle prices normalise to 9 dp and a
184
+ // threshold silently assumed to share that basis is what let a raw 14-dp
185
+ // bound permit everything.
186
+ oraclePriceBound: z
187
+ .array(
188
+ z.object({
189
+ asset: z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'),
190
+ operator: z.enum(['eq', 'lt', 'lte', 'gt', 'gte']),
191
+ value: z.string().regex(/^[0-9]+$/),
192
+ decimals: z.number().int().min(0).max(U32_MAX),
193
+ })
194
+ )
195
+ .optional(),
160
196
  })
161
197
  .passthrough()
162
198
 
@@ -293,7 +329,12 @@ export const PredicateLeafSchema: z.ZodType<unknown> = z.lazy(() =>
293
329
  windowSeconds: z.number().int().positive(),
294
330
  }),
295
331
  z.object({ kind: z.literal('now') }),
296
- z.object({ kind: z.literal('valid_until') }),
332
+ // `valid_until` is NOT in the public predicate grammar: the encoder +
333
+ // decoder already throw on it, so accepting it here would let a
334
+ // hand-crafted payload through the schema and only surface a 200+
335
+ // character internal-commentary error at encode time. Drop it - the
336
+ // policy's expiry is carried at the install layer (MandateSpec +
337
+ // validUntilLedger) instead.
297
338
  z.object({
298
339
  kind: z.literal('invocation_count_in_window'),
299
340
  windowSecs: z.number().int().positive(),
@@ -475,26 +516,36 @@ const MandateSpecSchemaForRule = z
475
516
  // reference resolves at module init. They are the new tools on top of the
476
517
  // existing four.
477
518
 
478
- /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:128.
519
+ /** Pinned interpreter address (testnet).
479
520
  * Single source for the MCP layer; do not embed elsewhere. */
480
521
  export const PINNED_INTERPRETER_TESTNET_ADDRESS =
481
- 'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP'
482
-
483
- /** Pinned interpreter address (mainnet). Mirrors DEPLOYMENTS.md:18. Mainnet
484
- * has now been deployed (2026-08-04); the mainnet interpreter IS the binary
485
- * that was exercised on testnet (same wasm sha256, see
486
- * PINNED_INTERPRETER_WASM_SHA256). The address differs because instance
487
- * ids are network-scoped. UNAUDITED at the time of writing - see
488
- * DEPLOYMENTS.md:29-32 for the recorded decision and what is still pending. */
522
+ 'CALHNU4LXZRKFAXYRBODTGDANTNXHJPBTJNYGLUWLIFIP24NWSGWIE4K'
523
+
524
+ /** Pinned interpreter address (mainnet), deployed 2026-08-04.
525
+ * UNAUDITED at the time of writing. */
489
526
  export const PINNED_INTERPRETER_MAINNET_ADDRESS =
490
527
  'CALZAMUPREIRY4TULBEXIK77AUTOEJG63XLCPUWEHHQDOVK6ZVVS7VQ2'
491
528
 
492
- /** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
529
+ /** Pinned interpreter wasm sha256 (hex), mainnet.
530
+ *
531
+ * The two networks no longer run the same binary. Testnet carries the
532
+ * selector-leaf minimum and the signer-set cap; mainnet predates both. Read
533
+ * the hash through `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK` rather than
534
+ * this constant unless mainnet is specifically what is meant, or
535
+ * `get_interpreter_info` will report a hash the queried network does not
536
+ * run. */
493
537
  export const PINNED_INTERPRETER_WASM_SHA256 =
494
538
  '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443'
495
539
 
540
+ /** Pinned interpreter wasm sha256 (hex), testnet. Byte-identical to the
541
+ * artifact built from `contracts/policy-interpreter` at the commit that
542
+ * introduced errors 216 and 217, verified by fetching the deployed wasm back
543
+ * off chain. */
544
+ export const PINNED_INTERPRETER_WASM_SHA256_TESTNET =
545
+ 'a4d58bc88fd82bbb8e223941ba5889db919717fae92ced13f6f55bfe583b8e22'
546
+
496
547
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
497
- * packages/policy-interpreter/src/version.rs). */
548
+ * contracts/policy-interpreter/src/version.rs). */
498
549
  export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1
499
550
 
500
551
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
@@ -509,14 +560,18 @@ export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org'
509
560
  * the deploy script hit during the 2026-08-04 mainnet rollout. */
510
561
  export const MAINNET_RPC_URL = 'https://mainnet.sorobanrpc.com'
511
562
 
512
- /** Pin + RPC lookup for the gate enforcement. The interpreters' wasm sha256
513
- * is identical across both networks (the same binary was uploaded both
514
- * places; DEPLOYMENTS.md:25-27), so `PINNED_INTERPRETER_WASM_SHA256` stays
515
- * a single constant - only the addresses and RPCs are network-scoped. */
563
+ /** Pin + RPC lookup for the gate enforcement. Addresses, RPCs and wasm
564
+ * hashes are all network-scoped: the networks diverged when the
565
+ * selector-leaf and signer-cap controls were deployed to testnet ahead of
566
+ * mainnet. */
516
567
  export const PINNED_INTERPRETER_ADDRESS_BY_NETWORK: Record<Network, string> = {
517
568
  testnet: PINNED_INTERPRETER_TESTNET_ADDRESS,
518
569
  mainnet: PINNED_INTERPRETER_MAINNET_ADDRESS,
519
570
  }
571
+ export const PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK: Record<Network, string> = {
572
+ testnet: PINNED_INTERPRETER_WASM_SHA256_TESTNET,
573
+ mainnet: PINNED_INTERPRETER_WASM_SHA256,
574
+ }
520
575
  export const RPC_URL_BY_NETWORK: Record<Network, string> = {
521
576
  testnet: TESTNET_RPC_URL,
522
577
  mainnet: MAINNET_RPC_URL,
@@ -534,13 +589,27 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
534
589
  mainnet: 'Public Global Stellar Network ; September 2015',
535
590
  }
536
591
 
592
+ // Stellar strkey shapes (StrKey base32, no checksum byte, version byte
593
+ // 0x30/0x40 prefixed by the decoder, then 32 base32 chars giving a 56-char
594
+ // total). The shared `isStellarAddress` helper accepts BOTH wallet (G...) and
595
+ // contract (C...) addresses; the install schema has to distinguish them
596
+ // because the smart account is a contract (C...) - a wallet address would
597
+ // mean the rule is being installed against the WRONG account kind entirely.
598
+ // `sourceAccount` is the signing wallet (G...).
599
+ const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
600
+ const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
601
+
537
602
  export const InstallPolicyInputSchema = z
538
603
  .object({
539
604
  /** The smart account contract address (C...) that will receive the rule. */
540
- smartAccount: z.string(),
605
+ smartAccount: z
606
+ .string()
607
+ .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
541
608
  /** The signer that authorises the install (G... wallet). Used only for
542
609
  * sequence number + auth nonce simulation; never persisted, never signed. */
543
- sourceAccount: z.string(),
610
+ sourceAccount: z
611
+ .string()
612
+ .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
544
613
  /** Target network for the install. Selects which interpreter pin and
545
614
  * which RPC URL are valid by default. Defaults to `testnet` so the
546
615
  * pre-mainnet callers keep working: they were always pointing at
@@ -570,6 +639,17 @@ export const InstallPolicyInputSchema = z
570
639
  * everything. Selecting `network: 'mainnet'` is NOT an opt-in -
571
640
  * the mainnet pin is its own deny-by-default anchor. */
572
641
  allowUnpinnedInterpreter: z.boolean().optional(),
642
+ /** Opt-in to installing when a signer of this rule can already reach the
643
+ * same calls through a context rule that has NO policy attached. OZ lets
644
+ * the signer choose which rule authorises a call and enforces only that
645
+ * rule's policies, so an unpoliced rule covering the same calls makes
646
+ * this policy decorative. Default-deny, because the caller almost
647
+ * certainly believes they are restricting something. */
648
+ allowAuthorityOverlap: z.boolean().optional(),
649
+ /** Skip the cross-rule authority scan entirely. The scan costs one RPC
650
+ * read per rule on the account; skipping it means the response carries
651
+ * no statement about what the signers can already do. */
652
+ skipAuthorityScan: z.boolean().optional(),
573
653
  /** Base fee in stroops; defaults to BASE_FEE (100). */
574
654
  baseFee: z.number().int().positive().optional(),
575
655
  })
@@ -578,15 +658,49 @@ export const InstallPolicyInputSchema = z
578
658
  })
579
659
  export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
580
660
 
661
+ /** `merge_policy` input.
662
+ *
663
+ * The tightening remedy for a cross-rule overlap: replace a rule's predicate
664
+ * with the conjunction of it and a new one. Two transactions, in order,
665
+ * because OZ refuses to re-attach a policy already on the rule - so the
666
+ * caller runs `detach`, waits for it to confirm, then runs `reinstall`. */
667
+ export const MergePolicyInputSchema = z
668
+ .object({
669
+ smartAccount: z
670
+ .string()
671
+ .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
672
+ sourceAccount: z
673
+ .string()
674
+ .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
675
+ /** The rule whose predicate is being tightened. */
676
+ ruleId: z.number().int().nonnegative(),
677
+ /** The predicate to conjoin, base64 canonical ScVal, as emitted by
678
+ * `synthesize_policy`. */
679
+ incomingPredicateBlobBase64: z.string().min(1),
680
+ /** Which half of the remedy to build. */
681
+ step: z.enum(['detach', 'reinstall']),
682
+ network: NetworkSchema.optional(),
683
+ rpcUrl: z.string().url().optional(),
684
+ allowUnpinnedRpcUrl: z.boolean().optional(),
685
+ baseFee: z.number().int().positive().optional(),
686
+ })
687
+ .strict()
688
+
689
+ export type MergePolicyInput = z.infer<typeof MergePolicyInputSchema>
690
+
581
691
  export const RevokePolicyInputSchema = z
582
692
  .object({
583
693
  /** The smart account contract address (C...). */
584
- smartAccount: z.string(),
694
+ smartAccount: z
695
+ .string()
696
+ .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
585
697
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
586
698
  * accepts that signer; this schema does not assert a rule it cannot
587
699
  * verify, since the account's source is not in this repo. Proven on
588
700
  * testnet: the account's deployer can revoke. */
589
- sourceAccount: z.string(),
701
+ sourceAccount: z
702
+ .string()
703
+ .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
590
704
  /** Target network for the revoke. Same `testnet`-default as install,
591
705
  * so pre-mainnet callers keep working without an explicit flag. */
592
706
  network: NetworkSchema.optional(),
@@ -610,8 +724,15 @@ export const GetInterpreterInfoInputSchema = z.object({
610
724
  /** When true, perform an optional live `grammar_version()` RPC call to
611
725
  * verify the deployed contract matches the pin. */
612
726
  verifyLive: z.boolean().optional(),
613
- /** Optional RPC URL override. */
727
+ /** Optional RPC URL override. When `verifyLive` is true, the URL must
728
+ * equal the pinned RPC for the selected network unless
729
+ * `allowUnpinnedRpcUrl: true` is also set (same opt-in shape as
730
+ * install/revoke - the live grammar_version() answer binds to whichever
731
+ * RPC returned it). */
614
732
  rpcUrl: z.string().url().optional(),
733
+ /** Opt-in to using a non-pinned RPC URL when `verifyLive` is true. Same
734
+ * semantics as install/revoke: the caller accepts the trust shift. */
735
+ allowUnpinnedRpcUrl: z.boolean().optional(),
615
736
  })
616
737
  export type GetInterpreterInfoInput = z.infer<typeof GetInterpreterInfoInputSchema>
617
738
 
@@ -1,52 +1,42 @@
1
- // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ-shape + interpreter-shape).
2
- //
3
- // Composes the canonical IR rules the two backends compile FROM. Fail-closed
4
- // composition rules:
5
- //
6
- // - identify the protocol of the top-level call (registry.identifyProtocol).
7
- // When it is unknown (null), emit NO OZ-primitive-producing IR node: the
8
- // scope is kept (CallContract + method) and every inferred bound is surfaced
9
- // as a descriptive warning. An unrecognised call never compiles to a
10
- // permissive OZ primitive.
1
+ // src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (OZ + interpreter).
11
2
  //
3
+ // Fail-closed composition rules:
4
+ // - unknown top-level protocol (registry.identifyProtocol returns null) ->
5
+ // emit no OZ-primitive-producing IR node; scope is kept (CallContract + method)
6
+ // and every inferred bound surfaces as a descriptive warning. An unrecognised
7
+ // call never compiles to a permissive OZ primitive.
12
8
  // - carry the recorded top-level function into `rule.scope.method` so the OZ
13
9
  // adapter flags per-method scoping as not covered (CallContract permits every
14
- // method on the contract; a per-method restriction needs the interpreter predicate).
15
- //
16
- // - a `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
17
- // the caller supplies BOTH the limit (userResponses.limitAmount) and the
18
- // window (userResponses.windowSeconds). A single recorded spend does NOT
19
- // authorise that amount every window, so the observed amount is NEVER used as
20
- // an auto-ceiling: a missing limit surfaces AMOUNT_BOUND_MISSING (the observed
21
- // amount is offered as a suggestion), a missing window surfaces
22
- // DURATION_UNSPECIFIED. EVERY spent token is handled - none is silently
23
- // dropped; a single caller limit binds only an unambiguous single-token spend.
24
- //
25
- // - incoming-only flows (e.g. Blend yield claim) emit an `invocation_count`
26
- // bound ONLY when the caller supplies the count (userResponses.invocationLimit)
27
- // and a window; otherwise FREQUENCY_BOUND_MISSING is surfaced with no
28
- // fabricated count.
29
- //
30
- // - the IR carries ONLY constraints justified by the recording (observed
31
- // recipient allowlist) + explicit user input. Nothing invented: no oracle
32
- // price fabricated from a slippage bound, no synthetic exact-path compare.
33
- // Those needs are surfaced as descriptive warnings instead.
10
+ // method; a per-method restriction needs the interpreter predicate).
11
+ // - `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
12
+ // the caller supplies BOTH limit (userResponses.limitAmount) AND window
13
+ // (userResponses.windowSeconds). A single recorded spend does NOT authorise
14
+ // that amount every window, so the observed amount is NEVER used as an
15
+ // auto-ceiling: missing limit -> AMOUNT_BOUND_MISSING (observed amount
16
+ // offered as a suggestion); missing window -> DURATION_UNSPECIFIED. EVERY
17
+ // spent token is handled - none is silently dropped.
18
+ // - incoming-only flows emit an `invocation_count` bound ONLY when the caller
19
+ // supplies both the count and the window; otherwise FREQUENCY_BOUND_MISSING
20
+ // with no fabricated count.
21
+ // - the IR carries ONLY constraints justified by the recording + explicit
22
+ // user input. Nothing invented: no oracle price fabricated from a slippage
23
+ // bound, no synthetic exact-path compare. Those needs surface as warnings.
34
24
  //
35
25
  // Split rule (P3 wiring): `ComposeResult` carries BOTH `ir` (OZ-shape) and
36
26
  // `interpreterIr` (predicate-shape). Each constraint is routed to EXACTLY ONE
37
27
  // adapter:
38
- // - `compare window_spent(token, w) <= limit` where `token === scope.contract`
39
- // and the protocol is known -> `ir` (OZ lowers to spending_limit).
40
- // - everything else the compose step emits (recipient allowlists, per-method
41
- // scoping via scope.method, invocation_count bounds, eq_seq swap paths,
42
- // oracle_price bounds, AND window_spent where token != scope.contract, i.e.
43
- // a SoroSwap input-token cap) -> `interpreterIr`.
28
+ // - `window_spent(token, w) <= limit` where `token === scope.contract` and
29
+ // protocol is known -> `ir` (OZ lowers to spending_limit).
30
+ // - everything else (recipient allowlists, per-method scope.method,
31
+ // invocation_count, eq_seq swap paths, oracle_price, AND window_spent
32
+ // where token != scope.contract i.e. SoroSwap input-token cap) ->
33
+ // `interpreterIr`.
44
34
  //
45
35
  // This prevents the interpreter adapter from emitting a duplicate
46
36
  // `window_spent` predicate leaf alongside an OZ `spending_limit` primitive
47
37
  // covering the same spend semantic - the two adapters never overlap.
48
38
  //
49
- // The default behavior is `deny_all` (OZ context rules are deny-by-default).
39
+ // Default policy is `deny_all` (OZ context rules are deny-by-default).
50
40
 
51
41
  import type { IRCompOp, IRCondition, IRPolicyRule, PolicyIR } from '../ir/types.ts'
52
42
  import { type IdentifiedProtocol, identifyProtocol } from '../registry/identify.ts'
@@ -81,25 +71,20 @@ export interface ComposeUserResponses {
81
71
  * invocation_count bound; absent -> FREQUENCY_BOUND_MISSING. */
82
72
  invocationLimit?: number
83
73
  /** Per-asset oracle-price bound(s). Each entry lowers to a single
84
- * `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
85
- * entries on the same asset emit multiple leaves. */
74
+ * `oracle_price(asset) OP value` compare in the interpreter IR. */
86
75
  oraclePriceBound?: OraclePriceBound[]
87
76
  /** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
88
- * `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
89
- * slippage floor bounding the output arg against the input arg of the same
90
- * call.
91
- *
92
- * REQUIRED to be supplied by the caller: it is never derived from the
93
- * recording. The recorded in/out pair is a price at one moment, and
94
- * freezing it as policy would deny ordinary trades as soon as the rate
95
- * moves. Absent, no floor is emitted and the existing unbounded-output
96
- * warning stands. */
77
+ * `{num:'95',den:'100'}` = accept losing at most 5%). REQUIRED to be
78
+ * supplied by the caller: never derived from the recording (the recorded
79
+ * in/out pair is a price at one moment, and freezing it as policy would
80
+ * deny ordinary trades as soon as the rate moves). Absent, no floor is
81
+ * emitted and the existing unbounded-output warning stands. */
97
82
  swapMinOutRatio?: { num: string; den: string }
98
83
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
99
- * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
100
- * pin. Absent -> the recipient is pinned to the recorded value (mirroring
101
- * SEP-41) and RECIPIENT_ALLOWLIST_EMPTY is surfaced as informational, never
102
- * a silent free pass. */
84
+ * swap_exact_tokens_for_tokens). When supplied, REPLACES the default pin.
85
+ * Absent -> recipient is pinned to the recorded value (mirroring SEP-41)
86
+ * and RECIPIENT_ALLOWLIST_EMPTY surfaces as informational, never a silent
87
+ * free pass. */
103
88
  swapRecipientAllowlist?: string[]
104
89
  }
105
90
 
@@ -144,6 +129,13 @@ export function composeFromRecording(
144
129
  const warnings: string[] = []
145
130
  const ozConstraints: IRCondition[] = []
146
131
  const interpreterConstraints: IRCondition[] = []
132
+ // Route to the matching IR. When the interpreter is enabled, constraints the
133
+ // OZ adapter cannot lower go there; otherwise they go to OZ (which flags them
134
+ // as uncovered) so today's warning-driven behaviour is preserved.
135
+ const routeToAdapter = (cond: IRCondition): void => {
136
+ if (interpreterEnabled) interpreterConstraints.push(cond)
137
+ else ozConstraints.push(cond)
138
+ }
147
139
 
148
140
  const protocol = topLevel
149
141
  ? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
@@ -154,14 +146,15 @@ export function composeFromRecording(
154
146
  const limitAmount = opts.userResponses?.limitAmount
155
147
  const spendTokens = Object.keys(facts.spendByToken)
156
148
 
157
- // Outgoing spend -> one spending_limit per spent token. A single caller limit
158
- // binds only an unambiguous single-token spend; a multi-token flow needs a
159
- // per-token limit, so each unmatched token surfaces AMOUNT_BOUND_MISSING.
149
+ // Outgoing spend -> one spending_limit per spent token. A single caller
150
+ // limit binds only an unambiguous single-token spend; a multi-token flow
151
+ // needs a per-token limit, so each unmatched token surfaces
152
+ // AMOUNT_BOUND_MISSING.
160
153
  //
161
154
  // Routing: a `window_spent(token, w) <= limit` constraint goes to the OZ IR
162
155
  // only when token === scope.contract (OZ's spending_limit binds the
163
156
  // CallContract target, not a token parameter). Otherwise it goes to the
164
- // interpreter IR (the interpreter lowers window_spent to a predicate leaf).
157
+ // interpreter IR.
165
158
  if (spendTokens.length > 0 && topLevel) {
166
159
  let durationFlagged = false
167
160
  for (const token of spendTokens) {
@@ -190,24 +183,23 @@ export function composeFromRecording(
190
183
  })
191
184
  continue
192
185
  }
193
- if (windowSeconds !== undefined && limit !== undefined) {
194
- const spendCond: IRCondition = {
186
+ if (windowSeconds !== undefined) {
187
+ // Rolling spend cap always goes to OZ (`spending_limit` is the audited
188
+ // implementation). The interpreter is NOT a fallback for token !=
189
+ // scopeContract: on chain it sees one authorized call, not the
190
+ // transaction's token movements, so it has no per-call amount to
191
+ // accumulate and the counter would never move. OZ reports the case it
192
+ // cannot cover (limit pins to the context contract) - the honest
193
+ // outcome; the old fallback produced an interpreter predicate that
194
+ // silently never bound.
195
+ ozConstraints.push({
195
196
  op: 'compare',
196
197
  compare: {
197
198
  selector: { kind: 'window_spent', token, windowSeconds },
198
199
  operator: 'lte',
199
200
  value: limit,
200
201
  },
201
- }
202
- // A rolling spend cap always goes to OZ, whose `spending_limit` is the
203
- // audited implementation. The interpreter is NOT a fallback for the
204
- // token != scopeContract case: on chain it sees one authorized call,
205
- // not the transaction's token movements, so it has no per-call amount
206
- // to accumulate and the counter would never move. OZ reports the case
207
- // it cannot cover (it pins the limit to the context contract), which
208
- // is the honest outcome - the old fallback produced an interpreter
209
- // predicate that silently never bound.
210
- ozConstraints.push(spendCond)
202
+ })
211
203
  if (interpreterEnabled && token !== scopeContract) {
212
204
  warnings.push(
213
205
  `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.`
@@ -222,18 +214,18 @@ export function composeFromRecording(
222
214
  // Routed to the interpreter IR when interpreter is enabled (OZ cannot lower
223
215
  // invocation_count); otherwise to the OZ IR (which flags it as uncovered).
224
216
  //
225
- // A recognised swap is NOT an incoming-only flow: it has an outgoing input leg
226
- // whose spend simply was not attributed to the source account (the
227
- // fee-sponsored / holder != source case). Its real restrictions - exact path,
228
- // recipient, and the input-amount cap - come from the protocol-specific pass,
229
- // so it does NOT get the incoming-only frequency prompt. A caller who wants to
217
+ // A recognised swap is NOT an incoming-only flow: it has an outgoing input
218
+ // leg whose spend simply was not attributed to the source account
219
+ // (fee-sponsored / holder != source). Its real restrictions - exact path,
220
+ // recipient, input-amount cap - come from the protocol-specific pass, so it
221
+ // does NOT get the incoming-only frequency prompt. A caller wanting to
230
222
  // rate-limit the swap can still supply an invocationLimit + window, which
231
223
  // lowers to an invocation_count for any flow.
232
224
  if (spendTokens.length === 0 && topLevel) {
233
225
  const invocationLimit = opts.userResponses?.invocationLimit
234
226
  const isRecognisedSwap = protocol?.protocol === 'soroswap'
235
227
  if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
236
- const icCond: IRCondition = {
228
+ routeToAdapter({
237
229
  op: 'compare',
238
230
  compare: {
239
231
  selector: { kind: 'invocation_count', windowSeconds },
@@ -243,12 +235,7 @@ export function composeFromRecording(
243
235
  operator: 'lt',
244
236
  value: String(invocationLimit),
245
237
  },
246
- }
247
- if (interpreterEnabled) {
248
- interpreterConstraints.push(icCond)
249
- } else {
250
- ozConstraints.push(icCond)
251
- }
238
+ })
252
239
  } else if (!isRecognisedSwap) {
253
240
  ambiguities.push({
254
241
  code: 'FREQUENCY_BOUND_MISSING',
@@ -266,7 +253,7 @@ export function composeFromRecording(
266
253
  const oracleBounds = opts.userResponses?.oraclePriceBound
267
254
  if (oracleBounds) {
268
255
  for (const b of oracleBounds) {
269
- const oracleCond: IRCondition = {
256
+ routeToAdapter({
270
257
  op: 'compare',
271
258
  compare: {
272
259
  selector: { kind: 'oracle_price', asset: b.asset },
@@ -274,12 +261,7 @@ export function composeFromRecording(
274
261
  value: b.value,
275
262
  valueDecimals: b.decimals,
276
263
  },
277
- }
278
- if (interpreterEnabled) {
279
- interpreterConstraints.push(oracleCond)
280
- } else {
281
- ozConstraints.push(oracleCond)
282
- }
264
+ })
283
265
  }
284
266
  }
285
267
 
@@ -296,8 +278,8 @@ export function composeFromRecording(
296
278
  // arg cap is skipped to avoid binding one limit to two different semantics.
297
279
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined
298
280
  appendProtocolSpecificConstraints(
299
- ozConstraints,
300
281
  interpreterConstraints,
282
+ routeToAdapter,
301
283
  warnings,
302
284
  ambiguities,
303
285
  facts,
@@ -353,8 +335,8 @@ export function composeFromRecording(
353
335
  * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
354
336
  * call_arg[2]). */
355
337
  function appendProtocolSpecificConstraints(
356
- ozConstraints: IRCondition[],
357
338
  interpreterConstraints: IRCondition[],
339
+ routeToAdapter: (cond: IRCondition) => void,
358
340
  warnings: string[],
359
341
  ambiguities: AmbiguityPrompt[],
360
342
  facts: IntentFacts,
@@ -365,31 +347,24 @@ function appendProtocolSpecificConstraints(
365
347
  swapMinOutRatio: { num: string; den: string } | undefined,
366
348
  interpreterEnabled: boolean
367
349
  ): void {
368
- // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
369
- // a single-element allowlist; the interpreter adapter lowers it to `in`.
350
+ // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit a
351
+ // single-element allowlist; the interpreter adapter lowers it to `in`.
370
352
  // When interpreter is not enabled, route to OZ so the caller sees today's
371
- // `value allowlist on arg 1 (arg allowlist)` warning.
353
+ // `value allowlist on arg 1` warning.
372
354
  if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
373
355
  const toArg = topLevel.args[1]
374
356
  if (toArg && toArg.type === 'address') {
375
- const cond: IRCondition = {
357
+ routeToAdapter({
376
358
  op: 'in',
377
359
  selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
378
360
  values: [toArg.value],
379
- }
380
- if (interpreterEnabled) {
381
- interpreterConstraints.push(cond)
382
- } else {
383
- ozConstraints.push(cond)
384
- }
361
+ })
385
362
  }
386
363
  }
387
364
 
388
365
  // Blend submit / claim: the `to` arg is the beneficiary - it receives the
389
366
  // resulting position shares on `submit` and the claimed tokens on `claim`.
390
- // That is the same role as the SEP-41 recipient above and the SoroSwap swap
391
- // recipient below, so it is pinned to the recorded address the same way:
392
- // without it a policy scoped to a pool and method still lets an agent send
367
+ // Without it, a policy scoped to a pool + method still lets an agent send
393
368
  // the proceeds anywhere. `from` and `spender` are deliberately NOT pinned -
394
369
  // the call already requires their authorisation, so binding them adds no
395
370
  // restriction the chain is not already enforcing.
@@ -398,25 +373,20 @@ function appendProtocolSpecificConstraints(
398
373
  // claim(from, reserve_token_ids, to).
399
374
  const toArg = topLevel.args[2]
400
375
  if (toArg && toArg.type === 'address') {
401
- const cond: IRCondition = {
376
+ routeToAdapter({
402
377
  op: 'in',
403
378
  selector: { kind: 'arg', argIndex: 2, scalarType: 'address' },
404
379
  values: [toArg.value],
405
- }
406
- if (interpreterEnabled) {
407
- interpreterConstraints.push(cond)
408
- } else {
409
- ozConstraints.push(cond)
410
- }
380
+ })
411
381
  }
412
382
  }
413
383
 
414
384
  // Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
415
385
  // reserve_token_ids - no map fields to bind). The `requests` vec
416
386
  // (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
417
- // Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
387
+ // Request is the per-reserve action selector (0 Supply, 1 Withdraw,
418
388
  // 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
419
- // liquidation/auction fills. Pinning only one element is unsafe: a caller
389
+ // liquidation/auction fills). Pinning only one element is unsafe: a caller
420
390
  // can append a second element with a different action (WithdrawCollateral
421
391
  // -> Borrow on a different asset, any amount, then auction fills). Length
422
392
  // + per-element pinning is total; a quantifier over elements is not. If we
@@ -565,19 +535,14 @@ function appendProtocolSpecificConstraints(
565
535
  inputAmountArg &&
566
536
  inputAmountArg.type === 'i128'
567
537
  ) {
568
- const cond: IRCondition = {
538
+ routeToAdapter({
569
539
  op: 'compare',
570
540
  compare: {
571
541
  selector: { kind: 'arg', argIndex: inputArgIndex, scalarType: 'i128' },
572
542
  operator: 'lte',
573
543
  value: swapInputAmountCap,
574
544
  },
575
- }
576
- if (interpreterEnabled) {
577
- interpreterConstraints.push(cond)
578
- } else {
579
- ozConstraints.push(cond)
580
- }
545
+ })
581
546
  }
582
547
 
583
548
  // Slippage floor: `out >= in * num/den`. Only when the caller supplied the
@@ -592,18 +557,13 @@ function appendProtocolSpecificConstraints(
592
557
  inputAmountArg &&
593
558
  inputAmountArg.type === 'i128'
594
559
  ) {
595
- const floor: IRCondition = {
560
+ routeToAdapter({
596
561
  op: 'slippage_floor',
597
562
  outArgIndex: outMinArgIndex,
598
563
  inArgIndex: inputArgIndex,
599
564
  num: minOutRatio.num,
600
565
  den: minOutRatio.den,
601
- }
602
- if (interpreterEnabled) {
603
- interpreterConstraints.push(floor)
604
- } else {
605
- ozConstraints.push(floor)
606
- }
566
+ })
607
567
  }
608
568
 
609
569
  // Swap recipient (call_arg[3]): when the caller supplies
@@ -620,16 +580,11 @@ function appendProtocolSpecificConstraints(
620
580
  // (today's behaviour, matching the other SoroSwap constraints).
621
581
  const recipientArg = topLevel.args[3]
622
582
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
623
- const cond: IRCondition = {
583
+ routeToAdapter({
624
584
  op: 'in',
625
585
  selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
626
586
  values: [...swapRecipientAllowlist],
627
- }
628
- if (interpreterEnabled) {
629
- interpreterConstraints.push(cond)
630
- } else {
631
- ozConstraints.push(cond)
632
- }
587
+ })
633
588
  } else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
634
589
  interpreterConstraints.push({
635
590
  op: 'in',