@crediolabs/policy-synth 0.1.10 → 0.1.12

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 (84) hide show
  1. package/dist/adapters/interpreter/adapter.js +83 -11
  2. package/dist/adapters/oz/adapter.js +4 -0
  3. package/dist/errors.d.ts +1 -1
  4. package/dist/install/build-add-context-rule.d.ts +48 -0
  5. package/dist/install/build-add-context-rule.js +304 -0
  6. package/dist/ir/types.d.ts +23 -0
  7. package/dist/predicate/decode.d.ts +11 -0
  8. package/dist/predicate/decode.js +234 -0
  9. package/dist/predicate/encode.js +79 -14
  10. package/dist/predicate/from-json.d.ts +4 -0
  11. package/dist/predicate/from-json.js +113 -0
  12. package/dist/predicate/index.d.ts +2 -0
  13. package/dist/predicate/index.js +5 -1
  14. package/dist/registry/identify.js +21 -2
  15. package/dist/registry/protocols.d.ts +50 -1
  16. package/dist/registry/protocols.js +88 -0
  17. package/dist/review-card/builder.d.ts +13 -0
  18. package/dist/review-card/builder.js +63 -5
  19. package/dist/review-card/cross-check.js +32 -3
  20. package/dist/review-card/index.d.ts +1 -1
  21. package/dist/review-card/index.js +1 -1
  22. package/dist/run/schemas.d.ts +4 -4
  23. package/dist/synth/compose-from-recording.d.ts +18 -0
  24. package/dist/synth/compose-from-recording.js +75 -10
  25. package/dist/synth/deny-cases.d.ts +4 -1
  26. package/dist/synth/deny-cases.js +3 -1
  27. package/dist/synth/evaluate.js +130 -3
  28. package/dist/synth/permit-context.d.ts +15 -0
  29. package/dist/synth/permit-context.js +116 -0
  30. package/dist/synth/synthesize-from-recording.js +14 -2
  31. package/dist/types.d.ts +9 -0
  32. package/dist-cjs/adapters/interpreter/adapter.js +83 -11
  33. package/dist-cjs/adapters/oz/adapter.js +4 -0
  34. package/dist-cjs/errors.d.ts +1 -1
  35. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  36. package/dist-cjs/install/build-add-context-rule.js +308 -0
  37. package/dist-cjs/ir/types.d.ts +23 -0
  38. package/dist-cjs/predicate/decode.d.ts +11 -0
  39. package/dist-cjs/predicate/decode.js +239 -0
  40. package/dist-cjs/predicate/encode.js +79 -14
  41. package/dist-cjs/predicate/from-json.d.ts +4 -0
  42. package/dist-cjs/predicate/from-json.js +116 -0
  43. package/dist-cjs/predicate/index.d.ts +2 -0
  44. package/dist-cjs/predicate/index.js +10 -2
  45. package/dist-cjs/registry/identify.js +20 -1
  46. package/dist-cjs/registry/protocols.d.ts +50 -1
  47. package/dist-cjs/registry/protocols.js +89 -1
  48. package/dist-cjs/review-card/builder.d.ts +13 -0
  49. package/dist-cjs/review-card/builder.js +64 -5
  50. package/dist-cjs/review-card/cross-check.js +32 -3
  51. package/dist-cjs/review-card/index.d.ts +1 -1
  52. package/dist-cjs/review-card/index.js +2 -1
  53. package/dist-cjs/run/schemas.d.ts +4 -4
  54. package/dist-cjs/synth/compose-from-recording.d.ts +18 -0
  55. package/dist-cjs/synth/compose-from-recording.js +75 -10
  56. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  57. package/dist-cjs/synth/deny-cases.js +3 -0
  58. package/dist-cjs/synth/evaluate.js +130 -3
  59. package/dist-cjs/synth/permit-context.d.ts +15 -0
  60. package/dist-cjs/synth/permit-context.js +119 -0
  61. package/dist-cjs/synth/synthesize-from-recording.js +14 -2
  62. package/dist-cjs/types.d.ts +9 -0
  63. package/package.json +5 -2
  64. package/src/adapters/interpreter/adapter.ts +93 -11
  65. package/src/adapters/oz/adapter.ts +4 -0
  66. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  67. package/src/errors.ts +13 -0
  68. package/src/install/build-add-context-rule.ts +429 -0
  69. package/src/ir/types.ts +23 -0
  70. package/src/predicate/decode.ts +242 -0
  71. package/src/predicate/encode.ts +110 -13
  72. package/src/predicate/from-json.ts +124 -0
  73. package/src/predicate/index.ts +5 -1
  74. package/src/registry/identify.ts +22 -2
  75. package/src/registry/protocols.ts +90 -1
  76. package/src/review-card/builder.ts +58 -5
  77. package/src/review-card/cross-check.ts +36 -3
  78. package/src/review-card/index.ts +1 -0
  79. package/src/synth/compose-from-recording.ts +101 -7
  80. package/src/synth/deny-cases.ts +3 -1
  81. package/src/synth/evaluate.ts +130 -3
  82. package/src/synth/permit-context.ts +136 -0
  83. package/src/synth/synthesize-from-recording.ts +13 -2
  84. package/src/types.ts +12 -0
