@crediolabs/policy-synth 0.3.1 → 0.5.0

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 (100) hide show
  1. package/dist/adapters/interpreter/adapter.d.ts +2 -2
  2. package/dist/adapters/interpreter/adapter.js +11 -3
  3. package/dist/errors.d.ts +6 -1
  4. package/dist/install/authority-overlap.d.ts +101 -0
  5. package/dist/install/authority-overlap.js +239 -0
  6. package/dist/install/build-add-context-rule.d.ts +1 -1
  7. package/dist/install/index.d.ts +1 -0
  8. package/dist/install/index.js +4 -0
  9. package/dist/install/read-account-rules.d.ts +79 -0
  10. package/dist/install/read-account-rules.js +241 -0
  11. package/dist/predicate/decode.js +22 -1
  12. package/dist/predicate/encode.js +52 -5
  13. package/dist/predicate/from-json.js +14 -1
  14. package/dist/record/index.d.ts +10 -0
  15. package/dist/record/index.js +32 -1
  16. package/dist/record/rpc.d.ts +4 -0
  17. package/dist/record/rpc.js +4 -1
  18. package/dist/registry/identify.d.ts +10 -1
  19. package/dist/registry/identify.js +4 -1
  20. package/dist/registry/on-chain-spec.d.ts +37 -0
  21. package/dist/registry/on-chain-spec.js +152 -0
  22. package/dist/review-card/builder.js +40 -0
  23. package/dist/review-card/cross-check.js +34 -0
  24. package/dist/review-card/render-leaf.d.ts +1 -1
  25. package/dist/review-card/render-leaf.js +7 -0
  26. package/dist/run/index.d.ts +25 -4
  27. package/dist/run/index.js +108 -4
  28. package/dist/run/schemas.d.ts +350 -6
  29. package/dist/run/schemas.js +95 -6
  30. package/dist/simulate/deny-cases.js +11 -0
  31. package/dist/simulate/evaluate.js +86 -5
  32. package/dist/synth/declare.d.ts +43 -0
  33. package/dist/synth/declare.js +127 -0
  34. package/dist/synth/index.d.ts +1 -0
  35. package/dist/synth/index.js +1 -0
  36. package/dist/synth/synthesize-from-recording.js +1 -1
  37. package/dist/types.d.ts +21 -1
  38. package/dist/types.js +1 -1
  39. package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
  40. package/dist-cjs/adapters/interpreter/adapter.js +11 -3
  41. package/dist-cjs/errors.d.ts +6 -1
  42. package/dist-cjs/install/authority-overlap.d.ts +101 -0
  43. package/dist-cjs/install/authority-overlap.js +248 -0
  44. package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
  45. package/dist-cjs/install/index.d.ts +1 -0
  46. package/dist-cjs/install/index.js +13 -2
  47. package/dist-cjs/install/read-account-rules.d.ts +79 -0
  48. package/dist-cjs/install/read-account-rules.js +252 -0
  49. package/dist-cjs/predicate/decode.js +22 -1
  50. package/dist-cjs/predicate/encode.js +52 -5
  51. package/dist-cjs/predicate/from-json.js +14 -1
  52. package/dist-cjs/record/index.d.ts +10 -0
  53. package/dist-cjs/record/index.js +31 -0
  54. package/dist-cjs/record/rpc.d.ts +4 -0
  55. package/dist-cjs/record/rpc.js +7 -3
  56. package/dist-cjs/registry/identify.d.ts +10 -1
  57. package/dist-cjs/registry/identify.js +4 -0
  58. package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
  59. package/dist-cjs/registry/on-chain-spec.js +159 -0
  60. package/dist-cjs/review-card/builder.js +40 -0
  61. package/dist-cjs/review-card/cross-check.js +34 -0
  62. package/dist-cjs/review-card/render-leaf.d.ts +1 -1
  63. package/dist-cjs/review-card/render-leaf.js +7 -0
  64. package/dist-cjs/run/index.d.ts +25 -4
  65. package/dist-cjs/run/index.js +108 -2
  66. package/dist-cjs/run/schemas.d.ts +350 -6
  67. package/dist-cjs/run/schemas.js +96 -7
  68. package/dist-cjs/simulate/deny-cases.js +11 -0
  69. package/dist-cjs/simulate/evaluate.js +86 -5
  70. package/dist-cjs/synth/declare.d.ts +43 -0
  71. package/dist-cjs/synth/declare.js +130 -0
  72. package/dist-cjs/synth/index.d.ts +1 -0
  73. package/dist-cjs/synth/index.js +3 -1
  74. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  75. package/dist-cjs/types.d.ts +21 -1
  76. package/dist-cjs/types.js +1 -1
  77. package/package.json +1 -1
  78. package/src/adapters/interpreter/adapter.ts +13 -5
  79. package/src/errors.ts +5 -0
  80. package/src/install/authority-overlap.ts +323 -0
  81. package/src/install/index.ts +20 -0
  82. package/src/install/read-account-rules.ts +313 -0
  83. package/src/predicate/decode.ts +22 -1
  84. package/src/predicate/encode.ts +55 -5
  85. package/src/predicate/from-json.ts +14 -1
  86. package/src/record/index.ts +59 -2
  87. package/src/record/rpc.ts +4 -1
  88. package/src/registry/identify.ts +4 -1
  89. package/src/registry/on-chain-spec.ts +168 -0
  90. package/src/review-card/builder.ts +45 -2
  91. package/src/review-card/cross-check.ts +35 -1
  92. package/src/review-card/render-leaf.ts +8 -1
  93. package/src/run/index.ts +125 -2
  94. package/src/run/schemas.ts +100 -6
  95. package/src/simulate/deny-cases.ts +12 -1
  96. package/src/simulate/evaluate.ts +101 -9
  97. package/src/synth/declare.ts +206 -0
  98. package/src/synth/index.ts +5 -0
  99. package/src/synth/synthesize-from-recording.ts +1 -1
  100. package/src/types.ts +16 -1
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ // src/install/authority-overlap.ts - cross-rule authority analysis.
3
+ //
4
+ // An OZ smart account selects a context rule by CALLER DECLARATION and enforces
5
+ // only the policies of the rule that was named. A signer belonging to several
6
+ // rules therefore picks which one applies, so for any given call their
7
+ // authority is the MAXIMUM over the matching rules, never the intersection.
8
+ //
9
+ // The consequence is the one that catches people: installing a second, tighter
10
+ // rule restricts nothing. A key that also sits on an unpoliced rule is not
11
+ // constrained at all - it names that rule and the predicate never runs. This
12
+ // module detects that at install time, before the caller acts on a policy that
13
+ // looks binding and is not.
14
+ //
15
+ // Not theoretical. Proven on chain 2026-08-22: the same key, the same account
16
+ // and the same forbidden call was denied `#100` naming the policed rule and
17
+ // PERMITTED naming an unpoliced one. It happened in this project's own end-to-
18
+ // end harness, written by the author of the grammar, and was caught by review
19
+ // rather than by tooling - which is why the tooling now exists.
20
+ //
21
+ // Adapted to grammar 3 from the version published in `@crediolabs/policy-synth`
22
+ // 0.2.0, which came from the `octogate` repository and was lost when the npm
23
+ // lineage moved here. `or` and `not` are gone from the grammar, so the cases
24
+ // handling them are gone too; oracle bounds are gone from the stored document.
25
+ //
26
+ // Pure: no network. The caller supplies the account's rules.
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.ANY = void 0;
29
+ exports.signerKey = signerKey;
30
+ exports.intersectSelectors = intersectSelectors;
31
+ exports.permittedSelectors = permittedSelectors;
32
+ exports.selectorsForContextType = selectorsForContextType;
33
+ exports.effectiveSelectors = effectiveSelectors;
34
+ exports.findAuthorityOverlaps = findAuthorityOverlaps;
35
+ /** Wildcard component of a `Selector`: the predicate does not pin this half. */
36
+ exports.ANY = '*';
37
+ // ---- signer identity ----
38
+ /** Canonical key for signer equality. Mirrors OZ's `Signer` enum: a delegated
39
+ * signer is its address, an external signer is the verifier plus the key
40
+ * bytes, since one verifier may hold many keys. */
41
+ function signerKey(s) {
42
+ return s.kind === 'delegated' ? `delegated:${s.address}` : `external:${s.verifier}:${s.keyBytes}`;
43
+ }
44
+ function sharedSigners(a, b) {
45
+ const bKeys = new Set(b.map(signerKey));
46
+ return a.filter((s) => bKeys.has(signerKey(s)));
47
+ }
48
+ // ---- selector extraction ----
49
+ const WILDCARD = { contract: exports.ANY, fn: exports.ANY };
50
+ function selectorKey(s) {
51
+ return `${s.contract} ${s.fn}`;
52
+ }
53
+ function dedupe(sels) {
54
+ const seen = new Map();
55
+ for (const s of sels)
56
+ seen.set(selectorKey(s), s);
57
+ return [...seen.values()];
58
+ }
59
+ /** Intersect one pair. `ANY` absorbs, equal literals survive, and two
60
+ * different literals cannot both hold for a single call. */
61
+ function intersectOne(a, b) {
62
+ const contract = a.contract === exports.ANY
63
+ ? b.contract
64
+ : b.contract === exports.ANY
65
+ ? a.contract
66
+ : a.contract === b.contract
67
+ ? a.contract
68
+ : null;
69
+ if (contract === null)
70
+ return null;
71
+ const fn = a.fn === exports.ANY ? b.fn : b.fn === exports.ANY ? a.fn : a.fn === b.fn ? a.fn : null;
72
+ if (fn === null)
73
+ return null;
74
+ return { contract, fn };
75
+ }
76
+ /** Intersection of two selector SETS: every compatible pairing survives. */
77
+ function intersectSelectors(a, b) {
78
+ const out = [];
79
+ for (const x of a) {
80
+ for (const y of b) {
81
+ const hit = intersectOne(x, y);
82
+ if (hit)
83
+ out.push(hit);
84
+ }
85
+ }
86
+ return dedupe(out);
87
+ }
88
+ function literalAddress(leaf) {
89
+ return leaf.kind === 'literal_address' ? leaf.value : null;
90
+ }
91
+ function literalSymbol(leaf) {
92
+ return leaf.kind === 'literal_symbol' ? leaf.value : null;
93
+ }
94
+ /** Selector pinned by a single `eq`, whichever side the literal sits on. */
95
+ function selectorFromEq(left, right) {
96
+ if (left.kind === 'call_contract') {
97
+ const addr = literalAddress(right);
98
+ return addr === null ? null : { contract: addr, fn: exports.ANY };
99
+ }
100
+ if (right.kind === 'call_contract') {
101
+ const addr = literalAddress(left);
102
+ return addr === null ? null : { contract: addr, fn: exports.ANY };
103
+ }
104
+ if (left.kind === 'call_fn') {
105
+ const sym = literalSymbol(right);
106
+ return sym === null ? null : { contract: exports.ANY, fn: sym };
107
+ }
108
+ if (right.kind === 'call_fn') {
109
+ const sym = literalSymbol(left);
110
+ return sym === null ? null : { contract: exports.ANY, fn: sym };
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * The set of `(contract, fn)` selectors a predicate may permit.
116
+ *
117
+ * A deliberate OVER-approximation: every call the predicate actually permits is
118
+ * covered by some returned selector, and unrecognised structure widens to the
119
+ * wildcard rather than narrowing. That direction is what makes the emptiness
120
+ * test sound. A call carries exactly one `(contract, fn)`, so if two
121
+ * predicates' over-approximations do not intersect, no single call can be
122
+ * routed to either and the rules provably cannot collide.
123
+ *
124
+ * Narrowing instead would be the fail-OPEN direction: it would let this report
125
+ * "no overlap" for rules that do collide.
126
+ */
127
+ function permittedSelectors(node) {
128
+ switch (node.op) {
129
+ case 'and': {
130
+ // Every conjunct must hold at once, so the permitted set is the
131
+ // intersection. Intersecting over-approximations stays one.
132
+ let acc = [WILDCARD];
133
+ for (const child of node.children)
134
+ acc = intersectSelectors(acc, permittedSelectors(child));
135
+ return acc;
136
+ }
137
+ case 'or': {
138
+ // Any branch may hold, so the permitted set is the UNION. A union of
139
+ // over-approximations is still an over-approximation, so this keeps the
140
+ // fail-safe direction while staying tighter than the wildcard the
141
+ // default branch would give. Precision matters here: `or` is how a
142
+ // policy says "pair A or pair B", and widening that to the wildcard
143
+ // would report an overlap against every rule on the account.
144
+ const acc = [];
145
+ for (const child of node.children)
146
+ acc.push(...permittedSelectors(child));
147
+ return dedupe(acc);
148
+ }
149
+ case 'eq': {
150
+ const sel = selectorFromEq(node.left, node.right);
151
+ return sel === null ? [WILDCARD] : [sel];
152
+ }
153
+ case 'in': {
154
+ // Set membership over the selector halves: `call_fn in {a, b}` permits
155
+ // both. A haystack element that is not the matching literal kind makes
156
+ // the node uninformative rather than narrower.
157
+ if (node.needle.kind === 'call_contract') {
158
+ const addrs = node.haystack.map(literalAddress);
159
+ if (addrs.some((a) => a === null))
160
+ return [WILDCARD];
161
+ return dedupe(addrs.map((a) => ({ contract: a, fn: exports.ANY })));
162
+ }
163
+ if (node.needle.kind === 'call_fn') {
164
+ const syms = node.haystack.map(literalSymbol);
165
+ if (syms.some((s) => s === null))
166
+ return [WILDCARD];
167
+ return dedupe(syms.map((s) => ({ contract: exports.ANY, fn: s })));
168
+ }
169
+ return [WILDCARD];
170
+ }
171
+ default:
172
+ // `lte` binds an amount, never the selector.
173
+ return [WILDCARD];
174
+ }
175
+ }
176
+ /** Selectors a context type admits, before the predicate narrows them. */
177
+ function selectorsForContextType(ct) {
178
+ switch (ct.kind) {
179
+ case 'default':
180
+ return [WILDCARD];
181
+ case 'call_contract':
182
+ return [{ contract: ct.contract, fn: exports.ANY }];
183
+ case 'create_contract':
184
+ // A contract-creation context is a different `Context` shape. The
185
+ // interpreter refuses anything that is not `Context::Contract`, and a
186
+ // creation rule can never serve a call, so it shares no selector.
187
+ return [];
188
+ }
189
+ }
190
+ /** What a rule can actually authorise: its context type narrowed by its
191
+ * predicate. An unpoliced or unreadable rule contributes no narrowing. */
192
+ function effectiveSelectors(rule) {
193
+ const fromType = selectorsForContextType(rule.contextType);
194
+ if (!rule.predicate)
195
+ return fromType;
196
+ return intersectSelectors(fromType, permittedSelectors(rule.predicate));
197
+ }
198
+ function classifyRule(rule) {
199
+ if (rule.policyAddresses.length === 0)
200
+ return 'unpoliced';
201
+ return rule.predicate ? 'interpreter' : 'foreign';
202
+ }
203
+ function adviceFor(cls, ruleId) {
204
+ switch (cls) {
205
+ case 'unpoliced':
206
+ return `rule ${ruleId} has no policy attached, so a shared signer may make these calls with no constraint at all - the predicate you are installing will never run for them. Remove the shared signer from rule ${ruleId}, or attach a policy to it.`;
207
+ case 'foreign':
208
+ return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule.`;
209
+ case 'interpreter':
210
+ return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other.`;
211
+ }
212
+ }
213
+ /**
214
+ * Every existing rule a signer of the intended install could name instead.
215
+ *
216
+ * A rule collides when it shares at least one signer AND at least one selector.
217
+ * Both are needed for the signer to have a choice: same signer but disjoint
218
+ * calls means no call can be rerouted, and same calls but no shared signer
219
+ * means nobody can reroute them.
220
+ */
221
+ function findAuthorityOverlaps(args) {
222
+ const intendedSelectors = intersectSelectors(selectorsForContextType(args.intended.contextType), permittedSelectors(args.intended.predicate));
223
+ const out = [];
224
+ for (const rule of args.existing) {
225
+ if (rule.id === args.intended.ruleId)
226
+ continue;
227
+ const shared = sharedSigners(args.intended.signers, rule.signers);
228
+ if (shared.length === 0)
229
+ continue;
230
+ const sharedSelectors = intersectSelectors(intendedSelectors, effectiveSelectors(rule));
231
+ if (sharedSelectors.length === 0)
232
+ continue;
233
+ const ruleClass = classifyRule(rule);
234
+ out.push({
235
+ ruleId: rule.id,
236
+ ruleClass,
237
+ severity: ruleClass === 'unpoliced'
238
+ ? 'bypass'
239
+ : ruleClass === 'foreign'
240
+ ? 'unknown'
241
+ : 'not-restricting',
242
+ sharedSigners: shared,
243
+ sharedSelectors,
244
+ advice: adviceFor(ruleClass, rule.id),
245
+ });
246
+ }
247
+ return out;
248
+ }
@@ -23,7 +23,7 @@ export interface BuildAddContextRuleArgs {
23
23
  * before the chain does; the contract will refuse a mismatch again. */
24
24
  grammarVersion?: number;
25
25
  }
26
- export declare const DEFAULT_GRAMMAR_VERSION: 3;
26
+ export declare const DEFAULT_GRAMMAR_VERSION: 4;
27
27
  /** The verb `add_context_rule` takes on the wire. */
28
28
  export declare const ADD_CONTEXT_RULE_SYMBOL: "add_context_rule";
29
29
  /** Tuple of `ScVal` arguments to pass to `Operation.invokeHostFunction` for
@@ -1 +1,2 @@
1
+ export { ANY, type AuthorityOverlap, type ContextType, effectiveSelectors, findAuthorityOverlaps, type IntendedInstall, intersectSelectors, type ObservedRule, type OverlapSeverity, permittedSelectors, type RuleClass, type Selector, selectorsForContextType, signerKey, } from './authority-overlap.ts';
1
2
  export { ADD_CONTEXT_RULE_SYMBOL, type AddContextRuleArgs, type BuildAddContextRuleArgs, buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION, } from './build-add-context-rule.ts';
@@ -1,6 +1,4 @@
1
1
  "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_GRAMMAR_VERSION = exports.buildAddContextRuleArgs = exports.ADD_CONTEXT_RULE_SYMBOL = void 0;
4
2
  // Public entry for the install-argument builders.
5
3
  //
6
4
  // `buildAddContextRuleArgs` is the encoder `runInstallPolicy` uses to turn a
@@ -15,6 +13,19 @@ exports.DEFAULT_GRAMMAR_VERSION = exports.buildAddContextRuleArgs = exports.ADD_
15
13
  // Exported here rather than from the package root to keep the root surface
16
14
  // about synthesis, and because these are transaction-building primitives whose
17
15
  // callers should know they are reaching for them.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.DEFAULT_GRAMMAR_VERSION = exports.buildAddContextRuleArgs = exports.ADD_CONTEXT_RULE_SYMBOL = exports.signerKey = exports.selectorsForContextType = exports.permittedSelectors = exports.intersectSelectors = exports.findAuthorityOverlaps = exports.effectiveSelectors = exports.ANY = void 0;
18
+ // Cross-rule authority analysis. Exported because the check has to happen
19
+ // wherever an install is BUILT, and a client that assembles its own
20
+ // `add_context_rule` call never reaches `runInstallPolicy`.
21
+ var authority_overlap_ts_1 = require("./authority-overlap.js");
22
+ Object.defineProperty(exports, "ANY", { enumerable: true, get: function () { return authority_overlap_ts_1.ANY; } });
23
+ Object.defineProperty(exports, "effectiveSelectors", { enumerable: true, get: function () { return authority_overlap_ts_1.effectiveSelectors; } });
24
+ Object.defineProperty(exports, "findAuthorityOverlaps", { enumerable: true, get: function () { return authority_overlap_ts_1.findAuthorityOverlaps; } });
25
+ Object.defineProperty(exports, "intersectSelectors", { enumerable: true, get: function () { return authority_overlap_ts_1.intersectSelectors; } });
26
+ Object.defineProperty(exports, "permittedSelectors", { enumerable: true, get: function () { return authority_overlap_ts_1.permittedSelectors; } });
27
+ Object.defineProperty(exports, "selectorsForContextType", { enumerable: true, get: function () { return authority_overlap_ts_1.selectorsForContextType; } });
28
+ Object.defineProperty(exports, "signerKey", { enumerable: true, get: function () { return authority_overlap_ts_1.signerKey; } });
18
29
  var build_add_context_rule_ts_1 = require("./build-add-context-rule.js");
19
30
  Object.defineProperty(exports, "ADD_CONTEXT_RULE_SYMBOL", { enumerable: true, get: function () { return build_add_context_rule_ts_1.ADD_CONTEXT_RULE_SYMBOL; } });
20
31
  Object.defineProperty(exports, "buildAddContextRuleArgs", { enumerable: true, get: function () { return build_add_context_rule_ts_1.buildAddContextRuleArgs; } });
@@ -0,0 +1,79 @@
1
+ import { rpc, xdr } from '@stellar/stellar-sdk';
2
+ import type { SignerDraft } from '../types.ts';
3
+ import type { ContextType, ObservedRule } from './authority-overlap.ts';
4
+ /** `storage.rs` - the third element of the persistent doc key tuple. */
5
+ export declare const K_DOC = 1;
6
+ /** Persistent-storage key for a rule's stored document:
7
+ * `(account, rule_id, K_DOC)`. */
8
+ export declare function docKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal;
9
+ /** Ledger key for the interpreter's persistent entry holding that document. */
10
+ export declare function docLedgerKey(interpreter: string, smartAccount: string, ruleId: number): xdr.LedgerKey;
11
+ /** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
12
+ * is the widest reading and therefore the safe one: it makes the rule look
13
+ * like it could serve any call, so overlap is over-reported, never missed. */
14
+ export declare function decodeContextType(v: xdr.ScVal | undefined): ContextType;
15
+ /** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
16
+ export declare function decodeSigner(v: xdr.ScVal): SignerDraft | undefined;
17
+ /** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
18
+ * `predicate` is filled in separately from the ledger entry. */
19
+ export declare function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined;
20
+ /** The interpreter's `StoredDoc { predicate_bytes }`. */
21
+ export declare function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined;
22
+ /** The three reads the scan needs. Kept as an interface so the collection
23
+ * below is testable without a network. */
24
+ export interface AccountRuleReader {
25
+ /** OZ `get_context_rules_count()`. */
26
+ getContextRuleCount(smartAccount: string): Promise<number>;
27
+ /** OZ `get_context_rule(id)`. Undefined when the id is absent. */
28
+ getContextRule(smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
29
+ /** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
30
+ * Undefined when no document is stored for that rule. */
31
+ getStoredDoc(interpreter: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
32
+ }
33
+ /** How far the id scan will probe before giving up. OZ imposes no per-account
34
+ * rule cap, so there is no exact bound to derive; this one is far above any
35
+ * realistic account and keeps a malformed `Count` from spinning forever. */
36
+ export declare const MAX_RULE_ID_SCAN = 512;
37
+ export interface CollectedRules {
38
+ rules: ObservedRule[];
39
+ /** Rule ids whose stored predicate could not be read even though the
40
+ * interpreter is attached. Such a rule is reported without a predicate,
41
+ * which classifies it as opaque rather than as safely narrow. */
42
+ unreadablePredicateRuleIds: number[];
43
+ /** True when the scan stopped before accounting for every live rule. The
44
+ * result is then a SUBSET of the account's rules, so an empty overlap list
45
+ * proves nothing and the caller must not present it as safety. */
46
+ incomplete: boolean;
47
+ }
48
+ /**
49
+ * Every context rule on the account, with predicates filled in for the rules
50
+ * our interpreter polices.
51
+ *
52
+ * Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
53
+ * decrements `Count` on removal without ever reusing an id, so after any
54
+ * removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
55
+ * would silently skip live rules at higher ids, and a skipped rule is a missed
56
+ * overlap - the one error that reports safety which does not exist. Instead the
57
+ * scan walks ids upward until it has accounted for `Count` live rules.
58
+ *
59
+ * A rule whose predicate cannot be read is deliberately left without one. That
60
+ * demotes it to the `foreign` class, so the scan reports it as opaque instead
61
+ * of assuming it is narrow.
62
+ */
63
+ export declare function collectObservedRules(args: {
64
+ reader: AccountRuleReader;
65
+ smartAccount: string;
66
+ interpreterAddress: string;
67
+ maxRuleIdScan?: number;
68
+ }): Promise<CollectedRules>;
69
+ /**
70
+ * An `AccountRuleReader` over a live RPC server.
71
+ *
72
+ * The two OZ getters are read-only simulations: the source account is
73
+ * constructed locally because a simulation never checks its sequence number,
74
+ * and asking the network for a random key would 404.
75
+ *
76
+ * The stored document is fetched as a ledger entry rather than a contract
77
+ * call, because the interpreter publishes no getter for it.
78
+ */
79
+ export declare function accountRuleReaderFromServer(server: rpc.Server, networkPassphrase: string): AccountRuleReader;
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ //! Reading an OpenZeppelin smart account's context rules back off chain.
3
+ //!
4
+ //! `authority-overlap.ts` needs to know what a signer can already do before a
5
+ //! new policy is installed. That means every rule on the account: its context
6
+ //! type, its signers, its attached policies, and - for rules our interpreter
7
+ //! polices - the predicate itself.
8
+ //!
9
+ //! Without this, the overlap scan can only report on rules the CALLER supplied,
10
+ //! which means it answers "what did you tell me about" rather than "what is on
11
+ //! the account". Those are different questions, and only the second one is
12
+ //! worth anything to someone deciding whether to sign.
13
+ //!
14
+ //! The predicate is NOT reachable through a contract call. The interpreter
15
+ //! publishes only `grammar_version`, `install`, `enforce`, `uninstall` and
16
+ //! `rotate_master_signer_set`, so the stored document is read as a ledger entry
17
+ //! instead. That keeps this a purely client-side capability: adding a getter
18
+ //! would change a deployed contract's ABI and force a redeploy plus re-audit to
19
+ //! obtain data the ledger already exposes.
20
+ //!
21
+ //! The decoders are pure so they can be tested without a network; the caller
22
+ //! supplies raw `ScVal`s.
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.MAX_RULE_ID_SCAN = exports.K_DOC = void 0;
25
+ exports.docKeyScVal = docKeyScVal;
26
+ exports.docLedgerKey = docLedgerKey;
27
+ exports.decodeContextType = decodeContextType;
28
+ exports.decodeSigner = decodeSigner;
29
+ exports.decodeContextRule = decodeContextRule;
30
+ exports.decodeStoredPredicateBytes = decodeStoredPredicateBytes;
31
+ exports.collectObservedRules = collectObservedRules;
32
+ exports.accountRuleReaderFromServer = accountRuleReaderFromServer;
33
+ const stellar_sdk_1 = require("@stellar/stellar-sdk");
34
+ const decode_ts_1 = require("../predicate/decode.js");
35
+ /** `storage.rs` - the third element of the persistent doc key tuple. */
36
+ exports.K_DOC = 1;
37
+ /** Persistent-storage key for a rule's stored document:
38
+ * `(account, rule_id, K_DOC)`. */
39
+ function docKeyScVal(smartAccount, ruleId) {
40
+ return stellar_sdk_1.xdr.ScVal.scvVec([
41
+ new stellar_sdk_1.Address(smartAccount).toScVal(),
42
+ stellar_sdk_1.xdr.ScVal.scvU32(ruleId),
43
+ stellar_sdk_1.xdr.ScVal.scvU32(exports.K_DOC),
44
+ ]);
45
+ }
46
+ /** Ledger key for the interpreter's persistent entry holding that document. */
47
+ function docLedgerKey(interpreter, smartAccount, ruleId) {
48
+ return stellar_sdk_1.xdr.LedgerKey.contractData(new stellar_sdk_1.xdr.LedgerKeyContractData({
49
+ contract: new stellar_sdk_1.Address(interpreter).toScAddress(),
50
+ key: docKeyScVal(smartAccount, ruleId),
51
+ durability: stellar_sdk_1.xdr.ContractDataDurability.persistent(),
52
+ }));
53
+ }
54
+ // ---- ScVal helpers -----
55
+ /** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
56
+ * by field-name symbol. Returns undefined when the field is absent so a
57
+ * caller can distinguish "not there" from "there and empty". */
58
+ function mapField(v, name) {
59
+ if (v.switch() !== stellar_sdk_1.xdr.ScValType.scvMap())
60
+ return undefined;
61
+ for (const entry of v.map() ?? []) {
62
+ const key = entry.key();
63
+ if (key.switch() === stellar_sdk_1.xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
64
+ return entry.val();
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+ function u32Of(v) {
70
+ return v?.switch() === stellar_sdk_1.xdr.ScValType.scvU32() ? v.u32() : undefined;
71
+ }
72
+ function addressOf(v) {
73
+ if (!v || v.switch() !== stellar_sdk_1.xdr.ScValType.scvAddress())
74
+ return undefined;
75
+ return stellar_sdk_1.Address.fromScAddress(v.address()).toString();
76
+ }
77
+ /** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
78
+ function enumVariant(v) {
79
+ if (!v || v.switch() !== stellar_sdk_1.xdr.ScValType.scvVec())
80
+ return undefined;
81
+ const items = v.vec() ?? [];
82
+ const head = items[0];
83
+ if (!head || head.switch() !== stellar_sdk_1.xdr.ScValType.scvSymbol())
84
+ return undefined;
85
+ return { tag: head.sym().toString(), args: items.slice(1) };
86
+ }
87
+ // ---- decoders -----
88
+ /** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
89
+ * is the widest reading and therefore the safe one: it makes the rule look
90
+ * like it could serve any call, so overlap is over-reported, never missed. */
91
+ function decodeContextType(v) {
92
+ const variant = enumVariant(v);
93
+ if (!variant)
94
+ return { kind: 'default' };
95
+ if (variant.tag === 'CallContract') {
96
+ const addr = addressOf(variant.args[0]);
97
+ return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' };
98
+ }
99
+ if (variant.tag === 'CreateContract') {
100
+ const arg = variant.args[0];
101
+ const hash = arg?.switch() === stellar_sdk_1.xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : '';
102
+ return { kind: 'create_contract', wasmHash: hash };
103
+ }
104
+ return { kind: 'default' };
105
+ }
106
+ /** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
107
+ function decodeSigner(v) {
108
+ const variant = enumVariant(v);
109
+ if (!variant)
110
+ return undefined;
111
+ if (variant.tag === 'Delegated') {
112
+ const addr = addressOf(variant.args[0]);
113
+ return addr ? { kind: 'delegated', address: addr } : undefined;
114
+ }
115
+ if (variant.tag === 'External') {
116
+ const verifier = addressOf(variant.args[0]);
117
+ const keyArg = variant.args[1];
118
+ const keyBytes = keyArg?.switch() === stellar_sdk_1.xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : '';
119
+ return verifier ? { kind: 'external', verifier, keyBytes } : undefined;
120
+ }
121
+ return undefined;
122
+ }
123
+ /** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
124
+ * `predicate` is filled in separately from the ledger entry. */
125
+ function decodeContextRule(v) {
126
+ const id = u32Of(mapField(v, 'id'));
127
+ if (id === undefined)
128
+ return undefined;
129
+ const signersVal = mapField(v, 'signers');
130
+ const signers = [];
131
+ if (signersVal?.switch() === stellar_sdk_1.xdr.ScValType.scvVec()) {
132
+ for (const s of signersVal.vec() ?? []) {
133
+ const decoded = decodeSigner(s);
134
+ if (decoded)
135
+ signers.push(decoded);
136
+ }
137
+ }
138
+ const policiesVal = mapField(v, 'policies');
139
+ const policyAddresses = [];
140
+ if (policiesVal?.switch() === stellar_sdk_1.xdr.ScValType.scvVec()) {
141
+ for (const p of policiesVal.vec() ?? []) {
142
+ const addr = addressOf(p);
143
+ if (addr)
144
+ policyAddresses.push(addr);
145
+ }
146
+ }
147
+ return {
148
+ id,
149
+ contextType: decodeContextType(mapField(v, 'context_type')),
150
+ signers,
151
+ policyAddresses,
152
+ };
153
+ }
154
+ /** The interpreter's `StoredDoc { predicate_bytes }`. */
155
+ function decodeStoredPredicateBytes(v) {
156
+ const field = mapField(v, 'predicate_bytes');
157
+ if (!field || field.switch() !== stellar_sdk_1.xdr.ScValType.scvBytes())
158
+ return undefined;
159
+ return field.bytes();
160
+ }
161
+ /** How far the id scan will probe before giving up. OZ imposes no per-account
162
+ * rule cap, so there is no exact bound to derive; this one is far above any
163
+ * realistic account and keeps a malformed `Count` from spinning forever. */
164
+ exports.MAX_RULE_ID_SCAN = 512;
165
+ /**
166
+ * Every context rule on the account, with predicates filled in for the rules
167
+ * our interpreter polices.
168
+ *
169
+ * Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
170
+ * decrements `Count` on removal without ever reusing an id, so after any
171
+ * removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
172
+ * would silently skip live rules at higher ids, and a skipped rule is a missed
173
+ * overlap - the one error that reports safety which does not exist. Instead the
174
+ * scan walks ids upward until it has accounted for `Count` live rules.
175
+ *
176
+ * A rule whose predicate cannot be read is deliberately left without one. That
177
+ * demotes it to the `foreign` class, so the scan reports it as opaque instead
178
+ * of assuming it is narrow.
179
+ */
180
+ async function collectObservedRules(args) {
181
+ const count = await args.reader.getContextRuleCount(args.smartAccount);
182
+ const limit = args.maxRuleIdScan ?? exports.MAX_RULE_ID_SCAN;
183
+ const rules = [];
184
+ const unreadablePredicateRuleIds = [];
185
+ let id = 0;
186
+ while (rules.length < count && id < limit) {
187
+ const raw = await args.reader.getContextRule(args.smartAccount, id);
188
+ id++;
189
+ if (!raw)
190
+ continue;
191
+ const rule = decodeContextRule(raw);
192
+ if (!rule)
193
+ continue;
194
+ if (rule.policyAddresses.includes(args.interpreterAddress)) {
195
+ const doc = await args.reader.getStoredDoc(args.interpreterAddress, args.smartAccount, rule.id);
196
+ const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined;
197
+ if (bytes) {
198
+ try {
199
+ rule.predicate = (0, decode_ts_1.decodePredicate)(bytes);
200
+ }
201
+ catch {
202
+ unreadablePredicateRuleIds.push(rule.id);
203
+ }
204
+ }
205
+ else {
206
+ unreadablePredicateRuleIds.push(rule.id);
207
+ }
208
+ }
209
+ rules.push(rule);
210
+ }
211
+ return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count };
212
+ }
213
+ /**
214
+ * An `AccountRuleReader` over a live RPC server.
215
+ *
216
+ * The two OZ getters are read-only simulations: the source account is
217
+ * constructed locally because a simulation never checks its sequence number,
218
+ * and asking the network for a random key would 404.
219
+ *
220
+ * The stored document is fetched as a ledger entry rather than a contract
221
+ * call, because the interpreter publishes no getter for it.
222
+ */
223
+ function accountRuleReaderFromServer(server, networkPassphrase) {
224
+ async function simulateCall(contract, method, ...args) {
225
+ const account = new stellar_sdk_1.Account(stellar_sdk_1.Keypair.random().publicKey(), '0');
226
+ const tx = new stellar_sdk_1.TransactionBuilder(account, { fee: stellar_sdk_1.BASE_FEE, networkPassphrase })
227
+ .addOperation(new stellar_sdk_1.Contract(contract).call(method, ...args))
228
+ .setTimeout(30)
229
+ .build();
230
+ const sim = await server.simulateTransaction(tx);
231
+ if (stellar_sdk_1.rpc.Api.isSimulationError(sim))
232
+ return undefined;
233
+ return sim.result?.retval;
234
+ }
235
+ return {
236
+ async getContextRuleCount(smartAccount) {
237
+ const val = await simulateCall(smartAccount, 'get_context_rules_count');
238
+ return u32Of(val) ?? 0;
239
+ },
240
+ async getContextRule(smartAccount, ruleId) {
241
+ return simulateCall(smartAccount, 'get_context_rule', stellar_sdk_1.xdr.ScVal.scvU32(ruleId));
242
+ },
243
+ async getStoredDoc(interpreter, smartAccount, ruleId) {
244
+ const key = docLedgerKey(interpreter, smartAccount, ruleId);
245
+ const res = await server.getLedgerEntries(key);
246
+ const entry = res.entries?.[0]?.val;
247
+ if (!entry || entry.switch() !== stellar_sdk_1.xdr.LedgerEntryType.contractData())
248
+ return undefined;
249
+ return entry.contractData().val();
250
+ },
251
+ };
252
+ }
@@ -60,6 +60,15 @@ function expectSymbol(v, what) {
60
60
  throw malformed(`${what} is not a symbol`);
61
61
  return v.sym().toString();
62
62
  }
63
+ /** Strict i128, returned as a decimal string. Strict on purpose: the Rust
64
+ * decoder refuses a u32 in an i128 slot rather than widening it, so
65
+ * accepting one here would let a predicate decode off chain and be refused
66
+ * on chain. */
67
+ function expectI128(v, what) {
68
+ if (!v || v.switch() !== stellar_sdk_1.xdr.ScValType.scvI128())
69
+ throw malformed(`${what} is not an i128`);
70
+ return (0, stellar_sdk_1.scValToBigInt)(v).toString();
71
+ }
63
72
  /** Arity check with the same intent as the Rust `check_arity`: a selector with
64
73
  * the wrong element count is malformed, not silently truncated. */
65
74
  function arity(items, n, selector) {
@@ -89,6 +98,14 @@ function decodeSelectorLeaf(items, sym) {
89
98
  element: expectU32(items[2], 'call_arg_field element'),
90
99
  field: expectSymbol(items[3], 'call_arg_field field'),
91
100
  };
101
+ case 'call_arg_scaled':
102
+ arity(items, 4, sym);
103
+ return {
104
+ kind: 'call_arg_scaled',
105
+ index: expectU32(items[1], 'call_arg_scaled index'),
106
+ num: expectI128(items[2], 'call_arg_scaled num'),
107
+ den: expectI128(items[3], 'call_arg_scaled den'),
108
+ };
92
109
  default:
93
110
  // Deliberately NOT a literal_vec fallback - see the header note.
94
111
  throw malformed(`unknown selector symbol '${sym}'`);
@@ -125,7 +142,8 @@ function decodeNode(v) {
125
142
  if (op === null)
126
143
  throw malformed('node does not start with an operator symbol');
127
144
  switch (op) {
128
- case 'and': {
145
+ case 'and':
146
+ case 'or': {
129
147
  arity(items, 2, op);
130
148
  const children = expectVec(items[1], `${op} children`).map(decodeNode);
131
149
  if (children.length === 0)
@@ -133,7 +151,10 @@ function decodeNode(v) {
133
151
  return { op, children };
134
152
  }
135
153
  case 'eq':
154
+ case 'lt':
136
155
  case 'lte':
156
+ case 'gt':
157
+ case 'gte':
137
158
  arity(items, 3, op);
138
159
  return {
139
160
  op,