@crediolabs/policy-synth 0.1.2 → 0.1.4
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/README.md +193 -5
- package/dist/adapters/interpreter/adapter.d.ts +38 -0
- package/dist/adapters/interpreter/adapter.js +522 -0
- package/dist/adapters/interpreter/index.d.ts +1 -0
- package/dist/adapters/interpreter/index.js +2 -0
- package/dist/adapters/oz/adapter.js +20 -18
- package/dist/codegen/compile-gate.d.ts +33 -0
- package/dist/codegen/compile-gate.js +119 -0
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/template.d.ts +18 -0
- package/dist/codegen/template.js +131 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ir/types.d.ts +13 -2
- package/dist/mandate/to-ir.js +4 -4
- package/dist/mandate/types.d.ts +1 -1
- package/dist/predicate/encode.d.ts +10 -0
- package/dist/predicate/encode.js +249 -0
- package/dist/predicate/index.d.ts +1 -0
- package/dist/predicate/index.js +2 -0
- package/dist/review-card/builder.d.ts +14 -0
- package/dist/review-card/builder.js +261 -0
- package/dist/review-card/conflict.d.ts +40 -0
- package/dist/review-card/conflict.js +111 -0
- package/dist/review-card/cross-check.d.ts +11 -0
- package/dist/review-card/cross-check.js +148 -0
- package/dist/review-card/index.d.ts +3 -0
- package/dist/review-card/index.js +4 -0
- package/dist/seams/types.d.ts +1 -1
- package/dist/synth/compose-from-recording.d.ts +35 -7
- package/dist/synth/compose-from-recording.js +231 -40
- package/dist/synth/deny-cases.d.ts +12 -0
- package/dist/synth/deny-cases.js +361 -0
- package/dist/synth/evaluate.d.ts +39 -0
- package/dist/synth/evaluate.js +425 -0
- package/dist/synth/harness.d.ts +16 -0
- package/dist/synth/harness.js +26 -0
- package/dist/synth/index.d.ts +4 -0
- package/dist/synth/index.js +4 -0
- package/dist/synth/lower.d.ts +2 -2
- package/dist/synth/minimize.d.ts +4 -0
- package/dist/synth/minimize.js +38 -0
- package/dist/synth/predicate-literals.d.ts +5 -0
- package/dist/synth/predicate-literals.js +25 -0
- package/dist/synth/synthesize-from-mandate.js +5 -5
- package/dist/synth/synthesize-from-recording.d.ts +32 -3
- package/dist/synth/synthesize-from-recording.js +491 -17
- package/dist/types.d.ts +23 -1
- package/dist/verify/envelope.d.ts +15 -0
- package/dist/verify/envelope.js +22 -0
- package/dist/verify/index.d.ts +3 -0
- package/dist/verify/index.js +3 -0
- package/dist/verify/simulate.d.ts +31 -0
- package/dist/verify/simulate.js +242 -0
- package/dist/verify/verify.d.ts +21 -0
- package/dist/verify/verify.js +173 -0
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +642 -0
- package/src/adapters/interpreter/index.ts +8 -0
- package/src/adapters/oz/adapter.ts +20 -18
- package/src/codegen/compile-gate.ts +162 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/template.ts +148 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +2 -0
- package/src/ir/types.ts +9 -2
- package/src/mandate/to-ir.ts +4 -4
- package/src/mandate/types.ts +1 -1
- package/src/predicate/encode.ts +307 -0
- package/src/predicate/index.ts +3 -0
- package/src/review-card/builder.ts +303 -0
- package/src/review-card/conflict.ts +143 -0
- package/src/review-card/cross-check.ts +158 -0
- package/src/review-card/index.ts +12 -0
- package/src/seams/types.ts +1 -1
- package/src/synth/compose-from-recording.ts +283 -49
- package/src/synth/deny-cases.ts +447 -0
- package/src/synth/evaluate.ts +493 -0
- package/src/synth/harness.ts +40 -0
- package/src/synth/index.ts +12 -0
- package/src/synth/lower.ts +2 -2
- package/src/synth/minimize.ts +44 -0
- package/src/synth/predicate-literals.ts +27 -0
- package/src/synth/synthesize-from-mandate.ts +5 -5
- package/src/synth/synthesize-from-recording.ts +575 -18
- package/src/types.ts +19 -2
- package/src/verify/envelope.ts +28 -0
- package/src/verify/index.ts +5 -0
- package/src/verify/simulate.ts +292 -0
- package/src/verify/verify.ts +224 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// src/predicate/encode.ts - canonical predicate encoder.
|
|
2
|
+
//
|
|
3
|
+
// Pure function. Maps a `PredicateNode` AST to the canonical ScVal wire format
|
|
4
|
+
// described in `packages/policy-interpreter/INTERPRETER_INSTALL_PARAMS.md`:
|
|
5
|
+
// - every node is a `ScVal::Vec` whose head element is the tag `ScVal::Symbol`
|
|
6
|
+
// - children of `and` / `or` are sorted ascending by their canonical XDR bytes
|
|
7
|
+
// - `in` haystacks are ALWAYS sorted by canonical XDR bytes (pure set
|
|
8
|
+
// membership); an EXACT ordered sequence is expressed as
|
|
9
|
+
// `eq(selector, literal_vec)` where the `literal_vec` element order is
|
|
10
|
+
// preserved verbatim (the order IS the semantic)
|
|
11
|
+
// - `literal_vec` encodes to a bare `ScVal::Vec` of its element encodings;
|
|
12
|
+
// order is preserved, NOT sorted
|
|
13
|
+
// - i128 uses `Int128Parts{hi: Int64 (signed), lo: Uint64 (unsigned)}`,
|
|
14
|
+
// value = hi*2^64 + lo (NOT signed-magnitude)
|
|
15
|
+
// - no `ScMap` anywhere in the predicate
|
|
16
|
+
// - `encodedPredicate = base64(root.toXDR())`,
|
|
17
|
+
// `predicateHash = sha256(raw XDR bytes)` as hex
|
|
18
|
+
//
|
|
19
|
+
// Caps from `PREDICATE_CAPS` are enforced BEFORE returning; a cap breach throws
|
|
20
|
+
// a `ToolError` with the matching error code and `severity: 'error'`.
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { Address, xdr } from '@stellar/stellar-sdk'
|
|
24
|
+
import type { ToolError } from '../errors.ts'
|
|
25
|
+
import { PREDICATE_CAPS, type PredicateLeaf, type PredicateNode } from '../types.ts'
|
|
26
|
+
|
|
27
|
+
export interface EncodedPredicate {
|
|
28
|
+
/** base64 of the canonical ScVal XDR of the predicate root. */
|
|
29
|
+
encodedPredicate: string
|
|
30
|
+
/** sha256 hex digest of the raw XDR bytes (post-canonicalisation). */
|
|
31
|
+
predicateHash: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const INT64_MAX = (1n << 63n) - 1n
|
|
35
|
+
const INT64_MIN = -(1n << 63n)
|
|
36
|
+
const UINT64_MAX = (1n << 64n) - 1n
|
|
37
|
+
|
|
38
|
+
/** Encode a `PredicateNode` to the canonical ScVal wire format and hash it.
|
|
39
|
+
* Pure function: same input -> byte-identical output every run. */
|
|
40
|
+
export function encodePredicate(node: PredicateNode): EncodedPredicate {
|
|
41
|
+
// --- pass 1: structural cap checks (no encoding needed) ---
|
|
42
|
+
const stats = computeStats(node)
|
|
43
|
+
if (stats.depth > PREDICATE_CAPS.MAX_DEPTH) {
|
|
44
|
+
throw capError(
|
|
45
|
+
'PREDICATE_TOO_DEEP',
|
|
46
|
+
`predicate depth ${stats.depth} exceeds MAX_DEPTH ${PREDICATE_CAPS.MAX_DEPTH}`
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
if (stats.leaves > PREDICATE_CAPS.MAX_LEAVES) {
|
|
50
|
+
throw capError(
|
|
51
|
+
'TOO_MANY_LEAVES',
|
|
52
|
+
`predicate leaf count ${stats.leaves} exceeds MAX_LEAVES ${PREDICATE_CAPS.MAX_LEAVES}`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
for (const c of stats.inCounts) {
|
|
56
|
+
if (c.count > PREDICATE_CAPS.MAX_IN_OPERAND_COUNT) {
|
|
57
|
+
throw capError(
|
|
58
|
+
'IN_OPERAND_LIMIT',
|
|
59
|
+
`\`in\` operand count ${c.count} exceeds MAX_IN_OPERAND_COUNT ${PREDICATE_CAPS.MAX_IN_OPERAND_COUNT}`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Two-round confirmation costs 2 oracle reads per UNIQUE asset referenced.
|
|
64
|
+
const oracleReads = stats.oracleAssets.size * 2
|
|
65
|
+
if (oracleReads > PREDICATE_CAPS.MAX_ORACLE_READS) {
|
|
66
|
+
throw capError(
|
|
67
|
+
'PREDICATE_ORACLE_OVER_LIMIT',
|
|
68
|
+
`oracle reads ${oracleReads} exceed MAX_ORACLE_READS ${PREDICATE_CAPS.MAX_ORACLE_READS}`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- pass 2: build + canonicalise the ScVal ---
|
|
73
|
+
const root = encodeNode(node)
|
|
74
|
+
const rawBytes = root.toXDR()
|
|
75
|
+
if (rawBytes.length > PREDICATE_CAPS.MAX_PREDICATE_BYTES) {
|
|
76
|
+
throw capError(
|
|
77
|
+
'PREDICATE_TOO_LARGE',
|
|
78
|
+
`predicate bytes ${rawBytes.length} exceed MAX_PREDICATE_BYTES ${PREDICATE_CAPS.MAX_PREDICATE_BYTES}`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const encodedPredicate = rawBytes.toString('base64')
|
|
82
|
+
const predicateHash = createHash('sha256').update(rawBytes).digest('hex')
|
|
83
|
+
return { encodedPredicate, predicateHash }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface PredicateStats {
|
|
87
|
+
depth: number
|
|
88
|
+
leaves: number
|
|
89
|
+
inCounts: Array<{ count: number }>
|
|
90
|
+
/** Unique assets referenced by `oracle_price` leaves - each costs 2 reads. */
|
|
91
|
+
oracleAssets: Set<string>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function computeStats(node: PredicateNode): PredicateStats {
|
|
95
|
+
const inCounts: Array<{ count: number }> = []
|
|
96
|
+
const oracleAssets = new Set<string>()
|
|
97
|
+
const { depth, leaves } = walk(node, inCounts, oracleAssets)
|
|
98
|
+
return { depth, leaves, inCounts, oracleAssets }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function walk(
|
|
102
|
+
node: PredicateNode,
|
|
103
|
+
inCounts: Array<{ count: number }>,
|
|
104
|
+
oracleAssets: Set<string>
|
|
105
|
+
): { depth: number; leaves: number } {
|
|
106
|
+
switch (node.op) {
|
|
107
|
+
case 'and':
|
|
108
|
+
case 'or': {
|
|
109
|
+
if (node.children.length === 0) return { depth: 1, leaves: 0 }
|
|
110
|
+
let maxChildDepth = 0
|
|
111
|
+
let totalLeaves = 0
|
|
112
|
+
for (const c of node.children) {
|
|
113
|
+
const child = walk(c, inCounts, oracleAssets)
|
|
114
|
+
if (child.depth > maxChildDepth) maxChildDepth = child.depth
|
|
115
|
+
totalLeaves += child.leaves
|
|
116
|
+
}
|
|
117
|
+
return { depth: maxChildDepth + 1, leaves: totalLeaves }
|
|
118
|
+
}
|
|
119
|
+
case 'not': {
|
|
120
|
+
const child = walk(node.child, inCounts, oracleAssets)
|
|
121
|
+
return { depth: child.depth + 1, leaves: child.leaves }
|
|
122
|
+
}
|
|
123
|
+
case 'eq':
|
|
124
|
+
case 'lt':
|
|
125
|
+
case 'lte':
|
|
126
|
+
case 'gt':
|
|
127
|
+
case 'gte': {
|
|
128
|
+
collectOracle(node.left, oracleAssets)
|
|
129
|
+
collectOracle(node.right, oracleAssets)
|
|
130
|
+
return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) }
|
|
131
|
+
}
|
|
132
|
+
case 'in': {
|
|
133
|
+
inCounts.push({ count: node.haystack.length })
|
|
134
|
+
collectOracle(node.needle, oracleAssets)
|
|
135
|
+
let haystackLeaves = 0
|
|
136
|
+
for (const h of node.haystack) {
|
|
137
|
+
collectOracle(h, oracleAssets)
|
|
138
|
+
haystackLeaves += leafCount(h)
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
depth: 1,
|
|
142
|
+
leaves: leafCount(node.needle) + haystackLeaves,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Leaf-count contribution of one PredicateLeaf: 1 for any flat leaf, and the
|
|
149
|
+
* sum of element leaf counts for `literal_vec` so MAX_LEAVES caps see nested
|
|
150
|
+
* vector elements. */
|
|
151
|
+
function leafCount(leaf: PredicateLeaf): number {
|
|
152
|
+
if (leaf.kind === 'literal_vec') {
|
|
153
|
+
let total = 1 // the literal_vec itself counts as one leaf node
|
|
154
|
+
for (const el of leaf.elements) total += leafCount(el)
|
|
155
|
+
return total
|
|
156
|
+
}
|
|
157
|
+
return 1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function collectOracle(leaf: PredicateLeaf, oracleAssets: Set<string>): void {
|
|
161
|
+
// literal_vec is the only nested leaf; recurse so an oracle_price buried in a
|
|
162
|
+
// vector literal (which the lowering would forbid, but the cap-walker must
|
|
163
|
+
// still see) is counted toward the oracle-read budget.
|
|
164
|
+
if (leaf.kind === 'oracle_price') {
|
|
165
|
+
oracleAssets.add(leaf.asset)
|
|
166
|
+
} else if (leaf.kind === 'literal_vec') {
|
|
167
|
+
for (const el of leaf.elements) collectOracle(el, oracleAssets)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function encodeNode(node: PredicateNode): xdr.ScVal {
|
|
172
|
+
switch (node.op) {
|
|
173
|
+
case 'and':
|
|
174
|
+
case 'or': {
|
|
175
|
+
const encoded = node.children.map(encodeNode)
|
|
176
|
+
// sort children by their canonical XDR bytes ascending.
|
|
177
|
+
const sorted = sortByCanonicalBytes(encoded)
|
|
178
|
+
return xdr.ScVal.scvVec([symbol(node.op), xdr.ScVal.scvVec(sorted)])
|
|
179
|
+
}
|
|
180
|
+
case 'not': {
|
|
181
|
+
return xdr.ScVal.scvVec([symbol('not'), encodeNode(node.child)])
|
|
182
|
+
}
|
|
183
|
+
case 'eq':
|
|
184
|
+
case 'lt':
|
|
185
|
+
case 'lte':
|
|
186
|
+
case 'gt':
|
|
187
|
+
case 'gte': {
|
|
188
|
+
return xdr.ScVal.scvVec([symbol(node.op), encodeLeaf(node.left), encodeLeaf(node.right)])
|
|
189
|
+
}
|
|
190
|
+
case 'in': {
|
|
191
|
+
const needle = encodeLeaf(node.needle)
|
|
192
|
+
// `in` is PURE set membership: the haystack is ALWAYS sorted by canonical
|
|
193
|
+
// XDR bytes ascending. An exact ordered sequence (e.g. a swap hop path)
|
|
194
|
+
// is expressed as `eq(selector, literal_vec)` where the vec's element
|
|
195
|
+
// order is preserved verbatim (handled in `encodeLeaf(literal_vec)`).
|
|
196
|
+
const haystack = sortByCanonicalBytes(node.haystack.map(encodeLeaf))
|
|
197
|
+
return xdr.ScVal.scvVec([symbol('in'), needle, xdr.ScVal.scvVec(haystack)])
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function encodeLeaf(leaf: PredicateLeaf): xdr.ScVal {
|
|
203
|
+
switch (leaf.kind) {
|
|
204
|
+
case 'call_contract':
|
|
205
|
+
return xdr.ScVal.scvVec([symbol('call_contract')])
|
|
206
|
+
case 'call_fn':
|
|
207
|
+
return xdr.ScVal.scvVec([symbol('call_fn')])
|
|
208
|
+
case 'call_arg':
|
|
209
|
+
return xdr.ScVal.scvVec([symbol('call_arg'), xdr.ScVal.scvU32(leaf.index)])
|
|
210
|
+
case 'amount':
|
|
211
|
+
return xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)])
|
|
212
|
+
case 'window_spent':
|
|
213
|
+
return xdr.ScVal.scvVec([
|
|
214
|
+
symbol('window_spent'),
|
|
215
|
+
scvAddressFromStrkey(leaf.token),
|
|
216
|
+
scvU64FromValue(leaf.windowSeconds),
|
|
217
|
+
])
|
|
218
|
+
case 'now':
|
|
219
|
+
return xdr.ScVal.scvVec([symbol('now')])
|
|
220
|
+
case 'valid_until':
|
|
221
|
+
return xdr.ScVal.scvVec([symbol('valid_until')])
|
|
222
|
+
case 'invocation_count_in_window':
|
|
223
|
+
return xdr.ScVal.scvVec([symbol('invocation_count'), scvU64FromValue(leaf.windowSecs)])
|
|
224
|
+
case 'oracle_price':
|
|
225
|
+
return xdr.ScVal.scvVec([symbol('oracle_price'), scvAddressFromStrkey(leaf.asset)])
|
|
226
|
+
case 'literal_address':
|
|
227
|
+
return scvAddressFromStrkey(leaf.value)
|
|
228
|
+
case 'literal_i128':
|
|
229
|
+
return scvI128FromDecimal(leaf.value)
|
|
230
|
+
case 'literal_symbol':
|
|
231
|
+
return xdr.ScVal.scvSymbol(leaf.value)
|
|
232
|
+
case 'literal_u32':
|
|
233
|
+
return xdr.ScVal.scvU32(leaf.value)
|
|
234
|
+
case 'literal_u64':
|
|
235
|
+
return scvU64FromValue(leaf.value)
|
|
236
|
+
case 'literal_bytes':
|
|
237
|
+
return xdr.ScVal.scvBytes(Buffer.from(leaf.value, 'hex'))
|
|
238
|
+
case 'literal_vec':
|
|
239
|
+
// Bare ScVal::Vec of element encodings - order is preserved verbatim
|
|
240
|
+
// because the order IS the semantic (exact ordered sequence equality).
|
|
241
|
+
return xdr.ScVal.scvVec(leaf.elements.map(encodeLeaf))
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function symbol(s: string): xdr.ScVal {
|
|
246
|
+
return xdr.ScVal.scvSymbol(s)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sortByCanonicalBytes(values: xdr.ScVal[]): xdr.ScVal[] {
|
|
250
|
+
const pairs = values.map((v) => ({ v, bytes: v.toXDR() }))
|
|
251
|
+
pairs.sort((a, b) => Buffer.compare(a.bytes, b.bytes))
|
|
252
|
+
return pairs.map((p) => p.v)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function scvAddressFromStrkey(strkey: string): xdr.ScVal {
|
|
256
|
+
return xdr.ScVal.scvAddress(Address.fromString(strkey).toScAddress())
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function scvU64FromValue(value: number | string): xdr.ScVal {
|
|
260
|
+
// The Uint64 (UnsignedHyper) constructor accepts string | bigint | number;
|
|
261
|
+
// string is safest for values > 2^53.
|
|
262
|
+
return xdr.ScVal.scvU64(new xdr.Uint64(String(value)))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Build `ScVal::I128(Int128Parts{hi, lo})` from a signed decimal string.
|
|
266
|
+
* `Int128Parts` encodes the value as `(hi << 64) + lo` with `hi` a SIGNED
|
|
267
|
+
* 64-bit int and `lo` an UNSIGNED 64-bit int (this is NOT signed-magnitude).
|
|
268
|
+
* The inverse split is `hi = v >> 64n` (arithmetic right shift) and
|
|
269
|
+
* `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
|
|
270
|
+
* bigint/string/number. */
|
|
271
|
+
function scvI128FromDecimal(decimal: string): xdr.ScVal {
|
|
272
|
+
const v = BigInt(decimal)
|
|
273
|
+
const hi = v >> 64n
|
|
274
|
+
const lo = v & UINT64_MAX
|
|
275
|
+
// Guard against accidental over-range. Int128Parts.hi must be a valid Int64
|
|
276
|
+
// (-2^63 .. 2^63-1); values outside this range are invalid i128 entirely.
|
|
277
|
+
if (hi < INT64_MIN || hi > INT64_MAX) {
|
|
278
|
+
throw capError(
|
|
279
|
+
'MALFORMED_PREDICATE',
|
|
280
|
+
`literal_i128 value ${decimal} is outside the Int128 range`
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
if (lo < 0n || lo > UINT64_MAX) {
|
|
284
|
+
throw capError(
|
|
285
|
+
'MALFORMED_PREDICATE',
|
|
286
|
+
`literal_i128 value ${decimal} has an invalid Int128Parts.lo`
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
return xdr.ScVal.scvI128(
|
|
290
|
+
new xdr.Int128Parts({
|
|
291
|
+
hi: new xdr.Int64(hi),
|
|
292
|
+
lo: new xdr.Uint64(lo),
|
|
293
|
+
})
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function capError(code: ToolError['code'], message: string): ToolError {
|
|
298
|
+
const err = new Error(message) as Error & {
|
|
299
|
+
code: ToolError['code']
|
|
300
|
+
severity: string
|
|
301
|
+
retryable: boolean
|
|
302
|
+
}
|
|
303
|
+
err.code = code
|
|
304
|
+
err.severity = 'error'
|
|
305
|
+
err.retryable = false
|
|
306
|
+
throw err
|
|
307
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// src/review-card/builder.ts - deterministic, pure review-card summary
|
|
2
|
+
// builder.
|
|
3
|
+
//
|
|
4
|
+
// `buildReviewCardSummary` renders the per-policy text the user-facing review
|
|
5
|
+
// card quotes. The whole DX win of non-engineer reviewability hinges on the
|
|
6
|
+
// summary being REPRODUCIBLE + TESTABLE + NON-HALLUCINABLE, so this module is
|
|
7
|
+
// pure: same inputs -> byte-identical output, no clock, no randomness, no I/O.
|
|
8
|
+
//
|
|
9
|
+
// The builder walks two inputs and emits ONE constraint string per leaf or
|
|
10
|
+
// primitive, in a fixed deterministic order:
|
|
11
|
+
//
|
|
12
|
+
// 1. The OZ built-in `PolicyRef`s. Each `spending_limit` primitive becomes a
|
|
13
|
+
// `spending_limit(token, limitAmount, windowSecs)` line; other OZ
|
|
14
|
+
// primitives (threshold) are skipped - the review card does not quote
|
|
15
|
+
// them (they are signer-config concerns, not transactional bounds).
|
|
16
|
+
//
|
|
17
|
+
// 2. The interpreter `PredicateNode`. One string per constraint leaf,
|
|
18
|
+
// rendered by enclosing-comparison kind. Templates (Task 7b):
|
|
19
|
+
// - invocation_count_in_window <= N -> Invocations <= N per <window> seconds
|
|
20
|
+
// - call_arg[i] in [list] -> Recipient/arg must be one of [list]
|
|
21
|
+
// - eq(call_arg[i], literal_vec[...]) -> Path must be exactly [list]
|
|
22
|
+
// - oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
23
|
+
// - call_fn == x -> Function must be x
|
|
24
|
+
// - call_contract == c -> Contract must be c
|
|
25
|
+
// - amount <= v -> Amount <= v
|
|
26
|
+
//
|
|
27
|
+
// The content hash is a stable sha256 hex of a canonical JSON of
|
|
28
|
+
// { ruleName, plainEnglish, constraints, expiry, backend } - identical
|
|
29
|
+
// inputs (incl. the context-rule expiry and simulation backend) -> identical
|
|
30
|
+
// hash. There is no clock; the hash never includes a timestamp.
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import type {
|
|
34
|
+
ContextRuleDraft,
|
|
35
|
+
OZPrimitiveConfig,
|
|
36
|
+
PolicyRef,
|
|
37
|
+
PredicateLeaf,
|
|
38
|
+
PredicateNode,
|
|
39
|
+
} from '../types.ts'
|
|
40
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
41
|
+
|
|
42
|
+
export interface ReviewCardSummary {
|
|
43
|
+
ruleName: string
|
|
44
|
+
plainEnglish: string
|
|
45
|
+
constraints: string[]
|
|
46
|
+
expiry: string
|
|
47
|
+
backend: 'interpreter-v1' | 'ts-model'
|
|
48
|
+
/** Stable hash of the builder inputs - identical policy + summary = identical hash. */
|
|
49
|
+
contentHash: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build a deterministic review-card summary from a policy + context rule +
|
|
53
|
+
* simulation result. Pure: same inputs -> byte-identical output. */
|
|
54
|
+
export function buildReviewCardSummary(
|
|
55
|
+
predicate: PredicateNode | null,
|
|
56
|
+
policyRefs: PolicyRef[],
|
|
57
|
+
contextRule: ContextRuleDraft,
|
|
58
|
+
simulation: SimulationResult
|
|
59
|
+
): ReviewCardSummary {
|
|
60
|
+
const constraints: string[] = []
|
|
61
|
+
for (const ref of policyRefs) {
|
|
62
|
+
const line = renderOzPrimitive(ref)
|
|
63
|
+
if (line !== null) constraints.push(line)
|
|
64
|
+
}
|
|
65
|
+
if (predicate !== null) {
|
|
66
|
+
walkPredicate(predicate, (node) => {
|
|
67
|
+
const line = renderConstraint(node)
|
|
68
|
+
if (line !== null) constraints.push(line)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ruleName = contextRule.name
|
|
73
|
+
const plainEnglish = renderPlainEnglish(ruleName, constraints)
|
|
74
|
+
const expiry = renderExpiry(contextRule.validUntilLedger)
|
|
75
|
+
const backend = simulation.backend
|
|
76
|
+
|
|
77
|
+
const contentHash = computeContentHash({
|
|
78
|
+
ruleName,
|
|
79
|
+
plainEnglish,
|
|
80
|
+
constraints,
|
|
81
|
+
expiry,
|
|
82
|
+
backend,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return { ruleName, plainEnglish, constraints, expiry, backend, contentHash }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Render the OZ built-in primitive summary line. Only `spending_limit` is
|
|
89
|
+
* quoted by the review card (it is the only primitive that defines a
|
|
90
|
+
* transactional bound). Other primitives (threshold) are signer-config
|
|
91
|
+
* concerns handled by the OZ adapter's own `uncovered` machinery.
|
|
92
|
+
* Spending_limit takes `period_ledgers` on-chain (~5s/ledger); the card
|
|
93
|
+
* states the window in seconds so the user reads it consistently with the
|
|
94
|
+
* interpreter templates. */
|
|
95
|
+
function renderOzPrimitive(ref: PolicyRef): string | null {
|
|
96
|
+
if (ref.kind !== 'oz_builtin') return null
|
|
97
|
+
const primitive: OZPrimitiveConfig = ref.primitive
|
|
98
|
+
if (primitive.primitive !== 'spending_limit') return null
|
|
99
|
+
const params = primitive.params as { spending_limit?: string; period_ledgers?: number }
|
|
100
|
+
const limit = params.spending_limit ?? '0'
|
|
101
|
+
const periodLedgers = params.period_ledgers ?? 0
|
|
102
|
+
const windowSecs = periodLedgers * 5
|
|
103
|
+
return `spending_limit(${limit}, ${windowSecs})`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Walk every comparison / membership node of the predicate and invoke
|
|
107
|
+
* `visit` on each. The walk is depth-first, left-to-right, so the
|
|
108
|
+
* constraint list is stable across runs. Pure boolean nodes contribute no
|
|
109
|
+
* constraint lines themselves; their leaf children do, via the visitor. */
|
|
110
|
+
function walkPredicate(node: PredicateNode, visit: (node: PredicateNode) => void): void {
|
|
111
|
+
switch (node.op) {
|
|
112
|
+
case 'and':
|
|
113
|
+
case 'or':
|
|
114
|
+
for (const child of node.children) walkPredicate(child, visit)
|
|
115
|
+
return
|
|
116
|
+
case 'not':
|
|
117
|
+
walkPredicate(node.child, visit)
|
|
118
|
+
return
|
|
119
|
+
case 'in':
|
|
120
|
+
visit(node)
|
|
121
|
+
return
|
|
122
|
+
case 'eq':
|
|
123
|
+
case 'lt':
|
|
124
|
+
case 'lte':
|
|
125
|
+
case 'gt':
|
|
126
|
+
case 'gte':
|
|
127
|
+
visit(node)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Render ONE constraint sentence for ONE interpreter predicate node. The
|
|
133
|
+
* shape of the output is pinned by Task 7b so the test suite can assert
|
|
134
|
+
* byte-for-byte equality. Returns `null` when the node is a structural
|
|
135
|
+
* boolean (`and` / `or` / `not`) - those are not constraint leaves. */
|
|
136
|
+
function renderConstraint(node: PredicateNode): string | null {
|
|
137
|
+
switch (node.op) {
|
|
138
|
+
case 'and':
|
|
139
|
+
case 'or':
|
|
140
|
+
case 'not':
|
|
141
|
+
return null
|
|
142
|
+
case 'eq':
|
|
143
|
+
case 'lt':
|
|
144
|
+
case 'lte':
|
|
145
|
+
case 'gt':
|
|
146
|
+
case 'gte':
|
|
147
|
+
return renderComparison(node)
|
|
148
|
+
case 'in':
|
|
149
|
+
return renderMembership(node)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderComparison(
|
|
154
|
+
node: Extract<PredicateNode, { op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte' }>
|
|
155
|
+
): string | null {
|
|
156
|
+
const left = node.left
|
|
157
|
+
const right = node.right
|
|
158
|
+
|
|
159
|
+
// eq(call_contract, literal_address) -> Contract must be <addr>
|
|
160
|
+
if (left.kind === 'call_contract' && node.op === 'eq' && right.kind === 'literal_address') {
|
|
161
|
+
return `Contract must be ${right.value}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// eq(call_fn, literal_symbol) -> Function must be <sym>
|
|
165
|
+
if (left.kind === 'call_fn' && node.op === 'eq' && right.kind === 'literal_symbol') {
|
|
166
|
+
return `Function must be ${right.value}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// eq(call_arg[i], literal_vec) -> Path must be exactly [list]
|
|
170
|
+
if (left.kind === 'call_arg' && node.op === 'eq' && right.kind === 'literal_vec') {
|
|
171
|
+
return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// invocation_count_in_window <= N -> Invocations <= N per <window> seconds
|
|
175
|
+
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
176
|
+
return `Invocations <= ${right.value} per ${left.windowSecs} seconds`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// amount <= v -> Amount <= v
|
|
180
|
+
if (left.kind === 'amount' && right.kind === 'literal_i128') {
|
|
181
|
+
return `Amount <= ${right.value}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// oracle_price(asset) OP price -> Only when oracle_price(asset) OP price
|
|
185
|
+
if (left.kind === 'oracle_price' && right.kind === 'literal_i128') {
|
|
186
|
+
return `Only when oracle_price(${left.asset}) ${comparisonOpText(node.op)} ${right.value}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Any other comparison shape is a structural fail-closed: do not surface
|
|
190
|
+
// a misleading line. Cross-check still requires every leaf produce a
|
|
191
|
+
// constraint string; the only leaves we emit lines for are the ones we
|
|
192
|
+
// recognise above, so the test fixtures cover exactly the supported
|
|
193
|
+
// shapes.
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderMembership(node: Extract<PredicateNode, { op: 'in' }>): string | null {
|
|
198
|
+
// call_arg[i] in [list] -> Recipient/arg must be one of [list]
|
|
199
|
+
if (node.needle.kind === 'call_arg') {
|
|
200
|
+
const list = node.haystack.map(renderHaystackElement).join(', ')
|
|
201
|
+
return `Recipient/arg must be one of [${list}]`
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderVecElement(leaf: PredicateLeaf): string {
|
|
207
|
+
switch (leaf.kind) {
|
|
208
|
+
case 'literal_address':
|
|
209
|
+
return leaf.value
|
|
210
|
+
case 'literal_i128':
|
|
211
|
+
return leaf.value
|
|
212
|
+
case 'literal_symbol':
|
|
213
|
+
return leaf.value
|
|
214
|
+
case 'literal_u32':
|
|
215
|
+
return String(leaf.value)
|
|
216
|
+
case 'literal_u64':
|
|
217
|
+
return leaf.value
|
|
218
|
+
case 'literal_bytes':
|
|
219
|
+
return leaf.value
|
|
220
|
+
case 'literal_vec':
|
|
221
|
+
return `[${leaf.elements.map(renderVecElement).join(', ')}]`
|
|
222
|
+
case 'call_contract':
|
|
223
|
+
case 'call_fn':
|
|
224
|
+
case 'call_arg':
|
|
225
|
+
case 'amount':
|
|
226
|
+
case 'window_spent':
|
|
227
|
+
case 'now':
|
|
228
|
+
case 'valid_until':
|
|
229
|
+
case 'invocation_count_in_window':
|
|
230
|
+
case 'oracle_price':
|
|
231
|
+
return `<${leaf.kind}>`
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderHaystackElement(leaf: PredicateLeaf): string {
|
|
236
|
+
if (leaf.kind === 'literal_address') return leaf.value
|
|
237
|
+
if (leaf.kind === 'literal_i128') return leaf.value
|
|
238
|
+
if (leaf.kind === 'literal_symbol') return leaf.value
|
|
239
|
+
if (leaf.kind === 'literal_u32') return String(leaf.value)
|
|
240
|
+
if (leaf.kind === 'literal_u64') return leaf.value
|
|
241
|
+
if (leaf.kind === 'literal_bytes') return leaf.value
|
|
242
|
+
if (leaf.kind === 'literal_vec') {
|
|
243
|
+
return `[${leaf.elements.map(renderHaystackElement).join(', ')}]`
|
|
244
|
+
}
|
|
245
|
+
return `<${leaf.kind}>`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function comparisonOpText(op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte'): string {
|
|
249
|
+
switch (op) {
|
|
250
|
+
case 'lt':
|
|
251
|
+
return '<'
|
|
252
|
+
case 'lte':
|
|
253
|
+
return '<='
|
|
254
|
+
case 'gt':
|
|
255
|
+
return '>'
|
|
256
|
+
case 'gte':
|
|
257
|
+
return '>='
|
|
258
|
+
case 'eq':
|
|
259
|
+
return '=='
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Render the plain-English one-liner. Format: `<ruleName>: <constraints>`,
|
|
264
|
+
* joined by `; ` so the user reads one sentence per constraint. */
|
|
265
|
+
function renderPlainEnglish(ruleName: string, constraints: string[]): string {
|
|
266
|
+
if (constraints.length === 0) return `${ruleName}: (no constraints)`
|
|
267
|
+
return `${ruleName}: ${constraints.join('; ')}`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Render the expiry line. `null` -> "No expiry"; a ledger sequence -> the
|
|
271
|
+
* ledger number so the user reads it in the same units the OZ context rule
|
|
272
|
+
* applies it. */
|
|
273
|
+
function renderExpiry(validUntilLedger: number | null): string {
|
|
274
|
+
if (validUntilLedger === null) return 'No expiry'
|
|
275
|
+
return `Valid until ledger ${validUntilLedger}`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function computeContentHash(input: {
|
|
279
|
+
ruleName: string
|
|
280
|
+
plainEnglish: string
|
|
281
|
+
constraints: string[]
|
|
282
|
+
expiry: string
|
|
283
|
+
backend: 'interpreter-v1' | 'ts-model'
|
|
284
|
+
}): string {
|
|
285
|
+
return createHash('sha256').update(canonicalStringify(input)).digest('hex')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Canonical JSON with recursively sorted object keys (stable across runs). */
|
|
289
|
+
function canonicalStringify(value: unknown): string {
|
|
290
|
+
return JSON.stringify(sortKeys(value))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sortKeys(value: unknown): unknown {
|
|
294
|
+
if (Array.isArray(value)) return value.map(sortKeys)
|
|
295
|
+
if (value && typeof value === 'object') {
|
|
296
|
+
const out: Record<string, unknown> = {}
|
|
297
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
298
|
+
out[key] = sortKeys((value as Record<string, unknown>)[key])
|
|
299
|
+
}
|
|
300
|
+
return out
|
|
301
|
+
}
|
|
302
|
+
return value
|
|
303
|
+
}
|