@@ -29,6 +29,11 @@
29
29
  // Amounts: BigInt on decimal strings (never JS `number`). Oracle errors
30
30
  // modelled by throwing (never a boolean-false that `not`/`or` could mask).
31
31
  import { literalNumericBigInt } from "./predicate-literals.js";
32
+ /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
33
+ * in oracle.rs. A threshold on any other basis must say so. */
34
+ const NORMALISED_DECIMALS = 9;
35
+ /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
36
+ const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
32
37
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
33
38
  * and converted to the matching `ORACLE_*` deny reason. NOT a
34
39
  * boolean-false that `not` / `or` could mask. */
@@ -165,6 +170,13 @@ function evalCompare(op, left, right, ctx) {
165
170
  }
166
171
  // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
167
172
  if (left.kind === 'call_arg') {
173
+ // The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
174
+ // Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
175
+ // -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
176
+ // generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
177
+ if (right.kind === 'call_arg_scaled') {
178
+ return evalScaledArgCompare(op, left, right, ctx);
179
+ }
168
180
  const actual = ctx.args[left.index];
169
181
  // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
170
182
  // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
@@ -175,6 +187,11 @@ function evalCompare(op, left, right, ctx) {
175
187
  return evalArgOrderedCompare(op, actual, right);
176
188
  return evalArgEq(op, actual, right, ctx);
177
189
  }
190
+ // --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
191
+ // Symmetric form so a policy that phrases the floor either way works.
192
+ if (left.kind === 'call_arg_scaled') {
193
+ return evalScaledArgCompare(op, right, left, ctx);
194
+ }
178
195
  // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
179
196
  // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
180
197
  if (left.kind === 'call_arg_len') {
@@ -293,6 +310,102 @@ function evalArgEq(op, actual, right, ctx) {
293
310
  return { permit: false, reason: 'ARG_MISMATCH' };
294
311
  return { permit: false, reason: 'ARG_MISMATCH' };
295
312
  }
313
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
314
+ * path: the scaled leaf is `args[index] * num / den` (truncating toward
315
+ * zero). On `checked_mul` / `checked_div` failure (overflow or
316
+ * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
317
+ * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
318
+ * rather than the generic `ARG_MISMATCH`.
319
+ *
320
+ * `left` is whichever side of the compare is NOT the scaled leaf. A
321
+ * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
322
+ * leaves has no definable semantics). The other operand must be a
323
+ * numeric shape (`call_arg` carrying a number, or a numeric literal);
324
+ * anything else is `ARG_MISMATCH`. */
325
+ function evalScaledArgCompare(op, left, right, ctx) {
326
+ // Identify which side is scaled, and pull the other side as a number.
327
+ let scaled;
328
+ let other;
329
+ let scaledOnRight;
330
+ if (left.kind === 'call_arg_scaled') {
331
+ scaled = left;
332
+ other = right;
333
+ scaledOnRight = false;
334
+ }
335
+ else if (right.kind === 'call_arg_scaled') {
336
+ scaled = right;
337
+ other = left;
338
+ scaledOnRight = true;
339
+ }
340
+ else {
341
+ // Neither side is scaled - this is a programming error in the
342
+ // dispatcher; the contract returns ARG_MISMATCH to fail closed.
343
+ return { permit: false, reason: 'ARG_MISMATCH' };
344
+ }
345
+ // Source `args[index]` -> BigInt. An out-of-bounds index or a
346
+ // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
347
+ // a violated floor is the wrong code when the operand itself could
348
+ // not be read.
349
+ if (scaled.index >= ctx.args.length) {
350
+ return { permit: false, reason: 'ARG_MISMATCH' };
351
+ }
352
+ const input = argNumericBigInt(ctx.args[scaled.index]);
353
+ if (input === null)
354
+ return { permit: false, reason: 'ARG_MISMATCH' };
355
+ let num;
356
+ let den;
357
+ try {
358
+ num = BigInt(scaled.num);
359
+ den = BigInt(scaled.den);
360
+ }
361
+ catch {
362
+ return { permit: false, reason: 'ARG_MISMATCH' };
363
+ }
364
+ // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
365
+ // check is a defensive belt-and-braces - a future validator
366
+ // regression cannot panic the frame on a divide-by-zero.
367
+ if (den === 0n)
368
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
369
+ const product = input * num;
370
+ // BigInt overflow is silent in JS (it does not throw on multiplication).
371
+ // We can detect over-range by dividing and checking the result against
372
+ // i128 bounds, but the more practical check is whether the result
373
+ // divides cleanly. We rely on BigInt's arbitrary precision and then
374
+ // surface ARITHMETIC_OVERFLOW if the result is outside i128.
375
+ let scaledValue;
376
+ try {
377
+ // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
378
+ scaledValue = product / den;
379
+ }
380
+ catch {
381
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
382
+ }
383
+ // i128 range check: if the scaled value is outside i128, the contract
384
+ // would have wrapped on i128 arithmetic; surface the same deny.
385
+ const I128_MAX = (1n << 127n) - 1n;
386
+ const I128_MIN = -(1n << 127n);
387
+ if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
388
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
389
+ }
390
+ // Resolving the operand when the scaled side is on the right: the
391
+ // operand is `left`; the scaled value is the RHS. The AST order is
392
+ // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
393
+ // When the scaled side is on the left, the order is `scaled <op> other`,
394
+ // i.e. `scaled <op> other_val`.
395
+ let otherVal;
396
+ if (other.kind === 'call_arg') {
397
+ otherVal = argNumericBigInt(ctx.args[other.index]);
398
+ }
399
+ else {
400
+ otherVal = literalNumericBigInt(other);
401
+ }
402
+ if (otherVal === null)
403
+ return { permit: false, reason: 'ARG_MISMATCH' };
404
+ const pass = scaledOnRight
405
+ ? bigintCmp(op, otherVal.toString(), scaledValue.toString())
406
+ : bigintCmp(op, scaledValue.toString(), otherVal.toString());
407
+ return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' };
408
+ }
296
409
  /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
297
410
  * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
298
411
  * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
@@ -389,6 +502,7 @@ function resolveLeaf(leaf, ctx) {
389
502
  case 'invocation_count_in_window':
390
503
  case 'now':
391
504
  case 'valid_until':
505
+ case 'call_arg_scaled':
392
506
  return undefined; // selector leaves with no ScVal projection
393
507
  case 'literal_address':
394
508
  return { type: 'address', value: leaf.value };
@@ -447,10 +561,23 @@ function evalOracleCompare(op, asset, right, ctx) {
447
561
  throw new OracleError('ORACLE_STALE');
448
562
  throw new OracleError(mapped);
449
563
  }
450
- const literal = right.kind === 'literal_i128' ? right.value : null;
451
- if (literal === null)
564
+ // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
565
+ // decimal basis: prices are on the normalised 9-dp basis, and assuming a
566
+ // bare literal shares it is what let a raw 14-dp threshold permit
567
+ // everything. A bare literal is refused rather than assumed.
568
+ if (right.kind !== 'oracle_threshold')
452
569
  throw new OracleError('ORACLE_DECIMALS_MISMATCH');
453
- return bigintCmp(op, entry.price, literal)
570
+ if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
571
+ throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
572
+ }
573
+ // Scale BOTH sides up to the wider basis rather than dividing the threshold
574
+ // down: dividing truncates, and a truncated bound moves the permit boundary.
575
+ const decimals = BigInt(right.decimals);
576
+ const normalised = BigInt(NORMALISED_DECIMALS);
577
+ const common = decimals > normalised ? decimals : normalised;
578
+ const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
579
+ const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
580
+ return bigintCmp(op, String(priceScaled), String(literalScaled))
454
581
  ? { permit: true }
455
582
  : { permit: false, reason: 'FN_MISMATCH' };
456
583
  }
