@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
@@ -139,10 +139,98 @@ export const SOROSWAP_ABI = {
139
139
  ],
140
140
  },
141
141
  };
142
+ /** OpenZeppelin smart-account ABI subset.
143
+ *
144
+ * Why this lives in the recogniser: every user's smart account has a
145
+ * different C-address, so a per-deployment pin would never cover them.
146
+ * What is the same across every OpenZeppelin multisig account is the
147
+ * small set of public context-rule entrypoints. A recording that
148
+ * invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
149
+ * is overwhelmingly likely to be calling the user's own OpenZeppelin
150
+ * smart account (the canonical reason this product exists); we
151
+ * recognise that pattern by interface, the same way SEP-41 is
152
+ * recognised by interface.
153
+ *
154
+ * SECURITY PROPERTY (load-bearing):
155
+ * - Recognition claims the call TARGETS a particular protocol. We
156
+ * only claim `oz_account` when the (fn, args) shape uniquely matches
157
+ * one of the three entrypoints below AND the call looks like a real
158
+ * context-rule operation. The match is exact: off-by-one arg count,
159
+ * off-by-one arg type, or a method name close-but-wrong returns null
160
+ * (fail-closed). A contract that incidentally happens to expose a
161
+ * single matching fn is not sufficient - the recorder requires ALL
162
+ * invocations to individually match, so a hostile contract with
163
+ * exactly one matching fn would still contribute to the unknown
164
+ * bucket for any other calls.
165
+ * - We do NOT claim `oz_account` for unknown calls, for arg-shape
166
+ * mismatches, or for calls where the recorder could not pin down
167
+ * the (fn, args) shape. The freshness gate then runs at the same
168
+ * 1.0 threshold - lowering the gate for unknown protocols remains
169
+ * a separate, opt-in production override (see RecordInput below).
170
+ *
171
+ * ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
172
+ * pinned from the OpenZeppelin Reloaded `multisig_account_example`
173
+ * contract (commit ef82b65, fetched 2026-07-28).
174
+ *
175
+ * SCOPE NOTE: this ABI only covers the three entrypoints whose args
176
+ * map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
177
+ * ...). `add_context_rule` - the install path - takes `(Symbol,
178
+ * String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
179
+ * `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
180
+ * not in the AbiArgType set today (the recogniser's `argsMatchAbi`
181
+ * helper rejects `other` deliberately so a close-but-wrong call does
182
+ * not slip through). Excluding `add_context_rule` is the right
183
+ * trade-off for now - it never blocks OZ smart-account recognition,
184
+ * because the install tx always follows a recognised
185
+ * `batch_add_signer` (the demo's first-pass tx), so the
186
+ * contract still ends up in `knownContracts`. Updating the Arg type
187
+ * vocabulary to cover these shapes is a separate concern (and would
188
+ * also lift the SEP-41 / Blend recognition in the same change).
189
+ */
190
+ export const OZ_ACCOUNT_ABI = {
191
+ batch_add_signer: {
192
+ args: [
193
+ {
194
+ name: 'context_rule_id',
195
+ type: 'u32',
196
+ meaning: 'OZ context rule id this signer is added to',
197
+ },
198
+ {
199
+ name: 'signers',
200
+ type: 'vec',
201
+ meaning: 'vec<SignerKey> signers to add (Delegated G-address or Account C-address)',
202
+ },
203
+ ],
204
+ },
205
+ batch_remove_signer: {
206
+ args: [
207
+ {
208
+ name: 'context_rule_id',
209
+ type: 'u32',
210
+ meaning: 'OZ context rule id to remove signers from',
211
+ },
212
+ {
213
+ name: 'signers',
214
+ type: 'vec',
215
+ meaning: 'vec<SignerKey> signers to remove from the rule',
216
+ },
217
+ ],
218
+ },
219
+ remove_context_rule: {
220
+ args: [
221
+ {
222
+ name: 'context_rule_id',
223
+ type: 'u32',
224
+ meaning: 'OZ context rule id to delete',
225
+ },
226
+ ],
227
+ },
228
+ };
142
229
  export const PROTOCOL_ABIS = {
143
230
  sep41: SEP41_ABI,
144
231
  blend: BLEND_ABI,
145
232
  soroswap: SOROSWAP_ABI,
233
+ oz_account: OZ_ACCOUNT_ABI,
146
234
  };
