@crediolabs/policy-synth 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/interpreter/adapter.js +83 -11
- package/dist/adapters/oz/adapter.js +4 -0
- 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 +23 -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 +63 -5
- package/dist/review-card/cross-check.js +32 -3
- package/dist/review-card/index.d.ts +1 -1
- package/dist/review-card/index.js +1 -1
- package/dist/run/schemas.d.ts +4 -4
- package/dist/synth/compose-from-recording.d.ts +18 -0
- package/dist/synth/compose-from-recording.js +75 -10
- 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-recording.js +14 -2
- package/dist/types.d.ts +9 -0
- package/dist-cjs/adapters/interpreter/adapter.js +83 -11
- package/dist-cjs/adapters/oz/adapter.js +4 -0
- 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 +23 -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 +64 -5
- package/dist-cjs/review-card/cross-check.js +32 -3
- package/dist-cjs/review-card/index.d.ts +1 -1
- package/dist-cjs/review-card/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +4 -4
- package/dist-cjs/synth/compose-from-recording.d.ts +18 -0
- package/dist-cjs/synth/compose-from-recording.js +75 -10
- 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-recording.js +14 -2
- package/dist-cjs/types.d.ts +9 -0
- package/package.json +5 -2
- package/src/adapters/interpreter/adapter.ts +93 -11
- package/src/adapters/oz/adapter.ts +4 -0
- 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 +23 -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 +58 -5
- package/src/review-card/cross-check.ts +36 -3
- package/src/review-card/index.ts +1 -0
- package/src/synth/compose-from-recording.ts +101 -7
- package/src/synth/deny-cases.ts +3 -1
- package/src/synth/evaluate.ts +130 -3
- package/src/synth/permit-context.ts +136 -0
- package/src/synth/synthesize-from-recording.ts +13 -2
- 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
|
+
}
|
|
@@ -811,13 +811,25 @@ function visitOracleLeaves(node, visit) {
|
|
|
811
811
|
return;
|
|
812
812
|
}
|
|
813
813
|
}
|
|
814
|
+
/** An oracle threshold restated on the normalised 9-dp basis prices use.
|
|
815
|
+
* Thresholds carry their own basis, so reading the digits raw would build a
|
|
816
|
+
* permit context off by 10^(decimals-9) and the intended call would not
|
|
817
|
+
* satisfy its own bound. Mirrors NORMALISED_DECIMALS in oracle.rs. */
|
|
814
818
|
function oracleLiteralFromLeaf(leaf) {
|
|
815
|
-
if (leaf.kind !== '
|
|
819
|
+
if (leaf.kind !== 'oracle_threshold')
|
|
816
820
|
return undefined;
|
|
821
|
+
let value;
|
|
817
822
|
try {
|
|
818
|
-
|
|
823
|
+
value = BigInt(leaf.value);
|
|
819
824
|
}
|
|
820
825
|
catch {
|
|
821
826
|
return undefined;
|
|
822
827
|
}
|
|
828
|
+
const normalised = 9n;
|
|
829
|
+
const decimals = BigInt(leaf.decimals);
|
|
830
|
+
if (decimals <= normalised)
|
|
831
|
+
return value * 10n ** (normalised - decimals);
|
|
832
|
+
// Floor: a finer-grained threshold has no exact 9-dp representation; the
|
|
833
|
+
// caller offsets by one from here to land on the right side of the bound.
|
|
834
|
+
return value / 10n ** (decimals - normalised);
|
|
823
835
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -190,6 +190,11 @@ export type PredicateLeaf = {
|
|
|
190
190
|
index: number;
|
|
191
191
|
element: number;
|
|
192
192
|
field: string;
|
|
193
|
+
} | {
|
|
194
|
+
kind: 'call_arg_scaled';
|
|
195
|
+
index: number;
|
|
196
|
+
num: string;
|
|
197
|
+
den: string;
|
|
193
198
|
} | {
|
|
194
199
|
kind: 'amount';
|
|
195
200
|
token: string;
|
|
@@ -207,6 +212,10 @@ export type PredicateLeaf = {
|
|
|
207
212
|
} | {
|
|
208
213
|
kind: 'oracle_price';
|
|
209
214
|
asset: string;
|
|
215
|
+
} | {
|
|
216
|
+
kind: 'oracle_threshold';
|
|
217
|
+
value: string;
|
|
218
|
+
decimals: number;
|
|
210
219
|
} | {
|
|
211
220
|
kind: 'literal_address';
|
|
212
221
|
value: string;
|
|
@@ -52,7 +52,10 @@ exports.ORACLE_DEFAULTS = {
|
|
|
52
52
|
const CAPABILITIES = {
|
|
53
53
|
supportsSpendWindow: true,
|
|
54
54
|
supportsThreshold: false, // thresholds are the OZ adapter's job
|
|
55
|
-
|
|
55
|
+
// False for the predicate-leaf path: the interpreter refuses a `valid_until`
|
|
56
|
+
// leaf at install. Expiry is still available, but as the context rule's
|
|
57
|
+
// validUntilLedger, which the smart account enforces - not as a predicate.
|
|
58
|
+
supportsTimeExpiry: false,
|
|
56
59
|
supportsOraclePrice: true,
|
|
57
60
|
supportsInvocationCount: true,
|
|
58
61
|
supportsGeneralPredicate: true,
|
|
@@ -220,34 +223,86 @@ function lowerRule(rule, config) {
|
|
|
220
223
|
* by `lowerRule` to populate `uncovered` before lowering. */
|
|
221
224
|
function unsupportedConstruct(cond) {
|
|
222
225
|
switch (cond.op) {
|
|
226
|
+
// Expressible: `call_arg_scaled` is exactly this construct.
|
|
227
|
+
case 'slippage_floor':
|
|
228
|
+
return null;
|
|
223
229
|
case 'in':
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
// The oracle position rule is a hard error and must be raised before a
|
|
231
|
+
// construct can be written off as uncovered - otherwise a subtree that
|
|
232
|
+
// is BOTH misplaced-oracle and unsourceable would be silently dropped
|
|
233
|
+
// instead of rejected.
|
|
234
|
+
assertNoOracleDescendants(cond);
|
|
235
|
+
return (unsourceableSelector(cond.selector) ??
|
|
236
|
+
(cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
|
|
237
|
+
? `EVM \`${cond.selector.kind}\` selector on allowlist (predicate DSL)`
|
|
238
|
+
: null));
|
|
227
239
|
case 'eq_seq':
|
|
228
|
-
return cond.selector
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
return (unsourceableSelector(cond.selector) ??
|
|
241
|
+
(cond.selector.kind === 'calldata' || cond.selector.kind === 'value'
|
|
242
|
+
? `EVM \`${cond.selector.kind}\` selector on ordered-sequence equality (predicate DSL)`
|
|
243
|
+
: null));
|
|
231
244
|
case 'compare': {
|
|
232
245
|
const s = cond.compare.selector;
|
|
233
246
|
if (s.kind === 'calldata')
|
|
234
247
|
return 'EVM calldata comparison (predicate DSL)';
|
|
235
248
|
if (s.kind === 'value')
|
|
236
249
|
return 'tx.value comparison (predicate DSL)';
|
|
237
|
-
|
|
250
|
+
// The on-chain interpreter sees ONE authorized call - there is no
|
|
251
|
+
// `Context.sub_invocations` in v1 - so it cannot observe the
|
|
252
|
+
// transaction's token movements. `amount` has no value to read, and
|
|
253
|
+
// `window_spent` accumulates BY that amount, so its counter would never
|
|
254
|
+
// move. Deriving either from the call payload would quietly swap "value
|
|
255
|
+
// actually moved" for "value the caller declared" - a weaker guarantee
|
|
256
|
+
// than the review card would be claiming.
|
|
257
|
+
//
|
|
258
|
+
// Rolling spend caps belong to the OZ `spending_limit` primitive, which
|
|
259
|
+
// is already audited and which the OZ adapter emits. A per-call cap is
|
|
260
|
+
// expressible here as `arg_field`; bounding it with `invocation_count`
|
|
261
|
+
// gives an enforceable ceiling per window.
|
|
262
|
+
return unsourceableSelector(s);
|
|
238
263
|
}
|
|
264
|
+
// Recurse: a nested `and`/`or`/`not` must not smuggle a selector past the
|
|
265
|
+
// pre-scan, which only sees top-level constraints.
|
|
239
266
|
case 'and':
|
|
267
|
+
return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
|
|
240
268
|
case 'or':
|
|
269
|
+
assertNoOracleDescendants(cond);
|
|
270
|
+
return cond.children.map(unsupportedConstruct).find((u) => u !== null) ?? null;
|
|
241
271
|
case 'not':
|
|
242
|
-
|
|
272
|
+
assertNoOracleDescendants(cond);
|
|
273
|
+
return unsupportedConstruct(cond.child);
|
|
243
274
|
}
|
|
244
275
|
}
|
|
276
|
+
/** Selectors whose value the on-chain interpreter has no way to obtain. */
|
|
277
|
+
function unsourceableSelector(s) {
|
|
278
|
+
if (s.kind === 'amount') {
|
|
279
|
+
return `per-call amount comparison on ${s.token} - the interpreter cannot observe token movements; express a per-call cap with arg_field, or a rolling cap with the OZ spending_limit primitive`;
|
|
280
|
+
}
|
|
281
|
+
if (s.kind === 'window_spent') {
|
|
282
|
+
return `rolling spend cap on ${s.token} over ${s.windowSeconds}s - not enforceable by the interpreter; use the OZ spending_limit primitive, or bound per-call value with arg_field plus invocation_count`;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
245
286
|
/** Lower one IR condition to a PredicateNode. Enforces the oracle position
|
|
246
287
|
* rule (oracle_price leaves must sit directly under the top-level `and`) and
|
|
247
288
|
* the self-call rule (`in` allowlist / `compare eq` vs the smart account
|
|
248
289
|
* address -> SCOPE_SELF_CALL). */
|
|
249
290
|
function lowerCondition(cond, config) {
|
|
250
291
|
switch (cond.op) {
|
|
292
|
+
// `out >= in * num/den`. The contract refuses den == 0 or a non-positive
|
|
293
|
+
// ratio at install, so a malformed floor fails loudly rather than
|
|
294
|
+
// silently inverting the comparison.
|
|
295
|
+
case 'slippage_floor':
|
|
296
|
+
return {
|
|
297
|
+
op: 'gte',
|
|
298
|
+
left: { kind: 'call_arg', index: cond.outArgIndex },
|
|
299
|
+
right: {
|
|
300
|
+
kind: 'call_arg_scaled',
|
|
301
|
+
index: cond.inArgIndex,
|
|
302
|
+
num: cond.num,
|
|
303
|
+
den: cond.den,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
251
306
|
case 'and':
|
|
252
307
|
return { op: 'and', children: cond.children.map((c) => lowerCondition(c, config)) };
|
|
253
308
|
case 'or':
|
|
@@ -316,10 +371,13 @@ function lowerSelector(s) {
|
|
|
316
371
|
return { kind: 'call_arg_len', index: s.argIndex };
|
|
317
372
|
case 'arg_field':
|
|
318
373
|
return { kind: 'call_arg_field', index: s.argIndex, element: s.element, field: s.field };
|
|
374
|
+
// `amount` / `window_spent` are filtered out by `unsupportedConstruct`
|
|
375
|
+
// before lowering - the interpreter cannot source either on chain. Reaching
|
|
376
|
+
// here means the pre-scan was bypassed, so fail loudly rather than emit a
|
|
377
|
+
// leaf the contract will refuse.
|
|
319
378
|
case 'amount':
|
|
320
|
-
return { kind: 'amount', token: s.token };
|
|
321
379
|
case 'window_spent':
|
|
322
|
-
|
|
380
|
+
throw new Error(`interpreter adapter cannot lower \`${s.kind}\`: it should have been reported as uncovered`);
|
|
323
381
|
case 'invocation_count':
|
|
324
382
|
return { kind: 'invocation_count_in_window', windowSecs: s.windowSeconds };
|
|
325
383
|
case 'now':
|
|
@@ -346,6 +404,16 @@ function lowerSelector(s) {
|
|
|
346
404
|
* - invocation_count -> u32 (counts are small non-negative integers)
|
|
347
405
|
* - now / valid_until -> u64 (unix timestamps in seconds) */
|
|
348
406
|
function literalFromIRCompare(c) {
|
|
407
|
+
// An oracle comparand is not a bare literal: the interpreter refuses one,
|
|
408
|
+
// because assuming it shares the normalised 9-dp basis is what made a raw
|
|
409
|
+
// 14-dp threshold permit everything it was written to deny.
|
|
410
|
+
if (c.selector.kind === 'oracle_price') {
|
|
411
|
+
if (c.valueDecimals === undefined) {
|
|
412
|
+
throw toolError('ORACLE_THRESHOLD_BASIS_REQUIRED', 'An oracle price bound must declare the decimal basis of its threshold ' +
|
|
413
|
+
'(oracle prices normalise to 9 decimals).');
|
|
414
|
+
}
|
|
415
|
+
return { kind: 'oracle_threshold', value: c.value, decimals: c.valueDecimals };
|
|
416
|
+
}
|
|
349
417
|
const scalarType = selectorScalarType(c.selector);
|
|
350
418
|
return literalFromScalar(c.value, scalarType);
|
|
351
419
|
}
|
|
@@ -412,6 +480,8 @@ function assertNoOracleDescendants(node) {
|
|
|
412
480
|
}
|
|
413
481
|
function containsOracle(node) {
|
|
414
482
|
switch (node.op) {
|
|
483
|
+
case 'slippage_floor':
|
|
484
|
+
return false;
|
|
415
485
|
case 'compare':
|
|
416
486
|
return node.compare.selector.kind === 'oracle_price';
|
|
417
487
|
case 'in':
|
|
@@ -450,6 +520,8 @@ function assertOracleParamsTighten(params) {
|
|
|
450
520
|
* express. Mirrors the OZ adapter's `describeCondition` for parity. */
|
|
451
521
|
function describeCondition(cond) {
|
|
452
522
|
switch (cond.op) {
|
|
523
|
+
case 'slippage_floor':
|
|
524
|
+
return `slippage floor: arg[${cond.outArgIndex}] >= arg[${cond.inArgIndex}] * ${cond.num}/${cond.den}`;
|
|
453
525
|
case 'in':
|
|
454
526
|
return `value allowlist on ${describeSelector(cond.selector)} (predicate DSL)`;
|
|
455
527
|
case 'eq_seq':
|
|
@@ -212,6 +212,10 @@ function matchSpendingLimit(c) {
|
|
|
212
212
|
* cannot express, used to populate `uncovered`. */
|
|
213
213
|
function describeCondition(cond) {
|
|
214
214
|
switch (cond.op) {
|
|
215
|
+
case 'slippage_floor':
|
|
216
|
+
// The OZ primitives bound a value against a constant; this bounds one
|
|
217
|
+
// call argument against another, which none of them can express.
|
|
218
|
+
return `slippage floor on arg[${cond.outArgIndex}] (OZ built-ins cannot bound one argument against another)`;
|
|
215
219
|
case 'in':
|
|
216
220
|
return `value allowlist on ${describeSelector(cond.selector)} (arg allowlist)`;
|
|
217
221
|
case 'eq_seq':
|
package/dist-cjs/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'PREDICATE_ORACLE_OVER_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ORACLE_STALE' | 'ORACLE_MISSING' | 'ORACLE_DEVIATION_EXCEEDED' | 'ORACLE_MALFORMED_HISTORY' | 'ORACLE_FINGERPRINT_DRIFT' | 'ORACLE_PAUSED' | 'ORACLE_LEAF_INVALID_POSITION' | 'ORACLE_PARAMS_OUT_OF_RANGE' | 'ORACLE_DECIMALS_MISMATCH' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
|
|
1
|
+
export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'PREDICATE_ORACLE_OVER_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ARG_MISMATCH' | 'CONTRACT_SCOPE' | 'UNSUPPORTED_NODE' | 'STATEFUL_BOUND' | 'NOT_IN_ALLOWLIST' | 'FREQUENCY' | 'SLIPPAGE_FLOOR' | 'ORACLE_STALE' | 'ORACLE_MISSING' | 'ORACLE_NO_CONFIRMATION' | 'ORACLE_DEVIATION_EXCEEDED' | 'ORACLE_MALFORMED_HISTORY' | 'ORACLE_FINGERPRINT_DRIFT' | 'ORACLE_PAUSED' | 'ORACLE_LEAF_INVALID_POSITION' | 'ORACLE_PARAMS_OUT_OF_RANGE' | 'ORACLE_DECIMALS_MISMATCH' | 'ORACLE_THRESHOLD_DECIMALS_OUT_OF_RANGE' | 'ORACLE_THRESHOLD_BASIS_REQUIRED' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
|
|
2
2
|
export interface ToolError {
|
|
3
3
|
code: ErrorCode;
|
|
4
4
|
message: string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { xdr } from '@stellar/stellar-sdk';
|
|
2
|
+
import { type ContextRuleDraft, type PolicyRef, type SignerDraft } from '../types.ts';
|
|
3
|
+
/** Inputs for `buildAddContextRuleArgs`. The fields are the ones the contract
|
|
4
|
+
* validates at install; everything else is downstream. */
|
|
5
|
+
export interface BuildAddContextRuleArgs {
|
|
6
|
+
/** The auth-context rule signers to attach - `Vec<Signer>` wire form.
|
|
7
|
+
* These are the SIGNERS the new rule authorises, not the deployer's
|
|
8
|
+
* admin session. The deployer signs the install call via the admin rule
|
|
9
|
+
* already on the account, which is separate from this list. */
|
|
10
|
+
signers: SignerDraft[];
|
|
11
|
+
/** The policy(ies) to attach to the new rule. Only `interpreter` refs
|
|
12
|
+
* reach the wire today; `oz_builtin` is reserved for Phase 06 follow-ups. */
|
|
13
|
+
policies: PolicyRef[];
|
|
14
|
+
/** Per-policy install nonce; 1 for a fresh install. */
|
|
15
|
+
installNonce: number;
|
|
16
|
+
/** Already-encoded (base64) canonical ScVal of the predicate. The builder
|
|
17
|
+
* decodes it to bytes for the `predicate` field and re-hashes to confirm
|
|
18
|
+
* the caller-supplied `predicateHash` matches. */
|
|
19
|
+
encodedPredicate: string;
|
|
20
|
+
/** Hex sha256 of the canonical predicate XDR bytes. */
|
|
21
|
+
predicateHash: string;
|
|
22
|
+
/** Per-policy oracle overrides. Absent on any key uses the wasm default. */
|
|
23
|
+
oracleParams?: {
|
|
24
|
+
maxStalenessSeconds?: number;
|
|
25
|
+
maxDeviationBps?: number;
|
|
26
|
+
/** Bound between the primary and secondary feeds. Tighten-only against
|
|
27
|
+
* the wasm default, like the other two. */
|
|
28
|
+
maxCrossFeedDeviationBps?: number;
|
|
29
|
+
};
|
|
30
|
+
/** Hard pin to the interpreter's wasm grammar. Refusing here fails closed
|
|
31
|
+
* before the chain does; the contract will refuse a mismatch again. */
|
|
32
|
+
grammarVersion?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const DEFAULT_GRAMMAR_VERSION: 1;
|
|
35
|
+
/** The verb `add_context_rule` takes on the wire. */
|
|
36
|
+
export declare const ADD_CONTEXT_RULE_SYMBOL: "add_context_rule";
|
|
37
|
+
/** Tuple of `ScVal` arguments to pass to `Operation.invokeHostFunction` for
|
|
38
|
+
* `add_context_rule`. Order matches the OZ trait signature. */
|
|
39
|
+
export type AddContextRuleArgs = readonly [
|
|
40
|
+
contextType: xdr.ScVal,
|
|
41
|
+
name: xdr.ScVal,
|
|
42
|
+
validUntil: xdr.ScVal,
|
|
43
|
+
signers: xdr.ScVal,
|
|
44
|
+
policies: xdr.ScVal
|
|
45
|
+
];
|
|
46
|
+
/** Build the `add_context_rule` invocation args. Throws a `ToolError`-shaped
|
|
47
|
+
* error on limit breaches or malformed input. */
|
|
48
|
+
export declare function buildAddContextRuleArgs(draft: ContextRuleDraft, args: BuildAddContextRuleArgs): AddContextRuleArgs;
|