@crediolabs/policy-synth 0.4.0 → 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 (66) 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 +14 -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.js +51 -8
  16. package/dist/run/schemas.d.ts +45 -14
  17. package/dist/run/schemas.js +43 -6
  18. package/dist/simulate/deny-cases.js +11 -0
  19. package/dist/simulate/evaluate.js +86 -5
  20. package/dist/synth/declare.d.ts +13 -0
  21. package/dist/synth/declare.js +34 -5
  22. package/dist/synth/synthesize-from-recording.js +1 -1
  23. package/dist/types.d.ts +21 -1
  24. package/dist/types.js +1 -1
  25. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  26. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  27. package/dist-cjs/errors.d.ts +6 -1
  28. package/dist-cjs/install/authority-overlap.js +12 -0
  29. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  30. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  31. package/dist-cjs/install/read-account-rules.js +252 -0
  32. package/dist-cjs/predicate/decode.js +22 -1
  33. package/dist-cjs/predicate/encode.js +52 -5
  34. package/dist-cjs/predicate/from-json.js +14 -1
  35. package/dist-cjs/review-card/builder.js +40 -0
  36. package/dist-cjs/review-card/cross-check.js +34 -0
  37. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  38. package/dist-cjs/review-card/render-leaf.js +7 -0
  39. package/dist-cjs/run/index.js +51 -8
  40. package/dist-cjs/run/schemas.d.ts +45 -14
  41. package/dist-cjs/run/schemas.js +43 -6
  42. package/dist-cjs/simulate/deny-cases.js +11 -0
  43. package/dist-cjs/simulate/evaluate.js +86 -5
  44. package/dist-cjs/synth/declare.d.ts +13 -0
  45. package/dist-cjs/synth/declare.js +34 -5
  46. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  47. package/dist-cjs/types.d.ts +21 -1
  48. package/dist-cjs/types.js +1 -1
  49. package/package.json +1 -1
  50. package/src/adapters/interpreter/adapter.ts +13 -5
  51. package/src/errors.ts +5 -0
  52. package/src/install/authority-overlap.ts +11 -0
  53. package/src/install/read-account-rules.ts +313 -0
  54. package/src/predicate/decode.ts +22 -1
  55. package/src/predicate/encode.ts +55 -5
  56. package/src/predicate/from-json.ts +14 -1
  57. package/src/review-card/builder.ts +45 -2
  58. package/src/review-card/cross-check.ts +35 -1
  59. package/src/review-card/render-leaf.ts +8 -1
  60. package/src/run/index.ts +54 -8
  61. package/src/run/schemas.ts +43 -6
  62. package/src/simulate/deny-cases.ts +12 -1
  63. package/src/simulate/evaluate.ts +101 -9
  64. package/src/synth/declare.ts +54 -5
  65. package/src/synth/synthesize-from-recording.ts +1 -1
  66. package/src/types.ts +16 -1
@@ -80,15 +80,31 @@ function walkPredicate(node, visit) {
80
80
  for (const child of node.children)
81
81
  walkPredicate(child, visit);
82
82
  return;
83
+ // NOT descended into. Every line the card emits reads as a requirement,
84
+ // and `and` is what makes that true. Listing an `or`'s branches as
85
+ // separate lines would state the opposite of what the policy means, so
86
+ // the whole disjunction is rendered as ONE line instead.
87
+ case 'or':
88
+ visit(node);
89
+ return;
83
90
  case 'in':
84
91
  visit(node);
85
92
  return;
86
93
  case 'eq':
94
+ case 'lt':
87
95
  case 'lte':
96
+ case 'gt':
97
+ case 'gte':
88
98
  visit(node);
89
99
  return;
90
100
  }
91
101
  }
