@crediolabs/policy-synth 1.0.0 → 1.1.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 (35) hide show
  1. package/dist/install/build-add-context-rule.js +48 -16
  2. package/dist/install/build-install-policy.d.ts +21 -0
  3. package/dist/install/build-install-policy.js +41 -3
  4. package/dist/predicate/encode.d.ts +12 -0
  5. package/dist/predicate/encode.js +5 -1
  6. package/dist/run/index.d.ts +14 -5
  7. package/dist/run/index.js +263 -14
  8. package/dist/run/schemas.d.ts +2277 -474
  9. package/dist/run/schemas.js +173 -14
  10. package/dist/synth/lower.d.ts +6 -2
  11. package/dist/synth/lower.js +21 -8
  12. package/dist/synth/synthesize-from-recording.js +1 -1
  13. package/dist/types.d.ts +18 -1
  14. package/dist-cjs/install/build-add-context-rule.js +48 -16
  15. package/dist-cjs/install/build-install-policy.d.ts +21 -0
  16. package/dist-cjs/install/build-install-policy.js +42 -3
  17. package/dist-cjs/predicate/encode.d.ts +12 -0
  18. package/dist-cjs/predicate/encode.js +5 -0
  19. package/dist-cjs/run/index.d.ts +14 -5
  20. package/dist-cjs/run/index.js +263 -13
  21. package/dist-cjs/run/schemas.d.ts +2277 -474
  22. package/dist-cjs/run/schemas.js +174 -15
  23. package/dist-cjs/synth/lower.d.ts +6 -2
  24. package/dist-cjs/synth/lower.js +21 -8
  25. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  26. package/dist-cjs/types.d.ts +18 -1
  27. package/package.json +1 -1
  28. package/src/install/build-add-context-rule.ts +65 -21
  29. package/src/install/build-install-policy.ts +65 -10
  30. package/src/predicate/encode.ts +5 -1
  31. package/src/run/index.ts +280 -23
  32. package/src/run/schemas.ts +201 -31
  33. package/src/synth/lower.ts +22 -8
  34. package/src/synth/synthesize-from-recording.ts +1 -1
  35. package/src/types.ts +25 -6
@@ -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.GetInterpreterInfoInputSchema = exports.RevokePolicyInputSchema = exports.InstallPolicyInputSchema = exports.DeclarePolicyInputSchema = exports.NETWORK_PASSPHRASES = exports.PINNED_OZ_POLICY_WASM_SHA256 = exports.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK = exports.RPC_URL_BY_NETWORK = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.MAINNET_RPC_URL = exports.TESTNET_RPC_URL = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.ObservedRuleSchema = exports.VerifyPolicyInputSchema = exports.SimulatePolicyInputSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.SynthesizePolicyInputSchema = exports.InterpreterOptionsSchema = exports.RecordTransactionInputSchema = exports.ComposeUserResponsesSchema = 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.DeclarePolicyInputSchema = exports.NETWORK_PASSPHRASES = exports.PINNED_OZ_POLICY_WASM_SHA256 = exports.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK = exports.PINNED_OZ_STELLAR_CONTRACTS_TAG = exports.RPC_URL_BY_NETWORK = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.MAINNET_RPC_URL = exports.TESTNET_RPC_URL = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.ObservedRuleSchema = exports.VerifyPolicyInputSchema = exports.SimulatePolicyInputSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.SynthesizePolicyInputSchema = exports.InterpreterOptionsSchema = exports.RecordTransactionInputSchema = exports.ComposeUserResponsesSchema = 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
@@ -137,9 +137,25 @@ exports.InterpreterOptionsSchema = zod_1.z.object({
137
137
  smartAccountAddress: zod_1.z.string(),
138
138
  installNonce: zod_1.z.number().int().positive().optional(),
139
139
  });
