@crediolabs/policy-synth 0.1.11 → 0.1.13

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 (48) hide show
  1. package/dist/adapters/interpreter/adapter.js +21 -0
  2. package/dist/adapters/oz/adapter.js +4 -0
  3. package/dist/install/build-install-policy.d.ts +218 -0
  4. package/dist/install/build-install-policy.js +427 -0
  5. package/dist/install/get-interpreter-info.d.ts +29 -0
  6. package/dist/install/get-interpreter-info.js +41 -0
  7. package/dist/ir/types.d.ts +19 -0
  8. package/dist/predicate/index.d.ts +1 -1
  9. package/dist/predicate/index.js +1 -1
  10. package/dist/review-card/builder.js +32 -0
  11. package/dist/review-card/cross-check.js +20 -0
  12. package/dist/run/index.d.ts +49 -4
  13. package/dist/run/index.js +324 -4
  14. package/dist/run/schemas.d.ts +1500 -4
  15. package/dist/run/schemas.js +288 -0
  16. package/dist/synth/compose-from-recording.d.ts +14 -0
  17. package/dist/synth/compose-from-recording.js +41 -2
  18. package/dist-cjs/adapters/interpreter/adapter.js +21 -0
  19. package/dist-cjs/adapters/oz/adapter.js +4 -0
  20. package/dist-cjs/install/build-install-policy.d.ts +218 -0
  21. package/dist-cjs/install/build-install-policy.js +431 -0
  22. package/dist-cjs/install/get-interpreter-info.d.ts +29 -0
  23. package/dist-cjs/install/get-interpreter-info.js +44 -0
  24. package/dist-cjs/ir/types.d.ts +19 -0
  25. package/dist-cjs/predicate/index.d.ts +1 -1
  26. package/dist-cjs/predicate/index.js +3 -3
  27. package/dist-cjs/review-card/builder.js +32 -0
  28. package/dist-cjs/review-card/cross-check.js +20 -0
  29. package/dist-cjs/run/index.d.ts +49 -4
  30. package/dist-cjs/run/index.js +339 -3
  31. package/dist-cjs/run/schemas.d.ts +1500 -4
  32. package/dist-cjs/run/schemas.js +289 -1
  33. package/dist-cjs/synth/compose-from-recording.d.ts +14 -0
  34. package/dist-cjs/synth/compose-from-recording.js +41 -2
  35. package/package.json +1 -1
  36. package/src/adapters/interpreter/adapter.ts +21 -0
  37. package/src/adapters/oz/adapter.ts +4 -0
  38. package/src/install/build-install-policy.ts +686 -0
  39. package/src/install/get-interpreter-info.ts +68 -0
  40. package/src/ir/types.ts +19 -0
  41. package/src/predicate/index.ts +1 -1
  42. package/src/review-card/builder.ts +27 -0
  43. package/src/review-card/cross-check.ts +25 -1
  44. package/src/run/index.ts +405 -5
  45. package/src/run/schemas.ts +326 -0
  46. package/src/synth/compose-from-recording.ts +55 -0
  47. package/src/synth/evaluate.ts +0 -1
  48. package/src/synth/permit-context.ts +0 -1
@@ -16,7 +16,7 @@
16
16
  // imports them here so its tool-shape bindings stay in step; the CLI imports
