@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
@@ -34,6 +34,7 @@ const index_ts_1 = require("../index.js");
34
34
  const authority_overlap_ts_1 = require("../install/authority-overlap.js");
35
35
  const build_install_policy_ts_1 = require("../install/build-install-policy.js");
36
36
  const get_interpreter_info_ts_1 = require("../install/get-interpreter-info.js");
37
+ const read_account_rules_ts_1 = require("../install/read-account-rules.js");
37
38
  const decode_ts_1 = require("../predicate/decode.js");
38
39
  const index_ts_2 = require("../simulate/index.js");
39
40
  const schemas_ts_1 = require("./schemas.js");
@@ -184,11 +185,18 @@ async function runInstallPolicy(raw) {
184
185
  rpc: rpcClient,
185
186
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
186
187
  });
187
- // Cross-rule scan, when the caller supplied what else is on the account.
188
- // ABSENT is reported as `null` rather than an empty list: "we did not
189
- // look" and "we looked and found nothing" are different answers, and
190
- // collapsing them would let a caller read silence as safety.
191
- const authorityScan = input.existingRules === undefined
188
+ // Cross-rule scan. The caller may supply `existingRules` (useful offline,
189
+ // and for testing); otherwise the account is READ, so the answer describes
190
+ // what is actually installed rather than what the caller happened to
191
+ // mention.
192
+ //
193
+ // `null` means NOT CHECKED and is returned whenever the scan cannot be
194
+ // trusted to be complete - the read failed, or it stopped before
195
+ // accounting for every live rule. An empty list would say "checked,
196
+ // nothing found", and a partial scan that reported `[]` would be claiming
197
+ // a safety it never established.
198
+ const observed = await resolveExistingRules(input, network, expectedInterpreter);
199
+ const authorityScan = observed === null
192
200
  ? null
193
201
  : (0, authority_overlap_ts_1.findAuthorityOverlaps)({
194
202
  intended: {
@@ -200,9 +208,7 @@ async function runInstallPolicy(raw) {
200
208
  signers: input.rule.signers,
201
209
  predicate: (0, decode_ts_1.decodePredicate)(encodedPredicate),
202
210
  },
203
- // The schema types `predicate` loosely (it is the shared
204
- // PredicateNodeSchema); the shape is already validated.
205
- existing: input.existingRules,
211
+ existing: observed,
206
212
  });
207
213
  return { ok: true, data: { ...result, authorityScan } };
208
214
  }
@@ -353,6 +359,7 @@ function runDeclarePolicy(raw) {
353
359
  ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
354
360
  ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
355
361
  ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
362
+ ...(d.minOutputRatio !== undefined ? { minOutputRatio: d.minOutputRatio } : {}),
356
363
  });
357
364
  const { encodedPredicate, predicateHash } = (0, index_ts_1.encodePredicate)(predicate);
358
365
  return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } };
