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