17
17
  // them here so it can build the same args envelope the MCP transport builds.
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.ToolErrorSchema = exports.SynthesizePolicyInputSchema = exports.SynthesizePolicyRecordingInputSchema = exports.InterpreterOptionsSchema = exports.SynthesizePolicyMandateInputSchema = exports.RecordTransactionInputSchema = exports.OzAdapterConfigSchema = exports.ComposeUserResponsesSchema = exports.MandateSpecSchema = exports.RecordedTransactionSchema = exports.ParseConfidenceSchema = exports.OnChainEventSchema = exports.TokenMovementSchema = exports.ContractInvocationSchema = exports.ScValSchema = exports.NetworkSchema = void 0;
19
+ exports.ToolErrorSchema = exports.GetInterpreterInfoInputSchema = exports.RevokePolicyInputSchema = exports.InstallPolicyInputSchema = exports.NETWORK_PASSPHRASES = exports.DEFAULT_AUTH_VALID_UNTIL_LEDGERS = exports.TESTNET_RPC_URL = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.VerifyPolicyInputSchema = exports.SimulatePolicyInputSchema = exports.OraclePriceFixtureSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.SynthesizePolicyInputSchema = exports.SynthesizePolicyRecordingInputSchema = exports.InterpreterOptionsSchema = exports.SynthesizePolicyMandateInputSchema = exports.RecordTransactionInputSchema = exports.OzAdapterConfigSchema = exports.ComposeUserResponsesSchema = exports.MandateSpecSchema = exports.RecordedTransactionSchema = exports.ParseConfidenceSchema = exports.OnChainEventSchema = exports.TokenMovementSchema = exports.ContractInvocationSchema = exports.ScValSchema = exports.NetworkSchema = void 0;
20
20
  const zod_1 = require("zod");
21
21
  const address_ts_1 = require("../synth/address.js");
22
22
  /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
@@ -222,6 +222,294 @@ exports.SynthesizePolicyInputSchema = zod_1.z.discriminatedUnion('source', [
222
222
  exports.SynthesizePolicyMandateInputSchema,
223
223
  exports.SynthesizePolicyRecordingInputSchema,
224
224
  ]);
