@crediolabs/policy-synth 0.5.6 → 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
@@ -134,9 +134,25 @@ export const InterpreterOptionsSchema = z.object({
134
134
  smartAccountAddress: z.string(),
135
135
  installNonce: z.number().int().positive().optional(),
136
136
  });
137
- export const SynthesizePolicyInputSchema = z.object({
137
+ export const SynthesizePolicyInputSchema = z
138
+ .object({
138
139
  source: z.literal('recording'),
139
- recordedTx: RecordedTransactionSchema,
140
+ // Two ways to name the recording, because an MCP client has no variable to
141
+ // pass by reference. `recordedTx` is the full RecordedTransaction, which a
142
+ // programmatic caller can hand straight over from `record_transaction`. An
143
+ // agent cannot: it sees that output as text and has to retype it, and the
144
+ // payload is thousands of characters and a dozen levels deep, with exact
145
+ // i128 strings that do not survive the round trip. `transactionHash` lets the
146
+ // agent carry a 64-character handle instead and have the server re-record.
147
+ //
148
+ // Re-recording rather than caching keeps the server stateless (see
149
+ // build-install-policy.ts). Recording is deterministic for a settled
150
+ // transaction, so the second read returns the same thing as the first.
151
+ recordedTx: RecordedTransactionSchema.optional(),
152
+ transactionHash: z
153
+ .string()
154
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
155
+ .optional(),
140
156
  network: NetworkSchema,
141
157
  userResponses: ComposeUserResponsesSchema.optional(),
142
158
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
@@ -150,6 +166,9 @@ export const SynthesizePolicyInputSchema = z.object({
150
166
  // (encodedPredicate, predicateHash, etc.) are never altered by enabling
151
167
  // explain.
152
168
  explain: z.boolean().optional(),
169
+ })
170
+ .refine((v) => v.recordedTx !== undefined || v.transactionHash !== undefined, {
171
+ message: 'supply either `recordedTx` (the full recording) or `transactionHash` (and the server will record it)',
153
172
  });
154
173
  // ===== PredicateNode / PredicateLeaf =====
155
174
  //
@@ -242,10 +261,38 @@ export const PredicateNodeSchema = z.lazy(() => z.union([
242
261
  // take the same input. A null predicate used to mean "OZ built-in policies
243
262
  // only"; that backend is gone, so every policy carries a predicate and there is
244
263
  // nothing to simulate without one.
245
- export const SimulatePolicyInputSchema = z.object({
246
- predicate: PredicateNodeSchema,
247
- permitTx: RecordedTransactionSchema,
264
+ export const SimulatePolicyInputSchema = z
265
+ .object({
266
+ // Same two ways in as `synthesize_policy`, for the same reason. The tree
267
+ // is only returned under `explain`, so a caller who did not ask for it has
268
+ // nothing to pass here and skips the check entirely - which is the one
269
+ // step that must not be skippable by accident. `transactionHash` re-records and
270
+ // re-synthesizes, so the predicate checked is the predicate that was built.
271
+ predicate: PredicateNodeSchema.optional(),
272
+ /** The canonical encoding `declare_policy` and `synthesize_policy` both
273
+ * return. A DECLARED policy has no recording behind it, so re-synthesizing
274
+ * from a hash would check a different predicate than the one declared -
275
+ * and the tree is the shape callers mistype. One opaque string is the
276
+ * handle that path was missing. */
277
+ encodedPredicate: z.string().optional(),
278
+ permitTx: RecordedTransactionSchema.optional(),
279
+ transactionHash: z
280
+ .string()
281
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
282
+ .optional(),
283
+ network: NetworkSchema.optional(),
284
+ /** Needed only with `transactionHash`: lowering a recording to an interpreter
285
+ * predicate is scoped to the account it will be installed on, and the
286
+ * self-call gate is defined against it. */
287
+ smartAccount: z.string().optional(),
288
+ userResponses: ComposeUserResponsesSchema.optional(),
248
289
  validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
290
+ })
291
+ // Two halves, each satisfiable on its own terms: something to check, and a
292
+ // call to check it against. `transactionHash` alone answers both.
293
+ .refine((v) => v.transactionHash !== undefined ||
294
+ ((v.predicate !== undefined || v.encodedPredicate !== undefined) && v.permitTx !== undefined), {
295
+ message: 'supply `transactionHash` (and the server will record and synthesize), or a predicate (`predicate` tree or `encodedPredicate` string) together with `permitTx` or `transactionHash`',
249
296
  });
250
297
  export const VerifyPolicyInputSchema = SimulatePolicyInputSchema;
251
298
  // ===== install_policy / revoke_policy / get_interpreter_info =====
@@ -310,12 +357,25 @@ const ContextRuleDraftSchema = z
310
357
  }),
311
358
  ]))
312
359
  .max(MAX_SIGNERS_PER_RULE),
360
+ /** Policies on one rule compose as ALL-OF, so an interpreter predicate and
361
+ * an OpenZeppelin built-in can sit together and both must permit. That
362
+ * pairing is what expresses a rolling total: the predicate bounds each
363
+ * call, the built-in bounds the sum across calls. */
313
364
  policies: z
314
- .array(z.object({
315
- kind: z.literal('interpreter'),
316
- interpreterAddress: z.string(),
317
- predicateBlobBase64: z.string().min(1),
318
- }))
365
+ .array(z.discriminatedUnion('kind', [
366
+ z.object({
367
+ kind: z.literal('interpreter'),
368
+ interpreterAddress: z.string(),
369
+ predicateBlobBase64: z.string().min(1),
370
+ }),
371
+ z.object({
372
+ kind: z.literal('spending_limit'),
373
+ policyAddress: z.string(),
374
+ /** LEDGERS, not seconds. */
375
+ periodLedgers: z.number().int().positive().max(U32_MAX),
376
+ spendingLimit: z.string().regex(/^[0-9]+$/),
377
+ }),
378
+ ]))
319
379
  .max(MAX_POLICIES_PER_RULE),
320
380
  })
321
381
  .passthrough()
@@ -364,6 +424,10 @@ export const RPC_URL_BY_NETWORK = {
364
424
  testnet: TESTNET_RPC_URL,
365
425
  mainnet: MAINNET_RPC_URL,
366
426
  };
427
+ /** The upstream tag the deployed policy instances were built from. Exported so
428
+ * `scripts/upstream-drift-check.ts` can compare it against the latest upstream
429
+ * release; a tag recorded only in prose cannot be checked by anything. */
430
+ export const PINNED_OZ_STELLAR_CONTRACTS_TAG = 'v0.7.2';
367
431
  /** Instance addresses per network. Exported so consumers import the pin
368
432
  * instead of copying a literal - a copied address is how a testnet id ends up
369
433
  * being queried against mainnet, which returns `Error(Storage, MissingValue)`
@@ -484,10 +548,70 @@ export const InstallPolicyInputSchema = z
484
548
  * A caller that targets mainnet MUST set this to `mainnet` (the
485
549
  * pin and RPC pin do not move by themselves). */
486
550
  network: NetworkSchema.optional(),
487
- /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
488
- rule: ContextRuleDraftSchema,
489
- /** Per-rule install nonce; 1 for a fresh install. */
490
- installNonce: z.number().int().positive(),
551
+ /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape.
552
+ *
553
+ * Optional because of `fromHash` below. An agent cannot reliably retype the
554
+ * `contextRule` that `synthesize_policy` returned - it is nested, and the
555
+ * observed failures were exactly that: `validUntilLedger` sent as a string,
556
+ * `signers` as "", `policies` as an object instead of an array. Supplying
557
+ * `fromHash` instead lets the server rebuild the same rule it just
558
+ * produced, rather than asking the caller to transcribe it. */
559
+ rule: ContextRuleDraftSchema.optional(),
560
+ /** Build the rule here instead of receiving it: record this transaction,
561
+ * synthesize against `smartAccount`, and install the result. The
562
+ * agent-friendly counterpart to `rule`, and the same handle
563
+ * `synthesize_policy` accepts. */
564
+ fromHash: z
565
+ .object({
566
+ transactionHash: z
567
+ .string()
568
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters'),
569
+ /** The keys this rule governs. Synthesis cannot choose them: it reads a
570
+ * transaction, and which keys a rule binds is the caller's security
571
+ * decision, not an inference from one recording. Naming a key here
572
+ * attaches it as a delegated signer. A rule with no signer is refused
573
+ * on chain, so this is required in practice; the `rule` form remains
574
+ * the way to attach an external (verifier + key bytes) signer. */
575
+ signers: z
576
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
577
+ .max(MAX_SIGNERS_PER_RULE)
578
+ .optional(),
579
+ userResponses: ComposeUserResponsesSchema.optional(),
580
+ })
581
+ .optional(),
582
+ /** Install a predicate the caller ALREADY holds - the base64 string
583
+ * `declare_policy` returns.
584
+ *
585
+ * Without this there is no route from `declare_policy` to here:
586
+ * `fromHash` re-synthesizes from a recording and would discard the
587
+ * declared predicate, and `rule` means hand-building a draft that the
588
+ * tool boundary types as `unknown`, so the caller is guessing. An agent
589
+ * asked to do that invented a requirement to deploy a signer contract,
590
+ * which is not a thing - a delegated signer is a plain account address.
591
+ *
592
+ * The context rule type is taken FROM the predicate: if it pins a
593
+ * contract, the rule is scoped to that contract. One source of truth, so
594
+ * the rule's scope cannot drift from what the predicate actually checks. */
595
+ fromPredicate: z
596
+ .object({
597
+ encodedPredicate: z.string().min(1),
598
+ /** The keys this rule governs, as plain Stellar account addresses. */
599
+ signers: z
600
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
601
+ .min(1)
602
+ .max(MAX_SIGNERS_PER_RULE),
603
+ name: z.string().min(1).optional(),
604
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
605
+ })
606
+ .optional(),
607
+ /** Per-rule install nonce. Defaults to 1, which is the only correct value
608
+ * here: this tool builds `add_context_rule`, the account assigns a NEW
609
+ * rule id, and the interpreter has no stored nonce for a rule that does
610
+ * not exist yet. Required, it was undiscoverable - an agent has no way to
611
+ * read it, and asking cost a round trip on a value the server already
612
+ * knows. Supply it only to re-install over an existing rule, where the
613
+ * interpreter wants `stored_nonce + 1`. */
614
+ installNonce: z.number().int().positive().optional(),
491
615
  /** Optional RPC URL override. Defaults to the pinned RPC for the
492
616
  * selected `network` (testnet by default, mainnet when
493
617
  * `network: 'mainnet'`); the override is refused unless
@@ -499,6 +623,38 @@ export const InstallPolicyInputSchema = z
499
623
  * network because the caller's auth-digest binds to whatever the
500
624
  * RPC returned. */
501
625
  allowUnpinnedRpcUrl: z.boolean().optional(),
626
+ /** Attach an OpenZeppelin `spending_limit` beside the predicate, giving the
627
+ * rule a ROLLING TOTAL as well as a per-call bound. Both must permit,
628
+ * because policies on one rule compose as all-of.
629
+ *
630
+ * This is the only way to express "N per day": the interpreter is handed
631
+ * one call and keeps no state, so a predicate cannot add up spending
632
+ * across calls. Composes with all three ways of naming the rule.
633
+ *
634
+ * The primitive meters the third argument of a call named exactly
635
+ * `transfer` and requires a `call_contract` rule scope, so the rule must
636
+ * be pinned to the token whose transfers it meters. */
637
+ spendingLimit: z
638
+ .object({
639
+ /** Rolling total in the token's smallest unit. */
640
+ amount: z
641
+ .string()
642
+ .regex(/^[0-9]+$/, 'amount must be a base-10 integer in the smallest unit'),
643
+ /** Window length in LEDGERS. Stellar closes one in roughly five
644
+ * seconds, so a period in seconds is an approximation of this. */
645
+ periodLedgers: z.number().int().positive().max(U32_MAX),
646
+ })
647
+ .optional(),
648
+ /** Opt-in to installing a rule that bounds no amount, when the recording
649
+ * behind `fromHash` showed a spend.
650
+ *
651
+ * Default-deny, because the failure is silent and reads as success: such
652
+ * a rule installs cleanly, verifies cleanly - a missing constraint
653
+ * generates no deny case to fail - and caps nothing. That combination
654
+ * reached the chain once already. A rule with no spend to bound is
655
+ * unaffected; only the case the synthesizer explicitly flagged is
656
+ * refused. */
657
+ allowUnboundedAmount: z.boolean().optional(),
502
658
  /** Opt-in to pointing the rule's interpreter policy at any address
503
659
  * other than the pinned interpreter for the selected network.
504
660
  * Default-deny: a caller that controls the interpreter can permit
@@ -507,6 +663,9 @@ export const InstallPolicyInputSchema = z
507
663
  allowUnpinnedInterpreter: z.boolean().optional(),
508
664
  /** Base fee in stroops; defaults to BASE_FEE (100). */
509
665
  baseFee: z.number().int().positive().optional(),
666
+ })
667
+ .refine((v) => v.rule !== undefined || v.fromHash !== undefined || v.fromPredicate !== undefined, {
668
+ 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)',
510
669
  })