@@ -0,0 +1,15 @@
1
+ import type { PredicateNode, RecordedTransaction } from '../types.ts';
2
+ import type { EvalContext } from './evaluate.ts';
3
+ export interface PermitContextResponses {
4
+ windowSeconds: number;
5
+ invocationLimit?: number;
6
+ limitAmount?: string;
7
+ validUntilLedger: number;
8
+ oraclePriceBound?: Array<{
9
+ asset: string;
10
+ operator: string;
11
+ value: string;
12
+ decimals: number;
13
+ }>;
14
+ }
15
+ export declare function buildPermitContext(tx: RecordedTransaction, responses: PermitContextResponses, predicate: PredicateNode): EvalContext;
@@ -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,
@@ -220,34 +223,86 @@ function lowerRule(rule, config) {
220
223
  * by `lowerRule` to populate `uncovered` before lowering. */
221
224
  function unsupportedConstruct(cond) {
222
225
  switch (cond.op) {
226
+ // Expressible: `call_arg_scaled` is exactly this construct.
227
+ case 'slippage_floor':
228
+ return null;
223
229
  case 'in':
224
- return cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
225
- ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
226
- : null;
230
+ // The oracle position rule is a hard error and must be raised before a
231
+ // construct can be written off as uncovered - otherwise a subtree that
232
+ // is BOTH misplaced-oracle and unsourceable would be silently dropped
233
+ // instead of rejected.
234
+ assertNoOracleDescendants(cond);
235
+ return (unsourceableSelector(cond.selector) ??
236
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
237
+ ? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
238
+ : null));
227
239
  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;
240
+ return (unsourceableSelector(cond.selector) ??
241
+ (cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
242
+ ? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
243
+ : null));
231
244
  case 'compare': {
232
245
  const s = cond.compare.selector;
233
246
  if (s.kind === 'calldata')
234
247
  return 'EVM calldata comparison (predicate DSL)';
235
248
  if (s.kind === 'value')
236
249
  return 'tx.value comparison (predicate DSL)';
237
- return null;
250
+ // The on-chain interpreter sees ONE authorized call - there is no
251
+ // `Context.sub_invocations` in v1 - so it cannot observe the
252
+ // transaction's token movements. `amount` has no value to read, and
253
+ // `window_spent` accumulates BY that amount, so its counter would never
254
+ // move. Deriving either from the call payload would quietly swap "value
255
+ // actually moved" for "value the caller declared" - a weaker guarantee
256
+ // than the review card would be claiming.
257
+ //
258
+ // Rolling spend caps belong to the OZ `spending_limit` primitive, which
259
+ // is already audited and which the OZ adapter emits. A per-call cap is
260
+ // expressible here as `arg_field`; bounding it with `invocation_count`
261
+ // gives an enforceable ceiling per window.
262
+ return unsourceableSelector(s);
238
263
  }
264
+ // Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
265
+ // pre-scan, which only sees top-level constraints.
239
266
  case 'and':
267
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
240
268
  case 'or':
269
+ assertNoOracleDescendants(cond);
270
+ return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
241
271
  case 'not':
242
- return null;
272
+ assertNoOracleDescendants(cond);
273
+ return unsupportedConstruct(cond.child);
243
274
  }
244
275
  }