225
+ // ===== PredicateNode / PredicateLeaf =====
226
+ //
227
+ // Hand-written zod mirrors of the predicate grammar in types.ts:132-180.
228
+ // Recursive (`and`/`or`/`not` nest), so the schemas use the same
229
+ // `z.lazy` + explicit `z.ZodType<unknown>` annotation that ScValSchema
230
+ // already uses - without the annotation TS's circular type inference
231
+ // breaks the recursive reference. i128-shaped numerics (`num`, `den`)
232
+ // are decimal STRINGS, never JS numbers, matching the on-chain i128
233
+ // convention used everywhere else in this file. The grammar is the
234
+ // single source of truth (types.ts); a drift test in this package
235
+ // asserts the two stay in step.
236
+ /** PredicateLeaf mirrors the core `PredicateLeaf` union. Every variant
237
+ * is a `kind` discriminator; literals carry their primitive value inline.
238
+ * `literal_vec.elements` is recursive (a vec may contain literals that
239
+ * themselves nest) so the leaf schema is lazily self-referential. */
240
+ exports.PredicateLeafSchema = zod_1.z.lazy(() => zod_1.z.union([
241
+ zod_1.z.object({ kind: zod_1.z.literal('call_contract') }),
242
+ zod_1.z.object({ kind: zod_1.z.literal('call_fn') }),
243
+ zod_1.z.object({ kind: zod_1.z.literal('call_arg'), index: zod_1.z.number().int().nonnegative() }),
244
+ zod_1.z.object({ kind: zod_1.z.literal('call_arg_len'), index: zod_1.z.number().int().nonnegative() }),
245
+ zod_1.z.object({
246
+ kind: zod_1.z.literal('call_arg_field'),
247
+ index: zod_1.z.number().int().nonnegative(),
248
+ element: zod_1.z.number().int().nonnegative(),
249
+ field: zod_1.z.string(),
250
+ }),
251
+ // num/den are i128 on chain; JSON numbers lose precision past 2^53,
252
+ // so the wire is a decimal string. The contract refuses `den == 0` and
253
+ // `num <= 0` / `den <= 0` at install (types.ts:162-163 + the install
254
+ // gate); mirroring that bound here closes the gap where simulate /
255
+ // verify green-light a policy the install gate will reject. The
256
+ // positive-only regex is the cheapest unambiguous check.
257
+ zod_1.z.object({
258
+ kind: zod_1.z.literal('call_arg_scaled'),
259
+ index: zod_1.z.number().int().nonnegative(),
260
+ num: zod_1.z.string().regex(/^[1-9][0-9]*$/),
261
+ den: zod_1.z.string().regex(/^[1-9][0-9]*$/),
262
+ }),
263
+ zod_1.z.object({ kind: zod_1.z.literal('amount'), token: zod_1.z.string() }),
264
+ zod_1.z.object({
265
+ kind: zod_1.z.literal('window_spent'),
266
+ token: zod_1.z.string(),
267
+ windowSeconds: zod_1.z.number().int().positive(),
268
+ }),
269
+ zod_1.z.object({ kind: zod_1.z.literal('now') }),
270
+ zod_1.z.object({ kind: zod_1.z.literal('valid_until') }),
271
+ zod_1.z.object({
272
+ kind: zod_1.z.literal('invocation_count_in_window'),
273
+ windowSecs: zod_1.z.number().int().positive(),
274
+ }),
275
+ zod_1.z.object({ kind: zod_1.z.literal('oracle_price'), asset: zod_1.z.string() }),
276
+ zod_1.z.object({
277
+ kind: zod_1.z.literal('oracle_threshold'),
278
+ value: zod_1.z.string().regex(/^-?[0-9]+$/),
279
+ decimals: zod_1.z.number().int().nonnegative(),
280
+ }),
281
+ zod_1.z.object({ kind: zod_1.z.literal('literal_address'), value: zod_1.z.string() }),
282
+ zod_1.z.object({ kind: zod_1.z.literal('literal_i128'), value: zod_1.z.string().regex(/^-?[0-9]+$/) }),
283
+ zod_1.z.object({ kind: zod_1.z.literal('literal_symbol'), value: zod_1.z.string() }),
284
+ // `literal_u32` is u32 on chain. A negative value would wrap, and a
285
+ // value above U32_MAX would be refused at the encode boundary
286
+ // anyway. Bound at the schema so the wire cannot carry a value the
287
+ // contract will reject.
288
+ zod_1.z.object({
289
+ kind: zod_1.z.literal('literal_u32'),
290
+ value: zod_1.z.number().int().nonnegative().max(U32_MAX),
291
+ }),
292
+ zod_1.z.object({ kind: zod_1.z.literal('literal_u64'), value: zod_1.z.string().regex(/^[0-9]+$/) }),
293
+ // `literal_bytes` is hex on chain. `Buffer.from(value, 'hex')` silently
294
+ // drops non-hex chars AND yields an empty buffer when the whole input
295
+ // is non-hex - so a user passing 'zzzz' would get a predicate that
296
+ // compares against empty bytes instead of being rejected. The
297
+ // strict even-length hex regex closes that gap at the boundary.
298
+ zod_1.z.object({
299
+ kind: zod_1.z.literal('literal_bytes'),
300
+ value: zod_1.z
301
+ .string()
302
+ .regex(/^[0-9a-fA-F]*$/)
303
+ .refine((v) => v.length % 2 === 0, 'must be even-length hex'),
304
+ }),
305
+ // elements are themselves PredicateLeaf; lazy to break the cycle.
306
+ zod_1.z.object({ kind: zod_1.z.literal('literal_vec'), elements: zod_1.z.array(exports.PredicateLeafSchema) }),
307
+ ]));
308
+ /** PredicateNode mirrors the core `PredicateNode` union. Recursive through
309
+ * `and`/`or`/`not`; the lazy + annotation pattern keeps the recursion
310
+ * type-safe. */
311
+ exports.PredicateNodeSchema = zod_1.z.lazy(() => zod_1.z.union([
312
+ zod_1.z.object({ op: zod_1.z.literal('and'), children: zod_1.z.array(exports.PredicateNodeSchema) }),
313
+ zod_1.z.object({ op: zod_1.z.literal('or'), children: zod_1.z.array(exports.PredicateNodeSchema) }),
314
+ zod_1.z.object({ op: zod_1.z.literal('not'), child: exports.PredicateNodeSchema }),
315
+ zod_1.z.object({
316
+ op: zod_1.z.literal('eq'),
317
+ left: exports.PredicateLeafSchema,
318
+ right: exports.PredicateLeafSchema,
319
+ }),
320
+ zod_1.z.object({
321
+ op: zod_1.z.enum(['lt', 'lte', 'gt', 'gte']),
322
+ left: exports.PredicateLeafSchema,
323
+ right: exports.PredicateLeafSchema,
324
+ }),
325
+ zod_1.z.object({
326
+ op: zod_1.z.literal('in'),
327
+ needle: exports.PredicateLeafSchema,
328
+ haystack: zod_1.z.array(exports.PredicateLeafSchema),
329
+ }),
330
+ ]));
331
+ // ===== simulate_policy / verify_policy =====
332
+ //
333
+ // The oracle fixture shape mirrors `SimulateOptions` / `VerifyOptions`
334
+ // in packages/policy-synth/src/verify/{simulate,verify}.ts - the same
335
+ // sorted set of error enum values is used by both engines. u32
336
+ // `validUntilLedger` is bounded at the boundary so a hand-crafted
337
+ // payload cannot leak a u32 overflow into the engine's permit context.
338
+ /** Oracle price fixture - mirror of the entry type in `SimulateOptions` /
339
+ * `VerifyOptions`. Discriminated by presence of `error` vs `price`. */
340
+ exports.OraclePriceFixtureSchema = zod_1.z.union([
341
+ zod_1.z.object({
342
+ price: zod_1.z.string().regex(/^[0-9]+$/),
343
+ timestampSeconds: zod_1.z.number().int().nonnegative(),
344
+ }),
345
+ zod_1.z.object({
346
+ error: zod_1.z.enum(['stale', 'missing', 'deviation', 'paused', 'decimals', 'fingerprint']),
347
+ }),
348
+ ]);
349
+ exports.SimulatePolicyInputSchema = zod_1.z.object({
350
+ // simulatePolicy accepts a null predicate (OZ-only policy); verifyPolicy
351
+ // does not. The asymmetry is mirrored at the schema boundary.
352
+ predicate: exports.PredicateNodeSchema.nullable(),
353
+ permitTx: exports.RecordedTransactionSchema,
354
+ validUntilLedger: zod_1.z.number().int().positive().max(U32_MAX).optional(),
355
+ oraclePricesByAsset: zod_1.z.record(zod_1.z.string(), exports.OraclePriceFixtureSchema).optional(),
356
+ });
357
+ exports.VerifyPolicyInputSchema = zod_1.z.object({
358
+ // verifyPolicy requires a non-null predicate; the engine refuses
359
+ // `null` outright. The schema mirrors that contract.
360
+ predicate: exports.PredicateNodeSchema,
361
+ permitTx: exports.RecordedTransactionSchema,
362
+ validUntilLedger: zod_1.z.number().int().positive().max(U32_MAX).optional(),
363
+ oraclePricesByAsset: zod_1.z.record(zod_1.z.string(), exports.OraclePriceFixtureSchema).optional(),
364
+ });
365
+ // ===== install_policy / revoke_policy / get_interpreter_info =====
366
+ //
367
+ // The install/revoke pair drives the OZ smart-account admin flow. Both surface
368
+ // unsigned Soroban transaction XDRs (base64) and the tool body NEVER signs:
369
+ // the wallet signature IS the user-confirmation step. See `run/index.ts` for
370
+ // the rationale (no `action_id` two-call pair; the MCP server is stateless).
371
+ //
372
+ // `get_interpreter_info` is a read-only fingerprint lookup. It returns the
373
+ // pinned address + grammar version + wasm hash AND, when requested, runs an
374
+ // optional `grammar_version()` RPC call to check whether the deployed contract
375
+ // matches the pin. A mismatch is worth MORE than a fabricated audit field.
376
+ //
377
+ // All three input schemas live BELOW the MandateSpecSchemaForRule declaration
378
+ // so the const reference there is not in the temporal dead zone (no JS hoisting
379
+ // for `const`).
380
+ /** Minimal rule-draft schema for the install input. Mirrors ContextRuleDraft
381
+ * from the core (`types.ts`); we keep this hand-rolled + flat rather than
382
+ * importing the recursive discriminated union because the wire shape is
383
+ * fixed (the user supplies one rule, not a nested AST). Declared BEFORE
384
+ * InstallPolicyInputSchema below so the const reference there is not in
385
+ * the temporal dead zone (no JS hoisting for `const`). */
386
+ const MAX_SIGNERS_PER_RULE = 15;
387
+ const MAX_POLICIES_PER_RULE = 5;
388
+ const MandateSpecSchemaForRule = zod_1.z
389
+ .object({
390
+ contextRuleType: zod_1.z.discriminatedUnion('kind', [
391
+ zod_1.z.object({ kind: zod_1.z.literal('default') }),
392
+ zod_1.z.object({ kind: zod_1.z.literal('call_contract'), contract: zod_1.z.string() }),
393
+ zod_1.z.object({ kind: zod_1.z.literal('create_contract'), wasmHash: zod_1.z.string() }),
394
+ ]),
395
+ name: zod_1.z.string().min(1),
396
+ validUntilLedger: zod_1.z.number().int().positive().max(U32_MAX).nullable(),
397
+ signers: zod_1.z
398
+ .array(zod_1.z.discriminatedUnion('kind', [
399
+ zod_1.z.object({ kind: zod_1.z.literal('delegated'), address: zod_1.z.string() }),
400
+ zod_1.z.object({
401
+ kind: zod_1.z.literal('external'),
402
+ verifier: zod_1.z.string(),
403
+ keyBytes: zod_1.z.string(),
404
+ }),
405
+ ]))
406
+ .max(MAX_SIGNERS_PER_RULE),
407
+ policies: zod_1.z
408
+ .array(zod_1.z.discriminatedUnion('kind', [
409
+ zod_1.z.object({
410
+ kind: zod_1.z.literal('interpreter'),
411
+ interpreterAddress: zod_1.z.string(),
412
+ predicateBlobBase64: zod_1.z.string().min(1),
413
+ }),
414
+ zod_1.z.object({
415
+ kind: zod_1.z.literal('oz_builtin'),
416
+ primitive: zod_1.z.object({
417
+ primitive: zod_1.z.enum(['spending_limit', 'simple_threshold', 'weighted_threshold']),
418
+ params: zod_1.z.record(zod_1.z.unknown()),
419
+ }),
420
+ instanceAddress: zod_1.z.string(),
421
+ }),
422
+ ]))
423
+ .max(MAX_POLICIES_PER_RULE),
424
+ })
425
+ .passthrough()
426
+ .refine((v) => !(v.signers.length === 0 && v.policies.length === 0), 'a rule with no signers and no policies is refused at install (NoSignersAndPolicies)');
427
+ // install/revoke/get_info schemas live HERE (after the helper) so the const
428
+ // reference resolves at module init. They are the new tools on top of the
429
+ // existing four.
430
+ /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:156-157.
431
+ * Single source for the MCP layer; do not embed elsewhere. */
432
+ exports.PINNED_INTERPRETER_TESTNET_ADDRESS = 'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP';
433
+ /** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
434
+ exports.PINNED_INTERPRETER_WASM_SHA256 = '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443';
435
+ /** The grammar version the interpreter enforces (matches SELF_VERSION in
436
+ * packages/policy-interpreter/src/version.rs). */
437
+ exports.PINNED_INTERPRETER_GRAMMAR_VERSION = 1;
438
+ /** Default Soroban RPC for the install / revoke / info tools. The recorder
439
+ * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
440
+ * than a Server; the two are deliberately different surfaces, so this is
441
+ * pinned beside the addresses it is used with rather than shared. */
442
+ exports.TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org';
443
+ /** Soroban `valid_until` window (ledgers) added to the latest ledger when
444
+ * building the auth entry. ~25 minutes at 5s/ledger. */
445
+ exports.DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
446
+ /** Stellar network passphrases. Pinned here so the XDR envelope uses the
447
+ * matching passphrase when the wallet signs (a mismatch yields invalid
448
+ * hashes). */
449
+ exports.NETWORK_PASSPHRASES = {
450
+ testnet: 'Test SDF Network ; September 2015',
451
+ mainnet: 'Public Global Stellar Network ; September 2015',
452
+ };
453
+ exports.InstallPolicyInputSchema = zod_1.z
454
+ .object({
455
+ /** The smart account contract address (C...) that will receive the rule. */
456
+ smartAccount: zod_1.z.string(),
457
+ /** The signer that authorises the install (G... wallet). Used only for
458
+ * sequence number + auth nonce simulation; never persisted, never signed. */
459
+ sourceAccount: zod_1.z.string(),
460
+ /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
461
+ rule: MandateSpecSchemaForRule,
462
+ /** Per-rule install nonce; 1 for a fresh install. */
463
+ installNonce: zod_1.z.number().int().positive(),
464
+ /** Optional RPC URL override. Defaults to the public testnet endpoint
465
+ * (`TESTNET_RPC_URL`); the override is refused unless
466
+ * `allowUnpinnedRpcUrl: true`, because the auth nonce and
467
+ * rootInvocation in the response come from whichever RPC answered. */
468
+ rpcUrl: zod_1.z.string().url().optional(),
469
+ /** Opt-in to using a non-pinned RPC URL. Without this flag the tool
470
+ * refuses any `rpcUrl` other than `TESTNET_RPC_URL` because the
471
+ * caller's auth-digest binds to whatever the RPC returned. */
472
+ allowUnpinnedRpcUrl: zod_1.z.boolean().optional(),
473
+ /** Opt-in to pointing the rule's interpreter policy at any address
474
+ * other than the pinned testnet interpreter. Default-deny: a caller
475
+ * that controls the interpreter can permit everything. */
476
+ allowUnpinnedInterpreter: zod_1.z.boolean().optional(),
477
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
478
+ baseFee: zod_1.z.number().int().positive().optional(),
479
+ })
480
+ .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
481
+ message: 'smartAccount and sourceAccount are required',
482
+ });
483
+ exports.RevokePolicyInputSchema = zod_1.z
484
+ .object({
485
+ /** The smart account contract address (C...). */
486
+ smartAccount: zod_1.z.string(),
487
+ /** The wallet that will sign the removal. The ACCOUNT decides whether it
488
+ * accepts that signer; this schema does not assert a rule it cannot
489
+ * verify, since the account's source is not in this repo. Proven on
490
+ * testnet: the account's deployer can revoke. */
491
+ sourceAccount: zod_1.z.string(),
492
+ /** The rule id to remove from the smart account + interpreter. */
493
+ ruleId: zod_1.z.number().int().nonnegative(),
494
+ /** Optional RPC URL override; same pin as install. */
495
+ rpcUrl: zod_1.z.string().url().optional(),
496
+ /** Opt-in to using a non-pinned RPC URL. Same semantics as install. */
497
+ allowUnpinnedRpcUrl: zod_1.z.boolean().optional(),
498
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
499
+ baseFee: zod_1.z.number().int().positive().optional(),
500
+ })
501
+ .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
502
+ message: 'smartAccount and sourceAccount are required',
503
+ });
504
+ exports.GetInterpreterInfoInputSchema = zod_1.z.object({
505
+ /** Network to query. The pin differs per network; defaults to testnet. */
506
+ network: exports.NetworkSchema.optional(),
507
+ /** When true, perform an optional live `grammar_version()` RPC call to
508
+ * verify the deployed contract matches the pin. */
509
+ verifyLive: zod_1.z.boolean().optional(),
510
+ /** Optional RPC URL override. */
511
+ rpcUrl: zod_1.z.string().url().optional(),
512
+ });
225
513
  // ===== Error envelope (canonical) =====
