@crediolabs/policy-synth 0.1.9 → 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 (96) 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 +61 -5
  18. package/dist/review-card/cross-check.js +50 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/index.d.ts +8 -2
  22. package/dist/run/index.js +2 -1
  23. package/dist/run/schemas.d.ts +16 -4
  24. package/dist/run/schemas.js +14 -0
  25. package/dist/synth/compose-from-recording.d.ts +4 -0
  26. package/dist/synth/compose-from-recording.js +34 -8
  27. package/dist/synth/deny-cases.d.ts +4 -1
  28. package/dist/synth/deny-cases.js +3 -1
  29. package/dist/synth/evaluate.js +130 -3
  30. package/dist/synth/permit-context.d.ts +15 -0
  31. package/dist/synth/permit-context.js +116 -0
  32. package/dist/synth/synthesize-from-mandate.d.ts +17 -2
  33. package/dist/synth/synthesize-from-mandate.js +27 -2
  34. package/dist/synth/synthesize-from-recording.d.ts +25 -1
  35. package/dist/synth/synthesize-from-recording.js +80 -3
  36. package/dist/types.d.ts +9 -0
  37. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  38. package/dist-cjs/errors.d.ts +1 -1
  39. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  40. package/dist-cjs/install/build-add-context-rule.js +308 -0
  41. package/dist-cjs/ir/types.d.ts +4 -0
  42. package/dist-cjs/predicate/decode.d.ts +11 -0
  43. package/dist-cjs/predicate/decode.js +239 -0
  44. package/dist-cjs/predicate/encode.js +79 -14
  45. package/dist-cjs/predicate/from-json.d.ts +4 -0
  46. package/dist-cjs/predicate/from-json.js +116 -0
  47. package/dist-cjs/predicate/index.d.ts +2 -0
  48. package/dist-cjs/predicate/index.js +10 -2
  49. package/dist-cjs/registry/identify.js +20 -1
  50. package/dist-cjs/registry/protocols.d.ts +50 -1
  51. package/dist-cjs/registry/protocols.js +89 -1
  52. package/dist-cjs/review-card/builder.d.ts +13 -0
  53. package/dist-cjs/review-card/builder.js +62 -5
  54. package/dist-cjs/review-card/cross-check.js +50 -3
  55. package/dist-cjs/review-card/index.d.ts +1 -1
  56. package/dist-cjs/review-card/index.js +2 -1
  57. package/dist-cjs/run/index.d.ts +8 -2
  58. package/dist-cjs/run/index.js +2 -1
  59. package/dist-cjs/run/schemas.d.ts +16 -4
  60. package/dist-cjs/run/schemas.js +14 -0
  61. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  62. package/dist-cjs/synth/compose-from-recording.js +34 -8
  63. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  64. package/dist-cjs/synth/deny-cases.js +3 -0
  65. package/dist-cjs/synth/evaluate.js +130 -3
  66. package/dist-cjs/synth/permit-context.d.ts +15 -0
  67. package/dist-cjs/synth/permit-context.js +119 -0
  68. package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
  69. package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
  70. package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
  71. package/dist-cjs/synth/synthesize-from-recording.js +80 -3
  72. package/dist-cjs/types.d.ts +9 -0
  73. package/package.json +5 -2
  74. package/src/adapters/interpreter/adapter.ts +72 -11
  75. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  76. package/src/errors.ts +13 -0
  77. package/src/install/build-add-context-rule.ts +429 -0
  78. package/src/ir/types.ts +4 -0
  79. package/src/predicate/decode.ts +242 -0
  80. package/src/predicate/encode.ts +110 -13
  81. package/src/predicate/from-json.ts +124 -0
  82. package/src/predicate/index.ts +5 -1
  83. package/src/registry/identify.ts +22 -2
  84. package/src/registry/protocols.ts +90 -1
  85. package/src/review-card/builder.ts +57 -5
  86. package/src/review-card/cross-check.ts +50 -3
  87. package/src/review-card/index.ts +1 -0
  88. package/src/run/index.ts +16 -2
  89. package/src/run/schemas.ts +14 -0
  90. package/src/synth/compose-from-recording.ts +46 -7
  91. package/src/synth/deny-cases.ts +3 -1
  92. package/src/synth/evaluate.ts +131 -3
  93. package/src/synth/permit-context.ts +137 -0
  94. package/src/synth/synthesize-from-mandate.ts +40 -4
  95. package/src/synth/synthesize-from-recording.ts +116 -6
  96. package/src/types.ts +12 -0
