@crediolabs/policy-synth 0.1.10 → 0.1.11

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 (81) hide show
  1. package/dist/adapters/interpreter/adapter.js +62 -11
  2. package/dist/errors.d.ts +1 -1
  3. package/dist/install/build-add-context-rule.d.ts +48 -0
  4. package/dist/install/build-add-context-rule.js +304 -0
  5. package/dist/ir/types.d.ts +4 -0
  6. package/dist/predicate/decode.d.ts +11 -0
  7. package/dist/predicate/decode.js +234 -0
  8. package/dist/predicate/encode.js +79 -14
  9. package/dist/predicate/from-json.d.ts +4 -0
  10. package/dist/predicate/from-json.js +113 -0
  11. package/dist/predicate/index.d.ts +2 -0
  12. package/dist/predicate/index.js +5 -1
  13. package/dist/registry/identify.js +21 -2
  14. package/dist/registry/protocols.d.ts +50 -1
  15. package/dist/registry/protocols.js +88 -0
  16. package/dist/review-card/builder.d.ts +13 -0
  17. package/dist/review-card/builder.js +31 -5
  18. package/dist/review-card/cross-check.js +12 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/schemas.d.ts +4 -4
  22. package/dist/synth/compose-from-recording.d.ts +4 -0
  23. package/dist/synth/compose-from-recording.js +34 -8
  24. package/dist/synth/deny-cases.d.ts +4 -1
  25. package/dist/synth/deny-cases.js +3 -1
  26. package/dist/synth/evaluate.js +130 -3
  27. package/dist/synth/permit-context.d.ts +15 -0
  28. package/dist/synth/permit-context.js +116 -0
  29. package/dist/synth/synthesize-from-recording.js +14 -2
  30. package/dist/types.d.ts +9 -0
  31. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  32. package/dist-cjs/errors.d.ts +1 -1
  33. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  34. package/dist-cjs/install/build-add-context-rule.js +308 -0
  35. package/dist-cjs/ir/types.d.ts +4 -0
  36. package/dist-cjs/predicate/decode.d.ts +11 -0
  37. package/dist-cjs/predicate/decode.js +239 -0
  38. package/dist-cjs/predicate/encode.js +79 -14
  39. package/dist-cjs/predicate/from-json.d.ts +4 -0
  40. package/dist-cjs/predicate/from-json.js +116 -0
  41. package/dist-cjs/predicate/index.d.ts +2 -0
  42. package/dist-cjs/predicate/index.js +10 -2
  43. package/dist-cjs/registry/identify.js +20 -1
  44. package/dist-cjs/registry/protocols.d.ts +50 -1
  45. package/dist-cjs/registry/protocols.js +89 -1
  46. package/dist-cjs/review-card/builder.d.ts +13 -0
  47. package/dist-cjs/review-card/builder.js +32 -5
  48. package/dist-cjs/review-card/cross-check.js +12 -3
  49. package/dist-cjs/review-card/index.d.ts +1 -1
  50. package/dist-cjs/review-card/index.js +2 -1
  51. package/dist-cjs/run/schemas.d.ts +4 -4
  52. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  53. package/dist-cjs/synth/compose-from-recording.js +34 -8
  54. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  55. package/dist-cjs/synth/deny-cases.js +3 -0
  56. package/dist-cjs/synth/evaluate.js +130 -3
  57. package/dist-cjs/synth/permit-context.d.ts +15 -0
  58. package/dist-cjs/synth/permit-context.js +119 -0
  59. package/dist-cjs/synth/synthesize-from-recording.js +14 -2
  60. package/dist-cjs/types.d.ts +9 -0
  61. package/package.json +5 -2
  62. package/src/adapters/interpreter/adapter.ts +72 -11
  63. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  64. package/src/errors.ts +13 -0
  65. package/src/install/build-add-context-rule.ts +429 -0
  66. package/src/ir/types.ts +4 -0
  67. package/src/predicate/decode.ts +242 -0
  68. package/src/predicate/encode.ts +110 -13
  69. package/src/predicate/from-json.ts +124 -0
  70. package/src/predicate/index.ts +5 -1
  71. package/src/registry/identify.ts +22 -2
  72. package/src/registry/protocols.ts +90 -1
  73. package/src/review-card/builder.ts +31 -5
  74. package/src/review-card/cross-check.ts +12 -3
  75. package/src/review-card/index.ts +1 -0
  76. package/src/synth/compose-from-recording.ts +46 -7
  77. package/src/synth/deny-cases.ts +3 -1
  78. package/src/synth/evaluate.ts +131 -3
  79. package/src/synth/permit-context.ts +137 -0
  80. package/src/synth/synthesize-from-recording.ts +13 -2
  81. package/src/types.ts +12 -0