226
514
  //
227
515
  // Mirrors ToolError from packages/policy-synth/src/errors.ts. We use a
@@ -31,6 +31,20 @@ export interface ComposeUserResponses {
31
31
  * `oracle_price(asset) OP value` compare in the interpreter IR. Multiple
32
32
  * entries on the same asset emit multiple leaves. */
33
33
  oraclePriceBound?: OraclePriceBound[];
34
+ /** Minimum acceptable swap output per unit of input, as `num/den` (e.g.
35
+ * `{num:'95',den:'100'}` = accept losing at most 5%). Emitted as a
36
+ * slippage floor bounding the output arg against the input arg of the same
37
+ * call.
38
+ *
39
+ * REQUIRED to be supplied by the caller: it is never derived from the
40
+ * recording. The recorded in/out pair is a price at one moment, and
41
+ * freezing it as policy would deny ordinary trades as soon as the rate
42
+ * moves. Absent, no floor is emitted and the existing unbounded-output
43
+ * warning stands. */
44
+ swapMinOutRatio?: {
45
+ num: string;
46
+ den: string;
47
+ };
34
48
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
35
49
  * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
36
50
  * pin. Absent -> the recipient is pinned to the recorded value (mirroring
@@ -200,7 +200,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
200
200
  // the window_spent path above already consumed the limit, so the per-call
201
201
  // arg cap is skipped to avoid binding one limit to two different semantics.
202
202
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
203
- appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled);
203
+ appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap, opts.userResponses?.swapMinOutRatio, interpreterEnabled);
204
204
  }