@@ -70,16 +70,57 @@ function pushComparison(left, right, op, out) {
70
70
  out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`);
71
71
  return;
72
72
  }
73
+ if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
74
+ out.push(`Length of arg[${left.index}] is ${right.value}`);
75
+ return;
76
+ }
77
+ // call_arg_field: must mirror the builder's templates byte-for-byte so
78
+ // the cross-check can assert a faithful summary.
79
+ if (left.kind === 'call_arg_field') {
80
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`;
81
+ const sep = op === 'eq' ? '=' : comparisonOpText(op);
82
+ if (right.kind === 'literal_address') {
83
+ out.push(`${head} ${sep} ${right.value}`);
84
+ return;
85
+ }
86
+ if (right.kind === 'literal_symbol') {
87
+ out.push(`${head} ${sep} ${right.value}`);
88
+ return;
89
+ }
90
+ if (right.kind === 'literal_bytes') {
91
+ out.push(`${head} ${sep} ${right.value}`);
92
+ return;
93
+ }
94
+ if (right.kind === 'literal_u64') {
95
+ out.push(`${head} ${sep} ${right.value}`);
96
+ return;
97
+ }
98
+ if (right.kind === 'literal_i128') {
99
+ out.push(`${head} ${sep} ${right.value}`);
100
+ return;
101
+ }
102
+ if (right.kind === 'literal_u32') {
103
+ out.push(`${head} ${sep} ${right.value}`);
104
+ return;
105
+ }
106
+ if (right.kind === 'literal_vec') {
107
+ out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`);
108
+ return;
109
+ }
110
+ }
111
+ // Mirrors the builder's wording byte-for-byte: how many calls the rule
112
+ // permits, since the bound counts the calls already made.
73
113
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
74
- out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
114
+ const allowed = op === 'lt' ? right.value : right.value + 1;
115
+ out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
75
116
  return;
76
117
  }
77
118
  if (left.kind === 'amount' && right.kind === 'literal_i128') {
78
119
  out.push(`Amount <= ${right.value}`);
79
120
  return;
80
121
  }
81
- if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
82
- out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`);
122
+ if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
123
+ out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`);
83
124
  return;
84
125
  }
85
126
  }
@@ -110,6 +151,7 @@ function renderVecElement(leaf) {
110
151
  case 'call_arg':
111
152
  case 'call_arg_len':
112
153
  case 'call_arg_field':
154
+ case 'call_arg_scaled':
113
155
  case 'amount':
114
156
  case 'window_spent':
115
157
  case 'now':
@@ -117,6 +159,11 @@ function renderVecElement(leaf) {
117
159
  case 'invocation_count_in_window':
118
160
  case 'oracle_price':
119
161
  return `<${leaf.kind}>`;
162
+ // Show the declared basis, not just the digits. A threshold on the wrong
163
+ // basis is the one policy error the contract cannot detect, so the review
164
+ // card is where a human has to be able to see it.
165
+ case 'oracle_threshold':
166
+ return `${leaf.value} (${leaf.decimals} dp)`;
120
167
  }
121
168
  }
122
169
  function renderHaystackElement(leaf) {
@@ -1,3 +1,3 @@
1
- export { buildReviewCardSummary, type ReviewCardSummary, } from './builder.ts';
1
+ export { buildReviewCardSummary, describePredicate, type ReviewCardSummary, } from './builder.ts';
2
2
  export { type ConflictAnnotation, classifyConflict, type RuleRef, } from './conflict.ts';
3
3
  export { summaryCrossCheck } from './cross-check.ts';
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  // src/review-card/index.ts - re-export the deterministic review-card surface.
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.summaryCrossCheck = exports.classifyConflict = exports.buildReviewCardSummary = void 0;
4
+ exports.summaryCrossCheck = exports.classifyConflict = exports.describePredicate = exports.buildReviewCardSummary = void 0;
5
5
  var builder_ts_1 = require("./builder.js");
6
6
  Object.defineProperty(exports, "buildReviewCardSummary", { enumerable: true, get: function () { return builder_ts_1.buildReviewCardSummary; } });
7
+ Object.defineProperty(exports, "describePredicate", { enumerable: true, get: function () { return builder_ts_1.describePredicate; } });
7
8
  var conflict_ts_1 = require("./conflict.js");
8
9
  Object.defineProperty(exports, "classifyConflict", { enumerable: true, get: function () { return conflict_ts_1.classifyConflict; } });
9
10
  var cross_check_ts_1 = require("./cross-check.js");
@@ -1,4 +1,5 @@
1
- import { type ErrorCode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
1
+ import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedTransaction, type ToolError, type ToolResponse } from '../index.ts';
2
+ import type { SimulationResult } from '../verify/envelope.ts';
2
3
  import { type RecordTransactionInput, type SynthesizePolicyInput } from './schemas.ts';
3
4
  export type { RecordTransactionInput, SynthesizePolicyInput } from './schemas.ts';
4
5
  export { ComposeUserResponsesSchema, InterpreterOptionsSchema, MandateSpecSchema, NetworkSchema, OzAdapterConfigSchema, RecordedTransactionSchema, RecordTransactionInputSchema, SynthesizePolicyInputSchema, ToolErrorSchema, } from './schemas.ts';
@@ -28,7 +29,12 @@ export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse
28
29
  * `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
29
30
  * XDR decode error in the adapter) would otherwise surface as