102
+ /** Argument index of a `call_arg` leaf, for the scaled-comparison line. Any
103
+ * other leaf renders as its kind so the line stays readable rather than
104
+ * claiming an index that does not exist. */
105
+ function leftArgLabel(leaf) {
106
+ return leaf.kind === 'call_arg' ? String(leaf.index) : `<${leaf.kind}>`;
107
+ }
92
108
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
93
109
  * shape of the output is pinned by Task 7b so the test suite can assert
94
110
  * byte-for-byte equality. Returns `null` when the node is a structural
@@ -97,14 +113,38 @@ function renderConstraint(node) {
97
113
  switch (node.op) {
98
114
  case 'and':
99
115
  return null;
116
+ case 'or': {
117
+ // One line for the whole disjunction. If any branch is a shape the
118
+ // card cannot render, the entire line is withheld rather than shown
119
+ // with a branch missing - a disjunction with a branch dropped reads
120
+ // as STRICTER than it is, which is the dangerous direction.
121
+ const parts = node.children.map(renderConstraint);
122
+ if (parts.some((p) => p === null))
123
+ return null;
124
+ return `Either: ${parts.join(' OR ')}`;
125
+ }
100
126
  case 'eq':
127
+ case 'lt':
101
128
  case 'lte':
129
+ case 'gt':
130
+ case 'gte':
102
131
  return renderComparison(node);
103
132
  case 'in':
104
133
  return renderMembership(node);
105
134
  }
106
135
  }