276
+ /** Selectors whose value the on-chain interpreter has no way to obtain. */
277
+ function unsourceableSelector(s) {
278
+ if (s.kind === 'amount') {
279
+ 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`;
280
+ }
281
+ if (s.kind === 'window_spent') {
282
+ 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`;
283
+ }
284
+ return null;
285
+ }
245
286
  /** Lower one IR condition to a PredicateNode. Enforces the oracle position
246
287
  * rule (oracle_price leaves must sit directly under the top-level `and`) and
247
288
  * the self-call rule (`in` allowlist / `compare eq` vs the smart account
248
289
  * address -> SCOPE_SELF_CALL). */
249
290
  function lowerCondition(cond, config) {
250
291
  switch (cond.op) {
292
+ // `out >= in * num/den`. The contract refuses den == 0 or a non-positive
293
+ // ratio at install, so a malformed floor fails loudly rather than
294
+ // silently inverting the comparison.
295
+ case 'slippage_floor':
296
+ return {
297
+ op: 'gte',
298
+ left: { kind: 'call_arg', index: cond.outArgIndex },
299
+ right: {
300
+ kind: 'call_arg_scaled',
301
+ index: cond.inArgIndex,
302
+ num: cond.num,
303
+ den: cond.den,
304
+ },
305
+ };
251
306
  case 'and':
252
307
  return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) };