30
31
  * "[object Object]" in the MCP transport. */
31
- export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;
32
+ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy> & {
33
+ explain?: {
34
+ predicateTree: PredicateNode | null;
35
+ simulation: SimulationResult;
36
+ };
37
+ }>;
32
38
  /** Build a canonical ToolError for a thrown exception caught by the tool
33
39
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
34
40
  * default, so we extract a string-friendly message and tag the original
@@ -104,7 +104,7 @@ async function runSynthesizePolicy(raw) {
104
104
  // Zod's optional fields widen to `T | undefined`, which the core's
105
105
  // exact-optional MandateSpec rejects; the schema already validated the
106
106
  // shape, so assert it (same pattern as the recordedTx cast below).
107
- return await (0, index_ts_1.synthesizeFromMandate)(input.mandate, ozConfig);
107
+ return await (0, index_ts_1.synthesizeFromMandate)(input.mandate, ozConfig, input.explain === true ? { explain: true } : {});
108
108
  }
109
109
  // recording source
110
110
  const recorded = input.recordedTx;
@@ -115,6 +115,7 @@ async function runSynthesizePolicy(raw) {
115
115
  ? { confidenceOverride: input.confidenceOverride }
116
116
  : {}),
117
117
  ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
118
+ ...(input.explain === true ? { explain: true } : {}),
118
119
  }, ozConfig);
119
120
  }
120
121
  catch (e) {
@@ -648,6 +648,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
648
648
  weighted_threshold: string;
649
649
  };
650
650
  }>>;
651
+ explain: z.ZodOptional<z.ZodBoolean>;
651
652
  }, "strip", z.ZodTypeAny, {
652
653
  source: "mandate";
653
654
  mandate: {
@@ -676,6 +677,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
676
677
  weighted_threshold: string;
677
678
  };
678
679
  } | undefined;
680
+ explain?: boolean | undefined;
679
681
  }, {
680
682
  source: "mandate";
681
683
  mandate: {
@@ -704,6 +706,7 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
704
706
  weighted_threshold: string;
705
707
  };
706
708
  } | undefined;
709
+ explain?: boolean | undefined;
707
710
  }>;
708
711
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
709
712
  * express (per-method scoping, invocation-count windows, oracle bounds, exact
@@ -1085,17 +1088,18 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1085
1088
  weighted_threshold: string;
1086
1089
  };
1087
1090
  }>>;
1091
+ explain: z.ZodOptional<z.ZodBoolean>;
1088
1092
  }, "strip", z.ZodTypeAny, {
1089
1093
  network: "mainnet" | "testnet";
1090
1094
  source: "recording";
1091
1095
  recordedTx: {
1096
+ signers: string[];
1092
1097
  events: {
1093
1098
  contract: string;
1094
1099
  topics: string[];
1095
1100
  data?: unknown;
1096
1101
  }[];
1097
1102
  network: "mainnet" | "testnet";
1098
- signers: string[];
1099
1103
  invocations: unknown[];
1100
1104
  tokenMovements: {
1101
1105
  amount: string;
@@ -1149,17 +1153,18 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1149
1153
  weighted_threshold: string;
1150
1154
  };
1151
1155
  } | undefined;
1156
+ explain?: boolean | undefined;
1152
1157
  }, {
1153
1158
  network: "mainnet" | "testnet";
1154
1159
  source: "recording";
1155
1160
  recordedTx: {
1161
+ signers: string[];
1156
1162
  events: {
1157
1163
  contract: string;
1158
1164
  topics: string[];
1159
1165
  data?: unknown;
1160
1166
  }[];
1161
1167
  network: "mainnet" | "testnet";
1162
- signers: string[];
1163
1168
  invocations: unknown[];
1164
1169
  tokenMovements: {
1165
1170
  amount: string;
@@ -1213,6 +1218,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1213
1218
  weighted_threshold: string;
1214
1219
  };
1215
1220
  } | undefined;
1221
+ explain?: boolean | undefined;
1216
1222
  }>;
1217
1223
  export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"source", [z.ZodObject<{
1218
1224
  source: z.ZodLiteral<"mandate">;
@@ -1334,6 +1340,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1334
1340
  weighted_threshold: string;
1335
1341
  };
1336
1342
  }>>;
1343
+ explain: z.ZodOptional<z.ZodBoolean>;
1337
1344
  }, "strip", z.ZodTypeAny, {
1338
1345
  source: "mandate";
1339
1346
  mandate: {
@@ -1362,6 +1369,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1362
1369
  weighted_threshold: string;
1363
1370
  };
1364
1371
  } | undefined;
1372
+ explain?: boolean | undefined;
1365
1373
  }, {
1366
1374
  source: "mandate";
1367
1375
  mandate: {
@@ -1390,6 +1398,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1390
1398
  weighted_threshold: string;
1391
1399
  };
1392
1400
  } | undefined;
1401
+ explain?: boolean | undefined;
1393
1402
  }>, z.ZodObject<{
1394
1403
  source: z.ZodLiteral<"recording">;
1395
1404
  recordedTx: z.ZodObject<{
@@ -1736,17 +1745,18 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1736
1745
  weighted_threshold: string;
1737
1746
  };
1738
1747
  }>>;
1748
+ explain: z.ZodOptional<z.ZodBoolean>;
1739
1749
  }, "strip", z.ZodTypeAny, {
1740
1750
  network: "mainnet" | "testnet";
1741
1751
  source: "recording";
1742
1752
  recordedTx: {
1753
+ signers: string[];
1743
1754
  events: {
1744
1755
  contract: string;
1745
1756
  topics: string[];
1746
1757
  data?: unknown;
1747
1758
  }[];
1748
1759
  network: "mainnet" | "testnet";
1749
- signers: string[];
1750
1760
  invocations: unknown[];
1751
1761
  tokenMovements: {
1752
1762
  amount: string;
@@ -1800,17 +1810,18 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1800
1810
  weighted_threshold: string;
1801
1811
  };
1802
1812
  } | undefined;
1813
+ explain?: boolean | undefined;
1803
1814
  }, {
1804
1815
  network: "mainnet" | "testnet";
1805
1816
  source: "recording";
1806
1817
  recordedTx: {
1818
+ signers: string[];
1807
1819
  events: {
1808
1820
  contract: string;
1809
1821
  topics: string[];
1810
1822
  data?: unknown;
1811
1823
  }[];
1812
1824
  network: "mainnet" | "testnet";
1813
- signers: string[];
1814
1825
  invocations: unknown[];
1815
1826
  tokenMovements: {
1816
1827
  amount: string;
@@ -1864,6 +1875,7 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1864
1875
  weighted_threshold: string;
1865
1876
  };
1866
1877
  } | undefined;
1878
+ explain?: boolean | undefined;
1867
1879
  }>]>;
1868
1880
  export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>;
1869
1881
  export declare const ToolErrorSchema: z.ZodObject<{
@@ -178,6 +178,11 @@ exports.SynthesizePolicyMandateInputSchema = zod_1.z.object({
178
178
  source: zod_1.z.literal('mandate'),
179
179
  mandate: exports.MandateSpecSchema,
180
180
  ozConfig: exports.OzAdapterConfigSchema.optional(),
181
+ // --explain opt-in. When true, the orchestrator attaches the
182
+ // in-memory predicate tree (null for the mandate path) + a minimal
183
+ // honest SimulationResult to the success envelope. Absent or false
184
+ // -> the success envelope is unchanged (byte-identical to today).
185
+ explain: zod_1.z.boolean().optional(),
181
186
  });
182
187
  /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
183
188
  * express (per-method scoping, invocation-count windows, oracle bounds, exact
@@ -203,6 +208,15 @@ exports.SynthesizePolicyRecordingInputSchema = zod_1.z.object({
203
208
  confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
204
209
  interpreter: exports.InterpreterOptionsSchema.optional(),
205
210
  ozConfig: exports.OzAdapterConfigSchema.optional(),
211
+ // --explain opt-in. When true, the orchestrator attaches the
212
+ // in-memory PredicateNode + the corresponding SimulationResult
213
+ // (real one from the self-verify pipeline when the interpreter is
214
+ // engaged, minimal honest value otherwise) to the success envelope.
215
+ // Absent or false -> the success envelope is unchanged (byte-identical
216
+ // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
217
+ // (encodedPredicate, predicateHash, etc.) are never altered by enabling
218
+ // explain.
219
+ explain: zod_1.z.boolean().optional(),
206
220
  });
207
221
  exports.SynthesizePolicyInputSchema = zod_1.z.discriminatedUnion('source', [
208
222
  exports.SynthesizePolicyMandateInputSchema,
@@ -8,6 +8,10 @@ export interface OraclePriceBound {
8
8
  asset: string;
9
9
  operator: IRCompOp;
10
10
  value: string;
11
+ /** Decimal basis `value` is written on. REQUIRED: oracle prices normalise to
12
+ * 9 dp, and a threshold silently assumed to share that basis is what let a
13
+ * raw 14-dp bound permit everything. The author states it; we convert. */
14
+ decimals: number;
11
15
  }