@@ -0,0 +1,116 @@
1
+ // src/synth/permit-context.ts - build the EvalContext that represents the
2
+ // recorded call being replayed.
3
+ //
4
+ // Extracted from the over-permissiveness harness so the same construction
5
+ // feeds both the in-process battery and the on-chain replay
6
+ // (scripts/verify-mutations-testnet.ts). Two callers building this
7
+ // separately would be two chances to disagree about what "the recorded
8
+ // call" means, which is the one thing both must share.
9
+ import { cloneScVal } from "./deny-cases.js";
10
+ export function buildPermitContext(tx, responses, predicate) {
11
+ const amountByToken = {};
12
+ const totals = new Map();
13
+ for (const m of tx.tokenMovements) {
14
+ const current = totals.get(m.token) ?? 0n;
15
+ totals.set(m.token, current + BigInt(m.amount));
16
+ }
17
+ for (const [token, total] of totals) {
18
+ amountByToken[token] = total.toString();
19
+ }
20
+ const topLevel = tx.invocations[0];
21
+ const scopeContract = topLevel?.contract ?? '';
22
+ const oraclePriceByAsset = {};
23
+ visitOracleLeaves(predicate, (asset, op, bound) => {
24
+ let price;
25
+ switch (op) {
26
+ case 'lt':
27
+ price = bound - 1n;
28
+ break;
29
+ case 'gt':
30
+ price = bound + 1n;
31
+ break;
32
+ default:
33
+ price = bound;
34
+ }
35
+ if (price < 0n)
36
+ price = 0n;
37
+ oraclePriceByAsset[asset] = { price: price.toString(), timestampSeconds: tx.fetchedAt };
38
+ });
39
+ const ctx = {
40
+ contract: scopeContract,
41
+ fn: topLevel?.fn ?? '',
42
+ args: (topLevel?.args ?? []).map(cloneScVal),
43
+ atLedger: tx.ledgerSequence,
44
+ nowSeconds: tx.fetchedAt,
45
+ amountByToken,
46
+ windowSpentByToken: {},
47
+ invocationCountByWindow: {},
48
+ oraclePriceByAsset,
49
+ };
50
+ if (responses.validUntilLedger !== undefined) {
51
+ ctx.validUntilLedger = responses.validUntilLedger;
52
+ }
53
+ return ctx;
54
+ }
55
+ function visitOracleLeaves(node, visit) {
56
+ switch (node.op) {
57
+ case 'and':
58
+ case 'or':
59
+ for (const child of node.children)
60
+ visitOracleLeaves(child, visit);
61
+ return;
62
+ case 'not':
63
+ visitOracleLeaves(node.child, visit);
64
+ return;
65
+ case 'eq':
66
+ case 'lt':
67
+ case 'lte':
68
+ case 'gt':
69
+ case 'gte': {
70
+ const leftIsOracle = node.left.kind === 'oracle_price';
71
+ const rightIsOracle = node.right.kind === 'oracle_price';
72
+ let asset;
73
+ let literal;
74
+ if (leftIsOracle) {
75
+ asset = node.left.kind === 'oracle_price' ? node.left.asset : undefined;
76
+ literal = oracleThresholdNormalised(node.right);
77
+ }
78
+ else if (rightIsOracle) {
79
+ asset = node.right.kind === 'oracle_price' ? node.right.asset : undefined;
80
+ literal = oracleThresholdNormalised(node.left);
81
+ }
82
+ if (asset !== undefined && literal !== undefined) {
83
+ visit(asset, node.op, literal);
84
+ }
85
+ return;
86
+ }
87
+ case 'in':
88
+ return;
89
+ }
90
+ }
91
+ /** Oracle prices normalise to 9 decimals; mirrors NORMALISED_DECIMALS in
92
+ * oracle.rs. */
93
+ const NORMALISED_DECIMALS = 9n;
94
+ /** A threshold restated on the normalised basis, so a derived permit price is
95
+ * comparable to it. Thresholds carry their own basis, so the conversion has
96
+ * to happen here - reading the digits raw would build a context off by a
97
+ * factor of 10^(decimals-9). Returns undefined for any other leaf. */
98
+ function oracleThresholdNormalised(leaf) {
99
+ if (leaf.kind !== 'oracle_threshold')
100
+ return undefined;
101
+ let value;
102
+ try {
103
+ value = BigInt(leaf.value);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ const decimals = BigInt(leaf.decimals);
109
+ if (decimals <= NORMALISED_DECIMALS) {
110
+ return value * 10n ** (NORMALISED_DECIMALS - decimals);
111
+ }
112
+ // Floor: a finer-grained threshold has no exact 9-dp representation. The
113
+ // caller only needs a price that lands on the right side of the bound, and
114
+ // it offsets by one from here.
115
+ return value / 10n ** (decimals - NORMALISED_DECIMALS);
116
+ }
@@ -811,13 +811,25 @@ function visitOracleLeaves(node, visit) {
811
811
  return;
812
812
  }
813
813
  }