140
- exports.SynthesizePolicyInputSchema = zod_1.z.object({
140
+ exports.SynthesizePolicyInputSchema = zod_1.z
141
+ .object({
141
142
  source: zod_1.z.literal('recording'),
142
- recordedTx: exports.RecordedTransactionSchema,
143
+ // Two ways to name the recording, because an MCP client has no variable to
144
+ // pass by reference. `recordedTx` is the full RecordedTransaction, which a
145
+ // programmatic caller can hand straight over from `record_transaction`. An
146
+ // agent cannot: it sees that output as text and has to retype it, and the
147
+ // payload is thousands of characters and a dozen levels deep, with exact
148
+ // i128 strings that do not survive the round trip. `transactionHash` lets the
149
+ // agent carry a 64-character handle instead and have the server re-record.
150
+ //
151
+ // Re-recording rather than caching keeps the server stateless (see
152
+ // build-install-policy.ts). Recording is deterministic for a settled
153
+ // transaction, so the second read returns the same thing as the first.
154
+ recordedTx: exports.RecordedTransactionSchema.optional(),
155
+ transactionHash: zod_1.z
156
+ .string()
157
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
158
+ .optional(),
143
159
  network: exports.NetworkSchema,
144
160
  userResponses: exports.ComposeUserResponsesSchema.optional(),
145
161
  confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
@@ -153,6 +169,9 @@ exports.SynthesizePolicyInputSchema = zod_1.z.object({
153
169
  // (encodedPredicate, predicateHash, etc.) are never altered by enabling
154
170
  // explain.
155
171
  explain: zod_1.z.boolean().optional(),
172
+ })
173
+ .refine((v) => v.recordedTx !== undefined || v.transactionHash !== undefined, {
174
+ message: 'supply either `recordedTx` (the full recording) or `transactionHash` (and the server will record it)',
156
175
  });
157
176
  // ===== PredicateNode / PredicateLeaf =====
158
177
  //
@@ -245,10 +264,38 @@ exports.PredicateNodeSchema = zod_1.z.lazy(() => zod_1.z.union([
245
264
  // take the same input. A null predicate used to mean "OZ built-in policies
246
265
  // only"; that backend is gone, so every policy carries a predicate and there is
247
266
  // nothing to simulate without one.
248
- exports.SimulatePolicyInputSchema = zod_1.z.object({
249
- predicate: exports.PredicateNodeSchema,
250
- permitTx: exports.RecordedTransactionSchema,
267
+ exports.SimulatePolicyInputSchema = zod_1.z
268
+ .object({
269
+ // Same two ways in as `synthesize_policy`, for the same reason. The tree
270
+ // is only returned under `explain`, so a caller who did not ask for it has
271
+ // nothing to pass here and skips the check entirely - which is the one
272
+ // step that must not be skippable by accident. `transactionHash` re-records and
273
+ // re-synthesizes, so the predicate checked is the predicate that was built.
274
+ predicate: exports.PredicateNodeSchema.optional(),
275
+ /** The canonical encoding `declare_policy` and `synthesize_policy` both
276
+ * return. A DECLARED policy has no recording behind it, so re-synthesizing
277
+ * from a hash would check a different predicate than the one declared -
278
+ * and the tree is the shape callers mistype. One opaque string is the
279
+ * handle that path was missing. */
280
+ encodedPredicate: zod_1.z.string().optional(),
281
+ permitTx: exports.RecordedTransactionSchema.optional(),
282
+ transactionHash: zod_1.z
283
+ .string()
284
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
285
+ .optional(),
286
+ network: exports.NetworkSchema.optional(),
287
+ /** Needed only with `transactionHash`: lowering a recording to an interpreter
288
+ * predicate is scoped to the account it will be installed on, and the
289
+ * self-call gate is defined against it. */
290
+ smartAccount: zod_1.z.string().optional(),
291
+ userResponses: exports.ComposeUserResponsesSchema.optional(),
251
292
  validUntilLedger: zod_1.z.number().int().positive().max(U32_MAX).optional(),
293
+ })
294
+ // Two halves, each satisfiable on its own terms: something to check, and a
295
+ // call to check it against. `transactionHash` alone answers both.
296
+ .refine((v) => v.transactionHash !== undefined ||
297
+ ((v.predicate !== undefined || v.encodedPredicate !== undefined) && v.permitTx !== undefined), {
298
+ message: 'supply `transactionHash` (and the server will record and synthesize), or a predicate (`predicate` tree or `encodedPredicate` string) together with `permitTx` or `transactionHash`',
252
299
  });
253
300
  exports.VerifyPolicyInputSchema = exports.SimulatePolicyInputSchema;
254
301
  // ===== install_policy / revoke_policy / get_interpreter_info =====
@@ -313,12 +360,25 @@ const ContextRuleDraftSchema = zod_1.z
313
360
  }),
314
361
  ]))
315
362
  .max(MAX_SIGNERS_PER_RULE),
363
+ /** Policies on one rule compose as ALL-OF, so an interpreter predicate and
364
+ * an OpenZeppelin built-in can sit together and both must permit. That
365
+ * pairing is what expresses a rolling total: the predicate bounds each
366
+ * call, the built-in bounds the sum across calls. */
316
367
  policies: zod_1.z
317
- .array(zod_1.z.object({
318
- kind: zod_1.z.literal('interpreter'),
319
- interpreterAddress: zod_1.z.string(),
320
- predicateBlobBase64: zod_1.z.string().min(1),
321
- }))
368
+ .array(zod_1.z.discriminatedUnion('kind', [
369
+ zod_1.z.object({
370
+ kind: zod_1.z.literal('interpreter'),
371
+ interpreterAddress: zod_1.z.string(),
372
+ predicateBlobBase64: zod_1.z.string().min(1),
373
+ }),
374
+ zod_1.z.object({
375
+ kind: zod_1.z.literal('spending_limit'),
376
+ policyAddress: zod_1.z.string(),
377
+ /** LEDGERS, not seconds. */
378
+ periodLedgers: zod_1.z.number().int().positive().max(U32_MAX),
379
+ spendingLimit: zod_1.z.string().regex(/^[0-9]+$/),
380
+ }),
381
+ ]))
322
382
  .max(MAX_POLICIES_PER_RULE),
323
383
  })
324
384
  .passthrough()
@@ -367,6 +427,10 @@ exports.RPC_URL_BY_NETWORK = {
367
427
  testnet: exports.TESTNET_RPC_URL,
368
428
  mainnet: exports.MAINNET_RPC_URL,
369
429
  };
430
+ /** The upstream tag the deployed policy instances were built from. Exported so
431
+ * `scripts/upstream-drift-check.ts` can compare it against the latest upstream
432
+ * release; a tag recorded only in prose cannot be checked by anything. */
433
+ exports.PINNED_OZ_STELLAR_CONTRACTS_TAG = 'v0.7.2';
370
434
  /** Instance addresses per network. Exported so consumers import the pin
371
435
  * instead of copying a literal - a copied address is how a testnet id ends up
372
436
  * being queried against mainnet, which returns `Error(Storage, MissingValue)`
@@ -487,10 +551,70 @@ exports.InstallPolicyInputSchema = zod_1.z
487
551
  * A caller that targets mainnet MUST set this to `mainnet` (the
488
552
  * pin and RPC pin do not move by themselves). */
489
553
  network: exports.NetworkSchema.optional(),
490
- /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
491
- rule: ContextRuleDraftSchema,
492
- /** Per-rule install nonce; 1 for a fresh install. */
493
- installNonce: zod_1.z.number().int().positive(),
554
+ /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape.
555
+ *
556
+ * Optional because of `fromHash` below. An agent cannot reliably retype the
557
+ * `contextRule` that `synthesize_policy` returned - it is nested, and the
558
+ * observed failures were exactly that: `validUntilLedger` sent as a string,
559
+ * `signers` as "", `policies` as an object instead of an array. Supplying
560
+ * `fromHash` instead lets the server rebuild the same rule it just
561
+ * produced, rather than asking the caller to transcribe it. */
562
+ rule: ContextRuleDraftSchema.optional(),
563
+ /** Build the rule here instead of receiving it: record this transaction,
564
+ * synthesize against `smartAccount`, and install the result. The
565
+ * agent-friendly counterpart to `rule`, and the same handle
566
+ * `synthesize_policy` accepts. */
567
+ fromHash: zod_1.z
568
+ .object({
569
+ transactionHash: zod_1.z
570
+ .string()
571
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters'),
572
+ /** The keys this rule governs. Synthesis cannot choose them: it reads a
573
+ * transaction, and which keys a rule binds is the caller's security
574
+ * decision, not an inference from one recording. Naming a key here
575
+ * attaches it as a delegated signer. A rule with no signer is refused
576
+ * on chain, so this is required in practice; the `rule` form remains
577
+ * the way to attach an external (verifier + key bytes) signer. */
578
+ signers: zod_1.z
579
+ .array(zod_1.z.string().refine(address_ts_1.isStellarAddress, 'must be a Stellar address (G... or C...)'))
580
+ .max(MAX_SIGNERS_PER_RULE)
581
+ .optional(),
582
+ userResponses: exports.ComposeUserResponsesSchema.optional(),
583
+ })
584
+ .optional(),
585
+ /** Install a predicate the caller ALREADY holds - the base64 string
586
+ * `declare_policy` returns.
587
+ *
588
+ * Without this there is no route from `declare_policy` to here:
589
+ * `fromHash` re-synthesizes from a recording and would discard the
590
+ * declared predicate, and `rule` means hand-building a draft that the
591
+ * tool boundary types as `unknown`, so the caller is guessing. An agent
592
+ * asked to do that invented a requirement to deploy a signer contract,
593
+ * which is not a thing - a delegated signer is a plain account address.
594
+ *
595
+ * The context rule type is taken FROM the predicate: if it pins a
596
+ * contract, the rule is scoped to that contract. One source of truth, so
597
+ * the rule's scope cannot drift from what the predicate actually checks. */
598
+ fromPredicate: zod_1.z
599
+ .object({
600
+ encodedPredicate: zod_1.z.string().min(1),
601
+ /** The keys this rule governs, as plain Stellar account addresses. */
602
+ signers: zod_1.z
603
+ .array(zod_1.z.string().refine(address_ts_1.isStellarAddress, 'must be a Stellar address (G... or C...)'))
604
+ .min(1)
605
+ .max(MAX_SIGNERS_PER_RULE),
606
+ name: zod_1.z.string().min(1).optional(),
607
+ validUntilLedger: zod_1.z.number().int().positive().max(U32_MAX).optional(),
608
+ })
609
+ .optional(),
610
+ /** Per-rule install nonce. Defaults to 1, which is the only correct value
611
+ * here: this tool builds `add_context_rule`, the account assigns a NEW
612
+ * rule id, and the interpreter has no stored nonce for a rule that does
613
+ * not exist yet. Required, it was undiscoverable - an agent has no way to
614
+ * read it, and asking cost a round trip on a value the server already
615
+ * knows. Supply it only to re-install over an existing rule, where the
616
+ * interpreter wants `stored_nonce + 1`. */
617
+ installNonce: zod_1.z.number().int().positive().optional(),
494
618
  /** Optional RPC URL override. Defaults to the pinned RPC for the
495
619
  * selected `network` (testnet by default, mainnet when
496
620
  * `network: 'mainnet'`); the override is refused unless
@@ -502,6 +626,38 @@ exports.InstallPolicyInputSchema = zod_1.z
502
626
  * network because the caller's auth-digest binds to whatever the
503
627
  * RPC returned. */
504
628
  allowUnpinnedRpcUrl: zod_1.z.boolean().optional(),
629
+ /** Attach an OpenZeppelin `spending_limit` beside the predicate, giving the
630
+ * rule a ROLLING TOTAL as well as a per-call bound. Both must permit,
631
+ * because policies on one rule compose as all-of.
632
+ *
633
+ * This is the only way to express "N per day": the interpreter is handed
634
+ * one call and keeps no state, so a predicate cannot add up spending
635
+ * across calls. Composes with all three ways of naming the rule.
636
+ *
637
+ * The primitive meters the third argument of a call named exactly
638
+ * `transfer` and requires a `call_contract` rule scope, so the rule must
639
+ * be pinned to the token whose transfers it meters. */
640
+ spendingLimit: zod_1.z
641
+ .object({
642
+ /** Rolling total in the token's smallest unit. */
643
+ amount: zod_1.z
644
+ .string()
645
+ .regex(/^[0-9]+$/, 'amount must be a base-10 integer in the smallest unit'),
646
+ /** Window length in LEDGERS. Stellar closes one in roughly five
647
+ * seconds, so a period in seconds is an approximation of this. */
648
+ periodLedgers: zod_1.z.number().int().positive().max(U32_MAX),
649
+ })
650
+ .optional(),
651
+ /** Opt-in to installing a rule that bounds no amount, when the recording
652
+ * behind `fromHash` showed a spend.
653
+ *
654
+ * Default-deny, because the failure is silent and reads as success: such
655
+ * a rule installs cleanly, verifies cleanly - a missing constraint
656
+ * generates no deny case to fail - and caps nothing. That combination
657
+ * reached the chain once already. A rule with no spend to bound is
658
+ * unaffected; only the case the synthesizer explicitly flagged is
659
+ * refused. */
660
+ allowUnboundedAmount: zod_1.z.boolean().optional(),
505
661
  /** Opt-in to pointing the rule's interpreter policy at any address
506
662
  * other than the pinned interpreter for the selected network.
507
663
  * Default-deny: a caller that controls the interpreter can permit
@@ -510,6 +666,9 @@ exports.InstallPolicyInputSchema = zod_1.z
510
666
  allowUnpinnedInterpreter: zod_1.z.boolean().optional(),
511
667
  /** Base fee in stroops; defaults to BASE_FEE (100). */
512
668
  baseFee: zod_1.z.number().int().positive().optional(),
669
+ })
670
+ .refine((v) => v.rule !== undefined || v.fromHash !== undefined || v.fromPredicate !== undefined, {
671
+ message: 'name the rule one of three ways: `fromHash` (server records, synthesizes and installs), `fromPredicate` (a predicate you already hold, plus the keys it governs), or `rule` (the full ContextRuleDraft, for programmatic callers)',
513
672
  })