511
670
  .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
512
671
  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;
@@ -9,12 +9,19 @@
9
9
  // invocations hit the same contract, and the per-contract hop-path lists for
10
10
  // router calls (SoroSwap `path`).
11
11
  /** Lower a recorded transaction to the canonical IntentFacts. Pure (no
12
- * randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
13
- export function lower(tx) {
12
+ * randomness, no clock); same `RecordedTransaction` -> byte-identical facts.
13
+ *
14
+ * `governedAccount` is the smart account the policy will be installed on, when
15
+ * one is known. It spends from itself while a wallet submits the transaction,
16
+ * so it counts as a spender alongside the source account. */
17
+ export function lower(tx, governedAccount) {
14
18
  const invocations = tx.invocations;
15
19
  const callTargets = uniqueOrdered(invocations.map((i) => i.contract));
16
20
  const functionsByContract = groupFunctionsByContract(invocations);
17
- const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, tx.sourceAccount);
21
+ const spenders = governedAccount !== undefined && governedAccount !== tx.sourceAccount
22
+ ? [tx.sourceAccount, governedAccount]
23
+ : [tx.sourceAccount];
24
+ const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, spenders);
18
25
  const allowedPaths = extractPathsByContract(invocations);
19
26
  const sharedRouter = inferSharedRouter(invocations);