12
16
  /** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
13
17
  * synth might apply must come from here - the recording supplies observed
@@ -108,11 +108,17 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
108
108
  value: limit,
109
109
  },
110
110
  };
111
- if (!interpreterEnabled || token === scopeContract) {
112
- ozConstraints.push(spendCond);
113
- }
114
- else {
115
- interpreterConstraints.push(spendCond);
111
+ // A rolling spend cap always goes to OZ, whose `spending_limit` is the
112
+ // audited implementation. The interpreter is NOT a fallback for the
113
+ // token != scopeContract case: on chain it sees one authorized call,
114
+ // not the transaction's token movements, so it has no per-call amount
115
+ // to accumulate and the counter would never move. OZ reports the case
116
+ // it cannot cover (it pins the limit to the context contract), which
117
+ // is the honest outcome - the old fallback produced an interpreter
118
+ // predicate that silently never bound.
119
+ ozConstraints.push(spendCond);
120
+ if (interpreterEnabled && token !== scopeContract) {
121
+ warnings.push(`rolling spend cap on ${token} cannot be enforced on chain: OZ spending_limit pins the limit to the context contract, and the interpreter cannot observe token movements. Bound the per-call value with an argument cap plus an invocation-count limit instead.`);
116
122
  }
117
123
  }
118
124
  }
@@ -137,7 +143,10 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
137
143
  op: 'compare',
138
144
  compare: {
139
145
  selector: { kind: 'invocation_count', windowSeconds },
140
- operator: 'lte',
146
+ // `lt`, not `lte`: the leaf reports the calls ALREADY made in the
147
+ // window, so `< N` is what permits N of them. With `lte` a limit
148
+ // of N let an N+1th call through.
149
+ operator: 'lt',
141
150
  value: String(invocationLimit),
142
151
  },
143
152
  };
@@ -168,6 +177,7 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
168
177
  selector: { kind: 'oracle_price', asset: b.asset },
169
178
  operator: b.operator,
170
179
  value: b.value,
180
+ valueDecimals: b.decimals,
171
181
  },
172
182
  };
173
183
  if (interpreterEnabled) {
@@ -303,8 +313,15 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
303
313
  });
304
314
  for (let i = 0; i < elements.length; i++) {
305
315
  const element = elements[i];
306
- if (!element || element.type !== 'map')
316
+ // An element we cannot bind leaves that request's action, asset and
317
+ // amount free while the length pin makes the policy LOOK total. The
318
+ // length pin still stays (it is a real restriction, and dropping it
319
+ // would only widen the policy) - what must not happen is dropping the
320
+ // element quietly.
321
+ if (element?.type !== 'map') {
322
+ warnings.push(`Blend submit requests element ${i} is not a Request map (recorded as ${element?.type ?? 'nothing'}): its request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`);
307
323
  continue;
324
+ }
308
325
  const fields = element.value;
309
326
  const fieldValue = (name, scalarType) => {
310
327
  const entry = fields.find((e) => e.key === name);
@@ -321,8 +338,17 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
321
338
  const address = fieldValue('address', 'address');
322
339
  const amount = fieldValue('amount', 'i128');
323
340
  const requestType = fieldValue('request_type', 'u32');
324
- if (address === null || amount === null || requestType === null)
341
+ if (address === null || amount === null || requestType === null) {
342
+ const missing = [
343
+ requestType === null ? 'request_type (u32)' : null,
344
+ address === null ? 'address' : null,
345
+ amount === null ? 'amount (i128)' : null,
346
+ ].filter((f) => f !== null);
347
+ // Partial pins are deliberately NOT emitted (see the note above): a
348
+ // half-pinned request reads as a bound one on the review card.
349
+ warnings.push(`Blend submit requests element ${i} could not be bound: ${missing.join(', ')} missing or of an unexpected type. That request's request_type, address and amount are NOT pinned, so any action on any asset for any amount is permitted in that position`);
325
350
  continue;
351
+ }
326
352
  interpreterConstraints.push({
327
353
  op: 'compare',
328
354
  compare: {
@@ -1,4 +1,4 @@
1
- import type { PredicateNode } from '../types.ts';
1
+ import type { PredicateNode, ScVal } from '../types.ts';
2
2
  import type { EvalContext } from './evaluate.ts';
3
3
  export interface DenyCase {
4
4
  dimension: string;
@@ -20,3 +20,6 @@ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS };
20
20
  * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
21
21
  * existing fixtures do not regress. */