514
673
  .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
515
674
  message: 'smartAccount and sourceAccount are required',
@@ -19,5 +19,9 @@ export interface IntentFacts {
19
19
  allowedPaths?: Record<string, string[][]>;
20
20
  }
21
21
  /** Lower a recorded transaction to the canonical IntentFacts. Pure (no
22
- * randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
23
- export declare function lower(tx: RecordedTransaction): IntentFacts;
22
+ * randomness, no clock); same `RecordedTransaction` -> byte-identical facts.
23
+ *
24
+ * `governedAccount` is the smart account the policy will be installed on, when
25
+ * one is known. It spends from itself while a wallet submits the transaction,
26
+ * so it counts as a spender alongside the source account. */
27
+ export declare function lower(tx: RecordedTransaction, governedAccount?: string): IntentFacts;
@@ -12,12 +12,19 @@
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.lower = lower;
14
14
  /** Lower a recorded transaction to the canonical IntentFacts. Pure (no
15
- * randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
16
- function lower(tx) {
15
+ * randomness, no clock); same `RecordedTransaction` -> byte-identical facts.
16
+ *
17
+ * `governedAccount` is the smart account the policy will be installed on, when
18
+ * one is known. It spends from itself while a wallet submits the transaction,
19
+ * so it counts as a spender alongside the source account. */
20
+ function lower(tx, governedAccount) {
17
21
  const invocations = tx.invocations;
18
22
  const callTargets = uniqueOrdered(invocations.map((i) => i.contract));
19
23
  const functionsByContract = groupFunctionsByContract(invocations);
20
- const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, tx.sourceAccount);
24
+ const spenders = governedAccount !== undefined && governedAccount !== tx.sourceAccount
25
+ ? [tx.sourceAccount, governedAccount]
26
+ : [tx.sourceAccount];
27
+ const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, spenders);
21
28
  const allowedPaths = extractPathsByContract(invocations);