814
+ /** An oracle threshold restated on the normalised 9-dp basis prices use.
815
+ * Thresholds carry their own basis, so reading the digits raw would build a
816
+ * permit context off by 10^(decimals-9) and the intended call would not
817
+ * satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
814
818
  function oracleLiteralFromLeaf(leaf) {
815
- if (leaf.kind !== 'literal_i128')
819
+ if (leaf.kind !== 'oracle_threshold')
816
820
  return undefined;
821
+ let value;
817
822
  try {
818
- return BigInt(leaf.value);
823
+ value = BigInt(leaf.value);
819
824
  }
820
825
  catch {
821
826
  return undefined;
822
827
  }
828
+ const normalised = 9n;
829
+ const decimals = BigInt(leaf.decimals);
830
+ if (decimals <= normalised)
831
+ return value * 10n ** (normalised - decimals);
832
+ // Floor: a finer-grained threshold has no exact 9-dp representation; the
833
+ // caller offsets by one from here to land on the right side of the bound.
834
+ return value / 10n ** (decimals - normalised);
823
835
  }
package/dist/types.d.ts CHANGED
@@ -190,6 +190,11 @@ export type PredicateLeaf = {
190
190
  index: number;
191
191
  element: number;
192
192
  field: string;
193
+ } | {
194
+ kind: 'call_arg_scaled';
195
+ index: number;
196
+ num: string;
197
+ den: string;
193
198
  } | {
194
199
  kind: 'amount';
195
200
  token: string;
@@ -207,6 +212,10 @@ export type PredicateLeaf = {
207
212
  } | {
208
213
  kind: 'oracle_price';
209
214
  asset: string;
215
+ } | {
216
+ kind: 'oracle_threshold';
217
+ value: string;
218
+ decimals: number;
210
219
  } | {
211
220
  kind: 'literal_address';
212
221
  value: string;
@@ -52,7 +52,10 @@ exports.ORACLE_DEFAULTS = {
52
52
  const CAPABILITIES = {
53
53
  supportsSpendWindow: true,
54
54
  supportsThreshold: false, // thresholds are the OZ adapter's job
55
- supportsTimeExpiry: true, // via valid_until ledger expiry
55
+ // False for the predicate-leaf path: the interpreter refuses a `valid_until`
56
+ // leaf at install. Expiry is still available, but as the context rule's
57
+ // validUntilLedger, which the smart account enforces - not as a predicate.
58
+ supportsTimeExpiry: false,
56
59
  supportsOraclePrice: true,
57
60
  supportsInvocationCount: true,
58
61
  supportsGeneralPredicate: true,
@@ -221,26 +224,61 @@ function lowerRule(rule, config) {
221
224
  function unsupportedConstruct(cond) {
222
225
  switch (cond.op) {
223
226
  case 'in':
224
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
225
- ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
226
- : null;
227
+ // The oracle position rule is a hard error and must be raised before a
228
+ // construct can be written off as uncovered - otherwise a subtree that
229
+ // is BOTH misplaced-oracle and unsourceable would be silently dropped
230
+ // instead of rejected.
231
+ assertNoOracleDescendants(cond);
232
+ return (unsourceableSelector(cond.selector) ??
233
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
234
+ ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
235
+ : null));
227
236
  case 'eq_seq':
228
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
229
- ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
230
- : null;
237
+ return (unsourceableSelector(cond.selector) ??
238
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
239
+ ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
240
+ : null));
231
241
  case 'compare': {
232
242
  const s = cond.compare.selector;
233
243
  if (s.kind === 'calldata')
234
244
  return 'EVM calldata comparison (predicate DSL)';
235
245
  if (s.kind === 'value')
236
246
  return 'tx.value comparison (predicate DSL)';
237
- return null;
247
+ // The on-chain interpreter sees ONE authorized call - there is no
248
+ // `Context.sub_invocations` in v1 - so it cannot observe the
249
+ // transaction's token movements. `amount` has no value to read, and
250
+ // `window_spent` accumulates BY that amount, so its counter would never
251
+ // move. Deriving either from the call payload would quietly swap "value
252
+ // actually moved" for "value the caller declared" - a weaker guarantee
253
+ // than the review card would be claiming.
254
+ //
255
+ // Rolling spend caps belong to the OZ `spending_limit` primitive, which
256
+ // is already audited and which the OZ adapter emits. A per-call cap is
257
+ // expressible here as `arg_field`; bounding it with `invocation_count`
258
+ // gives an enforceable ceiling per window.
259
+ return unsourceableSelector(s);
238
260
  }
261
+ // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
262
+ // pre-scan, which only sees top-level constraints.
239
263
  case 'and':
264
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
240
265
  case 'or':
266
+ assertNoOracleDescendants(cond);
267
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
241
268
  case 'not':
242
- return null;
269
+ assertNoOracleDescendants(cond);
270
+ return unsupportedConstruct(cond.child);
271
+ }
272
+ }
273
+ /** Selectors whose value the on-chain interpreter has no way to obtain. */
274
+ function unsourceableSelector(s) {
275
+ if (s.kind === 'amount') {
276
+ return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`;
277
+ }
278
+ if (s.kind === 'window_spent') {
279
+ return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`;
243
280
  }
281
+ return null;
244
282
  }