20
27
  const facts = {
@@ -53,13 +60,19 @@ function groupFunctionsByContract(invocations) {
53
60
  }
54
61
  return out;
55
62
  }
56
- /** Sum outgoing TokenMovement amounts per token, where `from === source`.
57
- * BigInt throughout; never lossy. Movements whose `from` does not match the
58
- * recorded source account are NOT counted (incoming yield, refund, etc.). */
59
- function aggregateOutgoingSpend(movements, sourceAccount) {
63
+ /** Sum outgoing TokenMovement amounts per token, across every spender.
64
+ * BigInt throughout; never lossy. Movements whose `from` is none of the
65
+ * spenders are NOT counted (incoming yield, refund, etc.).
66
+ *
67
+ * A smart account spends from ITSELF while a wallet submits the transaction,
68
+ * so matching the source account alone missed the entire treasury case: the
69
+ * flow read as incoming-only, no amount bound was required, and a rule that
70
+ * capped nothing installed with every check green. The account a policy
71
+ * governs is a spender in its own right. */
72
+ function aggregateOutgoingSpend(movements, spenders) {
60
73
  const totals = new Map();
61
74
  for (const m of movements) {
62
- if (m.from !== sourceAccount)
75
+ if (!spenders.includes(m.from))
63
76
  continue;
64
77
  const current = totals.get(m.token) ?? 0n;
65
78
  totals.set(m.token, current + BigInt(m.amount));
@@ -122,7 +122,7 @@ function synthesizeFromRecordingInner(tx, opts) {
122
122
  },
123
123
  };
124
124
  }
125
- const facts = lower(tx);
125
+ const facts = lower(tx, opts.interpreter?.smartAccountAddress);
126
126
  const scopeRes = decideScope(facts, {
127
127
  network: opts.network,
128
128
  ...(opts.userResponses?.validUntilLedger !== undefined
package/dist/types.d.ts CHANGED
@@ -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
@@ -52,6 +52,7 @@ exports.ADD_CONTEXT_RULE_SYMBOL = exports.DEFAULT_GRAMMAR_VERSION = void 0;
52
52
  exports.buildAddContextRuleArgs = buildAddContextRuleArgs;
53
53
  const node_crypto_1 = require("node:crypto");
54
54
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
55
+ const encode_ts_1 = require("../predicate/encode.js");
55
56
  const types_ts_1 = require("../types.js");
56
57
  exports.DEFAULT_GRAMMAR_VERSION = types_ts_1.GRAMMAR_VERSION;
57
58
  /** The verb `add_context_rule` takes on the wire. */
@@ -125,27 +126,58 @@ function encodeSigner(s) {
125
126
  function encodePoliciesMap(args) {
126
127
  const entries = [];
127
128
  for (const ref of args.policies) {
128
- // Refuse anything that is not an interpreter policy rather than skipping
129
- // it. This loop used to `if (kind === 'interpreter')` and drop everything
130
- // else in silence, so a caller attaching an OZ `spending_limit` beside the
131
- // interpreter got a rule WITHOUT the cap and no indication of it - the
132
- // policy the user asked for was simply absent from the install they signed.
133
- // `PolicyRef` has one shape today, so TypeScript narrows this branch to
134
- // `never`; the guard is for callers reaching the built JS untyped, and for
135
- // the next kind added to `PolicyRef` without a case here. Failing loudly is
136
- // the only safe direction: a dropped policy is a missing restriction.
137
- if (ref.kind !== 'interpreter') {
138
- const kind = JSON.stringify(ref.kind ?? null);
139
- throw limitError('INSTALL_BUILD_FAILED', `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.`);
129
+ // Each policy carries its OWN params. This loop once applied one set of
130
+ // install params to every entry, which is why nothing but the interpreter
131
+ // could be expressed; before that it dropped other kinds in silence, so a
132
+ // caller attaching a cap received a rule without it and no indication.
133
+ // Both directions were wrong. An unknown kind still fails loudly below - a
134
+ // dropped policy is a missing restriction.
135
+ switch (ref.kind) {
136
+ case 'interpreter':
137
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
138
+ key: stellar_sdk_1.Address.fromString(ref.interpreterAddress).toScVal(),
139
+ val: encodePolicyInstallParams(args),
140
+ }));
141
+ break;
142
+ case 'spending_limit':
143
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
144
+ key: stellar_sdk_1.Address.fromString(ref.policyAddress).toScVal(),
145
+ val: encodeSpendingLimitParams(ref),
146
+ }));
147
+ break;
148
+ default: {
149
+ const kind = JSON.stringify(ref.kind ?? null);
150
+ throw limitError('INSTALL_BUILD_FAILED', `policy kind ${kind} is not supported; dropping it here would install a rule missing the restriction you asked for`);
151
+ }
140
152
  }
141
- entries.push(new stellar_sdk_1.xdr.ScMapEntry({
142
- key: stellar_sdk_1.Address.fromString(ref.interpreterAddress).toScVal(),
143
- val: encodePolicyInstallParams(args),
144
- }));
145
153
  }
146
154
  entries.sort(sortByScValSymbolString);
147
155
  return stellar_sdk_1.xdr.ScVal.scvMap(entries);
148
156
  }