@@ -448,6 +455,42 @@ async function runGetInterpreterInfo(raw) {
448
455
  * network, falling back to the pinned RPC for the network. The caller
449
456
  * has already been gated against the pinned URL elsewhere, so the
450
457
  * fallback here only ever picks from a finite, audited pair. */
458
+ /** The account's other context rules, or `null` when they could not be
459
+ * established completely.
460
+ *
461
+ * Caller-supplied `existingRules` win: they let the scan run offline, and a
462
+ * caller who passes them has said what to compare against. Otherwise the
463
+ * account is read over RPC.
464
+ *
465
+ * Every failure path returns `null` rather than a short list. A read that
466
+ * threw, or one that stopped before accounting for every live rule, has not
467
+ * ruled anything out - and reporting `[]` there would turn "we could not
468
+ * check" into "there is nothing to worry about". */
469
+ async function resolveExistingRules(input, network, interpreterAddress) {
470
+ if (input.existingRules !== undefined) {
471
+ // The schema types `predicate` loosely (it is the shared
472
+ // PredicateNodeSchema); the shape is already validated.
473
+ return input.existingRules;
474
+ }
475
+ try {
476
+ const url = input.rpcUrl ?? schemas_ts_1.RPC_URL_BY_NETWORK[network];
477
+ const server = new stellar_sdk_1.rpc.Server(url, { allowHttp: false });
478
+ const collected = await (0, read_account_rules_ts_1.collectObservedRules)({
479
+ reader: (0, read_account_rules_ts_1.accountRuleReaderFromServer)(server, schemas_ts_1.NETWORK_PASSPHRASES[network]),
480
+ smartAccount: input.smartAccount,
481
+ interpreterAddress,
482
+ });
483
+ if (collected.incomplete)
484
+ return null;
485
+ return collected.rules;
486
+ }
487
+ catch {
488
+ // The install itself is unaffected: the scan is advisory, so a failed
489
+ // read must not block a policy the user asked for. It just cannot be
490
+ // reported as a clean scan.
491
+ return null;
492
+ }
493
+ }
451
494
  function buildRpcClientFromInput(urlOverride, network) {
452
495
  const url = urlOverride ?? schemas_ts_1.RPC_URL_BY_NETWORK[network];
453
496
  const passphrase = schemas_ts_1.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;
@@ -180,6 +180,15 @@ exports.PredicateLeafSchema = zod_1.z.lazy(() => zod_1.z.union([
180
180
  element: zod_1.z.number().int().nonnegative(),
181
181
  field: zod_1.z.string(),
182
182
  }),
183
+ // num/den are i128 decimal strings, matching `literal_i128`. The regex
184
+ // is the boundary guard; the ratio's SIGN is checked at encode, where
185
+ // the message can explain that a negative ratio inverts the comparison.
186
+ zod_1.z.object({
187
+ kind: zod_1.z.literal('call_arg_scaled'),
188
+ index: zod_1.z.number().int().nonnegative(),
189
+ num: zod_1.z.string().regex(/^-?[0-9]+$/),
190
+ den: zod_1.z.string().regex(/^-?[0-9]+$/),
191
+ }),
183
192
  zod_1.z.object({ kind: zod_1.z.literal('literal_address'), value: zod_1.z.string() }),
184
193
  zod_1.z.object({ kind: zod_1.z.literal('literal_i128'), value: zod_1.z.string().regex(/^-?[0-9]+$/) }),
185
194
  zod_1.z.object({ kind: zod_1.z.literal('literal_symbol'), value: zod_1.z.string() }),
@@ -198,16 +207,32 @@ exports.PredicateLeafSchema = zod_1.z.lazy(() => zod_1.z.union([
198
207
  * `and`; the lazy + annotation pattern keeps the recursion type-safe. */
199
208
  exports.PredicateNodeSchema = zod_1.z.lazy(() => zod_1.z.union([
200
209
  zod_1.z.object({ op: zod_1.z.literal('and'), children: zod_1.z.array(exports.PredicateNodeSchema) }),
210
+ zod_1.z.object({ op: zod_1.z.literal('or'), children: zod_1.z.array(exports.PredicateNodeSchema) }),
201
211
  zod_1.z.object({
202
212
  op: zod_1.z.literal('eq'),
203
213
  left: exports.PredicateLeafSchema,
204
214
  right: exports.PredicateLeafSchema,
205
215
  }),
216
+ zod_1.z.object({
217
+ op: zod_1.z.literal('lt'),
218
+ left: exports.PredicateLeafSchema,
219
+ right: exports.PredicateLeafSchema,
220
+ }),
206
221
  zod_1.z.object({
207
222
  op: zod_1.z.literal('lte'),
208
223
  left: exports.PredicateLeafSchema,
209
224
  right: exports.PredicateLeafSchema,
210
225
  }),
226
+ zod_1.z.object({
227
+ op: zod_1.z.literal('gt'),
228
+ left: exports.PredicateLeafSchema,
229
+ right: exports.PredicateLeafSchema,
230
+ }),
231
+ zod_1.z.object({
232
+ op: zod_1.z.literal('gte'),
233
+ left: exports.PredicateLeafSchema,
234
+ right: exports.PredicateLeafSchema,
235
+ }),
211
236
  zod_1.z.object({
212
237
  op: zod_1.z.literal('in'),
213
238
  needle: exports.PredicateLeafSchema,
@@ -303,23 +328,23 @@ const ContextRuleDraftSchema = zod_1.z
303
328
  // existing four.
304
329
  /** Pinned interpreter address (testnet).
305
330
  * Single source for the MCP layer; do not embed elsewhere. */
306
- exports.PINNED_INTERPRETER_TESTNET_ADDRESS = 'CCL336TCK2Y5OFNRCMN2M3HVPBCEX4PW5H6EQ5VW5NPMXOCP4ESB5XR4';
307
- /** Pinned interpreter address (mainnet), redeployed 2026-08-22 from a reproducible build. The mainnet
331
+ exports.PINNED_INTERPRETER_TESTNET_ADDRESS = 'CCBHVZ6HGGV7C4SNHCZ3S5665Z2WEMHTMBAEPO4XW6PKON464BEBANU5';
332
+ /** Pinned interpreter address (mainnet), redeployed 2026-08-22 for grammar 4, from a reproducible build. The mainnet
308
333
  * interpreter IS the binary exercised on testnet - both instances were created
309
334
  * from the same uploaded wasm hash (see PINNED_INTERPRETER_WASM_SHA256), and
310
- * both were read back with `grammar_version()` returning 3. The address differs
335
+ * both were read back with `grammar_version()` returning 4. The address differs
311
336
  * because instance ids are network-scoped. UNAUDITED at the time of writing.
312
337
  *
313
338
  * These four constants move together or not at all. The grammar version and
314
339
  * wasm hash are single values covering BOTH networks, so re-pinning one network
315
340
  * alone would have the builder emit a version the other network refuses - with
316
341
  * a green test run, since `grammar-version-parity.test.ts` would then pass. */
317
- exports.PINNED_INTERPRETER_MAINNET_ADDRESS = 'CBZXLSTQUITBFZHQH6XRXF3XIVRQR4RHRI64Q5WELS5KGY3ZKJPFWDPF';
342
+ exports.PINNED_INTERPRETER_MAINNET_ADDRESS = 'CDN755TDYZM3ZQ5OXTJ6TIBUBWZV2KRI2BYJPBXD2MVWED4STT3VBN52';
318
343
  /** Pinned interpreter wasm sha256 (hex). */
319
- exports.PINNED_INTERPRETER_WASM_SHA256 = 'a2b36e8ac5a61caf3757af26aa79e83f2995b451099f44772383806a55fe3414';
344
+ exports.PINNED_INTERPRETER_WASM_SHA256 = 'b5ba1e35ccf20cd8c13c3a2c3098bf337033a92bcaf475d63c03ddc0cba0fcae';
320
345
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
321
346
  * contracts/policy-interpreter/src/version.rs). */
322
- exports.PINNED_INTERPRETER_GRAMMAR_VERSION = 3;
347
+ exports.PINNED_INTERPRETER_GRAMMAR_VERSION = 4;
323
348
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
324
349
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
325
350
  * than a Server; the two are deliberately different surfaces, so this is
@@ -382,6 +407,18 @@ exports.DeclarePolicyInputSchema = zod_1.z
382
407
  recipients: zod_1.z.array(zod_1.z.string()).min(1, 'recipients must not be empty').optional(),
383
408
  recipientArgIndex: zod_1.z.number().int().nonnegative().max(U32_MAX).optional(),
384
409
  allowZeroCap: zod_1.z.boolean().optional(),
410
+ /** Minimum output as a ratio of the call's own input. num/den are decimal
411
+ * STRINGS for the same reason maxAmount is: an i128 ratio does not
412
+ * survive a JS number. */
413
+ minOutputRatio: zod_1.z
414
+ .object({
415
+ num: zod_1.z.string().regex(/^[0-9]+$/, 'num must be an unsigned integer'),
416
+ den: zod_1.z.string().regex(/^[0-9]+$/, 'den must be an unsigned integer'),
417
+ inputArgIndex: zod_1.z.number().int().nonnegative().max(U32_MAX),
418
+ outputArgIndex: zod_1.z.number().int().nonnegative().max(U32_MAX),
419
+ })
420
+ .strict()
421
+ .optional(),
385
422
  })
386
423
  .strict();
387
424
  exports.InstallPolicyInputSchema = zod_1.z
@@ -205,11 +205,22 @@ function visit(node, facts) {
205
205
  for (const child of node.children)
206
206
  visit(child, facts);
207
207
  return;
208
+ // NOT descended into. A deny case works by violating ONE constraint and
209
+ // asserting the predicate refuses the call. Violating one branch of an
210
+ // `or` proves nothing, because another branch can still permit, so the
211
+ // generated case would either fail or pass for the wrong reason. A sound
212
+ // deny case for a disjunction must violate EVERY branch at once, which
213
+ // this generator does not construct - so it emits none.
214
+ case 'or':
215
+ return;
208
216
  case 'in':
209
217
  facts.memberships.push(node);
210
218
  return;
211
219
  case 'eq':
220
+ case 'lt':
212
221
  case 'lte':
222
+ case 'gt':
223
+ case 'gte':
213
224
  facts.comparisons.push(node);
214
225
  }
215
226
  }
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // src/simulate/evaluate.ts - TypeScript reference evaluator for grammar version 3.
2
+ // src/simulate/evaluate.ts - TypeScript reference evaluator for grammar version 4.
3
3
  //
4
4
  // Pure function. Determinism: same `(predicate, ctx)` -> byte-identical result,
5
5
  // no clock, no randomness. Deny order (deny on FIRST violation, stable reason):
@@ -9,12 +9,13 @@
9
9
  // 3. `in` membership; empty haystack ALWAYS denies -> 'NOT_IN_ALLOWLIST'
10
10
  // 4. otherwise permit.
11
11
  //
12
- // Grammar version 3 nodes: and, eq, lte
13
- // Grammar version 3 leaves: call_contract, call_fn, call_arg(i),
12
+ // Grammar version 4 nodes: and, or, eq, lt, lte, gt, gte, in
13
+ // Grammar version 4 leaves: call_contract, call_fn, call_arg(i),
14
14
  // call_arg_len(i), call_arg_field(i, element, field),
15
+ // call_arg_scaled(i, num, den),
15
16
  // literal_address, literal_i128, literal_symbol, literal_u32, literal_vec
16
- // Grammar version 3 deny reasons: ARG_MISMATCH, CONTRACT_SCOPE,
17
- // UNSUPPORTED_NODE, NOT_IN_ALLOWLIST
17
+ // Grammar version 4 deny reasons: ARG_MISMATCH, CONTRACT_SCOPE,
18
+ // ARITHMETIC_OVERFLOW, UNSUPPORTED_NODE, NOT_IN_ALLOWLIST, SLIPPAGE_FLOOR
18
19
  Object.defineProperty(exports, "__esModule", { value: true });
19
20
  exports.evaluate = evaluate;
20
21
  /** Evaluate a `PredicateNode` against the candidate call described by `ctx`.
@@ -36,8 +37,25 @@ function walk(node, ctx) {
36
37
  }
37
38
  return lastDeny ?? { permit: true };
38
39
  }
40
+ // Permits on the first branch that holds; when none does, reports the
41
+ // FIRST branch's reason. Mirrors `Node::Or` in the Rust evaluator, which
42
+ // the conformance suite pins.
43
+ case 'or': {
44
+ let firstDeny = null;
45
+ for (const child of node.children) {
46
+ const r = walk(child, ctx);
47
+ if (r.permit)
48
+ return r;
49
+ if (firstDeny === null)
50
+ firstDeny = r;
51
+ }
52
+ return firstDeny ?? { permit: false, reason: 'UNSUPPORTED_NODE' };
53
+ }
39
54
  case 'eq':
55
+ case 'lt':
40
56
  case 'lte':
57
+ case 'gt':
58
+ case 'gte':
41
59
  return evalCompare(node.op, node.left, node.right, ctx);
42
60
  case 'in':
43
61
  return evalIn(node.needle, node.haystack, ctx);
@@ -45,6 +63,16 @@ function walk(node, ctx) {
45
63
  }
46
64
  /** Comparison leaf evaluation. */
47
65
  function evalCompare(op, left, right, ctx) {
66
+ // Scaled operands first, so the dedicated reasons reach the caller instead
67
+ // of a generic mismatch. Right-hand dispatch leads because
68
+ // `out >= in * num / den` is the canonical swap form. Mirrors the order in
69
+ // `eval_compare` on the Rust side.
70
+ if (right.kind === 'call_arg_scaled') {
71
+ return evalScaledCompare(op, left, right, true, ctx);
72
+ }
73
+ if (left.kind === 'call_arg_scaled') {
74
+ return evalScaledCompare(op, right, left, false, ctx);
75
+ }
48
76
  // CONTRACT_SCOPE on call_contract eq
49
77
  if (left.kind === 'call_contract' && op === 'eq') {
50
78
  if (right.kind !== 'literal_address')
@@ -99,6 +127,53 @@ function evalCompare(op, left, right, ctx) {
99
127
  // Unknown leaf/op combination - structural fail-closed.
100
128
  return { permit: false, reason: 'UNSUPPORTED_NODE' };
101
129
  }
130
+ /** i128 bounds. The contract computes in i128 and denies on overflow, so the
131
+ * reference has to draw the same line or the two layers disagree on inputs
132
+ * near the boundary. */
133
+ const I128_MIN = -(2n ** 127n);
134
+ const I128_MAX = 2n ** 127n - 1n;
135
+ /** Comparison where one side is `call_arg_scaled`. Mirrors
136
+ * `eval_scaled_arg_compare`: `args[index] * num / den` truncating toward
137
+ * zero, ARITHMETIC_OVERFLOW on arithmetic that does not fit or a zero
138
+ * denominator, SLIPPAGE_FLOOR on a comparison that simply fails. */
139
+ function evalScaledCompare(op, other, scaled, scaledOnRight, ctx) {
140
+ // Chaining two computed operands has no meaning a review card could state.
141
+ if (other.kind === 'call_arg_scaled')
142
+ return { permit: false, reason: 'UNSUPPORTED_NODE' };
143
+ // Could not READ the operand is a different failure from read-and-missed.
144
+ const input = argNumericBigInt(ctx.args[scaled.index]);
145
+ if (input === null)
146
+ return { permit: false, reason: 'ARG_MISMATCH' };
147
+ let num;
148
+ let den;
149
+ try {
150
+ num = BigInt(scaled.num);
151
+ den = BigInt(scaled.den);
152
+ }
153
+ catch {
154
+ return { permit: false, reason: 'ARG_MISMATCH' };
155
+ }
156
+ if (den === 0n)
157
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
158
+ const product = input * num;
159
+ if (product < I128_MIN || product > I128_MAX) {
160
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
161
+ }
162
+ // BigInt division already truncates toward zero, matching i128 semantics.
163
+ const quotient = product / den;
164
+ if (quotient < I128_MIN || quotient > I128_MAX) {
165
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
166
+ }
167
+ const otherVal = other.kind === 'call_arg'
168
+ ? argNumericBigInt(ctx.args[other.index])
169
+ : literalNumericBigInt(other);
170
+ if (otherVal === null)
171
+ return { permit: false, reason: 'ARG_MISMATCH' };
172
+ const [a, b] = scaledOnRight ? [otherVal, quotient] : [quotient, otherVal];
173
+ return bigintCmp(op, a.toString(), b.toString())
174
+ ? { permit: true }
175
+ : { permit: false, reason: 'SLIPPAGE_FLOOR' };
176
+ }
102
177
  /** Per-ScVal equality. Handles literal_vec as an EXACT ordered sequence:
103
178
  * compare element-by-element in order; deny if length or any element differs.
104
179
  * Opaque args (`type: 'other'`) fail closed. */
@@ -268,7 +343,13 @@ function bigintCmp(op, aStr, bStr) {
268
343
  switch (op) {
269
344
  case 'eq':
270
345
  return a === b;
346
+ case 'lt':
347
+ return a < b;
271
348
  case 'lte':
272
349
  return a <= b;
350
+ case 'gt':
351
+ return a > b;
352
+ case 'gte':
353
+ return a >= b;
273
354
  }
274
355
  }
@@ -18,6 +18,19 @@ export interface PolicyDeclaration {
18
18
  * explicitly. A rule that permits nothing is a plausible thing to want and
19
19
  * an implausible thing to want by accident. */
20
20
  allowZeroCap?: boolean;
21
+ /** Minimum output as a ratio of the call's own input: the output argument
22
+ * must be at least `input * num / den`.
23
+ *
24
+ * A swap's acceptable output depends on the size of the trade, so a fixed
25
+ * floor would pin the policy to one trade size. The ratio is DECLARED, never
26
+ * inferred from a recording: a recorded rate is a price at one moment, and
27
+ * freezing it as policy would deny ordinary trades later. */
28
+ minOutputRatio?: {
29
+ num: string;
30
+ den: string;
31
+ inputArgIndex: number;
32
+ outputArgIndex: number;
33
+ };
21
34
  }
22
35
  export interface DeclaredPredicate {
23
36
  predicate: PredicateNode;
@@ -16,11 +16,12 @@
16
16
  // first: it constrains the amount in THIS call rather than implying a rolling
17
17
  // total nothing tracks.
18
18
  //
19
- // What a declaration can say maps one-to-one onto grammar 3:
20
- // fn -> eq(call_fn, literal_symbol)
21
- // contract -> eq(call_contract, literal_address)
22
- // maxAmount -> lte(call_arg(i), literal_i128)
23
- // recipients -> in(call_arg(j), [literal_address, ...])
19
+ // What a declaration can say maps one-to-one onto grammar 4:
20
+ // fn -> eq(call_fn, literal_symbol)
21
+ // contract -> eq(call_contract, literal_address)
22
+ // maxAmount -> lte(call_arg(i), literal_i128)
23
+ // recipients -> in(call_arg(j), [literal_address, ...])
24
+ // minOutputRatio-> gte(call_arg(out), call_arg_scaled(in, num, den))
24
25
  Object.defineProperty(exports, "__esModule", { value: true });
25
26
  exports.declarePredicate = declarePredicate;
26
27
  const address_ts_1 = require("./address.js");
@@ -87,6 +88,34 @@ function declarePredicate(d) {
87
88
  right: { kind: 'literal_i128', value: d.maxAmount },
88
89
  });
89
90
  }
91
+ if (d.minOutputRatio !== undefined) {
92
+ const { num, den, inputArgIndex, outputArgIndex } = d.minOutputRatio;
93
+ if (!/^[0-9]+$/.test(num) || !/^[0-9]+$/.test(den)) {
94
+ throw declareError('SYNTHESIS_ERROR', `minOutputRatio num/den must be unsigned integers, got "${num}"/"${den}" (a 1% slippage tolerance is num "99", den "100")`);
95
+ }
96
+ // Both are refused on chain at install (INVALID_SCALED_RATIO). Refusing
97
+ // here too means the caller learns before a transaction is built.
98
+ if (den === '0') {
99
+ throw declareError('SYNTHESIS_ERROR', 'minOutputRatio.den is zero: the ratio has no value');
100
+ }
101
+ if (num === '0') {
102
+ throw declareError('SYNTHESIS_ERROR', 'minOutputRatio.num is zero: the floor would be zero, which constrains nothing. Omit it instead.');
103
+ }
104
+ if (inputArgIndex === outputArgIndex) {
105
+ throw declareError('SYNTHESIS_ERROR', `minOutputRatio bounds arg[${inputArgIndex}] against itself, which is true for any ratio at or below 1 and false above it - never a slippage floor. Pass the distinct input and output positions.`);
106
+ }
107
+ if (BigInt(num) > BigInt(den)) {
108
+ // Demanding MORE out than went in is not slippage protection; it is a
109
+ // rule that denies every honest trade. Loud beats a policy that never
110
+ // permits.
111
+ warnings.push(`minOutputRatio ${num}/${den} is above 1: it requires the output to EXCEED the input, which no ordinary swap satisfies. Check the ratio is not inverted.`);
112
+ }
113
+ children.push({
114
+ op: 'gte',
115
+ left: { kind: 'call_arg', index: outputArgIndex },
116
+ right: { kind: 'call_arg_scaled', index: inputArgIndex, num, den },
117
+ });
118
+ }
90
119
  // A single conjunct is emitted bare. `and` with one child encodes to
91
120
  // different bytes than the child alone, and the extra node buys nothing.
92
121
  const predicate = children.length === 1 ? children[0] : { op: 'and', children };
@@ -2,7 +2,7 @@
2
2
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
3
3
  //
4
4
  // `synthesizeFromRecording` INFERS a bounded policy from a `RecordedTransaction`
5
- // via the `PolicyIR` + interpreter adapter pair.
5
+ // via the composer + interpreter adapter pair.
6
6
  // Flow: validate -> parseConfidence gate -> lower -> decideScope -> composeFromRecording
7
7
  // -> interpreter compile + self-verify.
8
8
  // Same (tx, opts) -> byte-identical ProposedPolicy (no randomness, clock, globals).