205
205
  // `scope.method` is carried on BOTH IRs so each adapter produces a
206
206
  // self-consistent rule. The interpreter adapter lowers scope.method into a
@@ -242,7 +242,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
242
242
  * SoroSwap's slippage / oracle / exact-path needs come from `userResponses`
243
243
  * (oraclePriceBound + limitAmount) + the recorded path (eq_seq on
244
244
  * call_arg[2]). */
245
- function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, interpreterEnabled) {
245
+ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, swapRecipientAllowlist, swapInputAmountCap, swapMinOutRatio, interpreterEnabled) {
246
246
  // SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
247
247
  // a single-element allowlist; the interpreter adapter lowers it to `in`.
248
248
  // When interpreter is not enabled, route to OZ so the caller sees today's
@@ -450,6 +450,30 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
450
450
  ozConstraints.push(cond);
451
451
  }
452
452
  }
453
+ // Slippage floor: `out >= in * num/den`. Only when the caller supplied the
454
+ // ratio - see `swapMinOutRatio`. Without it the output arg stays free,
455
+ // which is the case the unbounded-swap warning above describes.
456
+ const outMinArgIndex = soroswapMinOutArgIndex(protocol.fn);
457
+ const minOutRatio = swapMinOutRatio;
458
+ if (minOutRatio !== undefined &&
459
+ inputArgIndex !== undefined &&
460
+ outMinArgIndex !== undefined &&
461
+ inputAmountArg &&
462
+ inputAmountArg.type === 'i128') {
463
+ const floor = {
464
+ op: 'slippage_floor',
465
+ outArgIndex: outMinArgIndex,
466
+ inArgIndex: inputArgIndex,
467
+ num: minOutRatio.num,
468
+ den: minOutRatio.den,
469
+ };
470
+ if (interpreterEnabled) {
471
+ interpreterConstraints.push(floor);
472
+ }
473
+ else {
474
+ ozConstraints.push(floor);
475
+ }
476
+ }
453
477
  // Swap recipient (call_arg[3]): when the caller supplies