147
235
  export function getAbi(protocol) {
148
236
  return PROTOCOL_ABIS[protocol];
@@ -12,3 +12,16 @@ export interface ReviewCardSummary {
12
12
  /** Build a deterministic review-card summary from a policy + context rule +
13
13
  * simulation result. Pure: same inputs -> byte-identical output. */
14
14
  export declare function buildReviewCardSummary(predicate: PredicateNode | null, policyRefs: PolicyRef[], contextRule: ContextRuleDraft, simulation: SimulationResult): ReviewCardSummary;
15
+ /** Walk every comparison / membership node of the predicate and invoke
16
+ * `visit` on each. The walk is depth-first, left-to-right, so the
17
+ * constraint list is stable across runs. Pure boolean nodes contribute no
18
+ * constraint lines themselves; their leaf children do, via the visitor. */
19
+ /** Every constraint sentence for one predicate, in walk order.
20
+ *
21
+ * `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
22
+ * and needs the refs / context rule / simulation to do it. This renders a
23
+ * predicate on its own, which is what a caller holding a policy already
24
+ * INSTALLED on chain has: it can read the document out of the interpreter's
25
+ * storage and decode it, but there is no proposal around it. Same renderers,
26
+ * so an installed rule reads exactly as it did on the review card. */
27
+ export declare function describePredicate(predicate: PredicateNode): string[];
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // 2. The interpreter `PredicateNode`. One string per constraint leaf,
18
18
  // rendered by enclosing-comparison kind. Templates (Task 7b):
19
- // - invocation_count_in_window <= N -> Invocations <= N per <window> seconds
19
+ // - invocation_count_in_window < N -> At most N calls per <window> seconds
20
20
  // - call_arg[i] in [list] -> Recipient/arg must be one of [list]
21
21
  // - eq(call_arg[i], literal_vec[...]) -> Path must be exactly [list]
22
22
  // - oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
@@ -81,6 +81,23 @@ function renderOzPrimitive(ref) {
81
81
  * `visit` on each. The walk is depth-first, left-to-right, so the
82
82
  * constraint list is stable across runs. Pure boolean nodes contribute no
83
83
  * constraint lines themselves; their leaf children do, via the visitor. */
84
+ /** Every constraint sentence for one predicate, in walk order.
85
+ *
86
+ * `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
87
+ * and needs the refs / context rule / simulation to do it. This renders a
88
+ * predicate on its own, which is what a caller holding a policy already
89
+ * INSTALLED on chain has: it can read the document out of the interpreter's
90
+ * storage and decode it, but there is no proposal around it. Same renderers,
91
+ * so an installed rule reads exactly as it did on the review card. */
92
+ export function describePredicate(predicate) {
93
+ const constraints = [];
94
+ walkPredicate(predicate, (node) => {
95
+ const line = renderConstraint(node);
96
+ if (line !== null)
97
+ constraints.push(line);
98
+ });
99
+ return constraints;
100
+ }
84
101
  function walkPredicate(node, visit) {
85
102
  switch (node.op) {
86
103
  case 'and':
@@ -168,17 +185,20 @@ function renderComparison(node) {
168
185
  return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
169
186
  }
170
187
  }
171
- // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
188
+ // The bound is compared against the calls ALREADY made in the window, so
189
+ // `< N` permits N of them and `<= N` permits one more. Report how many
190
+ // calls the rule allows rather than restating the comparison.
172
191
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
173
- return `Invocations <= ${right.value} per ${left.windowSecs} seconds`;
192
+ const allowed = node.op === 'lt' ? right.value : right.value + 1;
193
+ return `At most ${allowed} calls per ${left.windowSecs} seconds`;
174
194
  }
175
195
  // amount <= v -> Amount <= v
176
196
  if (left.kind === 'amount' && right.kind === 'literal_i128') {
177
197
  return `Amount <= ${right.value}`;
178
198
  }
179
199
  // oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
180
- if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
181
- return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`;
200
+ if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
201
+ return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value} (${right.decimals} dp)`;
182
202
  }
183
203
  // Any other comparison shape is a structural fail-closed: do not surface
184
204
  // a misleading line. Cross-check still requires every leaf produce a
@@ -216,6 +236,7 @@ function renderVecElement(leaf) {
216
236
  case 'call_arg':
217
237
  case 'call_arg_len':
218
238
  case 'call_arg_field':
239
+ case 'call_arg_scaled':
219
240
  case 'amount':
220
241
  case 'window_spent':
221
242
  case 'now':
@@ -223,6 +244,11 @@ function renderVecElement(leaf) {
223
244
  case 'invocation_count_in_window':
224
245
  case 'oracle_price':
225
246
  return `<${leaf.kind}>`;
247
+ // Show the declared basis, not just the digits. A threshold on the wrong
248
+ // basis is the one policy error the contract cannot detect, so the review
249
+ // card is where a human has to be able to see it.
250
+ case 'oracle_threshold':
251
+ return `${leaf.value} (${leaf.decimals} dp)`;
226
252
  }
227
253
  }
228
254
  function renderHaystackElement(leaf) {
@@ -105,16 +105,19 @@ function pushComparison(left, right, op, out) {
105
105
  return;
106
106
  }
107
107
  }
108
+ // Mirrors the builder's wording byte-for-byte: how many calls the rule
109
+ // permits, since the bound counts the calls already made.
108
110
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
109
- out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`);
111
+ const allowed = op === 'lt' ? right.value : right.value + 1;
112
+ out.push(`At most ${allowed} calls per ${left.windowSecs} seconds`);
110
113
  return;
111
114
  }
112
115
  if (left.kind === 'amount' && right.kind === 'literal_i128') {
113
116
  out.push(`Amount <= ${right.value}`);
114
117
  return;
115
118
  }
116
- if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
117
- out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value}`);
119
+ if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
120
+ out.push(`Only when oracle_price(${left.asset}) ${comparisonOpText(op)} ${right.value} (${right.decimals} dp)`);
118
121
  return;
119
122
  }
