@crediolabs/policy-synth 0.1.10 → 0.1.12

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