157
+ /** OpenZeppelin `spending_limit`'s install params: `{ period_ledgers: u32,
158
+ * spending_limit: i128 }`, emitted in symbol-string order. Validated here
159
+ * rather than left to the chain, because a rolling cap that fails at submit
160
+ * has already cost the caller a signature. */
161
+ function encodeSpendingLimitParams(ref) {
162
+ if (!Number.isInteger(ref.periodLedgers) || ref.periodLedgers <= 0) {
163
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit periodLedgers must be a positive integer - it counts LEDGERS, not seconds; got: ${ref.periodLedgers}`);
164
+ }
165
+ if (!/^[0-9]+$/.test(ref.spendingLimit) || BigInt(ref.spendingLimit) <= 0n) {
166
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit must be a positive integer in the token's smallest unit; got: ${ref.spendingLimit}`);
167
+ }
168
+ const entries = [
169
+ new stellar_sdk_1.xdr.ScMapEntry({
170
+ key: stellar_sdk_1.xdr.ScVal.scvSymbol('period_ledgers'),
171
+ val: stellar_sdk_1.xdr.ScVal.scvU32(ref.periodLedgers),
172
+ }),
173
+ new stellar_sdk_1.xdr.ScMapEntry({
174
+ key: stellar_sdk_1.xdr.ScVal.scvSymbol('spending_limit'),
175
+ val: (0, encode_ts_1.scvI128FromDecimal)(ref.spendingLimit),
176
+ }),
177
+ ];
178
+ entries.sort((a, b) => sortBySymbolString(a.key(), b.key()));
179
+ return stellar_sdk_1.xdr.ScVal.scvMap(entries);
180
+ }
149
181
  function encodePolicyInstallParams(args) {
150
182
  const predicate = Buffer.from(args.encodedPredicate, 'base64');
151
183
  const computedHash = (0, node_crypto_1.createHash)('sha256').update(predicate).digest('hex');
@@ -93,6 +93,13 @@ export interface InstallCallDescribes {
93
93
  installNonce: number;
94
94
  predicateHash: string;
95
95
  predicateSha256OfEmbeddedBytes: string;
96
+ } | {
97
+ /** An OpenZeppelin built-in bounding the SUM across calls, which the
98
+ * predicate cannot: the interpreter sees one call and keeps no state. */
99
+ kind: 'spending_limit';
100
+ address: string;
101
+ periodLedgers: number;
102
+ spendingLimit: string;
96
103
  }>;
97
104
  /** The install nonce, decoded from the interpreter policy's
98
105
  * `install_nonce` field. Echoed at the top level for reviewer convenience;
@@ -172,3 +179,17 @@ export interface BuildRevokePolicyResult {
172
179
  authValidUntilLedger: number;
173
180
  rootInvocationXdr: string;
174
181
  }
182
+ /** The actionable half of a failed simulation, with the transport half left out.
183
+ *
184
+ * `sim.error` names both why the chain refused the call and which host was
185
+ * asked, and the second half must not reach a caller. So we return only
186
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
187
+ * are what tells an operator whether the source account lacks authority, a
188
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
189
+ * failed" names nothing a caller can act on.
190
+ *
191
+ * Returns "" when the error carries no such form, so the caller keeps its short
192
+ * stable message rather than gaining an empty parenthesis. */
193
+ export declare function simulationReason(sim: {
194
+ error?: string;
195
+ }): string;
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.rpcClientFromServer = rpcClientFromServer;
22
22
  exports.buildInstallPolicyXdr = buildInstallPolicyXdr;
23
23
  exports.buildRevokePolicyXdr = buildRevokePolicyXdr;
24
+ exports.simulationReason = simulationReason;
24
25
  const node_crypto_1 = require("node:crypto");
25
26
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
26
27
  const build_add_context_rule_ts_1 = require("./build-add-context-rule.js");
@@ -136,6 +137,21 @@ async function buildRevokePolicyXdr(args) {
136
137
  /** ~25 minutes at 5s/ledger. */
137
138
  const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
138
139
  // ---- internals ----
140
+ /** The actionable half of a failed simulation, with the transport half left out.
141
+ *
142
+ * `sim.error` names both why the chain refused the call and which host was
143
+ * asked, and the second half must not reach a caller. So we return only
144
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
145
+ * are what tells an operator whether the source account lacks authority, a
146
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
147
+ * failed" names nothing a caller can act on.
148
+ *
149
+ * Returns "" when the error carries no such form, so the caller keeps its short
150
+ * stable message rather than gaining an empty parenthesis. */
151
+ function simulationReason(sim) {
152
+ const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])];
153
+ return codes.length > 0 ? ` (${codes.join(', ')})` : '';
154
+ }
139
155
  /** Record a bare call to the smart account, attach the deploy-time admin rule's
140
156
  * auth entries, and re-simulate to assemble the footprint.
141
157
  *
@@ -165,8 +181,10 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
165
181
  // Short, stable reason. The full `simulateTransaction` error (which
166
182
  // carries host + URL detail) stays in the SDK's own logs - never
167
183
  // reflected back into a user-facing message where it would
168
- // reconnoitre the RPC.
169
- throw new Error(`${errorPrefix}: simulateTransaction failed`);
184
+ // reconnoitre the RPC. `simulationReason` re-adds only the chain's own
185
+ // error codes, which say why the call was refused without saying where
186
+ // the RPC lives.
187
+ throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`);
170
188
  }
171
189
  const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
172
190
  stellar_sdk_1.Address.fromScAddress(entry.credentials().address().address()).toString() ===
@@ -185,7 +203,7 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
185
203
  const txWithAuth = buildTx(makeOperation(authEntries));
186
204
  const enforcing = await args.rpc.simulateTransaction(txWithAuth);
187
205
  if (stellar_sdk_1.rpc.Api.isSimulationError(enforcing)) {
188
- throw new Error(`${errorPrefix}: auth simulateTransaction failed`);
206
+ throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`);
189
207
  }
190
208
  return {
191
209
  finalTx: stellar_sdk_1.rpc.assembleTransaction(txWithAuth, enforcing).build(),
@@ -359,6 +377,27 @@ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
359
377
  observedInstallNonce = installNonce;
360
378
  continue;
361
379
  }
380
+ // OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
381
+ if (fields.has('period_ledgers') || fields.has('spending_limit')) {
382
+ const periodScv = fields.get('period_ledgers');
383
+ if (periodScv?.switch().name !== 'scvU32') {
384
+ throw new Error(`install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`);
385
+ }
386
+ const limitScv = fields.get('spending_limit');
387
+ if (limitScv?.switch().name !== 'scvI128') {
388
+ throw new Error(`install_policy: spending_limit policy ${address} is missing an i128 spending_limit`);
389
+ }
390
+ const parts = limitScv.i128();
391
+ const spendingLimit = ((BigInt(parts.hi().toString()) << 64n) +
392
+ BigInt(parts.lo().toString())).toString();
393
+ policies.push({
394
+ kind: 'spending_limit',
395
+ address,
396
+ periodLedgers: periodScv.u32(),
397
+ spendingLimit,
398
+ });
399
+ continue;
400
+ }
362
401
  throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
363
402
  }
364
403
  // `observedInstallNonce` is the nonce baked into whichever interpreter
@@ -1,3 +1,4 @@
1
+ import { xdr } from '@stellar/stellar-sdk';
1
2
  import { type PredicateNode } from '../types.ts';
2
3
  export interface EncodedPredicate {
3
4
  /** base64 of the canonical ScVal XDR of the predicate root. */
