@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
@@ -0,0 +1,234 @@
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
+ import { Address, scValToBigInt, xdr } from '@stellar/stellar-sdk';
25
+ /** Same shape the encoder raises, so both directions surface one error type.
26
+ * Returns `never` - it always throws; callers `throw malformed(...)` purely
27
+ * so the control flow reads at the call site. */
28
+ function malformed(detail) {
29
+ const err = new Error(`cannot decode predicate: ${detail}`);
30
+ err.code = 'MALFORMED_PREDICATE';
31
+ err.severity = 'error';
32
+ err.retryable = false;
33
+ throw err;
34
+ }
35
+ /** The symbol at a leaf's selector position, or null when the value is not a
36
+ * symbol-headed vec (i.e. it is a literal or a literal vector). */
37
+ function selectorSymbol(items) {
38
+ const head = items[0];
39
+ if (!head)
40
+ return null;
41
+ return head.switch() === xdr.ScValType.scvSymbol() ? head.sym().toString() : null;
42
+ }
43
+ function expectVec(v, what) {
44
+ if (v.switch() !== xdr.ScValType.scvVec())
45
+ throw malformed(`${what} is not a vec`);
46
+ return v.vec() ?? [];
47
+ }
48
+ function expectU32(v, what) {
49
+ if (!v || v.switch() !== xdr.ScValType.scvU32())
50
+ throw malformed(`${what} is not a u32`);
51
+ return v.u32();
52
+ }
53
+ function expectSymbol(v, what) {
54
+ if (!v || v.switch() !== xdr.ScValType.scvSymbol())
55
+ throw malformed(`${what} is not a symbol`);
56
+ return v.sym().toString();
57
+ }
58
+ function expectAddress(v, what) {
59
+ if (!v || v.switch() !== xdr.ScValType.scvAddress())
60
+ throw malformed(`${what} is not an address`);
61
+ return Address.fromScAddress(v.address()).toString();
62
+ }
63
+ function expectU64(v, what) {
64
+ if (!v || v.switch() !== xdr.ScValType.scvU64())
65
+ throw malformed(`${what} is not a u64`);
66
+ return v.u64().toString();
67
+ }
68
+ function expectI128(v, what) {
69
+ if (!v || v.switch() !== xdr.ScValType.scvI128())
70
+ throw malformed(`${what} is not an i128`);
71
+ return scValToBigInt(v).toString();
72
+ }
73
+ /** Arity check with the same intent as the Rust `check_arity`: a selector with
74
+ * the wrong element count is malformed, not silently truncated. */
75
+ function arity(items, n, selector) {
76
+ if (items.length !== n) {
77
+ throw malformed(`${selector} expects ${n} elements, got ${items.length}`);
78
+ }
79
+ }
80
+ function decodeSelectorLeaf(items, sym) {
81
+ switch (sym) {
82
+ case 'call_contract':
83
+ arity(items, 1, sym);
84
+ return { kind: 'call_contract' };
85
+ case 'call_fn':
86
+ arity(items, 1, sym);
87
+ return { kind: 'call_fn' };
88
+ case 'call_arg':
89
+ arity(items, 2, sym);
90
+ return { kind: 'call_arg', index: expectU32(items[1], 'call_arg index') };
91
+ case 'call_arg_len':
92
+ arity(items, 2, sym);
93
+ return { kind: 'call_arg_len', index: expectU32(items[1], 'call_arg_len index') };
94
+ case 'call_arg_field':
95
+ arity(items, 4, sym);
96
+ return {
97
+ kind: 'call_arg_field',
98
+ index: expectU32(items[1], 'call_arg_field index'),
99
+ element: expectU32(items[2], 'call_arg_field element'),
100
+ field: expectSymbol(items[3], 'call_arg_field field'),
101
+ };
102
+ case 'call_arg_scaled':
103
+ arity(items, 4, sym);
104
+ return {
105
+ kind: 'call_arg_scaled',
106
+ index: expectU32(items[1], 'call_arg_scaled index'),
107
+ num: expectI128(items[2], 'call_arg_scaled num'),
108
+ den: expectI128(items[3], 'call_arg_scaled den'),
109
+ };
110
+ case 'amount':
111
+ arity(items, 2, sym);
112
+ return { kind: 'amount', token: expectAddress(items[1], 'amount token') };
113
+ case 'window_spent':
114
+ arity(items, 3, sym);
115
+ return {
116
+ kind: 'window_spent',
117
+ token: expectAddress(items[1], 'window_spent token'),
118
+ windowSeconds: Number(expectU64(items[2], 'window_spent windowSeconds')),
119
+ };
120
+ case 'now':
121
+ arity(items, 1, sym);
122
+ return { kind: 'now' };
123
+ case 'valid_until':
124
+ // The encoder refuses to emit this and the interpreter refuses it at
125
+ // install, so it cannot appear in a document that installed. Decoding it
126
+ // anyway would present a rule the chain would never have accepted.
127
+ throw malformed('valid_until is not a usable predicate leaf');
128
+ case 'invocation_count':
129
+ arity(items, 2, sym);
130
+ return {
131
+ kind: 'invocation_count_in_window',
132
+ windowSecs: Number(expectU64(items[1], 'invocation_count windowSecs')),
133
+ };
134
+ case 'oracle_price':
135
+ arity(items, 2, sym);
136
+ return { kind: 'oracle_price', asset: expectAddress(items[1], 'oracle_price asset') };
137
+ case 'oracle_threshold':
138
+ arity(items, 3, sym);
139
+ return {
140
+ kind: 'oracle_threshold',
141
+ value: expectI128(items[1], 'oracle_threshold value'),
142
+ decimals: expectU32(items[2], 'oracle_threshold decimals'),
143
+ };
144
+ default:
145
+ // Deliberately NOT a literal_vec fallback - see the header note.
146
+ throw malformed(`unknown selector symbol '${sym}'`);
147
+ }
148
+ }
149
+ /** One leaf. Order of the bare-literal checks mirrors `decode_leaf` in dsl.rs. */
150
+ export function decodeLeaf(v) {
151
+ if (v.switch() === xdr.ScValType.scvVec()) {
152
+ const items = v.vec() ?? [];
153
+ if (items.length === 0)
154
+ throw malformed('empty vec is neither a selector nor a literal vector');
155
+ const sym = selectorSymbol(items);
156
+ if (sym !== null)
157
+ return decodeSelectorLeaf(items, sym);
158
+ return { kind: 'literal_vec', elements: items.map(decodeLeaf) };
159
+ }
160
+ switch (v.switch()) {
161
+ case xdr.ScValType.scvAddress():
162
+ return { kind: 'literal_address', value: Address.fromScAddress(v.address()).toString() };
163
+ case xdr.ScValType.scvSymbol():
164
+ return { kind: 'literal_symbol', value: v.sym().toString() };
165
+ case xdr.ScValType.scvU32():
166
+ return { kind: 'literal_u32', value: v.u32() };
167
+ case xdr.ScValType.scvU64():
168
+ return { kind: 'literal_u64', value: v.u64().toString() };
169
+ case xdr.ScValType.scvI128():
170
+ return { kind: 'literal_i128', value: scValToBigInt(v).toString() };
171
+ case xdr.ScValType.scvBytes():
172
+ return { kind: 'literal_bytes', value: Buffer.from(v.bytes()).toString('hex') };
173
+ default:
174
+ throw malformed(`unsupported leaf value type ${v.switch().name}`);
175
+ }
176
+ }
177
+ /** One node. `not` wraps a NODE; the comparison ops wrap two LEAVES. */
178
+ export function decodeNode(v) {
179
+ const items = expectVec(v, 'node');
180
+ const op = selectorSymbol(items);
181
+ if (op === null)
182
+ throw malformed('node does not start with an operator symbol');
183
+ switch (op) {
184
+ case 'and':
185
+ case 'or': {
186
+ arity(items, 2, op);
187
+ const children = expectVec(items[1], `${op} children`).map(decodeNode);
188
+ if (children.length === 0)
189
+ throw malformed(`${op} has no children`);
190
+ return { op, children };
191
+ }
192
+ case 'not':
193
+ arity(items, 2, op);
194
+ return { op: 'not', child: decodeNode(items[1]) };
195
+ case 'eq':
196
+ case 'lt':
197
+ case 'lte':
198
+ case 'gt':
199
+ case 'gte':
200
+ arity(items, 3, op);
201
+ return {
202
+ op,
203
+ left: decodeLeaf(items[1]),
204
+ right: decodeLeaf(items[2]),
205
+ };
206
+ case 'in': {
207
+ arity(items, 3, op);
208
+ return {
209
+ op: 'in',
210
+ needle: decodeLeaf(items[1]),
211
+ haystack: expectVec(items[2], 'in haystack').map(decodeLeaf),
212
+ };
213
+ }
214
+ default:
215
+ throw malformed(`unknown node operator '${op}'`);
216
+ }
217
+ }
218
+ /** Decode a predicate from the wire bytes the interpreter stores.
219
+ *
220
+ * Accepts the base64 the encoder emits (`encodePredicate().encodedPredicate`)
221
+ * or the raw XDR bytes read straight out of `StoredDoc.predicate_bytes`. */
222
+ export function decodePredicate(encoded) {
223
+ let scv;
224
+ try {
225
+ scv =
226
+ typeof encoded === 'string'
227
+ ? xdr.ScVal.fromXDR(Buffer.from(encoded, 'base64'))
228
+ : xdr.ScVal.fromXDR(Buffer.from(encoded));
229
+ }
230
+ catch (e) {
231
+ throw malformed(`not valid ScVal XDR (${e instanceof Error ? e.message : String(e)})`);
232
+ }
233
+ return decodeNode(scv);
234
+ }
@@ -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
  import { createHash } from 'node:crypto';
