@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.
- package/dist/adapters/interpreter/adapter.js +62 -11
- package/dist/errors.d.ts +1 -1
- package/dist/install/build-add-context-rule.d.ts +48 -0
- package/dist/install/build-add-context-rule.js +304 -0
- package/dist/ir/types.d.ts +4 -0
- package/dist/predicate/decode.d.ts +11 -0
- package/dist/predicate/decode.js +234 -0
- package/dist/predicate/encode.js +79 -14
- package/dist/predicate/from-json.d.ts +4 -0
- package/dist/predicate/from-json.js +113 -0
- package/dist/predicate/index.d.ts +2 -0
- package/dist/predicate/index.js +5 -1
- package/dist/registry/identify.js +21 -2
- package/dist/registry/protocols.d.ts +50 -1
- package/dist/registry/protocols.js +88 -0
- package/dist/review-card/builder.d.ts +13 -0
- package/dist/review-card/builder.js +61 -5
- package/dist/review-card/cross-check.js +50 -3
- package/dist/review-card/index.d.ts +1 -1
- package/dist/review-card/index.js +1 -1
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +16 -4
- package/dist/run/schemas.js +14 -0
- package/dist/synth/compose-from-recording.d.ts +4 -0
- package/dist/synth/compose-from-recording.js +34 -8
- package/dist/synth/deny-cases.d.ts +4 -1
- package/dist/synth/deny-cases.js +3 -1
- package/dist/synth/evaluate.js +130 -3
- package/dist/synth/permit-context.d.ts +15 -0
- package/dist/synth/permit-context.js +116 -0
- package/dist/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist/synth/synthesize-from-mandate.js +27 -2
- package/dist/synth/synthesize-from-recording.d.ts +25 -1
- package/dist/synth/synthesize-from-recording.js +80 -3
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +62 -11
- package/dist-cjs/errors.d.ts +1 -1
- package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
- package/dist-cjs/install/build-add-context-rule.js +308 -0
- package/dist-cjs/ir/types.d.ts +4 -0
- package/dist-cjs/predicate/decode.d.ts +11 -0
- package/dist-cjs/predicate/decode.js +239 -0
- package/dist-cjs/predicate/encode.js +79 -14
- package/dist-cjs/predicate/from-json.d.ts +4 -0
- package/dist-cjs/predicate/from-json.js +116 -0
- package/dist-cjs/predicate/index.d.ts +2 -0
- package/dist-cjs/predicate/index.js +10 -2
- package/dist-cjs/registry/identify.js +20 -1
- package/dist-cjs/registry/protocols.d.ts +50 -1
- package/dist-cjs/registry/protocols.js +89 -1
- package/dist-cjs/review-card/builder.d.ts +13 -0
- package/dist-cjs/review-card/builder.js +62 -5
- package/dist-cjs/review-card/cross-check.js +50 -3
- package/dist-cjs/review-card/index.d.ts +1 -1
- package/dist-cjs/review-card/index.js +2 -1
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +16 -4
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
- package/dist-cjs/synth/compose-from-recording.js +34 -8
- package/dist-cjs/synth/deny-cases.d.ts +4 -1
- package/dist-cjs/synth/deny-cases.js +3 -0
- package/dist-cjs/synth/evaluate.js +130 -3
- package/dist-cjs/synth/permit-context.d.ts +15 -0
- package/dist-cjs/synth/permit-context.js +119 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
- package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
- package/dist-cjs/synth/synthesize-from-recording.js +80 -3
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +72 -11
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
- package/src/errors.ts +13 -0
- package/src/install/build-add-context-rule.ts +429 -0
- package/src/ir/types.ts +4 -0
- package/src/predicate/decode.ts +242 -0
- package/src/predicate/encode.ts +110 -13
- package/src/predicate/from-json.ts +124 -0
- package/src/predicate/index.ts +5 -1
- package/src/registry/identify.ts +22 -2
- package/src/registry/protocols.ts +90 -1
- package/src/review-card/builder.ts +57 -5
- package/src/review-card/cross-check.ts +50 -3
- package/src/review-card/index.ts +1 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/compose-from-recording.ts +46 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +131 -3
- package/src/synth/permit-context.ts +137 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +116 -6
- package/src/types.ts +12 -0
package/dist/synth/evaluate.js
CHANGED
|
@@ -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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
|
|
2
2
|
import type { ToolResponse } from '../errors.ts';
|
|
3
3
|
import type { MandateSpec } from '../mandate/types.ts';
|
|
4
|
-
import type { ProposedPolicy } from '../types.ts';
|
|
5
|
-
|
|
4
|
+
import type { PredicateNode, ProposedPolicy } from '../types.ts';
|
|
5
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
6
|
+
export declare function synthesizeFromMandate(spec: MandateSpec, ozConfig: OzAdapterConfig,
|
|
7
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
8
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
9
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
10
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
11
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
12
|
+
* altered. */
|
|
13
|
+
opts?: {
|
|
14
|
+
explain?: true;
|
|
15
|
+
}): ToolResponse<ProposedPolicy> & {
|
|
16
|
+
explain?: {
|
|
17
|
+
predicateTree: PredicateNode | null;
|
|
18
|
+
simulation: SimulationResult;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
import { createOzAdapter } from "../adapters/oz/adapter.js";
|
|
11
11
|
import { mandateToPolicyIR } from "../mandate/to-ir.js";
|
|
12
12
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
|
|
13
|
-
export function synthesizeFromMandate(spec, ozConfig
|
|
13
|
+
export function synthesizeFromMandate(spec, ozConfig,
|
|
14
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
15
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
16
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
17
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
18
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
19
|
+
* altered. */
|
|
20
|
+
opts) {
|
|
14
21
|
const ir = mandateToPolicyIR(spec);
|
|
15
22
|
const adapter = createOzAdapter(ozConfig);
|
|
16
23
|
const result = adapter.compile(ir);
|
|
@@ -30,5 +37,23 @@ export function synthesizeFromMandate(spec, ozConfig) {
|
|
|
30
37
|
...result.proposed,
|
|
31
38
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
32
39
|
};
|
|
33
|
-
|
|
40
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
41
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
42
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
43
|
+
const envelope = { ok: true, data: proposed };
|
|
44
|
+
if (opts?.explain) {
|
|
45
|
+
envelope.explain = {
|
|
46
|
+
predicateTree: null,
|
|
47
|
+
simulation: {
|
|
48
|
+
permit: {
|
|
49
|
+
tx: 'deny',
|
|
50
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
51
|
+
},
|
|
52
|
+
evaluatedCases: [],
|
|
53
|
+
backend: 'ts-model',
|
|
54
|
+
simulatorVersion: 'not-run',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return envelope;
|
|
34
59
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
|
|
2
2
|
import type { ToolError, ToolResponse } from '../errors.ts';
|
|
3
3
|
import { type Network, type PredicateNode, type ProposedPolicy, type RecordedTransaction } from '../types.ts';
|
|
4
|
+
import type { SimulationResult } from '../verify/envelope.ts';
|
|
4
5
|
import { type ComposeUserResponses } from './compose-from-recording.ts';
|
|
5
6
|
/** Per-policy interpreter adapter config the caller opts into. Absent ->
|
|
6
7
|
* recording path runs in week-1 mode (OZ adapter only, warnings for the
|
|
@@ -41,8 +42,31 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
41
42
|
threshold: number;
|
|
42
43
|
};
|
|
43
44
|
interpreter?: InterpreterAdapterOptions;
|
|
45
|
+
/** --explain opt-in. When true, the orchestrator attaches the
|
|
46
|
+
* in-memory `PredicateNode` + the corresponding `SimulationResult`
|
|
47
|
+
* to the success envelope so the CLI can render a human-readable
|
|
48
|
+
* review card. Absent or false -> the success envelope is unchanged
|
|
49
|
+
* (byte-identical to today). The flag is ADDITIVE: the existing
|
|
50
|
+
* ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
|
|
51
|
+
* never altered by enabling explain. */
|
|
52
|
+
explain?: true;
|
|
44
53
|
}
|
|
45
|
-
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy
|
|
54
|
+
export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy> & {
|
|
55
|
+
/** Present iff `opts.explain === true` and the synthesis succeeded. The
|
|
56
|
+
* `predicateTree` is the exact in-memory `PredicateNode` (canonical
|
|
57
|
+
* JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
|
|
58
|
+
* - reading the predicate tree from the policy document is therefore
|
|
59
|
+
* unnecessary; the orchestrator carries it through. The `simulation`
|
|
60
|
+
* is the verdict produced by the same self-verify pipeline that already
|
|
61
|
+
* runs in the recording path (runHarness + evaluate), so the value is
|
|
62
|
+
* REAL not synthetic. Absent when the synthesis did not engage the
|
|
63
|
+
* interpreter (no interpreter opts supplied) - in that case the CLI
|
|
64
|
+
* builds a minimal honest SimulationResult downstream. */
|
|
65
|
+
explain?: {
|
|
66
|
+
predicateTree: PredicateNode | null;
|
|
67
|
+
simulation: SimulationResult;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
46
70
|
/** ToolError-shaped error helper used by the body to surface a structured
|
|
47
71
|
* failure that the envelope converts to `{ok:false, error}`. */
|
|
48
72
|
declare function throwToolError(code: ToolError['code'], message: string): never;
|
|
@@ -195,6 +195,14 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
195
195
|
...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
|
|
196
196
|
};
|
|
197
197
|
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
|
|
198
|
+
// --explain hook: capture the in-memory predicate tree + the real
|
|
199
|
+
// self-verify verdict so the CLI can render a faithful review card.
|
|
200
|
+
// The verdict below is built from the SAME runHarness + evaluate that
|
|
201
|
+
// already gates the synthesis (it is not a parallel simulation); the
|
|
202
|
+
// intermediate inputs (harnessCases, evalResult) are otherwise discarded
|
|
203
|
+
// after the gate, so the explain hook reuses them - no extra work.
|
|
204
|
+
let explain = null;
|
|
205
|
+
let explainSim = null;
|
|
198
206
|
// 5. OZ compile (always runs).
|
|
199
207
|
const ozAdapter = createOzAdapter(ozConfig);
|
|
200
208
|
const compileRes = ozAdapter.compile(composed.ir);
|
|
@@ -372,6 +380,36 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
372
380
|
},
|
|
373
381
|
};
|
|
374
382
|
}
|
|
383
|
+
// --explain capture: the interpreter path already produced the real
|
|
384
|
+
// self-verify verdict (runHarness passed, evalResult.permit is true).
|
|
385
|
+
// Build the SimulationResult from those outputs so the CLI card
|
|
386
|
+
// quotes the SAME verdict that gated the synthesis. We re-evaluate
|
|
387
|
+
// each deny case to surface its concrete reason; the harness only
|
|
388
|
+
// records whether the got-matches-expected boundary held.
|
|
389
|
+
if (opts.explain) {
|
|
390
|
+
explain = finalPredicate;
|
|
391
|
+
const evaluatedCases = [
|
|
392
|
+
{
|
|
393
|
+
dimension: 'permit',
|
|
394
|
+
outcome: evalResult.permit ? 'permit' : 'deny',
|
|
395
|
+
reason: 'matches recorded call',
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
for (const deny of harnessCases.denies) {
|
|
399
|
+
const r = evaluate(finalPredicate, deny.ctx);
|
|
400
|
+
evaluatedCases.push({
|
|
401
|
+
dimension: deny.dimension,
|
|
402
|
+
outcome: r.permit ? 'permit' : 'deny',
|
|
403
|
+
reason: r.permit ? 'no matching deny' : r.reason,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
explainSim = {
|
|
407
|
+
permit: { tx: 'permit' },
|
|
408
|
+
evaluatedCases,
|
|
409
|
+
backend: 'ts-model',
|
|
410
|
+
simulatorVersion: 'ts-model-1.0.0',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
375
413
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
376
414
|
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
377
415
|
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
@@ -469,7 +507,34 @@ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
|
|
|
469
507
|
],
|
|
470
508
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
471
509
|
};
|
|
472
|
-
|
|
510
|
+
// --explain success envelope. The interpreter path populated
|
|
511
|
+
// `explain` + `explainSim` from the real self-verify verdict above;
|
|
512
|
+
// the OZ-only path did not (no predicate tree exists). When opts.explain
|
|
513
|
+
// is set and the OZ-only path ran, construct the minimal honest
|
|
514
|
+
// SimulationResult: the verdict is NOT a passing simulation - the
|
|
515
|
+
// interpreter was never engaged, so permit is deny with a truthful
|
|
516
|
+
// reason and evaluatedCases is empty.
|
|
517
|
+
const envelope = { ok: true, data: proposed };
|
|
518
|
+
if (opts.explain) {
|
|
519
|
+
if (explainSim) {
|
|
520
|
+
envelope.explain = { predicateTree: explain, simulation: explainSim };
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
envelope.explain = {
|
|
524
|
+
predicateTree: null,
|
|
525
|
+
simulation: {
|
|
526
|
+
permit: {
|
|
527
|
+
tx: 'deny',
|
|
528
|
+
reason: 'No self-verification was performed (interpreter adapter was not engaged)',
|
|
529
|
+
},
|
|
530
|
+
evaluatedCases: [],
|
|
531
|
+
backend: 'ts-model',
|
|
532
|
+
simulatorVersion: 'not-run',
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return envelope;
|
|
473
538
|
}
|
|
474
539
|
// Re-export the throwToolError helper for callers that need to surface a
|
|
475
540
|
// ToolError-shaped throw inside the body (e.g. tests).
|
|
@@ -746,13 +811,25 @@ function visitOracleLeaves(node, visit) {
|
|
|
746
811
|
return;
|
|
747
812
|
}
|
|
748
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. */
|
|
749
818
|
function oracleLiteralFromLeaf(leaf) {
|
|
750
|
-
if (leaf.kind !== '
|
|
819
|
+
if (leaf.kind !== 'oracle_threshold')
|
|
751
820
|
return undefined;
|
|
821
|
+
let value;
|
|
752
822
|
try {
|
|
753
|
-
|
|
823
|
+
value = BigInt(leaf.value);
|
|
754
824
|
}
|
|
755
825
|
catch {
|
|
756
826
|
return undefined;
|
|
757
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);
|
|
758
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
|
-
|
|
55
|
+
// False for the predicate-leaf path: the interpreter refuses a `valid_until`
|
|
56
|
+
// leaf at install. Expiry is still available, but as the context rule's
|
|
57
|
+
// validUntilLedger, which the smart account enforces - not as a predicate.
|
|
58
|
+
supportsTimeExpiry: false,
|
|
56
59
|
supportsOraclePrice: true,
|
|
57
60
|
supportsInvocationCount: true,
|
|
58
61
|
supportsGeneralPredicate: true,
|
|
@@ -221,26 +224,61 @@ function lowerRule(rule, config) {
|
|
|
221
224
|
function unsupportedConstruct(cond) {
|
|
222
225
|
switch (cond.op) {
|
|
223
226
|
case 'in':
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
// The oracle position rule is a hard error and must be raised before a
|
|
228
|
+
// construct can be written off as uncovered - otherwise a subtree that
|
|
229
|
+
// is BOTH misplaced-oracle and unsourceable would be silently dropped
|
|
230
|
+
// instead of rejected.
|
|
231
|
+
assertNoOracleDescendants(cond);
|
|
232
|
+
return (unsourceableSelector(cond.selector) ??
|
|
233
|
+
(cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
|
|
234
|
+
? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
|
|
235
|
+
: null));
|
|
227
236
|
case 'eq_seq':
|
|
228
|
-
return cond.selector
|
|
229
|
-
|
|
230
|
-
|
|
237
|
+
return (unsourceableSelector(cond.selector) ??
|
|
238
|
+
(cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
|
|
239
|
+
? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
|
|
240
|
+
: null));
|
|
231
241
|
case 'compare': {
|
|
232
242
|
const s = cond.compare.selector;
|
|
233
243
|
if (s.kind === 'calldata')
|
|
234
244
|
return 'EVM calldata comparison (predicate DSL)';
|
|
235
245
|
if (s.kind === 'value')
|
|
236
246
|
return 'tx.value comparison (predicate DSL)';
|
|
237
|
-
|
|
247
|
+
// The on-chain interpreter sees ONE authorized call - there is no
|
|
248
|
+
// `Context.sub_invocations` in v1 - so it cannot observe the
|
|
249
|
+
// transaction's token movements. `amount` has no value to read, and
|
|
250
|
+
// `window_spent` accumulates BY that amount, so its counter would never
|
|
251
|
+
// move. Deriving either from the call payload would quietly swap "value
|
|
252
|
+
// actually moved" for "value the caller declared" - a weaker guarantee
|
|
253
|
+
// than the review card would be claiming.
|
|
254
|
+
//
|
|
255
|
+
// Rolling spend caps belong to the OZ `spending_limit` primitive, which
|
|
256
|
+
// is already audited and which the OZ adapter emits. A per-call cap is
|
|
257
|
+
// expressible here as `arg_field`; bounding it with `invocation_count`
|
|
258
|
+
// gives an enforceable ceiling per window.
|
|
259
|
+
return unsourceableSelector(s);
|
|
238
260
|
}
|
|
261
|
+
// Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
|
|
262
|
+
// pre-scan, which only sees top-level constraints.
|
|
239
263
|
case 'and':
|
|
264
|
+
return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
|
|
240
265
|
case 'or':
|
|
266
|
+
assertNoOracleDescendants(cond);
|
|
267
|
+
return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
|
|
241
268
|
case 'not':
|
|
242
|
-
|
|
269
|
+
assertNoOracleDescendants(cond);
|
|
270
|
+
return unsupportedConstruct(cond.child);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Selectors whose value the on-chain interpreter has no way to obtain. */
|
|
274
|
+
function unsourceableSelector(s) {
|
|
275
|
+
if (s.kind === 'amount') {
|
|
276
|
+
return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`;
|
|
277
|
+
}
|
|
278
|
+
if (s.kind === 'window_spent') {
|
|
279
|
+
return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`;
|
|
243
280
|
}
|
|
281
|
+
return null;
|
|
244
282
|
}
|
|
245
283
|
/** Lower one IR condition to a PredicateNode. Enforces the oracle position
|
|
246
284
|
* rule (oracle_price leaves must sit directly under the top-level `and`) and
|
|
@@ -316,10 +354,13 @@ function lowerSelector(s) {
|
|
|
316
354
|
return { kind: 'call_arg_len', index: s.argIndex };
|
|
317
355
|
case 'arg_field':
|
|
318
356
|
return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
|
|
357
|
+
// `amount` / `window_spent` are filtered out by `unsupportedConstruct`
|
|
358
|
+
// before lowering - the interpreter cannot source either on chain. Reaching
|
|
359
|
+
// here means the pre-scan was bypassed, so fail loudly rather than emit a
|
|
360
|
+
// leaf the contract will refuse.
|
|
319
361
|
case 'amount':
|
|
320
|
-
return { kind: 'amount', token: s.token };
|
|
321
362
|
case 'window_spent':
|
|
322
|
-
|
|
363
|
+
throw new Error(`interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`);
|
|
323
364
|
case 'invocation_count':
|
|
324
365
|
return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds };
|
|
325
366
|
case 'now':
|
|
@@ -346,6 +387,16 @@ function lowerSelector(s) {
|
|
|
346
387
|
* - invocation_count -> u32 (counts are small non-negative integers)
|
|
347
388
|
* - now / valid_until -> u64 (unix timestamps in seconds) */
|
|
348
389
|
function literalFromIRCompare(c) {
|
|
390
|
+
// An oracle comparand is not a bare literal: the interpreter refuses one,
|
|
391
|
+
// because assuming it shares the normalised 9-dp basis is what made a raw
|
|
392
|
+
// 14-dp threshold permit everything it was written to deny.
|
|
393
|
+
if (c.selector.kind === 'oracle_price') {
|
|
394
|
+
if (c.valueDecimals === undefined) {
|
|
395
|
+
throw toolError('ORACLE_THRESHOLD_BASIS_REQUIRED', 'An oracle price bound must declare the decimal basis of its threshold ' +
|
|
396
|
+
'(oracle prices normalise to 9 decimals).');
|
|
397
|
+
}
|
|
398
|
+
return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals };
|
|
399
|
+
}
|
|
349
400
|
const scalarType = selectorScalarType(c.selector);
|
|
350
401
|
return literalFromScalar(c.value, scalarType);
|
|
351
402
|
}
|