107
136
  function renderComparison(node) {
137
+ // The slippage floor: OP(call_arg[out], call_arg_scaled(in, num, den)).
138
+ // Rendered explicitly because the human approving the signature has to see
139
+ // that the bound is a RATIO of another argument, not a fixed amount.
140
+ if (node.right.kind === 'call_arg_scaled') {
141
+ const s = node.right;
142
+ return `arg[${leftArgLabel(node.left)}] ${comparisonOpText(node.op)} arg[${s.index}] * ${s.num}/${s.den}`;
143
+ }
144
+ if (node.left.kind === 'call_arg_scaled') {
145
+ const s = node.left;
146
+ return `arg[${s.index}] * ${s.num}/${s.den} ${comparisonOpText(node.op)} arg[${leftArgLabel(node.right)}]`;
147
+ }
108
148
  const left = node.left;
109
149
  const right = node.right;
110
150
  // eq(call_contract, literal_address) -> Contract must be <addr>
@@ -39,8 +39,30 @@ function collect(node, out) {
39
39
  for (const child of node.children)
40
40
  collect(child, out);
41
41
  return;
42
+ // ONE line for the whole disjunction, mirroring the builder. Emitting a
43
+ // line per branch would claim every branch is required, which is the
44
+ // opposite of what `or` means. If any branch renders to nothing the whole
45
+ // line is withheld, again mirroring the builder - a disjunction missing a
46
+ // branch reads STRICTER than it is.
47
+ case 'or': {
48
+ const parts = [];
49
+ for (const child of node.children) {
50
+ const childOut = [];
51
+ collect(child, childOut);
52
+ if (childOut.length !== 1)
53
+ return;
54
+ parts.push(childOut[0]);
55
+ }
56
+ if (parts.length === 0)
57
+ return;
58
+ out.push(`Either: ${parts.join(' OR ')}`);
59
+ return;
60
+ }
42
61
  case 'eq':
62
+ case 'lt':
43
63
  case 'lte':
64
+ case 'gt':
65
+ case 'gte':
44
66
  pushComparison(node.left, node.right, node.op, out);
45
67
  return;
46
68
  case 'in':
@@ -49,6 +71,18 @@ function collect(node, out) {
49
71
  }
50
72
  }
51
73
  function pushComparison(left, right, op, out) {
74
+ // Slippage floor, mirroring the builder. The human has to see that the
75
+ // bound is a RATIO of another argument, not a fixed amount.
76
+ if (right.kind === 'call_arg_scaled') {
77
+ const label = left.kind === 'call_arg' ? String(left.index) : `<${left.kind}>`;
78
+ out.push(`arg[${label}] ${comparisonOpText(op)} arg[${right.index}] * ${right.num}/${right.den}`);
79
+ return;
80
+ }
81
+ if (left.kind === 'call_arg_scaled') {
82
+ const label = right.kind === 'call_arg' ? String(right.index) : `<${right.kind}>`;
83
+ out.push(`arg[${left.index}] * ${left.num}/${left.den} ${comparisonOpText(op)} arg[${label}]`);
84
+ return;
85
+ }
52
86
  if (left.kind === 'call_contract' && op === 'eq' && right.kind === 'literal_address') {
53
87
  out.push(`Contract must be ${right.value}`);
54
88
  return;
@@ -1,4 +1,4 @@
1
1
  import type { PredicateLeaf } from '../types.ts';
2
2
  export declare function renderVecElement(leaf: PredicateLeaf): string;
3
3
  export declare function renderHaystackElement(leaf: PredicateLeaf): string;
4
- export declare function comparisonOpText(op: 'eq' | 'lte'): string;
4
+ export declare function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string;
@@ -20,6 +20,7 @@ export function renderVecElement(leaf) {
20
20
  case 'call_arg':
21
21
  case 'call_arg_len':
22
22
  case 'call_arg_field':
23
+ case 'call_arg_scaled':
23
24
  return `<${leaf.kind}>`;
24
25
  }
25
26
  }
@@ -39,8 +40,14 @@ export function renderHaystackElement(leaf) {
39
40
  }
40
41
  export function comparisonOpText(op) {
41
42
  switch (op) {
43
+ case 'lt':
44
+ return '<';
42
45
  case 'lte':
43
46
  return '<=';
47
+ case 'gt':
48
+ return '>';
49
+ case 'gte':
50
+ return '>=';
44
51
  case 'eq':
45
52
  return '==';
46
53
  }
package/dist/run/index.js CHANGED
@@ -22,6 +22,7 @@ import { declarePredicate, encodePredicate, recordTransaction, synthesizeFromRec
22
22
  import { findAuthorityOverlaps, } from "../install/authority-overlap.js";
23
23
  import { buildInstallPolicyXdr, buildRevokePolicyXdr, rpcClientFromServer, } from "../install/build-install-policy.js";
24
24
  import { getInterpreterInfo } from "../install/get-interpreter-info.js";
25
+ import { accountRuleReaderFromServer, collectObservedRules } from "../install/read-account-rules.js";
25
26
  import { decodePredicate } from "../predicate/decode.js";
26
27
  import { evaluate, generateCases } from "../simulate/index.js";
27
28
  import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
@@ -151,11 +152,18 @@ export async function runInstallPolicy(raw) {
151
152
  rpc: rpcClient,
152
153
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
153
154
  });
154
- // Cross-rule scan, when the caller supplied what else is on the account.
155
- // ABSENT is reported as `null` rather than an empty list: "we did not
156
- // look" and "we looked and found nothing" are different answers, and
157
- // collapsing them would let a caller read silence as safety.
158
- const authorityScan = input.existingRules === undefined
155
+ // Cross-rule scan. The caller may supply `existingRules` (useful offline,
156
+ // and for testing); otherwise the account is READ, so the answer describes
157
+ // what is actually installed rather than what the caller happened to
158
+ // mention.
159
+ //
160
+ // `null` means NOT CHECKED and is returned whenever the scan cannot be
161
+ // trusted to be complete - the read failed, or it stopped before
162
+ // accounting for every live rule. An empty list would say "checked,
163
+ // nothing found", and a partial scan that reported `[]` would be claiming
164
+ // a safety it never established.
165
+ const observed = await resolveExistingRules(input, network, expectedInterpreter);
166
+ const authorityScan = observed === null
159
167
  ? null
160
168
  : findAuthorityOverlaps({
161
169
  intended: {
@@ -167,9 +175,7 @@ export async function runInstallPolicy(raw) {
167
175
  signers: input.rule.signers,
168
176
  predicate: decodePredicate(encodedPredicate),
169
177
  },
170
- // The schema types `predicate` loosely (it is the shared
171
- // PredicateNodeSchema); the shape is already validated.
172
- existing: input.existingRules,
178
+ existing: observed,
173
179
  });
174
180
  return { ok: true, data: { ...result, authorityScan } };
175
181
  }
@@ -320,6 +326,7 @@ export function runDeclarePolicy(raw) {
320
326
  ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
321
327
  ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
322
328
  ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
329
+ ...(d.minOutputRatio !== undefined ? { minOutputRatio: d.minOutputRatio } : {}),
323
330
  });
324
331
  const { encodedPredicate, predicateHash } = encodePredicate(predicate);
325
332
  return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } };
@@ -415,6 +422,42 @@ export async function runGetInterpreterInfo(raw) {
415
422
  * network, falling back to the pinned RPC for the network. The caller
416
423
  * has already been gated against the pinned URL elsewhere, so the
417
424
  * fallback here only ever picks from a finite, audited pair. */
425
+ /** The account's other context rules, or `null` when they could not be
426
+ * established completely.
427
+ *
428
+ * Caller-supplied `existingRules` win: they let the scan run offline, and a
429
+ * caller who passes them has said what to compare against. Otherwise the
430
+ * account is read over RPC.
431
+ *
432
+ * Every failure path returns `null` rather than a short list. A read that
433
+ * threw, or one that stopped before accounting for every live rule, has not
434
+ * ruled anything out - and reporting `[]` there would turn "we could not
435
+ * check" into "there is nothing to worry about". */
436
+ async function resolveExistingRules(input, network, interpreterAddress) {
437
+ if (input.existingRules !== undefined) {
438
+ // The schema types `predicate` loosely (it is the shared
439
+ // PredicateNodeSchema); the shape is already validated.
440
+ return input.existingRules;
441
+ }
442
+ try {
443
+ const url = input.rpcUrl ?? RPC_URL_BY_NETWORK[network];
444
+ const server = new rpc.Server(url, { allowHttp: false });
445
+ const collected = await collectObservedRules({
446
+ reader: accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network]),
447
+ smartAccount: input.smartAccount,
448
+ interpreterAddress,
449
+ });
450
+ if (collected.incomplete)
451
+ return null;
452
+ return collected.rules;
453
+ }
454
+ catch {
455
+ // The install itself is unaffected: the scan is advisory, so a failed
456
+ // read must not block a policy the user asked for. It just cannot be
457
+ // reported as a clean scan.
458
+ return null;
459
+ }
460
+ }
418
461
  function buildRpcClientFromInput(urlOverride, network) {
419
462
  const url = urlOverride ?? RPC_URL_BY_NETWORK[network];
420
463
  const passphrase = NETWORK_PASSPHRASES[network];
@@ -1559,6 +1559,7 @@ export declare const ObservedRuleSchema: z.ZodObject<{
1559
1559
  keyBytes: string;
1560
1560
  })[];
1561
1561
  id: number;