245
283
  /** Lower one IR condition to a PredicateNode. Enforces the oracle position
246
284
  * rule (oracle_price leaves must sit directly under the top-level `and`) and
@@ -316,10 +354,13 @@ function lowerSelector(s) {
316
354
  return { kind: 'call_arg_len', index: s.argIndex };
317
355
  case 'arg_field':
318
356
  return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
357
+ // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
358
+ // before lowering - the interpreter cannot source either on chain. Reaching
359
+ // here means the pre-scan was bypassed, so fail loudly rather than emit a
360
+ // leaf the contract will refuse.
319
361
  case 'amount':
320
- return { kind: 'amount', token: s.token };
321
362
  case 'window_spent':
322
- return { kind: 'window_spent', token: s.token, windowSeconds: s.windowSeconds };
363
+ throw new Error(`interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`);
323
364
  case 'invocation_count':
324
365
  return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds };
325
366
  case 'now':
@@ -346,6 +387,16 @@ function lowerSelector(s) {
346
387
  * - invocation_count -> u32 (counts are small non-negative integers)
347
388
  * - now / valid_until -> u64 (unix timestamps in seconds) */
348
389
  function literalFromIRCompare(c) {
390
+ // An oracle comparand is not a bare literal: the interpreter refuses one,
391
+ // because assuming it shares the normalised 9-dp basis is what made a raw
392
+ // 14-dp threshold permit everything it was written to deny.
393
+ if (c.selector.kind === 'oracle_price') {
394
+ if (c.valueDecimals === undefined) {
395
+ throw toolError('ORACLE_THRESHOLD_BASIS_REQUIRED', 'An oracle price bound must declare the decimal basis of its threshold ' +
396
+ '(oracle prices normalise to 9 decimals).');
397
+ }
398
+ return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals };
399
+ }
349
400
  const scalarType = selectorScalarType(c.selector);
350
401
  return literalFromScalar(c.value, scalarType);
351
402
  }