@@ -8,3 +9,14 @@ export interface EncodedPredicate {
8
9
  /** Encode a `PredicateNode` to the canonical ScVal wire format and hash it.
9
10
  * Pure function: same input -> byte-identical output every run. */
10
11
  export declare function encodePredicate(node: PredicateNode): EncodedPredicate;
12
+ /** Build `ScVal::I128(Int128Parts{hi, lo})` from a signed decimal string.
13
+ * `Int128Parts` encodes the value as `(hi << 64) + lo` with `hi` a SIGNED
14
+ * 64-bit int and `lo` an UNSIGNED 64-bit int (this is NOT signed-magnitude).
15
+ * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
16
+ * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
17
+ * bigint/string/number. */
18
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
19
+ * guard on the high word. Exported so the install builder encodes an
20
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
21
+ * implementation is how a value above 2^64 silently loses its high word. */
22
+ export declare function scvI128FromDecimal(decimal: string): xdr.ScVal;
@@ -20,6 +20,7 @@
20
20
  //
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.encodePredicate = encodePredicate;
23
+ exports.scvI128FromDecimal = scvI128FromDecimal;
23
24
  const node_crypto_1 = require("node:crypto");
24
25
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
25
26
  const types_ts_1 = require("../types.js");
@@ -234,6 +235,10 @@ function scvAddressFromStrkey(strkey) {
234
235
  * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
235
236
  * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
236
237
  * bigint/string/number. */
238
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
239
+ * guard on the high word. Exported so the install builder encodes an
240
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
241
+ * implementation is how a value above 2^64 silently loses its high word. */
237
242
  function scvI128FromDecimal(decimal) {
238
243
  const v = BigInt(decimal);
239
244
  const hi = v >> 64n;
@@ -2,7 +2,7 @@ import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedT
2
2
  import { type AuthorityOverlap } from '../install/authority-overlap.ts';
3
3
  import { type BuildInstallPolicyResult, type BuildRevokePolicyResult } from '../install/build-install-policy.ts';
4
4
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts';
5
- import { type RecordTransactionInput, type SimulatePolicyInput, type SynthesizePolicyInput, type VerifyPolicyInput } from './schemas.ts';
5
+ import { type InstallPolicyInput, type RecordTransactionInput, type SimulatePolicyInput, type SynthesizePolicyInput, type VerifyPolicyInput } from './schemas.ts';
6
6
  export type { DeclarePolicyInput, GetInterpreterInfoInput, InstallPolicyInput, OzBuiltinPolicy, RecordTransactionInput, RevokePolicyInput, SimulatePolicyInput, SynthesizePolicyInput, VerifyPolicyInput, } from './schemas.ts';
7
7
  export { ComposeUserResponsesSchema, DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, InterpreterOptionsSchema, MAINNET_RPC_URL, NetworkSchema, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_MAINNET_ADDRESS, PINNED_INTERPRETER_TESTNET_ADDRESS, PINNED_INTERPRETER_WASM_SHA256, PINNED_OZ_POLICY_ADDRESS_BY_NETWORK, PINNED_OZ_POLICY_WASM_SHA256, PredicateLeafSchema, PredicateNodeSchema, RecordedTransactionSchema, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SynthesizePolicyInputSchema, TESTNET_RPC_URL, ToolErrorSchema, } from './schemas.ts';
8
8
  export type RunRecordTransactionInput = RecordTransactionInput;
@@ -51,13 +51,22 @@ export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<Bui
51
51
  * policy payload, so the interpreter pin is not re-checked here.
52
52
  * Pin selection follows `input.network` (defaults to `testnet`). */
53
53
  export declare function runRevokePolicy(raw: unknown): Promise<ToolResponse<BuildRevokePolicyResult>>;
54
+ /** Scope a rule to whatever contract its predicate pins.
55
+ *
56
+ * Taking this from the predicate rather than from a separate argument means
57
+ * the rule's scope cannot drift from what the predicate actually checks. A
58
+ * predicate that pins no contract yields the default (account-wide) type,
59
+ * which is what an unpinned predicate means. Only the top level is walked:
60
+ * a contract pin nested under an `or` does not scope the rule, because the
61
+ * other branch would not be covered by it. */
62
+ export declare function contextTypeForPredicate(predicate: PredicateNode): NonNullable<InstallPolicyInput['rule']>['contextRuleType'];
54
63
  /** `simulate_policy` body - evaluate a predicate against one recorded call.
55
64
  *
56
65
  * The evaluator is a second implementation of the on-chain semantics, and the
57
66
  * conformance harness asserts it agrees with the Rust interpreter case for
58
67
  * case. A verdict here is therefore a claim about what the contract would do,
59
68
  * not a guess. */
60
- export declare function runSimulatePolicy(raw: unknown): ToolResponse<{
69
+ export declare function runSimulatePolicy(raw: unknown): Promise<ToolResponse<{
61
70
  permitted: boolean;
62
71
  reason: string | null;
63
72
  call: {
@@ -65,7 +74,7 @@ export declare function runSimulatePolicy(raw: unknown): ToolResponse<{
65
74
  fn: string;
66
75
  argCount: number;
67
76
  };
68
- }>;
77
+ }>>;
69
78
  /** `declare_policy` body - the DECLARATIVE front-end.
70
79
  *
71
80
  * `synthesize_policy` infers a predicate from a transaction that happened;
@@ -92,7 +101,7 @@ export declare function runDeclarePolicy(raw: unknown): ToolResponse<{
92
101
  * very transaction it was synthesised from. A deny case that permits means it
93
102
  * is too LOOSE: some mutation of that transaction still gets through. `ok` is
94
103
  * true only when neither holds. */
95
- export declare function runVerifyPolicy(raw: unknown): ToolResponse<{
104
+ export declare function runVerifyPolicy(raw: unknown): Promise<ToolResponse<{
96
105
  ok: boolean;
97
106
  permit: {
98
107
  permitted: boolean;
@@ -104,6 +113,6 @@ export declare function runVerifyPolicy(raw: unknown): ToolResponse<{
104
113
  reason: string | null;
105
114
  }>;
106
115
  dimensionsCovered: number;
107
- }>;
116
+ }>>;
108
117
  export declare function runGetInterpreterInfo(raw: unknown): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>>;
109
118
  export declare function caughtError(toolName: RunToolName, code: ErrorCode, e: unknown): ToolError;