1562
+ policyAddresses: string[];
1562
1563
  contextType: {
1563
1564
  kind: "default";
1564
1565
  } | {
@@ -1568,7 +1569,6 @@ export declare const ObservedRuleSchema: z.ZodObject<{
1568
1569
  kind: "create_contract";
1569
1570
  wasmHash: string;
1570
1571
  };
1571
- policyAddresses: string[];
1572
1572
  predicate?: unknown;
1573
1573
  }, {
1574
1574
  signers: ({
@@ -1580,6 +1580,7 @@ export declare const ObservedRuleSchema: z.ZodObject<{
1580
1580
  keyBytes: string;
1581
1581
  })[];
1582
1582
  id: number;
1583
+ policyAddresses: string[];
1583
1584
  contextType: {
1584
1585
  kind: "default";
1585
1586
  } | {
@@ -1589,28 +1590,27 @@ export declare const ObservedRuleSchema: z.ZodObject<{
1589
1590
  kind: "create_contract";
1590
1591
  wasmHash: string;
1591
1592
  };
1592
- policyAddresses: string[];
1593
1593
  predicate?: unknown;
1594
1594
  }>;
1595
1595
  /** Pinned interpreter address (testnet).
1596
1596
  * Single source for the MCP layer; do not embed elsewhere. */
1597
- export declare const PINNED_INTERPRETER_TESTNET_ADDRESS = "CCL336TCK2Y5OFNRCMN2M3HVPBCEX4PW5H6EQ5VW5NPMXOCP4ESB5XR4";
1598
- /** Pinned interpreter address (mainnet), redeployed 2026-08-22 from a reproducible build. The mainnet
1597
+ export declare const PINNED_INTERPRETER_TESTNET_ADDRESS = "CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5";
1598
+ /** Pinned interpreter address (mainnet), redeployed 2026-08-22 for grammar 4, from a reproducible build. The mainnet
1599
1599
  * interpreter IS the binary exercised on testnet - both instances were created
1600
1600
  * from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
1601
- * both were read back with `grammar_version()` returning 3. The address differs
1601
+ * both were read back with `grammar_version()` returning 4. The address differs
1602
1602
  * because instance ids are network-scoped. UNAUDITED at the time of writing.
1603
1603
  *
1604
1604
  * These four constants move together or not at all. The grammar version and
1605
1605
  * wasm hash are single values covering BOTH networks, so re-pinning one network
1606
1606
  * alone would have the builder emit a version the other network refuses - with
1607
1607
  * a green test run, since `grammar-version-parity.test.ts` would then pass. */
1608
- export declare const PINNED_INTERPRETER_MAINNET_ADDRESS = "CBZXLSTQUITBFZHQH6XRXF3XIVRQR4RHRI64Q5WELS5KGY3ZKJPFWDPF";
1608
+ export declare const PINNED_INTERPRETER_MAINNET_ADDRESS = "CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52";
1609
1609
  /** Pinned interpreter wasm sha256 (hex). */
1610
- export declare const PINNED_INTERPRETER_WASM_SHA256 = "a2b36e8ac5a61caf3757af26aa79e83f2995b451099f44772383806a55fe3414";
1610
+ export declare const PINNED_INTERPRETER_WASM_SHA256 = "b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae";
1611
1611
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
1612
1612
  * contracts/policy-interpreter/src/version.rs). */
1613
- export declare const PINNED_INTERPRETER_GRAMMAR_VERSION = 3;
1613
+ export declare const PINNED_INTERPRETER_GRAMMAR_VERSION = 4;
1614
1614
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
1615
1615
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
1616
1616
  * than a Server; the two are deliberately different surfaces, so this is
@@ -1641,6 +1641,25 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1641
1641
  recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1642
1642
  recipientArgIndex: z.ZodOptional<z.ZodNumber>;
1643
1643
  allowZeroCap: z.ZodOptional<z.ZodBoolean>;
1644
+ /** Minimum output as a ratio of the call's own input. num/den are decimal
1645
+ * STRINGS for the same reason maxAmount is: an i128 ratio does not
1646
+ * survive a JS number. */
1647
+ minOutputRatio: z.ZodOptional<z.ZodObject<{
1648
+ num: z.ZodString;
1649
+ den: z.ZodString;
1650
+ inputArgIndex: z.ZodNumber;
1651
+ outputArgIndex: z.ZodNumber;
1652
+ }, "strict", z.ZodTypeAny, {
1653
+ num: string;
1654
+ den: string;
1655
+ inputArgIndex: number;
1656
+ outputArgIndex: number;
1657
+ }, {
1658
+ num: string;
1659
+ den: string;
1660
+ inputArgIndex: number;
1661
+ outputArgIndex: number;
1662
+ }>>;
1644
1663
  }, "strict", z.ZodTypeAny, {
1645
1664
  fn: string;
1646
1665
  contract?: string | undefined;
@@ -1649,6 +1668,12 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1649
1668
  recipients?: string[] | undefined;
1650
1669
  recipientArgIndex?: number | undefined;
1651
1670
  allowZeroCap?: boolean | undefined;
1671
+ minOutputRatio?: {
1672
+ num: string;
1673
+ den: string;
1674
+ inputArgIndex: number;
1675
+ outputArgIndex: number;
1676
+ } | undefined;
1652
1677
  }, {
1653
1678
  fn: string;
1654
1679
  contract?: string | undefined;
@@ -1657,6 +1682,12 @@ export declare const DeclarePolicyInputSchema: z.ZodObject<{
1657
1682
  recipients?: string[] | undefined;
1658
1683
  recipientArgIndex?: number | undefined;
1659
1684
  allowZeroCap?: boolean | undefined;
1685
+ minOutputRatio?: {
1686
+ num: string;
1687
+ den: string;
1688
+ inputArgIndex: number;
1689
+ outputArgIndex: number;
1690
+ } | undefined;
1660
1691
  }>;
1661
1692
  export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>;
1662
1693
  export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
@@ -1726,6 +1757,7 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
1726
1757
  keyBytes: string;
1727
1758
  })[];
