@crediolabs/policy-synth 1.0.0 → 1.1.1

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 +41 -0
  3. package/dist/install/build-install-policy.js +51 -5
  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 +2279 -476
  9. package/dist/run/schemas.js +192 -26
  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 +41 -0
  16. package/dist-cjs/install/build-install-policy.js +52 -5
  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 +2279 -476
  22. package/dist-cjs/run/schemas.js +193 -27
  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 +100 -12
  30. package/src/predicate/encode.ts +5 -1
  31. package/src/run/index.ts +280 -23
  32. package/src/run/schemas.ts +229 -43
  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
@@ -48,6 +48,7 @@
48
48
  // the account refuses with `Error(Auth, InvalidAction)`.
49
49
  import { createHash } from 'node:crypto';
50
50
  import { Address, xdr } from '@stellar/stellar-sdk';
51
+ import { scvI128FromDecimal } from "../predicate/encode.js";
51
52
  import { GRAMMAR_VERSION, OZ_LIMITS, } from "../types.js";
52
53
  export const DEFAULT_GRAMMAR_VERSION = GRAMMAR_VERSION;
53
54
  /** The verb `add_context_rule` takes on the wire. */
@@ -121,27 +122,58 @@ function encodeSigner(s) {
121
122
  function encodePoliciesMap(args) {
122
123
  const entries = [];
123
124
  for (const ref of args.policies) {
124
- // Refuse anything that is not an interpreter policy rather than skipping
125
- // it. This loop used to `if (kind === 'interpreter')` and drop everything
126
- // else in silence, so a caller attaching an OZ `spending_limit` beside the
127
- // interpreter got a rule WITHOUT the cap and no indication of it - the
128
- // policy the user asked for was simply absent from the install they signed.
129
- // `PolicyRef` has one shape today, so TypeScript narrows this branch to
130
- // `never`; the guard is for callers reaching the built JS untyped, and for
131
- // the next kind added to `PolicyRef` without a case here. Failing loudly is
132
- // the only safe direction: a dropped policy is a missing restriction.
133
- if (ref.kind !== 'interpreter') {
134
- const kind = JSON.stringify(ref.kind ?? null);
135
- 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.`);
125
+ // Each policy carries its OWN params. This loop once applied one set of
126
+ // install params to every entry, which is why nothing but the interpreter
127
+ // could be expressed; before that it dropped other kinds in silence, so a
128
+ // caller attaching a cap received a rule without it and no indication.
129
+ // Both directions were wrong. An unknown kind still fails loudly below - a
130
+ // dropped policy is a missing restriction.
131
+ switch (ref.kind) {
132
+ case 'interpreter':
133
+ entries.push(new xdr.ScMapEntry({
134
+ key: Address.fromString(ref.interpreterAddress).toScVal(),
135
+ val: encodePolicyInstallParams(args),
136
+ }));
137
+ break;
138
+ case 'spending_limit':
139
+ entries.push(new xdr.ScMapEntry({
140
+ key: Address.fromString(ref.policyAddress).toScVal(),
141
+ val: encodeSpendingLimitParams(ref),
142
+ }));
143
+ break;
144
+ default: {
145
+ const kind = JSON.stringify(ref.kind ?? null);
146
+ throw limitError('INSTALL_BUILD_FAILED', `policy kind ${kind} is not supported; dropping it here would install a rule missing the restriction you asked for`);
147
+ }
136
148
  }
137
- entries.push(new xdr.ScMapEntry({
138
- key: Address.fromString(ref.interpreterAddress).toScVal(),
139
- val: encodePolicyInstallParams(args),
140
- }));
141
149
  }
142
150
  entries.sort(sortByScValSymbolString);
143
151
  return xdr.ScVal.scvMap(entries);
144
152
  }
153
+ /** OpenZeppelin `spending_limit`'s install params: `{ period_ledgers: u32,
154
+ * spending_limit: i128 }`, emitted in symbol-string order. Validated here
155
+ * rather than left to the chain, because a rolling cap that fails at submit
156
+ * has already cost the caller a signature. */
157
+ function encodeSpendingLimitParams(ref) {
158
+ if (!Number.isInteger(ref.periodLedgers) || ref.periodLedgers <= 0) {
159
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit periodLedgers must be a positive integer - it counts LEDGERS, not seconds; got: ${ref.periodLedgers}`);
160
+ }
161
+ if (!/^[0-9]+$/.test(ref.spendingLimit) || BigInt(ref.spendingLimit) <= 0n) {
162
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit must be a positive integer in the token's smallest unit; got: ${ref.spendingLimit}`);
163
+ }
164
+ const entries = [
165
+ new xdr.ScMapEntry({
166
+ key: xdr.ScVal.scvSymbol('period_ledgers'),
167
+ val: xdr.ScVal.scvU32(ref.periodLedgers),
168
+ }),
169
+ new xdr.ScMapEntry({
170
+ key: xdr.ScVal.scvSymbol('spending_limit'),
171
+ val: scvI128FromDecimal(ref.spendingLimit),
172
+ }),
173
+ ];
174
+ entries.sort((a, b) => sortBySymbolString(a.key(), b.key()));
175
+ return xdr.ScVal.scvMap(entries);
176
+ }
145
177
  function encodePolicyInstallParams(args) {
146
178
  const predicate = Buffer.from(args.encodedPredicate, 'base64');
147
179
  const computedHash = 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;
@@ -105,6 +112,21 @@ export interface InstallCallDescribes {
105
112
  export interface BuildInstallPolicyResult {
106
113
  /** Unsigned Soroban transaction envelope, base64 XDR. */
107
114
  unsignedXdr: string;
115
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
116
+ * prove it arrived whole.
117
+ *
118
+ * This envelope runs to several thousand characters, and the only route
119
+ * from a tool result onto disk is the caller re-emitting it. A truncated
120
+ * copy is not obviously wrong - it fails later as
121
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
122
+ * transaction rather than a transport problem. Observed in practice: one of
123
+ * two envelopes written in the same session lost its tail and its base64
124
+ * length went from a multiple of four to `len % 4 == 3`.
125
+ *
126
+ * Check both before signing. They are cheap, and they turn a silent,
127
+ * fatal truncation into a retry. */
128
+ unsignedXdrLength: number;
129
+ unsignedXdrSha256: string;
108
130
  /** Smart account contract address (echo). */
109
131
  smartAccount: string;
110
132
  /** Source account (echo) - the address that must sign. */
@@ -161,6 +183,11 @@ export declare function buildRevokePolicyXdr(args: {
161
183
  }): Promise<BuildRevokePolicyResult>;
162
184
  export interface BuildRevokePolicyResult {
163
185
  unsignedXdr: string;
186
+ /** Same integrity pair as the install result, for the same reason: a revoke
187
+ * envelope also has to reach a signer intact, and a truncated copy fails as
188
+ * a malformed transaction rather than as a transport error. */
189
+ unsignedXdrLength: number;
190
+ unsignedXdrSha256: string;
164
191
  smartAccount: string;
165
192
  sourceAccount: string;
166
193
  call: {
@@ -172,3 +199,17 @@ export interface BuildRevokePolicyResult {
172
199
  authValidUntilLedger: number;
173
200
  rootInvocationXdr: string;
174
201
  }
202
+ /** The actionable half of a failed simulation, with the transport half left out.
203
+ *
204
+ * `sim.error` names both why the chain refused the call and which host was
205
+ * asked, and the second half must not reach a caller. So we return only
206
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
207
+ * are what tells an operator whether the source account lacks authority, a
208
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
209
+ * failed" names nothing a caller can act on.
210
+ *
211
+ * Returns "" when the error carries no such form, so the caller keeps its short
212
+ * stable message rather than gaining an empty parenthesis. */
213
+ export declare function simulationReason(sim: {
214
+ error?: string;
215
+ }): string;
@@ -62,6 +62,14 @@ export function rpcClientFromServer(server, networkPassphrase) {
62
62
  },
63
63
  };
64
64
  }
65
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
66
+ function xdrIntegrity(unsignedXdr) {
67
+ return {
68
+ unsignedXdr,
69
+ unsignedXdrLength: unsignedXdr.length,
70
+ unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
71
+ };
72
+ }
65
73
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
66
74
  * The output XDR is signed by the wallet, not by us. */
67
75
  export async function buildInstallPolicyXdr(args) {
@@ -90,7 +98,7 @@ export async function buildInstallPolicyXdr(args) {
90
98
  // The human approval binds to the exact bytes the wallet will sign.
91
99
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
92
100
  return {
93
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
101
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
94
102
  smartAccount: args.smartAccount,
95
103
  sourceAccount: args.sourceAccount,
96
104
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -119,7 +127,7 @@ export async function buildRevokePolicyXdr(args) {
119
127
  // consumer supplies only the ordinary envelope signature.
120
128
  const { finalTx, original, validUntilLedger } = await buildAuthorisedSmartAccountTx(args, 'remove_context_rule', [xdr.ScVal.scvU32(args.ruleId)], 'revoke_policy');
121
129
  return {
122
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
130
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
123
131
  smartAccount: args.smartAccount,
124
132
  sourceAccount: args.sourceAccount,
125
133
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -131,6 +139,21 @@ export async function buildRevokePolicyXdr(args) {
131
139
  /** ~25 minutes at 5s/ledger. */
132
140
  const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
133
141
  // ---- internals ----
142
+ /** The actionable half of a failed simulation, with the transport half left out.
143
+ *
144
+ * `sim.error` names both why the chain refused the call and which host was
145
+ * asked, and the second half must not reach a caller. So we return only
146
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
147
+ * are what tells an operator whether the source account lacks authority, a
148
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
149
+ * failed" names nothing a caller can act on.
150
+ *
151
+ * Returns "" when the error carries no such form, so the caller keeps its short
152
+ * stable message rather than gaining an empty parenthesis. */
153
+ export function simulationReason(sim) {
154
+ const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])];
155
+ return codes.length > 0 ? ` (${codes.join(', ')})` : '';
156
+ }
134
157
  /** Record a bare call to the smart account, attach the deploy-time admin rule's
135
158
  * auth entries, and re-simulate to assemble the footprint.
136
159
  *
@@ -160,8 +183,10 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
160
183
  // Short, stable reason. The full `simulateTransaction` error (which
161
184
  // carries host + URL detail) stays in the SDK's own logs - never
162
185
  // reflected back into a user-facing message where it would
163
- // reconnoitre the RPC.
164
- throw new Error(`${errorPrefix}: simulateTransaction failed`);
186
+ // reconnoitre the RPC. `simulationReason` re-adds only the chain's own
187
+ // error codes, which say why the call was refused without saying where
188
+ // the RPC lives.
189
+ throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`);
165
190
  }
166
191
  const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
167
192
  Address.fromScAddress(entry.credentials().address().address()).toString() ===
@@ -180,7 +205,7 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
180
205
  const txWithAuth = buildTx(makeOperation(authEntries));
181
206
  const enforcing = await args.rpc.simulateTransaction(txWithAuth);
182
207
  if (rpc.Api.isSimulationError(enforcing)) {
183
- throw new Error(`${errorPrefix}: auth simulateTransaction failed`);
208
+ throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`);
184
209
  }
185
210
  return {
186
211
  finalTx: rpc.assembleTransaction(txWithAuth, enforcing).build(),
@@ -354,6 +379,27 @@ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
354
379
  observedInstallNonce = installNonce;
355
380
  continue;
356
381
  }
382
+ // OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
383
+ if (fields.has('period_ledgers') || fields.has('spending_limit')) {
384
+ const periodScv = fields.get('period_ledgers');
385
+ if (periodScv?.switch().name !== 'scvU32') {
386
+ throw new Error(`install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`);
387
+ }
388
+ const limitScv = fields.get('spending_limit');
389
+ if (limitScv?.switch().name !== 'scvI128') {
390
+ throw new Error(`install_policy: spending_limit policy ${address} is missing an i128 spending_limit`);
391
+ }
392
+ const parts = limitScv.i128();
393
+ const spendingLimit = ((BigInt(parts.hi().toString()) << 64n) +
394
+ BigInt(parts.lo().toString())).toString();
395
+ policies.push({
396
+ kind: 'spending_limit',
397
+ address,
398
+ periodLedgers: periodScv.u32(),
399
+ spendingLimit,
400
+ });
401
+ continue;
402
+ }
357
403
  throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
358
404
  }
359
405
  // `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;
@@ -231,7 +231,11 @@ function scvAddressFromStrkey(strkey) {
231
231
  * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
232
232
  * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
233
233
  * bigint/string/number. */
234
- function scvI128FromDecimal(decimal) {
234
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
235
+ * guard on the high word. Exported so the install builder encodes an
236
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
237
+ * implementation is how a value above 2^64 silently loses its high word. */
238
+ export function scvI128FromDecimal(decimal) {
235
239
  const v = BigInt(decimal);
236
240
  const hi = v >> 64n;
237
241
  const lo = v & UINT64_MAX;
@@ -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;