253
308
  case 'or':
@@ -316,10 +371,13 @@ function lowerSelector(s) {
316
371
  return { kind: 'call_arg_len', index: s.argIndex };
317
372
  case 'arg_field':
318
373
  return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
374
+ // `amount` / `window_spent` are filtered out by `unsupportedConstruct`
375
+ // before lowering - the interpreter cannot source either on chain. Reaching
376
+ // here means the pre-scan was bypassed, so fail loudly rather than emit a
377
+ // leaf the contract will refuse.
319
378
  case 'amount':
320
- return { kind: 'amount', token: s.token };
321
379
  case 'window_spent':
322
- return { kind: 'window_spent', token: s.token, windowSeconds: s.windowSeconds };
380
+ throw new Error(`interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`);
323
381
  case 'invocation_count':
324
382
  return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds };
325
383
  case 'now':
@@ -346,6 +404,16 @@ function lowerSelector(s) {
346
404
  * - invocation_count -> u32 (counts are small non-negative integers)
347
405
  * - now / valid_until -> u64 (unix timestamps in seconds) */
348
406
  function literalFromIRCompare(c) {
407
+ // An oracle comparand is not a bare literal: the interpreter refuses one,
408
+ // because assuming it shares the normalised 9-dp basis is what made a raw
409
+ // 14-dp threshold permit everything it was written to deny.
410
+ if (c.selector.kind === 'oracle_price') {
411
+ if (c.valueDecimals === undefined) {
412
+ throw toolError('ORACLE_THRESHOLD_BASIS_REQUIRED', 'An oracle price bound must declare the decimal basis of its threshold ' +
413
+ '(oracle prices normalise to 9 decimals).');
414
+ }
415
+ return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals };
416
+ }
349
417
  const scalarType = selectorScalarType(c.selector);
350
418
  return literalFromScalar(c.value, scalarType);
351
419
  }
@@ -412,6 +480,8 @@ function assertNoOracleDescendants(node) {
412
480
  }
413
481
  function containsOracle(node) {
414
482
  switch (node.op) {
483
+ case 'slippage_floor':
484
+ return false;
415
485
  case 'compare':
416
486
  return node.compare.selector.kind === 'oracle_price';
417
487
  case 'in':
@@ -450,6 +520,8 @@ function assertOracleParamsTighten(params) {
450
520
  * express. Mirrors the OZ adapter's `describeCondition` for parity. */
451
521
  function describeCondition(cond) {
452
522
  switch (cond.op) {
523
+ case 'slippage_floor':
524
+ return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`;
453
525
  case 'in':
454
526
  return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`;
455
527
  case 'eq_seq':
@@ -212,6 +212,10 @@ function matchSpendingLimit(c) {
212
212
  * cannot express, used to populate `uncovered`. */
213
213
  function describeCondition(cond) {
214
214
  switch (cond.op) {
215
+ case 'slippage_floor':
216
+ // The OZ primitives bound a value against a constant; this bounds one
217
+ // call argument against another, which none of them can express.
218
+ return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`;
215
219
  case 'in':
216
220
  return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`;
217
221
  case 'eq_seq':
@@ -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;