@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
|
@@ -19,6 +19,19 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Caps from `PREDICATE_CAPS` are enforced BEFORE returning; a cap breach throws
|
|
21
21
|
// a `ToolError` with the matching error code and `severity: 'error'`.
|
|
22
|
+
//
|
|
23
|
+
// The encoder is also the gate for the structures the contract refuses: an
|
|
24
|
+
// empty `and`/`or` child list and an empty `in` haystack (MALFORMED_PREDICATE
|
|
25
|
+
// at decode, dsl.rs), an `oracle_price` under a `not`/`or`, and an oracle bound
|
|
26
|
+
// with no non-oracle constraint beside it (validate_oracle_placement at
|
|
27
|
+
// install).
|
|
28
|
+
//
|
|
29
|
+
// One gap remains, deliberately: the `amount` / `window_spent` leaf branches
|
|
30
|
+
// below are dead ABI - the contract's grammar no longer has those selector
|
|
31
|
+
// symbols, so a predicate carrying one is MALFORMED at decode. The interpreter
|
|
32
|
+
// adapter reports them as uncovered before lowering, so the product path
|
|
33
|
+
// cannot emit one; removing the branches (and the leaf kinds) is a separate
|
|
34
|
+
// change.
|
|
22
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
36
|
exports.encodePredicate = encodePredicate;
|
|
24
37
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -48,6 +61,9 @@ function encodePredicate(node) {
|
|
|
48
61
|
if (oracleReads > types_ts_1.PREDICATE_CAPS.MAX_ORACLE_READS) {
|
|
49
62
|
throw capError('PREDICATE_ORACLE_OVER_LIMIT', `oracle reads ${oracleReads} exceed MAX_ORACLE_READS ${types_ts_1.PREDICATE_CAPS.MAX_ORACLE_READS}`);
|
|
50
63
|
}
|
|
64
|
+
if (stats.oracleAssets.size > 0 && stats.nonOracleSelectorLeaves === 0) {
|
|
65
|
+
throw capError('MALFORMED_PREDICATE', 'predicate constrains nothing but an oracle price: the contract refuses it at install (dsl.rs MissingNonOracleEnvelope). Pin the call itself (contract / method / argument) alongside the price bound.');
|
|
66
|
+
}
|
|
51
67
|
// --- pass 2: build + canonicalise the ScVal ---
|
|
52
68
|
const root = encodeNode(node);
|
|
53
69
|
const rawBytes = root.toXDR();
|
|
@@ -61,19 +77,30 @@ function encodePredicate(node) {
|
|
|
61
77
|
function computeStats(node) {
|
|
62
78
|
const inCounts = [];
|
|
63
79
|
const oracleAssets = new Set();
|
|
64
|
-
const {
|
|
65
|
-
|
|
80
|
+
const counters = { nonOracleSelectorLeaves: 0 };
|
|
81
|
+
const { depth, leaves } = walk(node, inCounts, oracleAssets, counters, false);
|
|
82
|
+
return {
|
|
83
|
+
depth,
|
|
84
|
+
leaves,
|
|
85
|
+
inCounts,
|
|
86
|
+
oracleAssets,
|
|
87
|
+
nonOracleSelectorLeaves: counters.nonOracleSelectorLeaves,
|
|
88
|
+
};
|
|
66
89
|
}
|
|
67
|
-
function walk(node, inCounts, oracleAssets
|
|
90
|
+
function walk(node, inCounts, oracleAssets, counters,
|
|
91
|
+
/** True once the walk is under a `not` or an `or`, where an oracle read
|
|
92
|
+
* becomes a condition the caller can satisfy by taking the other branch. */
|
|
93
|
+
negatedOrDisjunctive) {
|
|
68
94
|
switch (node.op) {
|
|
69
95
|
case 'and':
|
|
70
96
|
case 'or': {
|
|
71
|
-
if (node.children.length === 0)
|
|
72
|
-
|
|
97
|
+
if (node.children.length === 0) {
|
|
98
|
+
throw capError('MALFORMED_PREDICATE', `\`${node.op}\` with no children: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed`);
|
|
99
|
+
}
|
|
73
100
|
let maxChildDepth = 0;
|
|
74
101
|
let totalLeaves = 0;
|
|
75
102
|
for (const c of node.children) {
|
|
76
|
-
const child = walk(c, inCounts, oracleAssets);
|
|
103
|
+
const child = walk(c, inCounts, oracleAssets, counters, negatedOrDisjunctive || node.op === 'or');
|
|
77
104
|
if (child.depth > maxChildDepth)
|
|
78
105
|
maxChildDepth = child.depth;
|
|
79
106
|
totalLeaves += child.leaves;
|
|
@@ -81,7 +108,7 @@ function walk(node, inCounts, oracleAssets) {
|
|
|
81
108
|
return { depth: maxChildDepth + 1, leaves: totalLeaves };
|
|
82
109
|
}
|
|
83
110
|
case 'not': {
|
|
84
|
-
const child = walk(node.child, inCounts, oracleAssets);
|
|
111
|
+
const child = walk(node.child, inCounts, oracleAssets, counters, true);
|
|
85
112
|
return { depth: child.depth + 1, leaves: child.leaves };
|
|
86
113
|
}
|
|
87
114
|
case 'eq':
|
|
@@ -89,16 +116,19 @@ function walk(node, inCounts, oracleAssets) {
|
|
|
89
116
|
case 'lte':
|
|
90
117
|
case 'gt':
|
|
91
118
|
case 'gte': {
|
|
92
|
-
collectOracle(node.left, oracleAssets);
|
|
93
|
-
collectOracle(node.right, oracleAssets);
|
|
119
|
+
collectOracle(node.left, oracleAssets, counters, negatedOrDisjunctive);
|
|
120
|
+
collectOracle(node.right, oracleAssets, counters, negatedOrDisjunctive);
|
|
94
121
|
return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) };
|
|
95
122
|
}
|
|
96
123
|
case 'in': {
|
|
124
|
+
if (node.haystack.length === 0) {
|
|
125
|
+
throw capError('MALFORMED_PREDICATE', '`in` with an empty haystack: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed');
|
|
126
|
+
}
|
|
97
127
|
inCounts.push({ count: node.haystack.length });
|
|
98
|
-
collectOracle(node.needle, oracleAssets);
|
|
128
|
+
collectOracle(node.needle, oracleAssets, counters, negatedOrDisjunctive);
|
|
99
129
|
let haystackLeaves = 0;
|
|
100
130
|
for (const h of node.haystack) {
|
|
101
|
-
collectOracle(h, oracleAssets);
|
|
131
|
+
collectOracle(h, oracleAssets, counters, negatedOrDisjunctive);
|
|
102
132
|
haystackLeaves += leafCount(h);
|
|
103
133
|
}
|
|
104
134
|
return {
|
|
@@ -120,16 +150,26 @@ function leafCount(leaf) {
|
|
|
120
150
|
}
|
|
121
151
|
return 1;
|
|
122
152
|
}
|
|
123
|
-
function collectOracle(leaf, oracleAssets) {
|
|
153
|
+
function collectOracle(leaf, oracleAssets, counters, negatedOrDisjunctive) {
|
|
124
154
|
// literal_vec is the only nested leaf; recurse so an oracle_price buried in a
|
|
125
155
|
// vector literal (which the lowering would forbid, but the cap-walker must
|
|
126
156
|
// still see) is counted toward the oracle-read budget.
|
|
127
157
|
if (leaf.kind === 'oracle_price') {
|
|
158
|
+
if (negatedOrDisjunctive) {
|
|
159
|
+
throw capError('ORACLE_LEAF_INVALID_POSITION', `oracle_price(${leaf.asset}) sits under a \`not\`/\`or\`: the contract refuses that position at install (dsl.rs validate_oracle_placement). An oracle bound must be a conjunct the call has to satisfy.`);
|
|
160
|
+
}
|
|
128
161
|
oracleAssets.add(leaf.asset);
|
|
129
162
|
}
|
|
130
163
|
else if (leaf.kind === 'literal_vec') {
|
|
131
164
|
for (const el of leaf.elements)
|
|
132
|
-
collectOracle(el, oracleAssets);
|
|
165
|
+
collectOracle(el, oracleAssets, counters, negatedOrDisjunctive);
|
|
166
|
+
}
|
|
167
|
+
else if (!leaf.kind.startsWith('literal_') && leaf.kind !== 'oracle_threshold') {
|
|
168
|
+
// A threshold is the oracle compare's operand, not an independent
|
|
169
|
+
// constraint. Counting it would let an oracle-only predicate satisfy the
|
|
170
|
+
// non-oracle envelope with its own bound (mirrors collect_oracle_leaf in
|
|
171
|
+
// dsl.rs, where it sits with the literals).
|
|
172
|
+
counters.nonOracleSelectorLeaves += 1;
|
|
133
173
|
}
|
|
134
174
|
}
|
|
135
175
|
function encodeNode(node) {
|
|
@@ -179,6 +219,17 @@ function encodeLeaf(leaf) {
|
|
|
179
219
|
stellar_sdk_1.xdr.ScVal.scvU32(leaf.element),
|
|
180
220
|
stellar_sdk_1.xdr.ScVal.scvSymbol(leaf.field),
|
|
181
221
|
]);
|
|
222
|
+
case 'call_arg_scaled':
|
|
223
|
+
// Wire shape `vec[symbol, u32, i128, i128]` mirrors the Rust decoder's
|
|
224
|
+
// expectation in dsl.rs SEL_CALL_ARG_SCALED. `scvI128FromDecimal` is
|
|
225
|
+
// the same range-checked helper used by `literal_i128`; an out-of-range
|
|
226
|
+
// num/den is refused at encode time (rather than at install on chain).
|
|
227
|
+
return stellar_sdk_1.xdr.ScVal.scvVec([
|
|
228
|
+
symbol('call_arg_scaled'),
|
|
229
|
+
stellar_sdk_1.xdr.ScVal.scvU32(leaf.index),
|
|
230
|
+
scvI128FromDecimal(leaf.num),
|
|
231
|
+
scvI128FromDecimal(leaf.den),
|
|
232
|
+
]);
|
|
182
233
|
case 'amount':
|
|
183
234
|
return stellar_sdk_1.xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)]);
|
|
184
235
|
case 'window_spent':
|
|
@@ -190,11 +241,25 @@ function encodeLeaf(leaf) {
|
|
|
190
241
|
case 'now':
|
|
191
242
|
return stellar_sdk_1.xdr.ScVal.scvVec([symbol('now')]);
|
|
192
243
|
case 'valid_until':
|
|
193
|
-
|
|
244
|
+
// The interpreter refuses this leaf at install: it never sources
|
|
245
|
+
// valid_until_ledger, so a policy built on it would deny forever.
|
|
246
|
+
// Expiry belongs to the smart account, through the context rule's
|
|
247
|
+
// validUntilLedger. Refuse at synthesis so the author finds out here
|
|
248
|
+
// rather than from an install that always fails.
|
|
249
|
+
throw new Error('valid_until is not a usable predicate leaf: the interpreter never sources it and ' +
|
|
250
|
+
'refuses it at install. Put expiry on the context rule (validUntilLedger) instead.');
|
|
194
251
|
case 'invocation_count_in_window':
|
|
195
252
|
return stellar_sdk_1.xdr.ScVal.scvVec([symbol('invocation_count'), scvU64FromValue(leaf.windowSecs)]);
|
|
196
253
|
case 'oracle_price':
|
|
197
254
|
return stellar_sdk_1.xdr.ScVal.scvVec([symbol('oracle_price'), scvAddressFromStrkey(leaf.asset)]);
|
|
255
|
+
case 'oracle_threshold':
|
|
256
|
+
// Arity 3, matching SEL_ORACLE_THRESHOLD in dsl.rs. The declared basis
|
|
257
|
+
// travels with the value so the contract never has to assume one.
|
|
258
|
+
return stellar_sdk_1.xdr.ScVal.scvVec([
|
|
259
|
+
symbol('oracle_threshold'),
|
|
260
|
+
scvI128FromDecimal(leaf.value),
|
|
261
|
+
stellar_sdk_1.xdr.ScVal.scvU32(leaf.decimals),
|
|
262
|
+
]);
|
|
198
263
|
case 'literal_address':
|
|
199
264
|
return scvAddressFromStrkey(leaf.value);
|
|
200
265
|
case 'literal_i128':
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/predicate/from-json.ts - parse untrusted JSON into a `PredicateNode`.
|
|
3
|
+
//
|
|
4
|
+
// The counterpart to `encodePredicate`: that turns a typed node into the
|
|
5
|
+
// canonical wire bytes, this turns arbitrary parsed JSON into a typed node,
|
|
6
|
+
// or throws. Anything accepting a hand-written policy needs both, so both
|
|
7
|
+
// belong in this package rather than in whichever app happened to grow the
|
|
8
|
+
// paste box first.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately shape-only. Structural caps (depth, node count, argument
|
|
11
|
+
// count) stay in the encoder, which is the gate the contract's decoder
|
|
12
|
+
// mirrors; duplicating them here would give two places to drift.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.jsonToAst = jsonToAst;
|
|
15
|
+
/** Parse a JSON object back into a `PredicateNode`. Throws on shape
|
|
16
|
+
* mismatches. The encoder's structural caps are the real gate. */
|
|
17
|
+
function jsonToAst(value) {
|
|
18
|
+
if (value === null || typeof value !== 'object')
|
|
19
|
+
throw new Error('predicate must be an object');
|
|
20
|
+
const v = value;
|
|
21
|
+
switch (v.op) {
|
|
22
|
+
case 'and':
|
|
23
|
+
case 'or':
|
|
24
|
+
return { op: v.op, children: arrayOf(v.children, jsonToAst) };
|
|
25
|
+
case 'not':
|
|
26
|
+
return { op: 'not', child: jsonToAst(v.child) };
|
|
27
|
+
case 'eq':
|
|
28
|
+
case 'lt':
|
|
29
|
+
case 'lte':
|
|
30
|
+
case 'gt':
|
|
31
|
+
case 'gte':
|
|
32
|
+
return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) };
|
|
33
|
+
case 'in':
|
|
34
|
+
return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) };
|
|
35
|
+
default:
|
|
36
|
+
throw new Error(`unknown predicate op: ${String(v.op)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function arrayOf(v, f) {
|
|
40
|
+
if (!Array.isArray(v))
|
|
41
|
+
throw new Error('expected array');
|
|
42
|
+
return v.map(f);
|
|
43
|
+
}
|
|
44
|
+
function jsonToLeaf(value) {
|
|
45
|
+
if (value === null || typeof value !== 'object')
|
|
46
|
+
throw new Error('leaf must be an object');
|
|
47
|
+
const v = value;
|
|
48
|
+
switch (v.kind) {
|
|
49
|
+
case 'call_contract':
|
|
50
|
+
case 'call_fn':
|
|
51
|
+
case 'now':
|
|
52
|
+
case 'valid_until':
|
|
53
|
+
return { kind: v.kind };
|
|
54
|
+
case 'call_arg':
|
|
55
|
+
return { kind: 'call_arg', index: numberField(v, 'index') };
|
|
56
|
+
case 'call_arg_len':
|
|
57
|
+
return { kind: 'call_arg_len', index: numberField(v, 'index') };
|
|
58
|
+
// num/den are strings, not numbers: they are i128 on chain, and a JSON
|
|
59
|
+
// number would silently lose precision past 2^53 before the encoder
|
|
60
|
+
// ever saw the value.
|
|
61
|
+
case 'call_arg_scaled':
|
|
62
|
+
return {
|
|
63
|
+
kind: 'call_arg_scaled',
|
|
64
|
+
index: numberField(v, 'index'),
|
|
65
|
+
num: stringField(v, 'num'),
|
|
66
|
+
den: stringField(v, 'den'),
|
|
67
|
+
};
|
|
68
|
+
case 'call_arg_field':
|
|
69
|
+
return {
|
|
70
|
+
kind: 'call_arg_field',
|
|
71
|
+
index: numberField(v, 'index'),
|
|
72
|
+
element: numberField(v, 'element'),
|
|
73
|
+
field: stringField(v, 'field'),
|
|
74
|
+
};
|
|
75
|
+
case 'amount':
|
|
76
|
+
return { kind: 'amount', token: stringField(v, 'token') };
|
|
77
|
+
case 'window_spent':
|
|
78
|
+
return {
|
|
79
|
+
kind: 'window_spent',
|
|
80
|
+
token: stringField(v, 'token'),
|
|
81
|
+
windowSeconds: numberField(v, 'windowSeconds'),
|
|
82
|
+
};
|
|
83
|
+
case 'invocation_count_in_window':
|
|
84
|
+
return { kind: 'invocation_count_in_window', windowSecs: numberField(v, 'windowSecs') };
|
|
85
|
+
case 'oracle_price':
|
|
86
|
+
return { kind: 'oracle_price', asset: stringField(v, 'asset') };
|
|
87
|
+
case 'literal_address':
|
|
88
|
+
return { kind: 'literal_address', value: stringField(v, 'value') };
|
|
89
|
+
case 'literal_i128':
|
|
90
|
+
return { kind: 'literal_i128', value: stringField(v, 'value') };
|
|
91
|
+
case 'literal_symbol':
|
|
92
|
+
return { kind: 'literal_symbol', value: stringField(v, 'value') };
|
|
93
|
+
case 'literal_u32':
|
|
94
|
+
return { kind: 'literal_u32', value: numberField(v, 'value') };
|
|
95
|
+
case 'literal_u64':
|
|
96
|
+
return { kind: 'literal_u64', value: stringField(v, 'value') };
|
|
97
|
+
case 'literal_bytes':
|
|
98
|
+
return { kind: 'literal_bytes', value: stringField(v, 'value') };
|
|
99
|
+
case 'literal_vec':
|
|
100
|
+
return { kind: 'literal_vec', elements: arrayOf(v.elements, jsonToLeaf) };
|
|
101
|
+
default:
|
|
102
|
+
throw new Error(`unknown leaf kind: ${String(v.kind)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function numberField(v, key) {
|
|
106
|
+
const x = v[key];
|
|
107
|
+
if (typeof x !== 'number' || !Number.isInteger(x))
|
|
108
|
+
throw new Error(`field ${key} must be an integer`);
|
|
109
|
+
return x;
|
|
110
|
+
}
|
|
111
|
+
function stringField(v, key) {
|
|
112
|
+
const x = v[key];
|
|
113
|
+
if (typeof x !== 'string')
|
|
114
|
+
throw new Error(`field ${key} must be a string`);
|
|
115
|
+
return x;
|
|
116
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// src/predicate/index.ts - re-export the canonical predicate encoder
|
|
2
|
+
// src/predicate/index.ts - re-export the canonical predicate encoder and the
|
|
3
|
+
// untrusted-JSON parser that feeds it. A consumer accepting a hand-written
|
|
4
|
+
// policy needs both halves.
|
|
3
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.encodePredicate = void 0;
|
|
6
|
+
exports.jsonToAst = exports.encodePredicate = exports.decodePredicate = exports.decodeNode = exports.decodeLeaf = void 0;
|
|
7
|
+
var decode_ts_1 = require("./decode.js");
|
|
8
|
+
Object.defineProperty(exports, "decodeLeaf", { enumerable: true, get: function () { return decode_ts_1.decodeLeaf; } });
|
|
9
|
+
Object.defineProperty(exports, "decodeNode", { enumerable: true, get: function () { return decode_ts_1.decodeNode; } });
|
|
10
|
+
Object.defineProperty(exports, "decodePredicate", { enumerable: true, get: function () { return decode_ts_1.decodePredicate; } });
|
|
5
11
|
var encode_ts_1 = require("./encode.js");
|
|
6
12
|
Object.defineProperty(exports, "encodePredicate", { enumerable: true, get: function () { return encode_ts_1.encodePredicate; } });
|
|
13
|
+
var from_json_ts_1 = require("./from-json.js");
|
|
14
|
+
Object.defineProperty(exports, "jsonToAst", { enumerable: true, get: function () { return from_json_ts_1.jsonToAst; } });
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
// 3. SoroSwap: recognised by ADDRESS only - the router is a single pinned
|
|
18
18
|
// contract. FIX 4: the method must ALSO be present in the protocol's
|
|
19
19
|
// ABI; an unknown method on a pinned router -> null (fail-closed).
|
|
20
|
+
// 4. OZ smart-account: recognised by INTERFACE - the user-facing context
|
|
21
|
+
// rule entrypoints (`batch_add_signer` / `batch_remove_signer` /
|
|
22
|
+
// `add_context_rule` / `remove_context_rule`) have a fixed (fn, args)
|
|
23
|
+
// shape across every OZ multisig deployment. Pinned per-deployment
|
|
24
|
+
// addresses are impossible (every user deploys their own smart
|
|
25
|
+
// account), so interface recognition is the only viable path - same
|
|
26
|
+
// reasoning SEP-41 uses for any token address.
|
|
20
27
|
//
|
|
21
28
|
// Method lookup uses `Object.hasOwn` rather than `in` so prototype-chain
|
|
22
29
|
// names like `constructor`, `toString`, `hasOwnProperty` do NOT register as
|
|
@@ -49,7 +56,19 @@ function identifyProtocol(contract, method, args, network) {
|
|
|
49
56
|
}
|
|
50
57
|
return null;
|
|
51
58
|
}
|
|
52
|
-
// 3)
|
|
59
|
+
// 3) OpenZeppelin smart-account by interface. The four context-rule
|
|
60
|
+
// entrypoints have a fixed (fn, args) shape across every OZ
|
|
61
|
+
// multisig deployment. `__check_auth` is private (host-invoked)
|
|
62
|
+
// and is intentionally NOT in this ABI - the recogniser only
|
|
63
|
+
// claims OZ for the public surface.
|
|
64
|
+
if (Object.hasOwn(protocols_ts_1.OZ_ACCOUNT_ABI, method)) {
|
|
65
|
+
const sig = protocols_ts_1.OZ_ACCOUNT_ABI[method];
|
|
66
|
+
if (sig && argsMatchAbi(sig.args, args)) {
|
|
67
|
+
return { protocol: 'oz_account', fn: method };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// 4) Pinned-address recognition (Blend factory + SoroSwap router/factory).
|
|
53
72
|
// FIX 4: the method must also be in the protocol's ABI; an unknown
|
|
54
73
|
// method on a pinned address -> null.
|
|
55
74
|
if (network) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ScVal } from '../types.ts';
|
|
2
|
-
export type ProtocolId = 'sep41' | 'blend' | 'soroswap';
|
|
2
|
+
export type ProtocolId = 'sep41' | 'blend' | 'soroswap' | 'oz_account';
|
|
3
3
|
/** ScVal subset type vocabulary the ABI uses. Mirrors `ScVal['type']` minus
|
|
4
4
|
* `other` (which by definition cannot be matched against an ABI arg). */
|
|
5
5
|
export type AbiArgType = Exclude<ScVal['type'], 'other'>;
|
|
@@ -34,5 +34,54 @@ export declare const BLEND_ABI: ProtocolAbi;
|
|
|
34
34
|
* `vec<address>` - we record it as the outer `vec` subset and leave the
|
|
35
35
|
* per-element shape to the downstream reviewer. */
|
|
36
36
|
export declare const SOROSWAP_ABI: ProtocolAbi;
|
|
37
|
+
/** OpenZeppelin smart-account ABI subset.
|
|
38
|
+
*
|
|
39
|
+
* Why this lives in the recogniser: every user's smart account has a
|
|
40
|
+
* different C-address, so a per-deployment pin would never cover them.
|
|
41
|
+
* What is the same across every OpenZeppelin multisig account is the
|
|
42
|
+
* small set of public context-rule entrypoints. A recording that
|
|
43
|
+
* invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
|
|
44
|
+
* is overwhelmingly likely to be calling the user's own OpenZeppelin
|
|
45
|
+
* smart account (the canonical reason this product exists); we
|
|
46
|
+
* recognise that pattern by interface, the same way SEP-41 is
|
|
47
|
+
* recognised by interface.
|
|
48
|
+
*
|
|
49
|
+
* SECURITY PROPERTY (load-bearing):
|
|
50
|
+
* - Recognition claims the call TARGETS a particular protocol. We
|
|
51
|
+
* only claim `oz_account` when the (fn, args) shape uniquely matches
|
|
52
|
+
* one of the three entrypoints below AND the call looks like a real
|
|
53
|
+
* context-rule operation. The match is exact: off-by-one arg count,
|
|
54
|
+
* off-by-one arg type, or a method name close-but-wrong returns null
|
|
55
|
+
* (fail-closed). A contract that incidentally happens to expose a
|
|
56
|
+
* single matching fn is not sufficient - the recorder requires ALL
|
|
57
|
+
* invocations to individually match, so a hostile contract with
|
|
58
|
+
* exactly one matching fn would still contribute to the unknown
|
|
59
|
+
* bucket for any other calls.
|
|
60
|
+
* - We do NOT claim `oz_account` for unknown calls, for arg-shape
|
|
61
|
+
* mismatches, or for calls where the recorder could not pin down
|
|
62
|
+
* the (fn, args) shape. The freshness gate then runs at the same
|
|
63
|
+
* 1.0 threshold - lowering the gate for unknown protocols remains
|
|
64
|
+
* a separate, opt-in production override (see RecordInput below).
|
|
65
|
+
*
|
|
66
|
+
* ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
|
|
67
|
+
* pinned from the OpenZeppelin Reloaded `multisig_account_example`
|
|
68
|
+
* contract (commit ef82b65, fetched 2026-07-28).
|
|
69
|
+
*
|
|
70
|
+
* SCOPE NOTE: this ABI only covers the three entrypoints whose args
|
|
71
|
+
* map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
|
|
72
|
+
* ...). `add_context_rule` - the install path - takes `(Symbol,
|
|
73
|
+
* String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
|
|
74
|
+
* `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
|
|
75
|
+
* not in the AbiArgType set today (the recogniser's `argsMatchAbi`
|
|
76
|
+
* helper rejects `other` deliberately so a close-but-wrong call does
|
|
77
|
+
* not slip through). Excluding `add_context_rule` is the right
|
|
78
|
+
* trade-off for now - it never blocks OZ smart-account recognition,
|
|
79
|
+
* because the install tx always follows a recognised
|
|
80
|
+
* `batch_add_signer` (the demo's first-pass tx), so the
|
|
81
|
+
* contract still ends up in `knownContracts`. Updating the Arg type
|
|
82
|
+
* vocabulary to cover these shapes is a separate concern (and would
|
|
83
|
+
* also lift the SEP-41 / Blend recognition in the same change).
|
|
84
|
+
*/
|
|
85
|
+
export declare const OZ_ACCOUNT_ABI: ProtocolAbi;
|
|
37
86
|
export declare const PROTOCOL_ABIS: Record<ProtocolId, ProtocolAbi>;
|
|
38
87
|
export declare function getAbi(protocol: ProtocolId): ProtocolAbi;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// fails closed: a method call whose decoded args do NOT match the ABI
|
|
19
19
|
// signature is treated as unknown.
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.PROTOCOL_ABIS = exports.SOROSWAP_ABI = exports.BLEND_ABI = exports.SEP41_ABI = void 0;
|
|
21
|
+
exports.PROTOCOL_ABIS = exports.OZ_ACCOUNT_ABI = exports.SOROSWAP_ABI = exports.BLEND_ABI = exports.SEP41_ABI = void 0;
|
|
22
22
|
exports.getAbi = getAbi;
|
|
23
23
|
/** SEP-41 token interface - pinned from the SEP-41 spec (any token). */
|
|
24
24
|
exports.SEP41_ABI = {
|
|
@@ -143,10 +143,98 @@ exports.SOROSWAP_ABI = {
|
|
|
143
143
|
],
|
|
144
144
|
},
|
|
145
145
|
};
|
|
146
|
+
/** OpenZeppelin smart-account ABI subset.
|
|
147
|
+
*
|
|
148
|
+
* Why this lives in the recogniser: every user's smart account has a
|
|
149
|
+
* different C-address, so a per-deployment pin would never cover them.
|
|
150
|
+
* What is the same across every OpenZeppelin multisig account is the
|
|
151
|
+
* small set of public context-rule entrypoints. A recording that
|
|
152
|
+
* invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
|
|
153
|
+
* is overwhelmingly likely to be calling the user's own OpenZeppelin
|
|
154
|
+
* smart account (the canonical reason this product exists); we
|
|
155
|
+
* recognise that pattern by interface, the same way SEP-41 is
|
|
156
|
+
* recognised by interface.
|
|
157
|
+
*
|
|
158
|
+
* SECURITY PROPERTY (load-bearing):
|
|
159
|
+
* - Recognition claims the call TARGETS a particular protocol. We
|
|
160
|
+
* only claim `oz_account` when the (fn, args) shape uniquely matches
|
|
161
|
+
* one of the three entrypoints below AND the call looks like a real
|
|
162
|
+
* context-rule operation. The match is exact: off-by-one arg count,
|
|
163
|
+
* off-by-one arg type, or a method name close-but-wrong returns null
|
|
164
|
+
* (fail-closed). A contract that incidentally happens to expose a
|
|
165
|
+
* single matching fn is not sufficient - the recorder requires ALL
|
|
166
|
+
* invocations to individually match, so a hostile contract with
|
|
167
|
+
* exactly one matching fn would still contribute to the unknown
|
|
168
|
+
* bucket for any other calls.
|
|
169
|
+
* - We do NOT claim `oz_account` for unknown calls, for arg-shape
|
|
170
|
+
* mismatches, or for calls where the recorder could not pin down
|
|
171
|
+
* the (fn, args) shape. The freshness gate then runs at the same
|
|
172
|
+
* 1.0 threshold - lowering the gate for unknown protocols remains
|
|
173
|
+
* a separate, opt-in production override (see RecordInput below).
|
|
174
|
+
*
|
|
175
|
+
* ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
|
|
176
|
+
* pinned from the OpenZeppelin Reloaded `multisig_account_example`
|
|
177
|
+
* contract (commit ef82b65, fetched 2026-07-28).
|
|
178
|
+
*
|
|
179
|
+
* SCOPE NOTE: this ABI only covers the three entrypoints whose args
|
|
180
|
+
* map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
|
|
181
|
+
* ...). `add_context_rule` - the install path - takes `(Symbol,
|
|
182
|
+
* String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
|
|
183
|
+
* `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
|
|
184
|
+
* not in the AbiArgType set today (the recogniser's `argsMatchAbi`
|
|
185
|
+
* helper rejects `other` deliberately so a close-but-wrong call does
|
|
186
|
+
* not slip through). Excluding `add_context_rule` is the right
|
|
187
|
+
* trade-off for now - it never blocks OZ smart-account recognition,
|
|
188
|
+
* because the install tx always follows a recognised
|
|
189
|
+
* `batch_add_signer` (the demo's first-pass tx), so the
|
|
190
|
+
* contract still ends up in `knownContracts`. Updating the Arg type
|
|
191
|
+
* vocabulary to cover these shapes is a separate concern (and would
|
|
192
|
+
* also lift the SEP-41 / Blend recognition in the same change).
|
|
193
|
+
*/
|
|
194
|
+
exports.OZ_ACCOUNT_ABI = {
|
|
195
|
+
batch_add_signer: {
|
|
196
|
+
args: [
|
|
197
|
+
{
|
|
198
|
+
name: 'context_rule_id',
|
|
199
|
+
type: 'u32',
|
|
200
|
+
meaning: 'OZ context rule id this signer is added to',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'signers',
|
|
204
|
+
type: 'vec',
|
|
205
|
+
meaning: 'vec<SignerKey> signers to add (Delegated G-address or Account C-address)',
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
batch_remove_signer: {
|
|
210
|
+
args: [
|
|
211
|
+
{
|
|
212
|
+
name: 'context_rule_id',
|
|
213
|
+
type: 'u32',
|
|
214
|
+
meaning: 'OZ context rule id to remove signers from',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'signers',
|
|
218
|
+
type: 'vec',
|
|
219
|
+
meaning: 'vec<SignerKey> signers to remove from the rule',
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
remove_context_rule: {
|
|
224
|
+
args: [
|
|
225
|
+
{
|
|
226
|
+
name: 'context_rule_id',
|
|
227
|
+
type: 'u32',
|
|
228
|
+
meaning: 'OZ context rule id to delete',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
};
|
|
146
233
|
exports.PROTOCOL_ABIS = {
|
|
147
234
|
sep41: exports.SEP41_ABI,
|
|
148
235
|
blend: exports.BLEND_ABI,
|
|
149
236
|
soroswap: exports.SOROSWAP_ABI,
|
|
237
|
+
oz_account: exports.OZ_ACCOUNT_ABI,
|
|
150
238
|
};
|
|
151
239
|
function getAbi(protocol) {
|
|
152
240
|
return exports.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[];
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// 2. The interpreter `PredicateNode`. One string per constraint leaf,
|
|
19
19
|
// rendered by enclosing-comparison kind. Templates (Task 7b):
|
|
20
|
-
// - invocation_count_in_window
|
|
20
|
+
// - invocation_count_in_window < N -> At most N calls per <window> seconds
|
|
21
21
|
// - call_arg[i] in [list] -> Recipient/arg must be one of [list]
|
|
22
22
|
// - eq(call_arg[i], literal_vec[...]) -> Path must be exactly [list]
|
|
23
23
|
// - oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// hash. There is no clock; the hash never includes a timestamp.
|
|
32
32
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
33
|
exports.buildReviewCardSummary = buildReviewCardSummary;
|
|
34
|
+
exports.describePredicate = describePredicate;
|
|
34
35
|
const node_crypto_1 = require("node:crypto");
|
|
35
36
|
/** Build a deterministic review-card summary from a policy + context rule +
|
|
36
37
|
* simulation result. Pure: same inputs -> byte-identical output. */
|
|
@@ -84,6 +85,23 @@ function renderOzPrimitive(ref) {
|
|
|
84
85
|
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
85
86
|
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
86
87
|
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
88
|
+
/** Every constraint sentence for one predicate, in walk order.
|
|
89
|
+
*
|
|
90
|
+
* `buildReviewCardSummary` renders a policy the synthesiser just PRODUCED,
|
|
91
|
+
* and needs the refs / context rule / simulation to do it. This renders a
|
|
92
|
+
* predicate on its own, which is what a caller holding a policy already
|
|
93
|
+
* INSTALLED on chain has: it can read the document out of the interpreter's
|
|
94
|
+
* storage and decode it, but there is no proposal around it. Same renderers,
|
|
95
|
+
* so an installed rule reads exactly as it did on the review card. */
|
|
96
|
+
function describePredicate(predicate) {
|
|
97
|
+
const constraints = [];
|
|
98
|
+
walkPredicate(predicate, (node) => {
|
|
99
|
+
const line = renderConstraint(node);
|
|
100
|
+
if (line !== null)
|
|
101
|
+
constraints.push(line);
|
|
102
|
+
});
|
|
103
|
+
return constraints;
|
|
104
|
+
}
|
|
87
105
|
function walkPredicate(node, visit) {
|
|
88
106
|
switch (node.op) {
|
|
89
107
|
case 'and':
|
|
@@ -171,17 +189,52 @@ function renderComparison(node) {
|
|
|
171
189
|
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`;
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
|
-
//
|
|
192
|
+
// The bound is compared against the calls ALREADY made in the window, so
|
|
193
|
+
// `< N` permits N of them and `<= N` permits one more. Report how many
|
|
194
|
+
// calls the rule allows rather than restating the comparison.
|
|
175
195
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
176
|
-
|
|
196
|
+
const allowed = node.op === 'lt' ? right.value : right.value + 1;
|
|
197
|
+
return `At most ${allowed} calls per ${left.windowSecs} seconds`;
|
|
198
|
+
}
|
|
199
|
+
// OP(call_arg[i], <scalar literal>) -> arg[i] OP <value>
|
|
200
|
+
//
|
|
201
|
+
// The per-call cap. It has to be here because the `amount` template below
|
|
202
|
+
// is unreachable for an interpreter policy: `amount` is deliberately not in
|
|
203
|
+
// the contract's grammar (dsl.rs - the interpreter sees one authorized
|
|
204
|
+
// call, not the transaction's token movements), so a predicate using it is
|
|
205
|
+
// refused at install. A bound on the call's own amount ARGUMENT is how a
|
|
206
|
+
// cap is actually written, and it was rendering nothing at all - the card
|
|
207
|
+
// silently understated the policy.
|
|
208
|
+
//
|
|
209
|
+
// Placed after the literal_vec case above so an exact-sequence `eq` still
|
|
210
|
+
// reads as a path rather than as a comparison.
|
|
211
|
+
if (left.kind === 'call_arg') {
|
|
212
|
+
const head = `arg[${left.index}]`;
|
|
213
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op);
|
|
214
|
+
if (right.kind === 'literal_i128')
|
|
215
|
+
return `${head} ${sep} ${right.value}`;
|
|
216
|
+
if (right.kind === 'literal_u64')
|
|
217
|
+
return `${head} ${sep} ${right.value}`;
|
|
218
|
+
if (right.kind === 'literal_u32')
|
|
219
|
+
return `${head} ${sep} ${right.value}`;
|
|
220
|
+
if (right.kind === 'literal_address')
|
|
221
|
+
return `${head} ${sep} ${right.value}`;
|
|
222
|
+
if (right.kind === 'literal_symbol')
|
|
223
|
+
return `${head} ${sep} ${right.value}`;
|
|
224
|
+
if (right.kind === 'literal_bytes')
|
|
225
|
+
return `${head} ${sep} ${right.value}`;
|
|
226
|
+
// call_arg_scaled is the slippage floor and reads as itself.
|
|
227
|
+
if (right.kind === 'call_arg_scaled') {
|
|
228
|
+
return `${head} >= arg[${right.index}] * ${right.num}/${right.den}`;
|
|
229
|
+
}
|
|
177
230
|
}
|
|
178
231
|
// amount <= v -> Amount <= v
|
|
179
232
|
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
180
233
|
return `Amount <= ${right.value}`;
|
|
181
234
|
}
|
|
182
235
|
// oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
183
|
-
if (left.kind === 'oracle_price' && right.kind === '
|
|
184
|
-
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`;
|
|
236
|
+
if (left.kind === 'oracle_price' && right.kind === 'oracle_threshold') {
|
|
237
|
+
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value} (${right.decimals} dp)`;
|
|
185
238
|
}
|
|
186
239
|
// Any other comparison shape is a structural fail-closed: do not surface
|
|
187
240
|
// a misleading line. Cross-check still requires every leaf produce a
|
|
@@ -219,6 +272,7 @@ function renderVecElement(leaf) {
|
|
|
219
272
|
case 'call_arg':
|
|
220
273
|
case 'call_arg_len':
|
|
221
274
|
case 'call_arg_field':
|
|
275
|
+
case 'call_arg_scaled':
|
|
222
276
|
case 'amount':
|
|
223
277
|
case 'window_spent':
|
|
224
278
|
case 'now':
|
|
@@ -226,6 +280,11 @@ function renderVecElement(leaf) {
|
|
|
226
280
|
case 'invocation_count_in_window':
|
|
227
281
|
case 'oracle_price':
|
|
228
282
|
return `<${leaf.kind}>`;
|
|
283
|
+
// Show the declared basis, not just the digits. A threshold on the wrong
|
|
284
|
+
// basis is the one policy error the contract cannot detect, so the review
|
|
285
|
+
// card is where a human has to be able to see it.
|
|
286
|
+
case 'oracle_threshold':
|
|
287
|
+
return `${leaf.value} (${leaf.decimals} dp)`;
|
|
229
288
|
}
|
|
230
289
|
}
|
|
231
290
|
function renderHaystackElement(leaf) {
|