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