@crediolabs/policy-synth 0.1.9 → 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.
Files changed (96) hide show
  1. package/dist/adapters/interpreter/adapter.js +62 -11
  2. package/dist/errors.d.ts +1 -1
  3. package/dist/install/build-add-context-rule.d.ts +48 -0
  4. package/dist/install/build-add-context-rule.js +304 -0
  5. package/dist/ir/types.d.ts +4 -0
  6. package/dist/predicate/decode.d.ts +11 -0
  7. package/dist/predicate/decode.js +234 -0
  8. package/dist/predicate/encode.js +79 -14
  9. package/dist/predicate/from-json.d.ts +4 -0
  10. package/dist/predicate/from-json.js +113 -0
  11. package/dist/predicate/index.d.ts +2 -0
  12. package/dist/predicate/index.js +5 -1
  13. package/dist/registry/identify.js +21 -2
  14. package/dist/registry/protocols.d.ts +50 -1
  15. package/dist/registry/protocols.js +88 -0
  16. package/dist/review-card/builder.d.ts +13 -0
  17. package/dist/review-card/builder.js +61 -5
  18. package/dist/review-card/cross-check.js +50 -3
  19. package/dist/review-card/index.d.ts +1 -1
  20. package/dist/review-card/index.js +1 -1
  21. package/dist/run/index.d.ts +8 -2
  22. package/dist/run/index.js +2 -1
  23. package/dist/run/schemas.d.ts +16 -4
  24. package/dist/run/schemas.js +14 -0
  25. package/dist/synth/compose-from-recording.d.ts +4 -0
  26. package/dist/synth/compose-from-recording.js +34 -8
  27. package/dist/synth/deny-cases.d.ts +4 -1
  28. package/dist/synth/deny-cases.js +3 -1
  29. package/dist/synth/evaluate.js +130 -3
  30. package/dist/synth/permit-context.d.ts +15 -0
  31. package/dist/synth/permit-context.js +116 -0
  32. package/dist/synth/synthesize-from-mandate.d.ts +17 -2
  33. package/dist/synth/synthesize-from-mandate.js +27 -2
  34. package/dist/synth/synthesize-from-recording.d.ts +25 -1
  35. package/dist/synth/synthesize-from-recording.js +80 -3
  36. package/dist/types.d.ts +9 -0
  37. package/dist-cjs/adapters/interpreter/adapter.js +62 -11
  38. package/dist-cjs/errors.d.ts +1 -1
  39. package/dist-cjs/install/build-add-context-rule.d.ts +48 -0
  40. package/dist-cjs/install/build-add-context-rule.js +308 -0
  41. package/dist-cjs/ir/types.d.ts +4 -0
  42. package/dist-cjs/predicate/decode.d.ts +11 -0
  43. package/dist-cjs/predicate/decode.js +239 -0
  44. package/dist-cjs/predicate/encode.js +79 -14
  45. package/dist-cjs/predicate/from-json.d.ts +4 -0
  46. package/dist-cjs/predicate/from-json.js +116 -0
  47. package/dist-cjs/predicate/index.d.ts +2 -0
  48. package/dist-cjs/predicate/index.js +10 -2
  49. package/dist-cjs/registry/identify.js +20 -1
  50. package/dist-cjs/registry/protocols.d.ts +50 -1
  51. package/dist-cjs/registry/protocols.js +89 -1
  52. package/dist-cjs/review-card/builder.d.ts +13 -0
  53. package/dist-cjs/review-card/builder.js +62 -5
  54. package/dist-cjs/review-card/cross-check.js +50 -3
  55. package/dist-cjs/review-card/index.d.ts +1 -1
  56. package/dist-cjs/review-card/index.js +2 -1
  57. package/dist-cjs/run/index.d.ts +8 -2
  58. package/dist-cjs/run/index.js +2 -1
  59. package/dist-cjs/run/schemas.d.ts +16 -4
  60. package/dist-cjs/run/schemas.js +14 -0
  61. package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
  62. package/dist-cjs/synth/compose-from-recording.js +34 -8
  63. package/dist-cjs/synth/deny-cases.d.ts +4 -1
  64. package/dist-cjs/synth/deny-cases.js +3 -0
  65. package/dist-cjs/synth/evaluate.js +130 -3
  66. package/dist-cjs/synth/permit-context.d.ts +15 -0
  67. package/dist-cjs/synth/permit-context.js +119 -0
  68. package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
  69. package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
  70. package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
  71. package/dist-cjs/synth/synthesize-from-recording.js +80 -3
  72. package/dist-cjs/types.d.ts +9 -0
  73. package/package.json +5 -2
  74. package/src/adapters/interpreter/adapter.ts +72 -11
  75. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +28 -3
  76. package/src/errors.ts +13 -0
  77. package/src/install/build-add-context-rule.ts +429 -0
  78. package/src/ir/types.ts +4 -0
  79. package/src/predicate/decode.ts +242 -0
  80. package/src/predicate/encode.ts +110 -13
  81. package/src/predicate/from-json.ts +124 -0
  82. package/src/predicate/index.ts +5 -1
  83. package/src/registry/identify.ts +22 -2
  84. package/src/registry/protocols.ts +90 -1
  85. package/src/review-card/builder.ts +57 -5
  86. package/src/review-card/cross-check.ts +50 -3
  87. package/src/review-card/index.ts +1 -0
  88. package/src/run/index.ts +16 -2
  89. package/src/run/schemas.ts +14 -0
  90. package/src/synth/compose-from-recording.ts +46 -7
  91. package/src/synth/deny-cases.ts +3 -1
  92. package/src/synth/evaluate.ts +131 -3
  93. package/src/synth/permit-context.ts +137 -0
  94. package/src/synth/synthesize-from-mandate.ts +40 -4
  95. package/src/synth/synthesize-from-recording.ts +116 -6
  96. package/src/types.ts +12 -0