22
22
  export declare function generateCases(predicate: PredicateNode, permitCtx: EvalContext, dimensions?: string[]): GeneratedCases;
23
+ /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
24
+ * Exported so the permit-context builder shares this one implementation. */
25
+ export declare function cloneScVal(value: ScVal, depth?: number): ScVal;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OVERPERMISSIVE_DIMENSIONS = exports.ORIGINAL_DIMENSIONS = void 0;
4
4
  exports.generateCases = generateCases;
5
+ exports.cloneScVal = cloneScVal;
5
6
  const types_ts_1 = require("../types.js");
6
7
  const predicate_literals_ts_1 = require("./predicate-literals.js");
7
8
  const ORACLE_CASES = [
@@ -481,6 +482,8 @@ function cloneContext(ctx) {
481
482
  cloned.signerWeights = { ...ctx.signerWeights };
482
483
  return cloned;
483
484
  }
485
+ /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
486
+ * Exported so the permit-context builder shares this one implementation. */
484
487
  function cloneScVal(value, depth = 0) {
485
488
  // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
486
489
  // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
@@ -32,6 +32,11 @@
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.evaluate = evaluate;
34
34
  const predicate_literals_ts_1 = require("./predicate-literals.js");
35
+ /** Oracle prices normalise to this many decimals; mirrors NORMALISED_DECIMALS
36
+ * in oracle.rs. A threshold on any other basis must say so. */
37
+ const NORMALISED_DECIMALS = 9;
38
+ /** Mirrors MAX_ORACLE_THRESHOLD_DECIMALS in dsl.rs. */
39
+ const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
35
40
  /** Internal fatal thrown by the oracle path; caught at the top of `evaluate`
36
41
  * and converted to the matching `ORACLE_*` deny reason. NOT a
37
42
  * boolean-false that `not` / `or` could mask. */
@@ -168,6 +173,13 @@ function evalCompare(op, left, right, ctx) {
168
173
  }
169
174
  // --- step 4b: call_arg comparison (eq / exact-vec, or an ordered numeric bound) ---
170
175
  if (left.kind === 'call_arg') {
176
+ // The swap's canonical form is `call_arg[out] >= call_arg_scaled(in, num, den)`.
177
+ // Dispatched here so the floor's dedicated reason codes (ARITHMETIC_OVERFLOW
178
+ // -> SLIPPAGE_FLOOR) reach the user; routing the scaled RHS through the
179
+ // generic `evalArgOrderedCompare` would mask overflow as ARG_MISMATCH.
180
+ if (right.kind === 'call_arg_scaled') {
181
+ return evalScaledArgCompare(op, left, right, ctx);
182
+ }
171
183
  const actual = ctx.args[left.index];
172
184
  // An ordered comparison (lt/lte/gt/gte) reads the arg as an integer and
173
185
  // compares it to a numeric literal via BigInt (e.g. a SoroSwap input-amount
@@ -178,6 +190,11 @@ function evalCompare(op, left, right, ctx) {
178
190
  return evalArgOrderedCompare(op, actual, right);
179
191
  return evalArgEq(op, actual, right, ctx);
180
192
  }
193
+ // --- step 4b': scaled leaf on the LEFT (`call_arg_scaled(in, num, den) <= call_arg[out]`).
194
+ // Symmetric form so a policy that phrases the floor either way works.
195
+ if (left.kind === 'call_arg_scaled') {
196
+ return evalScaledArgCompare(op, right, left, ctx);
197
+ }
181
198
  // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
182
199
  // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
183
200
  if (left.kind === 'call_arg_len') {
@@ -296,6 +313,102 @@ function evalArgEq(op, actual, right, ctx) {
296
313
  return { permit: false, reason: 'ARG_MISMATCH' };
297
314
  return { permit: false, reason: 'ARG_MISMATCH' };
298
315
  }
316
+ /** Step 4b': slippage-floor comparison. Mirrors the Rust `eval_scaled_arg_compare`
317
+ * path: the scaled leaf is `args[index] * num / den` (truncating toward
318
+ * zero). On `checked_mul` / `checked_div` failure (overflow or
319
+ * divide-by-zero) the comparison denies with `ARITHMETIC_OVERFLOW`. A
320
+ * failed comparison denies with `SLIPPAGE_FLOOR` (the dedicated reason)
321
+ * rather than the generic `ARG_MISMATCH`.
322
+ *
323
+ * `left` is whichever side of the compare is NOT the scaled leaf. A
324
+ * scaled-on-scaled compare denies `ARG_MISMATCH` (pipelining two scaled
325
+ * leaves has no definable semantics). The other operand must be a
326
+ * numeric shape (`call_arg` carrying a number, or a numeric literal);
327
+ * anything else is `ARG_MISMATCH`. */
328
+ function evalScaledArgCompare(op, left, right, ctx) {
329
+ // Identify which side is scaled, and pull the other side as a number.
330
+ let scaled;
331
+ let other;
332
+ let scaledOnRight;
333
+ if (left.kind === 'call_arg_scaled') {
334
+ scaled = left;
335
+ other = right;
336
+ scaledOnRight = false;
337
+ }
338
+ else if (right.kind === 'call_arg_scaled') {
339
+ scaled = right;
340
+ other = left;
341
+ scaledOnRight = true;
342
+ }
343
+ else {
344
+ // Neither side is scaled - this is a programming error in the
345
+ // dispatcher; the contract returns ARG_MISMATCH to fail closed.
346
+ return { permit: false, reason: 'ARG_MISMATCH' };
347
+ }
348
+ // Source `args[index]` -> BigInt. An out-of-bounds index or a
349
+ // non-numeric arg fails closed as ARG_MISMATCH (not SLIPPAGE_FLOOR) -
350
+ // a violated floor is the wrong code when the operand itself could
351
+ // not be read.
352
+ if (scaled.index >= ctx.args.length) {
353
+ return { permit: false, reason: 'ARG_MISMATCH' };
354
+ }
355
+ const input = argNumericBigInt(ctx.args[scaled.index]);
356
+ if (input === null)
357
+ return { permit: false, reason: 'ARG_MISMATCH' };
358
+ let num;
359
+ let den;
360
+ try {
361
+ num = BigInt(scaled.num);
362
+ den = BigInt(scaled.den);
363
+ }
364
+ catch {
365
+ return { permit: false, reason: 'ARG_MISMATCH' };
366
+ }
367
+ // Install refuses `den == 0` and `num <= 0` / `den <= 0`. The runtime
368
+ // check is a defensive belt-and-braces - a future validator
369
+ // regression cannot panic the frame on a divide-by-zero.
370
+ if (den === 0n)
371
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
372
+ const product = input * num;
373
+ // BigInt overflow is silent in JS (it does not throw on multiplication).
374
+ // We can detect over-range by dividing and checking the result against
375
+ // i128 bounds, but the more practical check is whether the result
376
+ // divides cleanly. We rely on BigInt's arbitrary precision and then
377
+ // surface ARITHMETIC_OVERFLOW if the result is outside i128.
378
+ let scaledValue;
379
+ try {
380
+ // BigInt division truncates toward zero (matches Rust `i128::checked_div`).
381
+ scaledValue = product / den;
382
+ }
383
+ catch {
384
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
385
+ }
386
+ // i128 range check: if the scaled value is outside i128, the contract
387
+ // would have wrapped on i128 arithmetic; surface the same deny.
388
+ const I128_MAX = (1n << 127n) - 1n;
389
+ const I128_MIN = -(1n << 127n);
390
+ if (scaledValue > I128_MAX || scaledValue < I128_MIN) {
391
+ return { permit: false, reason: 'ARITHMETIC_OVERFLOW' };
392
+ }
393
+ // Resolving the operand when the scaled side is on the right: the
394
+ // operand is `left`; the scaled value is the RHS. The AST order is
395
+ // `left <op> scaled`, so the comparator applies to `other <op> scaled`.
396
+ // When the scaled side is on the left, the order is `scaled <op> other`,
397
+ // i.e. `scaled <op> other_val`.
398
+ let otherVal;
399
+ if (other.kind === 'call_arg') {
400
+ otherVal = argNumericBigInt(ctx.args[other.index]);
401
+ }
402
+ else {
403
+ otherVal = (0, predicate_literals_ts_1.literalNumericBigInt)(other);
404
+ }
405
+ if (otherVal === null)
406
+ return { permit: false, reason: 'ARG_MISMATCH' };
407
+ const pass = scaledOnRight
408
+ ? bigintCmp(op, otherVal.toString(), scaledValue.toString())
409
+ : bigintCmp(op, scaledValue.toString(), otherVal.toString());
410
+ return pass ? { permit: true } : { permit: false, reason: 'SLIPPAGE_FLOOR' };
411
+ }
299
412
  /** Ordered numeric comparison (lt/lte/gt/gte) on a `call_arg`. The interpreter
300
413
  * reads the arg as an integer (i128 / u64 / u32 on the recorder's ScVal
301
414
  * surface) and compares it to a numeric literal via BigInt. A non-numeric arg
@@ -392,6 +505,7 @@ function resolveLeaf(leaf, ctx) {
392
505
  case 'invocation_count_in_window':
393
506
  case 'now':
394
507
  case 'valid_until':
508
+ case 'call_arg_scaled':
395
509
  return undefined; // selector leaves with no ScVal projection
396
510
  case 'literal_address':
397
511
  return { type: 'address', value: leaf.value };
@@ -450,10 +564,23 @@ function evalOracleCompare(op, asset, right, ctx) {
450
564
  throw new OracleError('ORACLE_STALE');
451
565
  throw new OracleError(mapped);
452
566
  }
453
- const literal = right.kind === 'literal_i128' ? right.value : null;
454
- if (literal === null)
567
+ // Mirrors eval_oracle_compare in dsl.rs. The threshold MUST declare its
568
+ // decimal basis: prices are on the normalised 9-dp basis, and assuming a
569
+ // bare literal shares it is what let a raw 14-dp threshold permit
570
+ // everything. A bare literal is refused rather than assumed.
571
+ if (right.kind !== 'oracle_threshold')
455
572
  throw new OracleError('ORACLE_DECIMALS_MISMATCH');
456
- return bigintCmp(op, entry.price, literal)
573
+ if (right.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
574
+ throw new OracleError('ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE');
575
+ }
576
+ // Scale BOTH sides up to the wider basis rather than dividing the threshold
577
+ // down: dividing truncates, and a truncated bound moves the permit boundary.
578
+ const decimals = BigInt(right.decimals);
579
+ const normalised = BigInt(NORMALISED_DECIMALS);
580
+ const common = decimals > normalised ? decimals : normalised;
581
+ const priceScaled = BigInt(entry.price) * 10n ** (common - normalised);
582
+ const literalScaled = BigInt(right.value) * 10n ** (common - decimals);
583
+ return bigintCmp(op, String(priceScaled), String(literalScaled))
457
584
  ? { permit: true }
458
585
  : { permit: false, reason: 'FN_MISMATCH' };
459
586
  }
@@ -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;