@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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// src/predicate/decode.ts - the inverse of `encode.ts`.
|
|
2
|
+
//
|
|
3
|
+
// Turns the canonical wire encoding back into a `PredicateNode`, so a policy
|
|
4
|
+
// already installed on chain can be read and explained. The interpreter has no
|
|
5
|
+
// getter for the document, but its persistent storage IS readable over RPC, so
|
|
6
|
+
// the bytes are available to any caller that can reconstruct the storage key -
|
|
7
|
+
// this module takes it from there.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors `decode_leaf` / `decode_node` in
|
|
10
|
+
// `packages/policy-interpreter/src/dsl.rs`. Two rules carry most of the
|
|
11
|
+
// weight, and both come from the Rust decoder verbatim:
|
|
12
|
+
//
|
|
13
|
+
// 1. A `Vec` whose FIRST element is a Symbol is a selector tuple. A `Vec`
|
|
14
|
+
// whose first element is anything else is a `literal_vec`, decoded
|
|
15
|
+
// element-wise with order preserved (the order IS the semantic for exact
|
|
16
|
+
// sequence equality).
|
|
17
|
+
// 2. An unknown symbol at a selector position is MALFORMED, never a
|
|
18
|
+
// literal_vec fallback. Guessing there would let an unsupported selector
|
|
19
|
+
// decode as data and read as permitted.
|
|
20
|
+
//
|
|
21
|
+
// The round-trip `decode(encode(node)) === node` is pinned in decode.test.ts
|
|
22
|
+
// for every leaf and node shape; that test is what keeps this file honest as
|
|
23
|
+
// the grammar grows.
|
|
24
|
+
|
|
25
|
+
import { Address, scValToBigInt, xdr } from '@stellar/stellar-sdk'
|
|
26
|
+
import type { ToolError } from '../errors.ts'
|
|
27
|
+
import type { PredicateLeaf, PredicateNode } from '../types.ts'
|
|
28
|
+
|
|
29
|
+
/** Same shape the encoder raises, so both directions surface one error type.
|
|
30
|
+
* Returns `never` - it always throws; callers `throw malformed(...)` purely
|
|
31
|
+
* so the control flow reads at the call site. */
|
|
32
|
+
function malformed(detail: string): never {
|
|
33
|
+
const err = new Error(`cannot decode predicate: ${detail}`) as Error & {
|
|
34
|
+
code: ToolError['code']
|
|
35
|
+
severity: string
|
|
36
|
+
retryable: boolean
|
|
37
|
+
}
|
|
38
|
+
err.code = 'MALFORMED_PREDICATE'
|
|
39
|
+
err.severity = 'error'
|
|
40
|
+
err.retryable = false
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The symbol at a leaf's selector position, or null when the value is not a
|
|
45
|
+
* symbol-headed vec (i.e. it is a literal or a literal vector). */
|
|
46
|
+
function selectorSymbol(items: xdr.ScVal[]): string | null {
|
|
47
|
+
const head = items[0]
|
|
48
|
+
if (!head) return null
|
|
49
|
+
return head.switch() === xdr.ScValType.scvSymbol() ? head.sym().toString() : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function expectVec(v: xdr.ScVal, what: string): xdr.ScVal[] {
|
|
53
|
+
if (v.switch() !== xdr.ScValType.scvVec()) throw malformed(`${what} is not a vec`)
|
|
54
|
+
return v.vec() ?? []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function expectU32(v: xdr.ScVal | undefined, what: string): number {
|
|
58
|
+
if (!v || v.switch() !== xdr.ScValType.scvU32()) throw malformed(`${what} is not a u32`)
|
|
59
|
+
return v.u32()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expectSymbol(v: xdr.ScVal | undefined, what: string): string {
|
|
63
|
+
if (!v || v.switch() !== xdr.ScValType.scvSymbol()) throw malformed(`${what} is not a symbol`)
|
|
64
|
+
return v.sym().toString()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expectAddress(v: xdr.ScVal | undefined, what: string): string {
|
|
68
|
+
if (!v || v.switch() !== xdr.ScValType.scvAddress()) throw malformed(`${what} is not an address`)
|
|
69
|
+
return Address.fromScAddress(v.address()).toString()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function expectU64(v: xdr.ScVal | undefined, what: string): string {
|
|
73
|
+
if (!v || v.switch() !== xdr.ScValType.scvU64()) throw malformed(`${what} is not a u64`)
|
|
74
|
+
return v.u64().toString()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expectI128(v: xdr.ScVal | undefined, what: string): string {
|
|
78
|
+
if (!v || v.switch() !== xdr.ScValType.scvI128()) throw malformed(`${what} is not an i128`)
|
|
79
|
+
return scValToBigInt(v).toString()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Arity check with the same intent as the Rust `check_arity`: a selector with
|
|
83
|
+
* the wrong element count is malformed, not silently truncated. */
|
|
84
|
+
function arity(items: xdr.ScVal[], n: number, selector: string): void {
|
|
85
|
+
if (items.length !== n) {
|
|
86
|
+
throw malformed(`${selector} expects ${n} elements, got ${items.length}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function decodeSelectorLeaf(items: xdr.ScVal[], sym: string): PredicateLeaf {
|
|
91
|
+
switch (sym) {
|
|
92
|
+
case 'call_contract':
|
|
93
|
+
arity(items, 1, sym)
|
|
94
|
+
return { kind: 'call_contract' }
|
|
95
|
+
case 'call_fn':
|
|
96
|
+
arity(items, 1, sym)
|
|
97
|
+
return { kind: 'call_fn' }
|
|
98
|
+
case 'call_arg':
|
|
99
|
+
arity(items, 2, sym)
|
|
100
|
+
return { kind: 'call_arg', index: expectU32(items[1], 'call_arg index') }
|
|
101
|
+
case 'call_arg_len':
|
|
102
|
+
arity(items, 2, sym)
|
|
103
|
+
return { kind: 'call_arg_len', index: expectU32(items[1], 'call_arg_len index') }
|
|
104
|
+
case 'call_arg_field':
|
|
105
|
+
arity(items, 4, sym)
|
|
106
|
+
return {
|
|
107
|
+
kind: 'call_arg_field',
|
|
108
|
+
index: expectU32(items[1], 'call_arg_field index'),
|
|
109
|
+
element: expectU32(items[2], 'call_arg_field element'),
|
|
110
|
+
field: expectSymbol(items[3], 'call_arg_field field'),
|
|
111
|
+
}
|
|
112
|
+
case 'call_arg_scaled':
|
|
113
|
+
arity(items, 4, sym)
|
|
114
|
+
return {
|
|
115
|
+
kind: 'call_arg_scaled',
|
|
116
|
+
index: expectU32(items[1], 'call_arg_scaled index'),
|
|
117
|
+
num: expectI128(items[2], 'call_arg_scaled num'),
|
|
118
|
+
den: expectI128(items[3], 'call_arg_scaled den'),
|
|
119
|
+
}
|
|
120
|
+
case 'amount':
|
|
121
|
+
arity(items, 2, sym)
|
|
122
|
+
return { kind: 'amount', token: expectAddress(items[1], 'amount token') }
|
|
123
|
+
case 'window_spent':
|
|
124
|
+
arity(items, 3, sym)
|
|
125
|
+
return {
|
|
126
|
+
kind: 'window_spent',
|
|
127
|
+
token: expectAddress(items[1], 'window_spent token'),
|
|
128
|
+
windowSeconds: Number(expectU64(items[2], 'window_spent windowSeconds')),
|
|
129
|
+
}
|
|
130
|
+
case 'now':
|
|
131
|
+
arity(items, 1, sym)
|
|
132
|
+
return { kind: 'now' }
|
|
133
|
+
case 'valid_until':
|
|
134
|
+
// The encoder refuses to emit this and the interpreter refuses it at
|
|
135
|
+
// install, so it cannot appear in a document that installed. Decoding it
|
|
136
|
+
// anyway would present a rule the chain would never have accepted.
|
|
137
|
+
throw malformed('valid_until is not a usable predicate leaf')
|
|
138
|
+
case 'invocation_count':
|
|
139
|
+
arity(items, 2, sym)
|
|
140
|
+
return {
|
|
141
|
+
kind: 'invocation_count_in_window',
|
|
142
|
+
windowSecs: Number(expectU64(items[1], 'invocation_count windowSecs')),
|
|
143
|
+
}
|
|
144
|
+
case 'oracle_price':
|
|
145
|
+
arity(items, 2, sym)
|
|
146
|
+
return { kind: 'oracle_price', asset: expectAddress(items[1], 'oracle_price asset') }
|
|
147
|
+
case 'oracle_threshold':
|
|
148
|
+
arity(items, 3, sym)
|
|
149
|
+
return {
|
|
150
|
+
kind: 'oracle_threshold',
|
|
151
|
+
value: expectI128(items[1], 'oracle_threshold value'),
|
|
152
|
+
decimals: expectU32(items[2], 'oracle_threshold decimals'),
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
// Deliberately NOT a literal_vec fallback - see the header note.
|
|
156
|
+
throw malformed(`unknown selector symbol '${sym}'`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One leaf. Order of the bare-literal checks mirrors `decode_leaf` in dsl.rs. */
|
|
161
|
+
export function decodeLeaf(v: xdr.ScVal): PredicateLeaf {
|
|
162
|
+
if (v.switch() === xdr.ScValType.scvVec()) {
|
|
163
|
+
const items = v.vec() ?? []
|
|
164
|
+
if (items.length === 0) throw malformed('empty vec is neither a selector nor a literal vector')
|
|
165
|
+
const sym = selectorSymbol(items)
|
|
166
|
+
if (sym !== null) return decodeSelectorLeaf(items, sym)
|
|
167
|
+
return { kind: 'literal_vec', elements: items.map(decodeLeaf) }
|
|
168
|
+
}
|
|
169
|
+
switch (v.switch()) {
|
|
170
|
+
case xdr.ScValType.scvAddress():
|
|
171
|
+
return { kind: 'literal_address', value: Address.fromScAddress(v.address()).toString() }
|
|
172
|
+
case xdr.ScValType.scvSymbol():
|
|
173
|
+
return { kind: 'literal_symbol', value: v.sym().toString() }
|
|
174
|
+
case xdr.ScValType.scvU32():
|
|
175
|
+
return { kind: 'literal_u32', value: v.u32() }
|
|
176
|
+
case xdr.ScValType.scvU64():
|
|
177
|
+
return { kind: 'literal_u64', value: v.u64().toString() }
|
|
178
|
+
case xdr.ScValType.scvI128():
|
|
179
|
+
return { kind: 'literal_i128', value: scValToBigInt(v).toString() }
|
|
180
|
+
case xdr.ScValType.scvBytes():
|
|
181
|
+
return { kind: 'literal_bytes', value: Buffer.from(v.bytes()).toString('hex') }
|
|
182
|
+
default:
|
|
183
|
+
throw malformed(`unsupported leaf value type ${v.switch().name}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One node. `not` wraps a NODE; the comparison ops wrap two LEAVES. */
|
|
188
|
+
export function decodeNode(v: xdr.ScVal): PredicateNode {
|
|
189
|
+
const items = expectVec(v, 'node')
|
|
190
|
+
const op = selectorSymbol(items)
|
|
191
|
+
if (op === null) throw malformed('node does not start with an operator symbol')
|
|
192
|
+
switch (op) {
|
|
193
|
+
case 'and':
|
|
194
|
+
case 'or': {
|
|
195
|
+
arity(items, 2, op)
|
|
196
|
+
const children = expectVec(items[1] as xdr.ScVal, `${op} children`).map(decodeNode)
|
|
197
|
+
if (children.length === 0) throw malformed(`${op} has no children`)
|
|
198
|
+
return { op, children }
|
|
199
|
+
}
|
|
200
|
+
case 'not':
|
|
201
|
+
arity(items, 2, op)
|
|
202
|
+
return { op: 'not', child: decodeNode(items[1] as xdr.ScVal) }
|
|
203
|
+
case 'eq':
|
|
204
|
+
case 'lt':
|
|
205
|
+
case 'lte':
|
|
206
|
+
case 'gt':
|
|
207
|
+
case 'gte':
|
|
208
|
+
arity(items, 3, op)
|
|
209
|
+
return {
|
|
210
|
+
op,
|
|
211
|
+
left: decodeLeaf(items[1] as xdr.ScVal),
|
|
212
|
+
right: decodeLeaf(items[2] as xdr.ScVal),
|
|
213
|
+
}
|
|
214
|
+
case 'in': {
|
|
215
|
+
arity(items, 3, op)
|
|
216
|
+
return {
|
|
217
|
+
op: 'in',
|
|
218
|
+
needle: decodeLeaf(items[1] as xdr.ScVal),
|
|
219
|
+
haystack: expectVec(items[2] as xdr.ScVal, 'in haystack').map(decodeLeaf),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
default:
|
|
223
|
+
throw malformed(`unknown node operator '${op}'`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Decode a predicate from the wire bytes the interpreter stores.
|
|
228
|
+
*
|
|
229
|
+
* Accepts the base64 the encoder emits (`encodePredicate().encodedPredicate`)
|
|
230
|
+
* or the raw XDR bytes read straight out of `StoredDoc.predicate_bytes`. */
|
|
231
|
+
export function decodePredicate(encoded: string | Uint8Array): PredicateNode {
|
|
232
|
+
let scv: xdr.ScVal
|
|
233
|
+
try {
|
|
234
|
+
scv =
|
|
235
|
+
typeof encoded === 'string'
|
|
236
|
+
? xdr.ScVal.fromXDR(Buffer.from(encoded, 'base64'))
|
|
237
|
+
: xdr.ScVal.fromXDR(Buffer.from(encoded))
|
|
238
|
+
} catch (e) {
|
|
239
|
+
throw malformed(`not valid ScVal XDR (${e instanceof Error ? e.message : String(e)})`)
|
|
240
|
+
}
|
|
241
|
+
return decodeNode(scv)
|
|
242
|
+
}
|
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
|
|
|
5
|
+
export { decodeLeaf, decodeNode, decodePredicate } from './decode.ts'
|
|
3
6
|
export { type EncodedPredicate, encodePredicate } from './encode.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) {
|