120
123
  }
@@ -145,6 +148,7 @@ function renderVecElement(leaf) {
145
148
  case 'call_arg':
146
149
  case 'call_arg_len':
147
150
  case 'call_arg_field':
151
+ case 'call_arg_scaled':
148
152
  case 'amount':
149
153
  case 'window_spent':
150
154
  case 'now':
@@ -152,6 +156,11 @@ function renderVecElement(leaf) {
152
156
  case 'invocation_count_in_window':
153
157
  case 'oracle_price':
154
158
  return `<${leaf.kind}>`;
159
+ // Show the declared basis, not just the digits. A threshold on the wrong
160
+ // basis is the one policy error the contract cannot detect, so the review
161
+ // card is where a human has to be able to see it.
162
+ case 'oracle_threshold':
163
+ return `${leaf.value} (${leaf.decimals} dp)`;
155
164
  }
156
165
  }
157
166
  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,4 +1,4 @@
1
1
  // src/review-card/index.ts - re-export the deterministic review-card surface.
2
- export { buildReviewCardSummary, } from "./builder.js";
2
+ export { buildReviewCardSummary, describePredicate, } from "./builder.js";
3
3
  export { classifyConflict, } from "./conflict.js";
4
4
  export { summaryCrossCheck } from "./cross-check.js";
@@ -1093,13 +1093,13 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1093
1093
  network: "mainnet" | "testnet";
1094
1094
  source: "recording";