1728
1759
  id: number;
1760
+ policyAddresses: string[];
1729
1761
  contextType: {
1730
1762
  kind: "default";
1731
1763
  } | {
@@ -1735,7 +1767,6 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
1735
1767
  kind: "create_contract";
1736
1768
  wasmHash: string;
1737
1769
  };
1738
- policyAddresses: string[];
1739
1770
  predicate?: unknown;
1740
1771
  }, {
1741
1772
  signers: ({
@@ -1747,6 +1778,7 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
1747
1778
  keyBytes: string;
1748
1779
  })[];
1749
1780
  id: number;
1781
+ policyAddresses: string[];
1750
1782
  contextType: {
1751
1783
  kind: "default";
1752
1784
  } | {
@@ -1756,7 +1788,6 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
1756
1788
  kind: "create_contract";
1757
1789
  wasmHash: string;
1758
1790
  };
1759
- policyAddresses: string[];
1760
1791
  predicate?: unknown;
1761
1792
  }>, "many">>;
1762
1793
  /** The smart account contract address (C...) that will receive the rule. */
@@ -2153,6 +2184,7 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2153
2184
  keyBytes: string;
2154
2185
  })[];
2155
2186
  id: number;
2187
+ policyAddresses: string[];
2156
2188
  contextType: {
2157
2189
  kind: "default";
2158
2190
  } | {
@@ -2162,7 +2194,6 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2162
2194
  kind: "create_contract";
2163
2195
  wasmHash: string;
2164
2196
  };
2165
- policyAddresses: string[];
2166
2197
  predicate?: unknown;
2167
2198
  }[] | undefined;
2168
2199
  rpcUrl?: string | undefined;
@@ -2212,6 +2243,7 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2212
2243
  keyBytes: string;
2213
2244
  })[];
2214
2245
  id: number;
2246
+ policyAddresses: string[];
2215
2247
  contextType: {
2216
2248
  kind: "default";
2217
2249
  } | {
@@ -2221,7 +2253,6 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2221
2253
  kind: "create_contract";
2222
2254
  wasmHash: string;
2223
2255
  };
2224
- policyAddresses: string[];
2225
2256
  predicate?: unknown;
2226
2257
  }[] | undefined;
2227
2258
  rpcUrl?: string | undefined;
@@ -2271,6 +2302,7 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2271
2302
  keyBytes: string;
2272
2303
  })[];
2273
2304
  id: number;