@@ -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 { depth, leaves } = walk(node, inCounts, oracleAssets)
98
- return { depth, leaves, inCounts, oracleAssets }
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) return { depth: 1, leaves: 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(c, inCounts, oracleAssets)
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(leaf: PredicateLeaf, oracleAssets: Set<string>): void {
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
- return xdr.ScVal.scvVec([symbol('valid_until')])
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
+ }
@@ -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'
@@ -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) Pinned-address recognition (Blend factory + SoroSwap router/factory).
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 <= N -> Invocations <= N per <window> seconds
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':
@@ -171,9 +188,38 @@ function renderComparison(
171
188
  return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`
172
189
  }
173
190
 
174
- // invocation_count_in_window <= N -> Invocations <= N per <window> seconds
191
+ // eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
192
+ // Binds the OUTER vec length so a caller cannot append an element to
193
+ // defeat a per-element pin. Rendered as a single readable line so the
194
+ // review card surfaces the implicit "no new elements" guarantee.
195
+ if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
196
+ return `Length of arg[${left.index}] is ${right.value}`
197
+ }
198
+
199
+ // eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
200
+ // The structured-argument bind pins a scalar field inside a vec element.
201
+ // For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
202
+ // is visible; eq reads "= <value>".
203
+ if (left.kind === 'call_arg_field') {
204
+ const head = `arg[${left.index}] element[${left.element}].${left.field}`
205
+ const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
206
+ if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
207
+ if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
208
+ if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
209
+ if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
210
+ if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
211
+ if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
212
+ if (right.kind === 'literal_vec') {
213
+ return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`
214
+ }
215
+ }
216
+
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.
175
220
  if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
176
- return `Invocations <= ${right.value} per ${left.windowSecs} seconds`
221
+ const allowed = node.op === 'lt' ? right.value : right.value + 1
222
+ return `At most ${allowed} calls per ${left.windowSecs} seconds`
177
223
  }
178
224
 
179
225
  // amount <= v -> Amount <= v
@@ -182,8 +228,8 @@ function renderComparison(
182
228
  }
183
229
 
184
230
  // 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}`
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)`
187
233
  }
188
234
 
189
235
  // Any other comparison shape is a structural fail-closed: do not surface
@@ -224,6 +270,7 @@ function renderVecElement(leaf: PredicateLeaf): string {
224
270
  case 'call_arg':
225
271
  case 'call_arg_len':
226
272
  case 'call_arg_field':
273
+ case 'call_arg_scaled':
227
274
  case 'amount':
228
275
  case 'window_spent':
229
276
  case 'now':
@@ -231,6 +278,11 @@ function renderVecElement(leaf: PredicateLeaf): string {
231
278
  case 'invocation_count_in_window':
232
279
  case 'oracle_price':
233
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)`
234
286
  }
235
287
  }
236
288