22
29
  const sharedRouter = inferSharedRouter(invocations);
23
30
  const facts = {
@@ -56,13 +63,19 @@ function groupFunctionsByContract(invocations) {
56
63
  }
57
64
  return out;
58
65
  }
59
- /** Sum outgoing TokenMovement amounts per token, where `from === source`.
60
- * BigInt throughout; never lossy. Movements whose `from` does not match the
61
- * recorded source account are NOT counted (incoming yield, refund, etc.). */
62
- function aggregateOutgoingSpend(movements, sourceAccount) {
66
+ /** Sum outgoing TokenMovement amounts per token, across every spender.
67
+ * BigInt throughout; never lossy. Movements whose `from` is none of the
68
+ * spenders are NOT counted (incoming yield, refund, etc.).
69
+ *
70
+ * A smart account spends from ITSELF while a wallet submits the transaction,
71
+ * so matching the source account alone missed the entire treasury case: the
72
+ * flow read as incoming-only, no amount bound was required, and a rule that
73
+ * capped nothing installed with every check green. The account a policy
74
+ * governs is a spender in its own right. */
75
+ function aggregateOutgoingSpend(movements, spenders) {
63
76
  const totals = new Map();
64
77
  for (const m of movements) {
65
- if (m.from !== sourceAccount)
78
+ if (!spenders.includes(m.from))
66
79
  continue;
67
80
  const current = totals.get(m.token) ?? 0n;
68
81
  totals.set(m.token, current + BigInt(m.amount));
@@ -126,7 +126,7 @@ function synthesizeFromRecordingInner(tx, opts) {
126
126
  },
127
127
  };
128
128
  }
129
- const facts = (0, lower_ts_1.lower)(tx);
129
+ const facts = (0, lower_ts_1.lower)(tx, opts.interpreter?.smartAccountAddress);
130
130
  const scopeRes = (0, scope_ts_1.decideScope)(facts, {
131
131
  network: opts.network,
132
132
  ...(opts.userResponses?.validUntilLedger !== undefined
@@ -114,11 +114,28 @@ export type SignerDraft = {
114
114
  verifier: string;
115
115
  keyBytes: string;
116
116
  };
117
- /** Reference to one policy attached to a context rule. */
117
+ /** Reference to one policy attached to a context rule.
118
+ *
119
+ * Policies on one rule compose as ALL-OF, so a rule may carry our interpreter
120
+ * AND an OpenZeppelin built-in, and both must permit. That is what expresses a
121
+ * rolling total: the interpreter bounds each call, the built-in bounds the sum
122
+ * across calls - state the interpreter deliberately does not keep. */
118
123
  export type PolicyRef = {
119
124
  kind: 'interpreter';
120
125
  interpreterAddress: string;
121
126
  predicateBlobBase64: string;
127
+ } | {
128
+ /** OpenZeppelin's `spending_limit`: a rolling total over a window of
129
+ * ledgers. It meters the third argument of a call named exactly
130
+ * `transfer` and refuses any rule scope other than `CallContract`. */
131
+ kind: 'spending_limit';
132
+ policyAddress: string;
133
+ /** Window length in LEDGERS, not seconds. Stellar closes a ledger in
134
+ * roughly five seconds, so a period given in seconds is an
135
+ * approximation of this number and should be reported as one. */
136
+ periodLedgers: number;
137
+ /** i128 as a base-10 string, in the token's smallest unit. */
138
+ spendingLimit: string;
122
139
  };
123
140
  /** Grammar version baked into the interpreter wasm, mirroring `SELF_VERSION` in
124
141
  * `contracts/policy-interpreter/src/version.rs`. Every value this package puts on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",
@@ -50,6 +50,7 @@
50
50
  import { createHash } from 'node:crypto'
51
51
  import { Address, xdr } from '@stellar/stellar-sdk'
52
52
  import type { ToolError } from '../errors.ts'
53
+ import { scvI128FromDecimal } from '../predicate/encode.ts'
53
54
  import {
54
55
  type ContextRuleDraft,
55
56
  GRAMMAR_VERSION,
@@ -194,33 +195,76 @@ function encodeSigner(s: SignerDraft): xdr.ScVal {
194
195
  function encodePoliciesMap(args: BuildAddContextRuleArgs): xdr.ScVal {
195
196
  const entries: xdr.ScMapEntry[] = []
196
197
  for (const ref of args.policies) {
197
- // Refuse anything that is not an interpreter policy rather than skipping
198
- // it. This loop used to `if (kind === 'interpreter')` and drop everything
199
- // else in silence, so a caller attaching an OZ `spending_limit` beside the
200
- // interpreter got a rule WITHOUT the cap and no indication of it - the
201
- // policy the user asked for was simply absent from the install they signed.
202
- // `PolicyRef` has one shape today, so TypeScript narrows this branch to
203
- // `never`; the guard is for callers reaching the built JS untyped, and for
204
- // the next kind added to `PolicyRef` without a case here. Failing loudly is
205
- // the only safe direction: a dropped policy is a missing restriction.
206
- if (ref.kind !== 'interpreter') {
207
- const kind = JSON.stringify((ref as { kind?: unknown }).kind ?? null)
208
- throw limitError(
209
- 'INSTALL_BUILD_FAILED',
210
- `policy kind ${kind} is not supported; this builder attaches interpreter policies only. Attach an OpenZeppelin built-in through the account layer instead - dropping it here would install a rule missing the restriction you asked for.`
211
- )
198
+ // Each policy carries its OWN params. This loop once applied one set of
199
+ // install params to every entry, which is why nothing but the interpreter
200
+ // could be expressed; before that it dropped other kinds in silence, so a
201
+ // caller attaching a cap received a rule without it and no indication.
202
+ // Both directions were wrong. An unknown kind still fails loudly below - a
203
+ // dropped policy is a missing restriction.
204
+ switch (ref.kind) {
205
+ case 'interpreter':
206
+ entries.push(
207
+ new xdr.ScMapEntry({
208
+ key: Address.fromString(ref.interpreterAddress).toScVal(),
209
+ val: encodePolicyInstallParams(args),
210
+ })
211
+ )
212
+ break
213
+ case 'spending_limit':
214
+ entries.push(
215
+ new xdr.ScMapEntry({
216
+ key: Address.fromString(ref.policyAddress).toScVal(),
217
+ val: encodeSpendingLimitParams(ref),
218
+ })
219
+ )
220
+ break
221
+ default: {
222
+ const kind = JSON.stringify((ref as { kind?: unknown }).kind ?? null)
223
+ throw limitError(
224
+ 'INSTALL_BUILD_FAILED',
225
+ `policy kind ${kind} is not supported; dropping it here would install a rule missing the restriction you asked for`
226
+ )
227
+ }
212
228
  }
213
- entries.push(
214
- new xdr.ScMapEntry({
215
- key: Address.fromString(ref.interpreterAddress).toScVal(),
216
- val: encodePolicyInstallParams(args),
217
- })
218
- )
219
229
  }
220
230
  entries.sort(sortByScValSymbolString)
221
231
  return xdr.ScVal.scvMap(entries)
222
232
  }
223
233
 
234
+ /** OpenZeppelin `spending_limit`'s install params: `{ period_ledgers: u32,
235
+ * spending_limit: i128 }`, emitted in symbol-string order. Validated here
236
+ * rather than left to the chain, because a rolling cap that fails at submit
237
+ * has already cost the caller a signature. */
238
+ function encodeSpendingLimitParams(ref: {
239
+ periodLedgers: number
240
+ spendingLimit: string
241
+ }): xdr.ScVal {
242
+ if (!Number.isInteger(ref.periodLedgers) || ref.periodLedgers <= 0) {
243
+ throw limitError(
244
+ 'INSTALL_BUILD_FAILED',
245
+ `spending_limit periodLedgers must be a positive integer - it counts LEDGERS, not seconds; got: ${ref.periodLedgers}`
246
+ )
247
+ }
248
+ if (!/^[0-9]+$/.test(ref.spendingLimit) || BigInt(ref.spendingLimit) <= 0n) {
249
+ throw limitError(
250
+ 'INSTALL_BUILD_FAILED',
251
+ `spending_limit must be a positive integer in the token's smallest unit; got: ${ref.spendingLimit}`
252
+ )
253
+ }
254
+ const entries = [
255
+ new xdr.ScMapEntry({
256
+ key: xdr.ScVal.scvSymbol('period_ledgers'),
257
+ val: xdr.ScVal.scvU32(ref.periodLedgers),
258
+ }),
259
+ new xdr.ScMapEntry({
260
+ key: xdr.ScVal.scvSymbol('spending_limit'),
261
+ val: scvI128FromDecimal(ref.spendingLimit),
262
+ }),
263
+ ]
264
+ entries.sort((a, b) => sortBySymbolString(a.key(), b.key()))
265
+ return xdr.ScVal.scvMap(entries)
266
+ }
267
+
224
268
  function encodePolicyInstallParams(args: BuildAddContextRuleArgs): xdr.ScVal {
225
269
  const predicate = Buffer.from(args.encodedPredicate, 'base64')
226
270
  const computedHash = createHash('sha256').update(predicate).digest('hex')
@@ -159,13 +159,23 @@ export interface InstallCallDescribes {
159
159
  * sha256 of the predicate blob actually embedded in the XDR - so a
160
160
  * mismatch between the wire bytes and the review card is detectable
161
161
  * by reading `describes`. */
162
- policies: Array<{
163
- kind: 'interpreter'
164
- address: string
165
- installNonce: number
166
- predicateHash: string
167
- predicateSha256OfEmbeddedBytes: string
168
- }>
162
+ policies: Array<
163
+ | {
164
+ kind: 'interpreter'
165
+ address: string
166
+ installNonce: number
167
+ predicateHash: string
168
+ predicateSha256OfEmbeddedBytes: string
169
+ }
170
+ | {
171
+ /** An OpenZeppelin built-in bounding the SUM across calls, which the
172
+ * predicate cannot: the interpreter sees one call and keeps no state. */
173
+ kind: 'spending_limit'
174
+ address: string
175
+ periodLedgers: number
176
+ spendingLimit: string
177
+ }
178
+ >
169
179
  /** The install nonce, decoded from the interpreter policy's
170
180
  * `install_nonce` field. Echoed at the top level for reviewer convenience;
171
181
  * the per-policy entry is the source of truth. */
@@ -317,6 +327,22 @@ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
317
327
 
318
328
  // ---- internals ----
319
329
 
330
+ /** The actionable half of a failed simulation, with the transport half left out.
331
+ *
332
+ * `sim.error` names both why the chain refused the call and which host was
333
+ * asked, and the second half must not reach a caller. So we return only
334
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
335
+ * are what tells an operator whether the source account lacks authority, a
336
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
337
+ * failed" names nothing a caller can act on.
338
+ *
339
+ * Returns "" when the error carries no such form, so the caller keeps its short
340
+ * stable message rather than gaining an empty parenthesis. */
341
+ export function simulationReason(sim: { error?: string }): string {
342
+ const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])]
343
+ return codes.length > 0 ? ` (${codes.join(', ')})` : ''
344
+ }
345
+
320
346
  /** Record a bare call to the smart account, attach the deploy-time admin rule's
321
347
  * auth entries, and re-simulate to assemble the footprint.
322
348
  *
@@ -367,8 +393,10 @@ async function buildAuthorisedSmartAccountTx(
367
393
  // Short, stable reason. The full `simulateTransaction` error (which
368
394
  // carries host + URL detail) stays in the SDK's own logs - never
369
395
  // reflected back into a user-facing message where it would
370
- // reconnoitre the RPC.
371
- throw new Error(`${errorPrefix}: simulateTransaction failed`)
396
+ // reconnoitre the RPC. `simulationReason` re-adds only the chain's own
397
+ // error codes, which say why the call was refused without saying where
398
+ // the RPC lives.
399
+ throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`)
372
400
  }
373
401
  const original = (recorded.result?.auth ?? []).find(
374
402
  (entry) =>
@@ -406,7 +434,7 @@ async function buildAuthorisedSmartAccountTx(
406
434
  const txWithAuth = buildTx(makeOperation(authEntries))
407
435
  const enforcing = await args.rpc.simulateTransaction(txWithAuth)
408
436
  if (rpc.Api.isSimulationError(enforcing)) {
409
- throw new Error(`${errorPrefix}: auth simulateTransaction failed`)
437
+ throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`)
410
438
  }
411
439
  return {
412
440
  finalTx: rpc.assembleTransaction(txWithAuth, enforcing).build(),
@@ -609,6 +637,33 @@ function decodeInstallCallDescribes(
609
637
  observedInstallNonce = installNonce
610
638
  continue
611
639
  }
640
+ // OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
641
+ if (fields.has('period_ledgers') || fields.has('spending_limit')) {
642
+ const periodScv = fields.get('period_ledgers')
643
+ if (periodScv?.switch().name !== 'scvU32') {
644
+ throw new Error(
645
+ `install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`
646
+ )
647
+ }
648
+ const limitScv = fields.get('spending_limit')
649
+ if (limitScv?.switch().name !== 'scvI128') {
650
+ throw new Error(
651
+ `install_policy: spending_limit policy ${address} is missing an i128 spending_limit`
652
+ )
653
+ }
654
+ const parts = limitScv.i128()
655
+ const spendingLimit = (
656
+ (BigInt(parts.hi().toString()) << 64n) +
657
+ BigInt(parts.lo().toString())
658
+ ).toString()
659
+ policies.push({
660
+ kind: 'spending_limit',
661
+ address,
662
+ periodLedgers: periodScv.u32(),
663
+ spendingLimit,
664
+ })
665
+ continue
666
+ }
612
667
  throw new Error(
613
668
  `install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`
614
669
  )
@@ -285,7 +285,11 @@ function scvAddressFromStrkey(strkey: string): xdr.ScVal {
285
285
  * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
286
286
  * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
287
287
  * bigint/string/number. */
288
- function scvI128FromDecimal(decimal: string): xdr.ScVal {
288
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
289
+ * guard on the high word. Exported so the install builder encodes an
290
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
291
+ * implementation is how a value above 2^64 silently loses its high word. */
292
+ export function scvI128FromDecimal(decimal: string): xdr.ScVal {
289
293
  const v = BigInt(decimal)
290
294
  const hi = v >> 64n
291
295
  const lo = v & UINT64_MAX