2305
+ policyAddresses: string[];
2274
2306
  contextType: {
2275
2307
  kind: "default";
2276
2308
  } | {
@@ -2280,7 +2312,6 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2280
2312
  kind: "create_contract";
2281
2313
  wasmHash: string;
2282
2314
  };
2283
- policyAddresses: string[];
2284
2315
  predicate?: unknown;
2285
2316
  }[] | undefined;
2286
2317
  rpcUrl?: string | undefined;
@@ -2330,6 +2361,7 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2330
2361
  keyBytes: string;
2331
2362
  })[];
2332
2363
  id: number;
2364
+ policyAddresses: string[];
2333
2365
  contextType: {
2334
2366
  kind: "default";
2335
2367
  } | {
@@ -2339,7 +2371,6 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodObject<{
2339
2371
  kind: "create_contract";
2340
2372
  wasmHash: string;
2341
2373
  };
2342
- policyAddresses: string[];
2343
2374
  predicate?: unknown;
2344
2375
  }[] | undefined;
2345
2376
  rpcUrl?: string | undefined;
@@ -177,6 +177,15 @@ export const PredicateLeafSchema = z.lazy(() => z.union([
177
177
  element: z.number().int().nonnegative(),
178
178
  field: z.string(),
179
179
  }),
180
+ // num/den are i128 decimal strings, matching `literal_i128`. The regex
181
+ // is the boundary guard; the ratio's SIGN is checked at encode, where
182
+ // the message can explain that a negative ratio inverts the comparison.
183
+ z.object({
184
+ kind: z.literal('call_arg_scaled'),
185
+ index: z.number().int().nonnegative(),
186
+ num: z.string().regex(/^-?[0-9]+$/),
187
+ den: z.string().regex(/^-?[0-9]+$/),
188
+ }),
180
189
  z.object({ kind: z.literal('literal_address'), value: z.string() }),
181
190
  z.object({ kind: z.literal('literal_i128'), value: z.string().regex(/^-?[0-9]+$/) }),
182
191
  z.object({ kind: z.literal('literal_symbol'), value: z.string() }),
@@ -195,16 +204,32 @@ export const PredicateLeafSchema = z.lazy(() => z.union([
195
204
  * `and`; the lazy + annotation pattern keeps the recursion type-safe. */
196
205
  export const PredicateNodeSchema = z.lazy(() => z.union([
197
206
  z.object({ op: z.literal('and'), children: z.array(PredicateNodeSchema) }),
207
+ z.object({ op: z.literal('or'), children: z.array(PredicateNodeSchema) }),
198
208
  z.object({
199
209
  op: z.literal('eq'),
200
210
  left: PredicateLeafSchema,
201
211
  right: PredicateLeafSchema,
202
212
  }),
213
+ z.object({
214
+ op: z.literal('lt'),
215
+ left: PredicateLeafSchema,
216
+ right: PredicateLeafSchema,
217
+ }),
203
218
  z.object({
204
219
  op: z.literal('lte'),
205
220
  left: PredicateLeafSchema,
206
221
  right: PredicateLeafSchema,
207
222
  }),
223
+ z.object({
224
+ op: z.literal('gt'),
225
+ left: PredicateLeafSchema,
226
+ right: PredicateLeafSchema,
227
+ }),
228
+ z.object({
229
+ op: z.literal('gte'),
230
+ left: PredicateLeafSchema,
231
+ right: PredicateLeafSchema,
232
+ }),
208
233
  z.object({
209
234
  op: z.literal('in'),
210
235
  needle: PredicateLeafSchema,
@@ -300,23 +325,23 @@ const ContextRuleDraftSchema = z
300
325
  // existing four.
301
326
  /** Pinned interpreter address (testnet).
302
327
  * Single source for the MCP layer; do not embed elsewhere. */
303
- export const PINNED_INTERPRETER_TESTNET_ADDRESS = 'CCL336TCK2Y5OFNRCMN2M3HVPBCEX4PW5H6EQ5VW5NPMXOCP4ESB5XR4';
304
- /** Pinned interpreter address (mainnet), redeployed 2026-08-22 from a reproducible build. The mainnet
328
+ export const PINNED_INTERPRETER_TESTNET_ADDRESS = 'CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5';
329
+ /** Pinned interpreter address (mainnet), redeployed 2026-08-22 for grammar 4, from a reproducible build. The mainnet
305
330
  * interpreter IS the binary exercised on testnet - both instances were created
306
331
  * from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
307
- * both were read back with `grammar_version()` returning 3. The address differs
332
+ * both were read back with `grammar_version()` returning 4. The address differs
308
333
  * because instance ids are network-scoped. UNAUDITED at the time of writing.
309
334
  *
310
335
  * These four constants move together or not at all. The grammar version and
311
336
  * wasm hash are single values covering BOTH networks, so re-pinning one network
312
337
  * alone would have the builder emit a version the other network refuses - with
313
338
  * a green test run, since `grammar-version-parity.test.ts` would then pass. */
314
- export const PINNED_INTERPRETER_MAINNET_ADDRESS = 'CBZXLSTQUITBFZHQH6XRXF3XIVRQR4RHRI64Q5WELS5KGY3ZKJPFWDPF';
339
+ export const PINNED_INTERPRETER_MAINNET_ADDRESS = 'CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52';
315
340
  /** Pinned interpreter wasm sha256 (hex). */
316
- export const PINNED_INTERPRETER_WASM_SHA256 = 'a2b36e8ac5a61caf3757af26aa79e83f2995b451099f44772383806a55fe3414';
341
+ export const PINNED_INTERPRETER_WASM_SHA256 = 'b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae';
317
342
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
318
343
  * contracts/policy-interpreter/src/version.rs). */
319
- export const PINNED_INTERPRETER_GRAMMAR_VERSION = 3;
344
+ export const PINNED_INTERPRETER_GRAMMAR_VERSION = 4;
320
345
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
321
346
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
322
347
  * than a Server; the two are deliberately different surfaces, so this is
@@ -379,6 +404,18 @@ export const DeclarePolicyInputSchema = z
379
404
  recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
380
405
  recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
381
406
  allowZeroCap: z.boolean().optional(),
407
+ /** Minimum output as a ratio of the call's own input. num/den are decimal
408
+ * STRINGS for the same reason maxAmount is: an i128 ratio does not
409
+ * survive a JS number. */
410
+ minOutputRatio: z
411
+ .object({
412
+ num: z.string().regex(/^[0-9]+$/, 'num must be an unsigned integer'),
413
+ den: z.string().regex(/^[0-9]+$/, 'den must be an unsigned integer'),
414
+ inputArgIndex: z.number().int().nonnegative().max(U32_MAX),
415
+ outputArgIndex: z.number().int().nonnegative().max(U32_MAX),
416
+ })
417
+ .strict()
418
+ .optional(),
382
419
  })
383
420
  .strict();
384
421
  export const InstallPolicyInputSchema = z
@@ -201,11 +201,22 @@ function visit(node, facts) {
201
201
  for (const child of node.children)
202
202
  visit(child, facts);
203
203
  return;
204
+ // NOT descended into. A deny case works by violating ONE constraint and
205
+ // asserting the predicate refuses the call. Violating one branch of an
206
+ // `or` proves nothing, because another branch can still permit, so the
207
+ // generated case would either fail or pass for the wrong reason. A sound
208
+ // deny case for a disjunction must violate EVERY branch at once, which
209
+ // this generator does not construct - so it emits none.
210
+ case 'or':
211
+ return;
204
212
  case 'in':
205
213
  facts.memberships.push(node);
206
214
  return;
207
215
  case 'eq':
216
+ case 'lt':
208
217
  case 'lte':
218
+ case 'gt':
219
+ case 'gte':
209
220
  facts.comparisons.push(node);
210
221
  }
211
222
  }