22
35
  import { Address, xdr } from '@stellar/stellar-sdk';
23
36
  import { PREDICATE_CAPS } from "../types.js";
@@ -45,6 +58,9 @@ export function encodePredicate(node) {
45
58
  if (oracleReads > PREDICATE_CAPS.MAX_ORACLE_READS) {
46
59
  throw capError('PREDICATE_ORACLE_OVER_LIMIT', `oracle reads ${oracleReads} exceed MAX_ORACLE_READS ${PREDICATE_CAPS.MAX_ORACLE_READS}`);
47
60
  }
61
+ if (stats.oracleAssets.size > 0 && stats.nonOracleSelectorLeaves === 0) {
62
+ throw capError('MALFORMED_PREDICATE', 'predicate constrains nothing but an oracle price: the contract refuses it at install (dsl.rs MissingNonOracleEnvelope). Pin the call itself (contract / method / argument) alongside the price bound.');
63
+ }
48
64
  // --- pass 2: build + canonicalise the ScVal ---
49
65
  const root = encodeNode(node);
50
66
  const rawBytes = root.toXDR();
@@ -58,19 +74,30 @@ export function encodePredicate(node) {
58
74
  function computeStats(node) {
59
75
  const inCounts = [];
60
76
  const oracleAssets = new Set();
61
- const { depth, leaves } = walk(node, inCounts, oracleAssets);
62
- return { depth, leaves, inCounts, oracleAssets };
77
+ const counters = { nonOracleSelectorLeaves: 0 };
78
+ const { depth, leaves } = walk(node, inCounts, oracleAssets, counters, false);
79
+ return {
80
+ depth,
81
+ leaves,
82
+ inCounts,
83
+ oracleAssets,
84
+ nonOracleSelectorLeaves: counters.nonOracleSelectorLeaves,
85
+ };
63
86
  }
64
- function walk(node, inCounts, oracleAssets) {
87
+ function walk(node, inCounts, oracleAssets, counters,
88
+ /** True once the walk is under a `not` or an `or`, where an oracle read
89
+ * becomes a condition the caller can satisfy by taking the other branch. */
90
+ negatedOrDisjunctive) {
65
91
  switch (node.op) {
66
92
  case 'and':
67
93
  case 'or': {
68
- if (node.children.length === 0)
69
- return { depth: 1, leaves: 0 };
94
+ if (node.children.length === 0) {
95
+ throw capError('MALFORMED_PREDICATE', `\`${node.op}\` with no children: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed`);
96
+ }
70
97
  let maxChildDepth = 0;
71
98
  let totalLeaves = 0;
72
99
  for (const c of node.children) {
73
- const child = walk(c, inCounts, oracleAssets);
100
+ const child = walk(c, inCounts, oracleAssets, counters, negatedOrDisjunctive || node.op === 'or');
74
101
  if (child.depth > maxChildDepth)
75
102
  maxChildDepth = child.depth;
76
103
  totalLeaves += child.leaves;
@@ -78,7 +105,7 @@ function walk(node, inCounts, oracleAssets) {
78
105
  return { depth: maxChildDepth + 1, leaves: totalLeaves };
79
106
  }
80
107
  case 'not': {
81
- const child = walk(node.child, inCounts, oracleAssets);
108
+ const child = walk(node.child, inCounts, oracleAssets, counters, true);
82
109
  return { depth: child.depth + 1, leaves: child.leaves };
83
110
  }
84
111
  case 'eq':
@@ -86,16 +113,19 @@ function walk(node, inCounts, oracleAssets) {
86
113
  case 'lte':
87
114
  case 'gt':
88
115
  case 'gte': {
89
- collectOracle(node.left, oracleAssets);
90
- collectOracle(node.right, oracleAssets);
116
+ collectOracle(node.left, oracleAssets, counters, negatedOrDisjunctive);
117
+ collectOracle(node.right, oracleAssets, counters, negatedOrDisjunctive);
91
118
  return { depth: 1, leaves: leafCount(node.left) + leafCount(node.right) };
92
119
  }
93
120
  case 'in': {
121
+ if (node.haystack.length === 0) {
122
+ throw capError('MALFORMED_PREDICATE', '`in` with an empty haystack: the contract refuses it at decode (MALFORMED_PREDICATE), so it can never be installed');
123
+ }
94
124
  inCounts.push({ count: node.haystack.length });
95
- collectOracle(node.needle, oracleAssets);
125
+ collectOracle(node.needle, oracleAssets, counters, negatedOrDisjunctive);
96
126
  let haystackLeaves = 0;
97
127
  for (const h of node.haystack) {
98
- collectOracle(h, oracleAssets);
128
+ collectOracle(h, oracleAssets, counters, negatedOrDisjunctive);
99
129
  haystackLeaves += leafCount(h);
100
130
  }
101
131
  return {
@@ -117,16 +147,26 @@ function leafCount(leaf) {
117
147
  }
118
148
  return 1;
119
149
  }
120
- function collectOracle(leaf, oracleAssets) {
150
+ function collectOracle(leaf, oracleAssets, counters, negatedOrDisjunctive) {
121
151
  // literal_vec is the only nested leaf; recurse so an oracle_price buried in a
122
152
  // vector literal (which the lowering would forbid, but the cap-walker must
123
153
  // still see) is counted toward the oracle-read budget.
124
154
  if (leaf.kind === 'oracle_price') {
155
+ if (negatedOrDisjunctive) {
156
+ throw capError('ORACLE_LEAF_INVALID_POSITION', `oracle_price(${leaf.asset}) sits under a \`not\`/\`or\`: the contract refuses that position at install (dsl.rs validate_oracle_placement). An oracle bound must be a conjunct the call has to satisfy.`);
157
+ }
125
158
  oracleAssets.add(leaf.asset);
126
159
  }
127
160
  else if (leaf.kind === 'literal_vec') {
128
161
  for (const el of leaf.elements)
129
- collectOracle(el, oracleAssets);
162
+ collectOracle(el, oracleAssets, counters, negatedOrDisjunctive);
163
+ }
164
+ else if (!leaf.kind.startsWith('literal_') && leaf.kind !== 'oracle_threshold') {
165
+ // A threshold is the oracle compare's operand, not an independent
166
+ // constraint. Counting it would let an oracle-only predicate satisfy the
167
+ // non-oracle envelope with its own bound (mirrors collect_oracle_leaf in
168
+ // dsl.rs, where it sits with the literals).
169
+ counters.nonOracleSelectorLeaves += 1;
130
170
  }
131
171
  }
132
172
  function encodeNode(node) {
@@ -176,6 +216,17 @@ function encodeLeaf(leaf) {
176
216
  xdr.ScVal.scvU32(leaf.element),
177
217
  xdr.ScVal.scvSymbol(leaf.field),
178
218
  ]);
219
+ case 'call_arg_scaled':
220
+ // Wire shape `vec[symbol, u32, i128, i128]` mirrors the Rust decoder's
221
+ // expectation in dsl.rs SEL_CALL_ARG_SCALED. `scvI128FromDecimal` is
222
+ // the same range-checked helper used by `literal_i128`; an out-of-range
223
+ // num/den is refused at encode time (rather than at install on chain).
224
+ return xdr.ScVal.scvVec([
225
+ symbol('call_arg_scaled'),
226
+ xdr.ScVal.scvU32(leaf.index),
227
+ scvI128FromDecimal(leaf.num),
228
+ scvI128FromDecimal(leaf.den),
229
+ ]);
179
230
  case 'amount':
180
231
  return xdr.ScVal.scvVec([symbol('amount'), scvAddressFromStrkey(leaf.token)]);
181
232
  case 'window_spent':
@@ -187,11 +238,25 @@ function encodeLeaf(leaf) {
187
238
  case 'now':
188
239
  return xdr.ScVal.scvVec([symbol('now')]);
189
240
  case 'valid_until':
190
- return xdr.ScVal.scvVec([symbol('valid_until')]);
241
+ // The interpreter refuses this leaf at install: it never sources
242
+ // valid_until_ledger, so a policy built on it would deny forever.
243
+ // Expiry belongs to the smart account, through the context rule's
244
+ // validUntilLedger. Refuse at synthesis so the author finds out here
245
+ // rather than from an install that always fails.
246
+ throw new Error('valid_until is not a usable predicate leaf: the interpreter never sources it and ' +
247
+ 'refuses it at install. Put expiry on the context rule (validUntilLedger) instead.');
191
248
  case 'invocation_count_in_window':
192
249
  return xdr.ScVal.scvVec([symbol('invocation_count'), scvU64FromValue(leaf.windowSecs)]);
193
250
  case 'oracle_price':
194
251
  return xdr.ScVal.scvVec([symbol('oracle_price'), scvAddressFromStrkey(leaf.asset)]);
252
+ case 'oracle_threshold':
253
+ // Arity 3, matching SEL_ORACLE_THRESHOLD in dsl.rs. The declared basis
254
+ // travels with the value so the contract never has to assume one.
255
+ return xdr.ScVal.scvVec([
256
+ symbol('oracle_threshold'),
257
+ scvI128FromDecimal(leaf.value),
258
+ xdr.ScVal.scvU32(leaf.decimals),
259
+ ]);
195
260
  case 'literal_address':
196
261
  return scvAddressFromStrkey(leaf.value);
197
262
  case 'literal_i128':
@@ -0,0 +1,4 @@
1
+ import type { PredicateNode } from '../types.ts';
2
+ /** Parse a JSON object back into a `PredicateNode`. Throws on shape
3
+ * mismatches. The encoder's structural caps are the real gate. */
4
+ export declare function jsonToAst(value: unknown): PredicateNode;
@@ -0,0 +1,113 @@
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
+ /** Parse a JSON object back into a `PredicateNode`. Throws on shape
13
+ * mismatches. The encoder's structural caps are the real gate. */
14
+ export function jsonToAst(value) {
15
+ if (value === null || typeof value !== 'object')
16
+ throw new Error('predicate must be an object');
17
+ const v = value;
18
+ switch (v.op) {
19
+ case 'and':
20
+ case 'or':
21
+ return { op: v.op, children: arrayOf(v.children, jsonToAst) };
22
+ case 'not':
23
+ return { op: 'not', child: jsonToAst(v.child) };
24
+ case 'eq':
25
+ case 'lt':
26
+ case 'lte':
27
+ case 'gt':
28
+ case 'gte':
29
+ return { op: v.op, left: jsonToLeaf(v.left), right: jsonToLeaf(v.right) };
30
+ case 'in':
31
+ return { op: 'in', needle: jsonToLeaf(v.needle), haystack: arrayOf(v.haystack, jsonToLeaf) };
32
+ default:
33
+ throw new Error(`unknown predicate op: ${String(v.op)}`);
34
+ }
35
+ }
36
+ function arrayOf(v, f) {
37
+ if (!Array.isArray(v))
38
+ throw new Error('expected array');
39
+ return v.map(f);
40
+ }
41
+ function jsonToLeaf(value) {
42
+ if (value === null || typeof value !== 'object')
43
+ throw new Error('leaf must be an object');
44
+ const v = value;
45
+ switch (v.kind) {
46
+ case 'call_contract':
47
+ case 'call_fn':
48
+ case 'now':
49
+ case 'valid_until':
50
+ return { kind: v.kind };
51
+ case 'call_arg':
52
+ return { kind: 'call_arg', index: numberField(v, 'index') };
53
+ case 'call_arg_len':
54
+ return { kind: 'call_arg_len', index: numberField(v, 'index') };
55
+ // num/den are strings, not numbers: they are i128 on chain, and a JSON
56
+ // number would silently lose precision past 2^53 before the encoder
57
+ // ever saw the value.
58
+ case 'call_arg_scaled':
59
+ return {
60
+ kind: 'call_arg_scaled',
61
+ index: numberField(v, 'index'),
62
+ num: stringField(v, 'num'),
63
+ den: stringField(v, 'den'),
64
+ };
65
+ case 'call_arg_field':
66
+ return {
67
+ kind: 'call_arg_field',
68
+ index: numberField(v, 'index'),
69
+ element: numberField(v, 'element'),
70
+ field: stringField(v, 'field'),
71
+ };
72
+ case 'amount':
73
+ return { kind: 'amount', token: stringField(v, 'token') };
74
+ case 'window_spent':
75
+ return {
76
+ kind: 'window_spent',
77
+ token: stringField(v, 'token'),
78
+ windowSeconds: numberField(v, 'windowSeconds'),
79
+ };
80
+ case 'invocation_count_in_window':
81
+ return { kind: 'invocation_count_in_window', windowSecs: numberField(v, 'windowSecs') };
82
+ case 'oracle_price':
83
+ return { kind: 'oracle_price', asset: stringField(v, 'asset') };
84
+ case 'literal_address':
85
+ return { kind: 'literal_address', value: stringField(v, 'value') };
86
+ case 'literal_i128':
87
+ return { kind: 'literal_i128', value: stringField(v, 'value') };
88
+ case 'literal_symbol':
89
+ return { kind: 'literal_symbol', value: stringField(v, 'value') };
90
+ case 'literal_u32':
91
+ return { kind: 'literal_u32', value: numberField(v, 'value') };
92
+ case 'literal_u64':
93
+ return { kind: 'literal_u64', value: stringField(v, 'value') };
94
+ case 'literal_bytes':
95
+ return { kind: 'literal_bytes', value: stringField(v, 'value') };
96
+ case 'literal_vec':
97
+ return { kind: 'literal_vec', elements: arrayOf(v.elements, jsonToLeaf) };
98
+ default:
99
+ throw new Error(`unknown leaf kind: ${String(v.kind)}`);
100
+ }
101
+ }
102
+ function numberField(v, key) {
103
+ const x = v[key];
104
+ if (typeof x !== 'number' || !Number.isInteger(x))
105
+ throw new Error(`field ${key} must be an integer`);
106
+ return x;
107
+ }
108
+ function stringField(v, key) {
109
+ const x = v[key];
110
+ if (typeof x !== 'string')
111
+ throw new Error(`field ${key} must be a string`);
112
+ return x;
113
+ }
@@ -1 +1,3 @@
1
1
  export { type EncodedPredicate, encodePredicate } from './encode.ts';
2
+ export { decodeLeaf, decodeNode, decodePredicate } from './decode.ts';
3
+ export { jsonToAst } from './from-json.ts';
@@ -1,2 +1,6 @@
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
  export { encodePredicate } from "./encode.js";
5
+ export { decodeLeaf, decodeNode, decodePredicate } from "./decode.js";
6
+ export { jsonToAst } from "./from-json.js";
@@ -16,13 +16,20 @@
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
22
29
  // valid method calls. Arg-shape validation compares arg count + each arg's
23
30
  // ScVal subset type against the ABI signature; mismatch -> null.
24
31
  import { addressToProtocol } from "./known-addresses.js";
25
- import { BLEND_ABI, SEP41_ABI, SOROSWAP_ABI } from "./protocols.js";
32
+ import { BLEND_ABI, OZ_ACCOUNT_ABI, SEP41_ABI, SOROSWAP_ABI } from "./protocols.js";
26
33
  /** Identify the protocol for a single (contract, method, args) invocation.
27
34
  * Returns null on ANY mismatch (method not in ABI, arg count off, arg type
28
35
  * off, or unknown method on a pinned address). The caller MUST keep the
@@ -46,7 +53,19 @@ export function identifyProtocol(contract, method, args, network) {
46
53
  }
47
54
  return null;
48
55
  }
49
- // 3) Pinned-address recognition (Blend factory + SoroSwap router/factory).
56
+ // 3) OpenZeppelin smart-account by interface. The four context-rule
57
+ // entrypoints have a fixed (fn, args) shape across every OZ
58
+ // multisig deployment. `__check_auth` is private (host-invoked)
59
+ // and is intentionally NOT in this ABI - the recogniser only
60
+ // claims OZ for the public surface.
61
+ if (Object.hasOwn(OZ_ACCOUNT_ABI, method)) {
62
+ const sig = OZ_ACCOUNT_ABI[method];
63
+ if (sig && argsMatchAbi(sig.args, args)) {
64
+ return { protocol: 'oz_account', fn: method };
65
+ }
66
+ return null;
67
+ }
68
+ // 4) Pinned-address recognition (Blend factory + SoroSwap router/factory).
50
69
  // FIX 4: the method must also be in the protocol's ABI; an unknown
51
70
  // method on a pinned address -> null.
52
71
  if (network) {
@@ -1,5 +1,5 @@
1
1
  import type { ScVal } from '../types.ts';
2
- export type ProtocolId = 'sep41' | 'blend' | 'soroswap';
2
+ export type ProtocolId = 'sep41' | 'blend' | 'soroswap' | 'oz_account';
3
3
  /** ScVal subset type vocabulary the ABI uses. Mirrors `ScVal['type']` minus
4
4
  * `other` (which by definition cannot be matched against an ABI arg). */
5
5
  export type AbiArgType = Exclude<ScVal['type'], 'other'>;
@@ -34,5 +34,54 @@ export declare const BLEND_ABI: ProtocolAbi;
34
34
  * `vec<address>` - we record it as the outer `vec` subset and leave the
35
35
  * per-element shape to the downstream reviewer. */
36
36
  export declare const SOROSWAP_ABI: ProtocolAbi;
37
+ /** OpenZeppelin smart-account ABI subset.
38
+ *
39
+ * Why this lives in the recogniser: every user's smart account has a
40
+ * different C-address, so a per-deployment pin would never cover them.
41
+ * What is the same across every OpenZeppelin multisig account is the
42
+ * small set of public context-rule entrypoints. A recording that
43
+ * invokes `batch_add_signer(u32, vec<signer>)` on an arbitrary contract
44
+ * is overwhelmingly likely to be calling the user's own OpenZeppelin
45
+ * smart account (the canonical reason this product exists); we
46
+ * recognise that pattern by interface, the same way SEP-41 is
47
+ * recognised by interface.
48
+ *
49
+ * SECURITY PROPERTY (load-bearing):
50
+ * - Recognition claims the call TARGETS a particular protocol. We
51
+ * only claim `oz_account` when the (fn, args) shape uniquely matches
52
+ * one of the three entrypoints below AND the call looks like a real
53
+ * context-rule operation. The match is exact: off-by-one arg count,
54
+ * off-by-one arg type, or a method name close-but-wrong returns null
55
+ * (fail-closed). A contract that incidentally happens to expose a
56
+ * single matching fn is not sufficient - the recorder requires ALL
57
+ * invocations to individually match, so a hostile contract with
58
+ * exactly one matching fn would still contribute to the unknown
59
+ * bucket for any other calls.
60
+ * - We do NOT claim `oz_account` for unknown calls, for arg-shape
61
+ * mismatches, or for calls where the recorder could not pin down
62
+ * the (fn, args) shape. The freshness gate then runs at the same
63
+ * 1.0 threshold - lowering the gate for unknown protocols remains
64
+ * a separate, opt-in production override (see RecordInput below).
65
+ *
66
+ * ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
67
+ * pinned from the OpenZeppelin Reloaded `multisig_account_example`
68
+ * contract (commit ef82b65, fetched 2026-07-28).
69
+ *
70
+ * SCOPE NOTE: this ABI only covers the three entrypoints whose args
71
+ * map cleanly onto the normalised `AbiArgType` vocabulary (u32, vec,
72
+ * ...). `add_context_rule` - the install path - takes `(Symbol,
73
+ * String, Option<u32>, Vec<SignerKey>, Map<Address, Val>)`. The
74
+ * `String`, `Option<u32>` (`scvVoid`), and `Map<...>` arg types are
75
+ * not in the AbiArgType set today (the recogniser's `argsMatchAbi`
76
+ * helper rejects `other` deliberately so a close-but-wrong call does
77
+ * not slip through). Excluding `add_context_rule` is the right
78
+ * trade-off for now - it never blocks OZ smart-account recognition,
79
+ * because the install tx always follows a recognised
80
+ * `batch_add_signer` (the demo's first-pass tx), so the
81
+ * contract still ends up in `knownContracts`. Updating the Arg type
82
+ * vocabulary to cover these shapes is a separate concern (and would
83
+ * also lift the SEP-41 / Blend recognition in the same change).
84
+ */
85
+ export declare const OZ_ACCOUNT_ABI: ProtocolAbi;
37
86
  export declare const PROTOCOL_ABIS: Record<ProtocolId, ProtocolAbi>;
38
87
  export declare function getAbi(protocol: ProtocolId): ProtocolAbi;