@@ -1,4 +1,4 @@
1
- export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'PREDICATE_ORACLE_OVER_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ORACLE_STALE' | 'ORACLE_MISSING' | 'ORACLE_DEVIATION_EXCEEDED' | 'ORACLE_MALFORMED_HISTORY' | 'ORACLE_FINGERPRINT_DRIFT' | 'ORACLE_PAUSED' | 'ORACLE_LEAF_INVALID_POSITION' | 'ORACLE_PARAMS_OUT_OF_RANGE' | 'ORACLE_DECIMALS_MISMATCH' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
1
+ export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'PREDICATE_ORACLE_OVER_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ARG_MISMATCH' | 'CONTRACT_SCOPE' | 'UNSUPPORTED_NODE' | 'STATEFUL_BOUND' | 'NOT_IN_ALLOWLIST' | 'FREQUENCY' | 'SLIPPAGE_FLOOR' | 'ORACLE_STALE' | 'ORACLE_MISSING' | 'ORACLE_NO_CONFIRMATION' | 'ORACLE_DEVIATION_EXCEEDED' | 'ORACLE_MALFORMED_HISTORY' | 'ORACLE_FINGERPRINT_DRIFT' | 'ORACLE_PAUSED' | 'ORACLE_LEAF_INVALID_POSITION' | 'ORACLE_PARAMS_OUT_OF_RANGE' | 'ORACLE_DECIMALS_MISMATCH' | 'ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE' | 'ORACLE_THRESHOLD_BASIS_REQUIRED' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
2
2
  export interface ToolError {
3
3
  code: ErrorCode;
4
4
  message: string;
@@ -0,0 +1,48 @@
1
+ import { xdr } from '@stellar/stellar-sdk';
2
+ import { type ContextRuleDraft, type PolicyRef, type SignerDraft } from '../types.ts';
3
+ /** Inputs for `buildAddContextRuleArgs`. The fields are the ones the contract
4
+ * validates at install; everything else is downstream. */
5
+ export interface BuildAddContextRuleArgs {
6
+ /** The auth-context rule signers to attach - `Vec<Signer>` wire form.
7
+ * These are the SIGNERS the new rule authorises, not the deployer's
8
+ * admin session. The deployer signs the install call via the admin rule
9
+ * already on the account, which is separate from this list. */
10
+ signers: SignerDraft[];
11
+ /** The policy(ies) to attach to the new rule. Only `interpreter` refs
12
+ * reach the wire today; `oz_builtin` is reserved for Phase 06 follow-ups. */
13
+ policies: PolicyRef[];
14
+ /** Per-policy install nonce; 1 for a fresh install. */
15
+ installNonce: number;
16
+ /** Already-encoded (base64) canonical ScVal of the predicate. The builder
17
+ * decodes it to bytes for the `predicate` field and re-hashes to confirm
18
+ * the caller-supplied `predicateHash` matches. */
19
+ encodedPredicate: string;
20
+ /** Hex sha256 of the canonical predicate XDR bytes. */
21
+ predicateHash: string;
22
+ /** Per-policy oracle overrides. Absent on any key uses the wasm default. */
23
+ oracleParams?: {
24
+ maxStalenessSeconds?: number;
25
+ maxDeviationBps?: number;
26
+ /** Bound between the primary and secondary feeds. Tighten-only against
27
+ * the wasm default, like the other two. */
28
+ maxCrossFeedDeviationBps?: number;
29
+ };
30
+ /** Hard pin to the interpreter's wasm grammar. Refusing here fails closed
31
+ * before the chain does; the contract will refuse a mismatch again. */
32
+ grammarVersion?: number;
33
+ }
34
+ export declare const DEFAULT_GRAMMAR_VERSION: 1;
35
+ /** The verb `add_context_rule` takes on the wire. */
36
+ export declare const ADD_CONTEXT_RULE_SYMBOL: "add_context_rule";
37
+ /** Tuple of `ScVal` arguments to pass to `Operation.invokeHostFunction` for
38
+ * `add_context_rule`. Order matches the OZ trait signature. */
39
+ export type AddContextRuleArgs = readonly [
40
+ contextType: xdr.ScVal,
41
+ name: xdr.ScVal,
42
+ validUntil: xdr.ScVal,
43
+ signers: xdr.ScVal,
44
+ policies: xdr.ScVal
45
+ ];
46
+ /** Build the `add_context_rule` invocation args. Throws a `ToolError`-shaped
47
+ * error on limit breaches or malformed input. */
48
+ export declare function buildAddContextRuleArgs(draft: ContextRuleDraft, args: BuildAddContextRuleArgs): AddContextRuleArgs;
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ // src/install/build-add-context-rule.ts - pure builder for the OZ
3
+ // `add_context_rule` invocation arguments.
4
+ //
5
+ // Hand-rolled instead of reusing the scripts' helpers because the product
6
+ // path (Phase 06 install_policy) needs a single callable that takes a typed
7
+ // `ContextRuleDraft` + predicate bytes and returns the `ScVal` argument list
8
+ // the host will pass to `add_context_rule`. The script-level helpers stay
9
+ // out of `src/` to avoid dragging the ts-node SDK into the library surface.
10
+ //
11
+ // The OZ trait signature is pinned in
12
+ // `stellar-contracts::smart_account::SmartAccount::add_context_rule`:
13
+ //
14
+ // fn add_context_rule(
15
+ // e: &Env,
16
+ // context_type: ContextRuleType,
17
+ // name: String,
18
+ // valid_until: Option<u32>,
19
+ // signers: Vec<Signer>,
20
+ // policies: Map<Address, Val>,
21
+ // ) -> ContextRule
22
+ //
23
+ // The default body calls `e.current_contract_address().require_auth()`, so
24
+ // installation goes through the account's `__check_auth` -> `do_check_auth`.
25
+ // That requires the DEPLOYER to have shipped an admin rule with NO
26
+ // restrictive policy, otherwise the policy would refuse the very call that
27
+ // installs it. The caller is responsible for that contract; this builder
28
+ // just emits the args.
29
+ //
30
+ // Two hard limits the contract enforces (verified against
31
+ // `storage::validate_signers_and_policies` at /tmp/ozsc):
32
+ // - signers.len() <= MAX_SIGNERS (15)
33
+ // - policies.len() <= MAX_POLICIES (5)
34
+ // - signers and policies must not both be empty
35
+ // The same limits are mirrored in `OZ_LIMITS` (see src/types.ts) and
36
+ // checked here fail-closed before any encoding work - the user gets a
37
+ // typed error with the specific cap, not a runtime trap.
38
+ //
39
+ // Policypayloads are `Map<Address, Val>`. The host orders map entries by the
40
+ // symbol STRING of the key, NOT by the key's XDR bytes (a length prefix
41
+ // would otherwise put `amount` before `address` and produce a map the
42
+ // contract reads differently - a previous session lost hours to this). Our
43
+ // only key is an `Address`, so we build a single-entry map and the ordering
44
+ // question is moot, but the comment is here so the next map-bearing code
45
+ // in this file does not regress.
46
+ //
47
+ // Pure: no network, no signing. The caller hands the returned ScVal[] to
48
+ // `Operation.invokeHostFunction` AFTER wiring the auth entries, otherwise
49
+ // the account refuses with `Error(Auth, InvalidAction)`.
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.ADD_CONTEXT_RULE_SYMBOL = exports.DEFAULT_GRAMMAR_VERSION = void 0;
52
+ exports.buildAddContextRuleArgs = buildAddContextRuleArgs;
53
+ const node_crypto_1 = require("node:crypto");
54
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
55
+ const types_ts_1 = require("../types.js");
56
+ exports.DEFAULT_GRAMMAR_VERSION = 1;
57
+ /** The verb `add_context_rule` takes on the wire. */
58
+ exports.ADD_CONTEXT_RULE_SYMBOL = 'add_context_rule';
59
+ /** Build the `add_context_rule` invocation args. Throws a `ToolError`-shaped
60
+ * error on limit breaches or malformed input. */
61
+ function buildAddContextRuleArgs(draft, args) {
62
+ // ---- 1. Cap checks (fail-closed before any encoding) ----
63
+ if (args.signers.length > types_ts_1.OZ_LIMITS.maxSignersPerRule) {
64
+ throw limitError('INSTALL_BUILD_FAILED', `signers ${args.signers.length} exceed MAX_SIGNERS_PER_RULE ${types_ts_1.OZ_LIMITS.maxSignersPerRule}`);
65
+ }
66
+ if (args.policies.length > types_ts_1.OZ_LIMITS.maxPoliciesPerRule) {
67
+ throw limitError('INSTALL_BUILD_FAILED', `policies ${args.policies.length} exceed MAX_POLICIES_PER_RULE ${types_ts_1.OZ_LIMITS.maxPoliciesPerRule}`);
68
+ }
69
+ if (args.signers.length === 0 && args.policies.length === 0) {
70
+ throw limitError('INSTALL_BUILD_FAILED', 'a rule with no signers and no policies is refused at install (NoSignersAndPolicies)');
71
+ }
72
+ // OZ's spending_limit caps spend on the CONTEXT CONTRACT - it takes no
73
+ // token argument - so it refuses a Default rule on chain with a bare
74
+ // #3227. Say which rule shape it needs here, before the encoding, rather
75
+ // than letting the user read a numeric trap.
76
+ if (args.policies.some((p) => p.kind === 'oz_builtin' && p.primitive.primitive === 'spending_limit')) {
77
+ if (draft.contextRuleType.kind !== 'call_contract') {
78
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit caps spend on the rule's context contract, so the rule must be call_contract-scoped to that token - a "${draft.contextRuleType.kind}" rule is refused on chain (#3227)`);
79
+ }
80
+ }
81
+ // ---- 2. context_type encoding ----
82
+ const contextType = encodeContextRuleType(draft.contextRuleType);
83
+ // ---- 3. name (string) + valid_until (Option<u32>) ----
84
+ const name = stellar_sdk_1.xdr.ScVal.scvString(draft.name);
85
+ const validUntil = draft.validUntilLedger === null ? stellar_sdk_1.xdr.ScVal.scvVoid() : stellar_sdk_1.xdr.ScVal.scvU32(draft.validUntilLedger);
86
+ // ---- 4. signers ----
87
+ const signersVec = stellar_sdk_1.xdr.ScVal.scvVec(args.signers.map(encodeSigner));
88
+ // ---- 5. policies Map<Address, Val> ----
89
+ const policies = encodePoliciesMap(args);
90
+ return [contextType, name, validUntil, signersVec, policies];
91
+ }
92
+ // ---- helpers ----
93
+ /** Field order of the `PolicyInstallParams` struct. The contract's
94
+ * `#[contracttype]` derives a Map<Symbol, Val> encoding, so the host
95
+ * sorts by symbol STRING (NOT by XDR bytes). This explicit list is the
96
+ * single source of truth for the field order. */
97
+ const POLICY_INSTALL_PARAM_FIELDS = [
98
+ 'grammar_version',
99
+ 'install_nonce',
100
+ 'predicate',
101
+ 'predicate_hash',
102
+ 'oracle_max_staleness_seconds',
103
+ 'oracle_max_deviation_bps',
104
+ // Cross-feed divergence bound. A field added to the contract's
105
+ // `PolicyInstallParams` changes the ABI: the host unpacks the map by field
106
+ // count, so omitting one fails the entire install with
107
+ // `Error(Object, UnexpectedSize)` and no indication of which field is
108
+ // missing. The Rust tests build that struct in Rust and never cross this
109
+ // boundary, so only a real install against a deployed contract catches it.
110
+ 'oracle_max_xfeed_dev_bps',
111
+ ];
112
+ function encodeContextRuleType(rule) {
113
+ switch (rule.kind) {
114
+ case 'default':
115
+ return stellar_sdk_1.xdr.ScVal.scvVec([stellar_sdk_1.xdr.ScVal.scvSymbol('Default')]);
116
+ case 'call_contract':
117
+ return stellar_sdk_1.xdr.ScVal.scvVec([
118
+ stellar_sdk_1.xdr.ScVal.scvSymbol('CallContract'),
119
+ stellar_sdk_1.Address.fromString(rule.contract).toScVal(),
120
+ ]);
121
+ case 'create_contract':
122
+ return stellar_sdk_1.xdr.ScVal.scvVec([
123
+ stellar_sdk_1.xdr.ScVal.scvSymbol('CreateContract'),
124
+ stellar_sdk_1.xdr.ScVal.scvBytes(Buffer.from(rule.wasmHash, 'hex')),
125
+ ]);
126
+ }
127
+ }
128
+ function encodeSigner(s) {
129
+ switch (s.kind) {
130
+ case 'delegated':
131
+ return stellar_sdk_1.xdr.ScVal.scvVec([
132
+ stellar_sdk_1.xdr.ScVal.scvSymbol('Delegated'),
133
+ stellar_sdk_1.Address.fromString(s.address).toScVal(),
134
+ ]);
135
+ case 'external':
136
+ return stellar_sdk_1.xdr.ScVal.scvVec([
137
+ stellar_sdk_1.xdr.ScVal.scvSymbol('External'),
138
+ stellar_sdk_1.Address.fromString(s.verifier).toScVal(),
139
+ stellar_sdk_1.xdr.ScVal.scvBytes(Buffer.from(s.keyBytes, 'hex')),
140
+ ]);
141
+ }
142
+ }
143
+ function encodePoliciesMap(args) {
144
+ const entries = [];
145
+ for (const ref of args.policies) {
146
+ if (ref.kind === 'interpreter') {
147
+ const val = encodePolicyInstallParams(args);
148
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
149
+ key: stellar_sdk_1.Address.fromString(ref.interpreterAddress).toScVal(),
150
+ val,
151
+ }));
152
+ }
153
+ if (ref.kind === 'oz_builtin') {
154
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
155
+ key: stellar_sdk_1.Address.fromString(ref.instanceAddress).toScVal(),
156
+ val: encodeOzPrimitiveParams(ref.primitive),
157
+ }));
158
+ }
159
+ }
160
+ entries.sort(sortByScValSymbolString);
161
+ return stellar_sdk_1.xdr.ScVal.scvMap(entries);
162
+ }
163
+ /** Encode the install params for one OZ built-in policy.
164
+ *
165
+ * Field names and types are read from the deployed contracts' own spec:
166
+ * SpendingLimitAccountParams { period_ledgers: u32, spending_limit: i128 }
167
+ * SimpleThresholdAccountParams { threshold: u32 }
168
+ * WeightedThresholdAccountParams { signer_weights: map, threshold: u32 }
169
+ * Entries are symbol-string ordered for the same reason the interpreter's
170
+ * are: the host orders by symbol, not by XDR bytes.
171
+ */
172
+ function encodeOzPrimitiveParams(primitive) {
173
+ const entries = [];
174
+ const push = (name, val) => entries.push(new stellar_sdk_1.xdr.ScMapEntry({ key: stellar_sdk_1.xdr.ScVal.scvSymbol(name), val }));
175
+ const p = primitive.params;
176
+ switch (primitive.primitive) {
177
+ case 'spending_limit': {
178
+ const limit = p.spending_limit;
179
+ const period = p.period_ledgers;
180
+ if (typeof limit !== 'string' || typeof period !== 'number') {
181
+ throw limitError('INSTALL_BUILD_FAILED', 'spending_limit needs { spending_limit: string; period_ledgers: number }');
182
+ }
183
+ push('period_ledgers', stellar_sdk_1.xdr.ScVal.scvU32(period));
184
+ push('spending_limit', encodeI128(limit));
185
+ break;
186
+ }
187
+ case 'simple_threshold': {
188
+ const threshold = p.threshold;
189
+ if (typeof threshold !== 'number') {
190
+ throw limitError('INSTALL_BUILD_FAILED', 'simple_threshold needs { threshold: number }');
191
+ }
192
+ push('threshold', stellar_sdk_1.xdr.ScVal.scvU32(threshold));
193
+ break;
194
+ }
195
+ case 'weighted_threshold': {
196
+ const threshold = p.threshold;
197
+ const weights = p.weights;
198
+ if (typeof threshold !== 'number' || !weights) {
199
+ throw limitError('INSTALL_BUILD_FAILED', 'weighted_threshold needs { threshold: number; weights: Record<address, number> }');
200
+ }
201
+ const weightEntries = Object.entries(weights)
202
+ .map(([addr, w]) => new stellar_sdk_1.xdr.ScMapEntry({
203
+ key: stellar_sdk_1.Address.fromString(addr).toScVal(),
204
+ val: stellar_sdk_1.xdr.ScVal.scvU32(w),
205
+ }))
206
+ .sort((a, b) => (a.key().toXDR('base64') < b.key().toXDR('base64') ? -1 : 1));
207
+ push('signer_weights', stellar_sdk_1.xdr.ScVal.scvMap(weightEntries));
208
+ push('threshold', stellar_sdk_1.xdr.ScVal.scvU32(threshold));
209
+ break;
210
+ }
211
+ }
212
+ entries.sort((a, b) => sortBySymbolString(a.key(), b.key()));
213
+ return stellar_sdk_1.xdr.ScVal.scvMap(entries);
214
+ }
215
+ function encodeI128(value) {
216
+ const v = BigInt(value);
217
+ return stellar_sdk_1.xdr.ScVal.scvI128(new stellar_sdk_1.xdr.Int128Parts({
218
+ hi: new stellar_sdk_1.xdr.Int64(BigInt.asIntN(64, v >> 64n)),
219
+ lo: new stellar_sdk_1.xdr.Uint64(BigInt.asUintN(64, v)),
220
+ }));
221
+ }
222
+ function encodePolicyInstallParams(args) {
223
+ const predicate = Buffer.from(args.encodedPredicate, 'base64');
224
+ const computedHash = (0, node_crypto_1.createHash)('sha256').update(predicate).digest('hex');
225
+ if (computedHash !== args.predicateHash) {
226
+ throw limitError('INSTALL_BUILD_FAILED', `predicateHash ${args.predicateHash.slice(0, 16)}... does not match sha256(encodedPredicate) ${computedHash.slice(0, 16)}...`);
227
+ }
228
+ const entries = [];
229
+ for (const k of POLICY_INSTALL_PARAM_FIELDS) {
230
+ const key = stellar_sdk_1.xdr.ScVal.scvSymbol(k);
231
+ switch (k) {
232
+ case 'grammar_version':
233
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
234
+ key,
235
+ val: stellar_sdk_1.xdr.ScVal.scvU32(args.grammarVersion ?? exports.DEFAULT_GRAMMAR_VERSION),
236
+ }));
237
+ break;
238
+ case 'install_nonce':
239
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({ key, val: stellar_sdk_1.xdr.ScVal.scvU32(args.installNonce) }));
240
+ break;
241
+ case 'predicate':
242
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({ key, val: stellar_sdk_1.xdr.ScVal.scvBytes(predicate) }));
243
+ break;
244
+ case 'predicate_hash':
245
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
246
+ key,
247
+ val: stellar_sdk_1.xdr.ScVal.scvBytes(Buffer.from(args.predicateHash, 'hex')),
248
+ }));
249
+ break;
250
+ case 'oracle_max_staleness_seconds':
251
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
252
+ key,
253
+ val: encodeOptionU32(args.oracleParams?.maxStalenessSeconds),
254
+ }));
255
+ break;
256
+ case 'oracle_max_deviation_bps':
257
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
258
+ key,
259
+ val: encodeOptionU32(args.oracleParams?.maxDeviationBps),
260
+ }));
261
+ break;
262
+ case 'oracle_max_xfeed_dev_bps':
263
+ entries.push(new stellar_sdk_1.xdr.ScMapEntry({
264
+ key,
265
+ val: encodeOptionU32(args.oracleParams?.maxCrossFeedDeviationBps),
266
+ }));
267
+ break;
268
+ }
269
+ }
270
+ // The host orders map entries by the SYMBOL STRING, not by XDR bytes. A
271
+ // length prefix in the XDR encoding would otherwise put `amount` before
272
+ // `address` and produce a struct the contract reads differently. Emit the
273
+ // entries in symbol-string order so the wire form matches the host's view.
274
+ entries.sort((a, b) => sortBySymbolString(a.key(), b.key()));
275
+ return stellar_sdk_1.xdr.ScVal.scvMap(entries);
276
+ }
277
+ function encodeOptionU32(v) {
278
+ if (v === undefined)
279
+ return stellar_sdk_1.xdr.ScVal.scvVoid();
280
+ if (!Number.isFinite(v) || v < 0 || v > 0xffffffff) {
281
+ throw limitError('INSTALL_BUILD_FAILED', `oracle params value ${v} is outside u32 range`);
282
+ }
283
+ return stellar_sdk_1.xdr.ScVal.scvU32(v);
284
+ }
285
+ /** Sort host-map keys by their symbol-string form. The host orders map
286
+ * entries by the SYMBOL STRING, not by the key's XDR bytes (a length
287
+ * prefix in the encoding would otherwise put `amount` before `address`).
288
+ * For non-symbol keys we fall back to length-prefixed XDR bytes; the
289
+ * caller's only Address keys today are length-stable so this is moot, but
290
+ * the helper stands ready for the multi-policy variant. */
291
+ function sortByScValSymbolString(a, b) {
292
+ return sortBySymbolString(a.key(), b.key());
293
+ }
294
+ function sortBySymbolString(a, b) {
295
+ const aSym = a.switch().name === 'scvSymbol' ? a.sym().toString() : null;
296
+ const bSym = b.switch().name === 'scvSymbol' ? b.sym().toString() : null;
297
+ if (aSym !== null && bSym !== null) {
298
+ return aSym < bSym ? -1 : aSym > bSym ? 1 : 0;
299
+ }
300
+ return Buffer.compare(a.toXDR(), b.toXDR());
301
+ }
302
+ function limitError(code, message) {
303
+ const err = new Error(message);
304
+ err.code = code;
305
+ err.severity = 'error';
306
+ err.retryable = false;
307
+ throw err;
308
+ }
@@ -53,6 +53,10 @@ export interface IRCompare {
53
53
  selector: IRSelector;
54
54
  operator: IRCompOp;
55
55
  value: string;
56
+ /** Decimal basis of `value`, for selectors whose comparand is not on a basis
57
+ * the contract can infer. Set for `oracle_price`, where prices normalise to
58
+ * 9 dp and an undeclared basis fails OPEN. */
59
+ valueDecimals?: number;
56
60
  }
57
61
  /** Condition tree. NEAR-V2 guard/constraint are flat And/Or; the IR allows
58
62
  * nesting + `not` + `in` + `eq_seq` so the same IR can later lower to the OZ