454
478
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
455
479
  // arg. When absent, PIN the recipient to the recorded value - mirroring the
@@ -494,6 +518,21 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
494
518
  * the exact input as arg[0]; `swap_tokens_for_exact_tokens` takes the maximum
495
519
  * input (`amount_in_max`) as arg[1] - its arg[0] is the exact OUTPUT. Any other
496
520
  * function has no positional input-amount argument -> undefined (no cap bound). */
521
+ /** The argument carrying the swap's MINIMUM ACCEPTABLE OUTPUT.
522
+ *
523
+ * Only the exact-input entrypoints have one: `swap_tokens_for_exact_tokens`
524
+ * fixes the output and varies the input, so its output needs no floor (its
525
+ * arg[1] is `amount_in_max`, already bounded by the input cap). Returning
526
+ * undefined there keeps a floor from being pinned to the wrong argument. */
527
+ function soroswapMinOutArgIndex(fn) {
528
+ switch (fn) {
529
+ case 'swap_exact_tokens_for_tokens':
530
+ case 'swap_exact_in_for_tokens':
531
+ return 1;
532
+ default:
533
+ return undefined;
534
+ }
535
+ }
497
536
  function soroswapInputAmountArgIndex(fn) {
498
537
  switch (fn) {
499
538
  case 'swap_exact_tokens_for_tokens':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -298,6 +298,9 @@ function lowerRule(rule: IRPolicyRule, config: InterpreterAdapterConfig): Lowere
298
298
  * by `lowerRule` to populate `uncovered` before lowering. */
299
299
  function unsupportedConstruct(cond: IRCondition): string | null {
300
300
  switch (cond.op) {
301
+ // Expressible: `call_arg_scaled` is exactly this construct.
302
+ case 'slippage_floor':
303
+ return null
301
304
  case 'in':
302
305
  // The oracle position rule is a hard error and must be raised before a
303
306
  // construct can be written off as uncovered - otherwise a subtree that
@@ -365,6 +368,20 @@ function unsourceableSelector(s: IRSelector): string | null {
365
368
  * address -> SCOPE_SELF_CALL). */
366
369
  function lowerCondition(cond: IRCondition, config: InterpreterAdapterConfig): PredicateNode {
367
370
  switch (cond.op) {
371
+ // `out >= in * num/den`. The contract refuses den == 0 or a non-positive
372
+ // ratio at install, so a malformed floor fails loudly rather than
373
+ // silently inverting the comparison.
374
+ case 'slippage_floor':
375
+ return {
376
+ op: 'gte',
377
+ left: { kind: 'call_arg', index: cond.outArgIndex },
378
+ right: {
379
+ kind: 'call_arg_scaled',
380
+ index: cond.inArgIndex,
381
+ num: cond.num,
382
+ den: cond.den,
383
+ },
384
+ }
368
385
  case 'and':
369
386
  return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) }
370
387
  case 'or':
@@ -563,6 +580,8 @@ function assertNoOracleDescendants(node: IRCondition): void {
563
580
 
564
581
  function containsOracle(node: IRCondition): boolean {
565
582
  switch (node.op) {
583
+ case 'slippage_floor':
584
+ return false
566
585
  case 'compare':
567
586
  return node.compare.selector.kind === 'oracle_price'
568
587
  case 'in':
@@ -616,6 +635,8 @@ function assertOracleParamsTighten(params: InterpreterAdapterConfig['oracleParam
616
635
  * express. Mirrors the OZ adapter's `describeCondition` for parity. */
617
636
  function describeCondition(cond: IRCondition): string {
618
637
  switch (cond.op) {
638
+ case 'slippage_floor':
639
+ return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`
619
640
  case 'in':
620
641
  return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`
621
642
  case 'eq_seq':
@@ -285,6 +285,10 @@ function matchSpendingLimit(c: IRCondition): SpendingLimitMatch | null {
285
285
  * cannot express, used to populate `uncovered`. */
286
286
  function describeCondition(cond: IRCondition): string {
287
287
  switch (cond.op) {
288
+ case 'slippage_floor':
289
+ // The OZ primitives bound a value against a constant; this bounds one
290
+ // call argument against another, which none of them can express.
291
+ return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`
288
292
  case 'in':
289
293
  return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`
290
294
  case 'eq_seq':