1095
1095
  recordedTx: {
1096
+ signers: string[];
1096
1097
  events: {
1097
1098
  contract: string;
1098
1099
  topics: string[];
1099
1100
  data?: unknown;
1100
1101
  }[];
1101
1102
  network: "mainnet" | "testnet";
1102
- signers: string[];
1103
1103
  invocations: unknown[];
1104
1104
  tokenMovements: {
1105
1105
  amount: string;
@@ -1158,13 +1158,13 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1158
1158
  network: "mainnet" | "testnet";
1159
1159
  source: "recording";
1160
1160
  recordedTx: {
1161
+ signers: string[];
1161
1162
  events: {
1162
1163
  contract: string;
1163
1164
  topics: string[];
1164
1165
  data?: unknown;
1165
1166
  }[];
1166
1167
  network: "mainnet" | "testnet";
1167
- signers: string[];
1168
1168
  invocations: unknown[];
1169
1169
  tokenMovements: {
1170
1170
  amount: string;
@@ -1750,13 +1750,13 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1750
1750
  network: "mainnet" | "testnet";
1751
1751
  source: "recording";
1752
1752
  recordedTx: {
1753
+ signers: string[];
1753
1754
  events: {
1754
1755
  contract: string;
1755
1756
  topics: string[];
1756
1757
  data?: unknown;
1757
1758
  }[];
1758
1759
  network: "mainnet" | "testnet";
1759
- signers: string[];
1760
1760
  invocations: unknown[];
1761
1761
  tokenMovements: {
1762
1762
  amount: string;
@@ -1815,13 +1815,13 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1815
1815
  network: "mainnet" | "testnet";
1816
1816
  source: "recording";
1817
1817
  recordedTx: {
1818
+ signers: string[];
1818
1819
  events: {
1819
1820
  contract: string;
1820
1821
  topics: string[];
1821
1822
  data?: unknown;
1822
1823
  }[];
1823
1824
  network: "mainnet" | "testnet";
1824
- signers: string[];
1825
1825
  invocations: unknown[];
1826
1826
  tokenMovements: {
1827
1827
  amount: string;
@@ -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
@@ -105,11 +105,17 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
105
105
  value: limit,
106
106
  },
107
107
  };
108
- if (!interpreterEnabled || token === scopeContract) {
109
- ozConstraints.push(spendCond);
110
- }
111
- else {
112
- interpreterConstraints.push(spendCond);
108
+ // A rolling spend cap always goes to OZ, whose `spending_limit` is the
109
+ // audited implementation. The interpreter is NOT a fallback for the
110
+ // token != scopeContract case: on chain it sees one authorized call,
111
+ // not the transaction's token movements, so it has no per-call amount
112
+ // to accumulate and the counter would never move. OZ reports the case
113
+ // it cannot cover (it pins the limit to the context contract), which
114
+ // is the honest outcome - the old fallback produced an interpreter
115
+ // predicate that silently never bound.
116
+ ozConstraints.push(spendCond);
117
+ if (interpreterEnabled && token !== scopeContract) {
118
+ 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.`);
113
119
  }
114
120
  }
115
121
  }
@@ -134,7 +140,10 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
134
140
  op: 'compare',
135
141
  compare: {
136
142
  selector: { kind: 'invocation_count', windowSeconds },
137
- operator: 'lte',
143
+ // `lt`, not `lte`: the leaf reports the calls ALREADY made in the
144
+ // window, so `< N` is what permits N of them. With `lte` a limit
145
+ // of N let an N+1th call through.
146
+ operator: 'lt',
138
147
  value: String(invocationLimit),
139
148
  },
140
149
  };
@@ -165,6 +174,7 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
165
174
  selector: { kind: 'oracle_price', asset: b.asset },
166
175
  operator: b.operator,
167
176
  value: b.value,
177
+ valueDecimals: b.decimals,
168
178
  },
169
179
  };
170
180
  if (interpreterEnabled) {
@@ -300,8 +310,15 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
300
310
  });
301
311
  for (let i = 0; i < elements.length; i++) {
302
312
  const element = elements[i];
303
- if (!element || element.type !== 'map')
313
+ // An element we cannot bind leaves that request's action, asset and
314
+ // amount free while the length pin makes the policy LOOK total. The
315
+ // length pin still stays (it is a real restriction, and dropping it
316
+ // would only widen the policy) - what must not happen is dropping the
317
+ // element quietly.
318
+ if (element?.type !== 'map') {
319
+ 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`);
304
320
  continue;
321
+ }
305
322
  const fields = element.value;
306
323
  const fieldValue = (name, scalarType) => {
307
324
  const entry = fields.find((e) => e.key === name);
@@ -318,8 +335,17 @@ function appendProtocolSpecificConstraints(ozConstraints, interpreterConstraints
318
335
  const address = fieldValue('address', 'address');
319
336
  const amount = fieldValue('amount', 'i128');
320
337
  const requestType = fieldValue('request_type', 'u32');
321
- if (address === null || amount === null || requestType === null)
338
+ if (address === null || amount === null || requestType === null) {
339
+ const missing = [
340
+ requestType === null ? 'request_type (u32)' : null,
341
+ address === null ? 'address' : null,
342
+ amount === null ? 'amount (i128)' : null,
343
+ ].filter((f) => f !== null);
344
+ // Partial pins are deliberately NOT emitted (see the note above): a
345
+ // half-pinned request reads as a bound one on the review card.
346
+ 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`);
322
347
  continue;
348
+ }
323
349
  interpreterConstraints.push({
324
350
  op: 'compare',
325
351
  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;
@@ -476,7 +476,9 @@ function cloneContext(ctx) {
476
476
  cloned.signerWeights = { ...ctx.signerWeights };
477
477
  return cloned;
478
478
  }
479
- function cloneScVal(value, depth = 0) {
479
+ /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
480
+ * Exported so the permit-context builder shares this one implementation. */
481
+ export function cloneScVal(value, depth = 0) {
480
482
  // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
481
483
  // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
482
484
  // throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
@@ -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;