@crediolabs/policy-synth 0.3.0 → 0.4.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 (152) hide show
  1. package/dist/install/authority-overlap.d.ts +101 -0
  2. package/dist/install/authority-overlap.js +227 -0
  3. package/dist/install/index.d.ts +1 -0
  4. package/dist/install/index.js +4 -0
  5. package/dist/record/freshness.d.ts +17 -11
  6. package/dist/record/freshness.js +28 -18
  7. package/dist/record/index.d.ts +10 -0
  8. package/dist/record/index.js +32 -1
  9. package/dist/record/rpc.d.ts +4 -0
  10. package/dist/record/rpc.js +4 -1
  11. package/dist/registry/identify.d.ts +10 -1
  12. package/dist/registry/identify.js +4 -1
  13. package/dist/registry/on-chain-spec.d.ts +37 -0
  14. package/dist/registry/on-chain-spec.js +152 -0
  15. package/dist/run/index.d.ts +25 -4
  16. package/dist/run/index.js +65 -4
  17. package/dist/run/schemas.d.ts +313 -0
  18. package/dist/run/schemas.js +52 -0
  19. package/dist/synth/declare.d.ts +30 -0
  20. package/dist/synth/declare.js +98 -0
  21. package/dist/synth/index.d.ts +1 -0
  22. package/dist/synth/index.js +1 -0
  23. package/dist-cjs/install/authority-overlap.d.ts +101 -0
  24. package/dist-cjs/install/authority-overlap.js +236 -0
  25. package/dist-cjs/install/index.d.ts +1 -0
  26. package/dist-cjs/install/index.js +13 -2
  27. package/dist-cjs/record/freshness.d.ts +17 -11
  28. package/dist-cjs/record/freshness.js +28 -18
  29. package/dist-cjs/record/index.d.ts +10 -0
  30. package/dist-cjs/record/index.js +31 -0
  31. package/dist-cjs/record/rpc.d.ts +4 -0
  32. package/dist-cjs/record/rpc.js +7 -3
  33. package/dist-cjs/registry/identify.d.ts +10 -1
  34. package/dist-cjs/registry/identify.js +4 -0
  35. package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
  36. package/dist-cjs/registry/on-chain-spec.js +159 -0
  37. package/dist-cjs/run/index.d.ts +25 -4
  38. package/dist-cjs/run/index.js +65 -2
  39. package/dist-cjs/run/schemas.d.ts +313 -0
  40. package/dist-cjs/run/schemas.js +53 -1
  41. package/dist-cjs/synth/declare.d.ts +30 -0
  42. package/dist-cjs/synth/declare.js +101 -0
  43. package/dist-cjs/synth/index.d.ts +1 -0
  44. package/dist-cjs/synth/index.js +3 -1
  45. package/package.json +2 -2
  46. package/src/install/authority-overlap.ts +312 -0
  47. package/src/install/index.ts +20 -0
  48. package/src/record/freshness.ts +29 -18
  49. package/src/record/index.ts +59 -2
  50. package/src/record/rpc.ts +4 -1
  51. package/src/registry/identify.ts +4 -1
  52. package/src/registry/on-chain-spec.ts +168 -0
  53. package/src/run/index.ts +79 -2
  54. package/src/run/schemas.ts +57 -0
  55. package/src/synth/declare.ts +157 -0
  56. package/src/synth/index.ts +5 -0
  57. package/dist/adapters/interpreter/index.d.ts +0 -1
  58. package/dist/adapters/interpreter/index.js +0 -2
  59. package/dist/adapters/oz/adapter.d.ts +0 -20
  60. package/dist/adapters/oz/adapter.js +0 -281
  61. package/dist/adapters/oz/index.d.ts +0 -1
  62. package/dist/adapters/oz/index.js +0 -2
  63. package/dist/codegen/compile-gate.d.ts +0 -33
  64. package/dist/codegen/compile-gate.js +0 -124
  65. package/dist/codegen/index.d.ts +0 -2
  66. package/dist/codegen/index.js +0 -8
  67. package/dist/codegen/template.d.ts +0 -18
  68. package/dist/codegen/template.js +0 -148
  69. package/dist/ir/index.d.ts +0 -1
  70. package/dist/ir/index.js +0 -2
  71. package/dist/ir/types.d.ts +0 -71
  72. package/dist/ir/types.js +0 -9
  73. package/dist/mandate/index.d.ts +0 -2
  74. package/dist/mandate/index.js +0 -2
  75. package/dist/mandate/to-ir.d.ts +0 -3
  76. package/dist/mandate/to-ir.js +0 -60
  77. package/dist/mandate/types.d.ts +0 -20
  78. package/dist/mandate/types.js +0 -8
  79. package/dist/seams/index.d.ts +0 -1
  80. package/dist/seams/index.js +0 -2
  81. package/dist/seams/types.d.ts +0 -64
  82. package/dist/seams/types.js +0 -11
  83. package/dist/synth/deny-cases.d.ts +0 -28
  84. package/dist/synth/deny-cases.js +0 -212
  85. package/dist/synth/evaluate.d.ts +0 -18
  86. package/dist/synth/evaluate.js +0 -271
  87. package/dist/synth/harness.d.ts +0 -28
  88. package/dist/synth/harness.js +0 -47
  89. package/dist/synth/minimize.d.ts +0 -4
  90. package/dist/synth/minimize.js +0 -38
  91. package/dist/synth/permit-context.d.ts +0 -8
  92. package/dist/synth/permit-context.js +0 -35
  93. package/dist/synth/predicate-literals.d.ts +0 -5
  94. package/dist/synth/predicate-literals.js +0 -25
  95. package/dist/synth/synthesize-from-mandate.d.ts +0 -20
  96. package/dist/synth/synthesize-from-mandate.js +0 -59
  97. package/dist/verify/envelope.d.ts +0 -15
  98. package/dist/verify/envelope.js +0 -22
  99. package/dist/verify/index.d.ts +0 -3
  100. package/dist/verify/index.js +0 -3
  101. package/dist/verify/simulate.d.ts +0 -18
  102. package/dist/verify/simulate.js +0 -172
  103. package/dist/verify/verify.d.ts +0 -15
  104. package/dist/verify/verify.js +0 -181
  105. package/dist-cjs/adapters/interpreter/index.d.ts +0 -1
  106. package/dist-cjs/adapters/interpreter/index.js +0 -7
  107. package/dist-cjs/adapters/oz/adapter.d.ts +0 -20
  108. package/dist-cjs/adapters/oz/adapter.js +0 -286
  109. package/dist-cjs/adapters/oz/index.d.ts +0 -1
  110. package/dist-cjs/adapters/oz/index.js +0 -8
  111. package/dist-cjs/codegen/compile-gate.d.ts +0 -33
  112. package/dist-cjs/codegen/compile-gate.js +0 -128
  113. package/dist-cjs/codegen/index.d.ts +0 -2
  114. package/dist-cjs/codegen/index.js +0 -14
  115. package/dist-cjs/codegen/template.d.ts +0 -18
  116. package/dist-cjs/codegen/template.js +0 -151
  117. package/dist-cjs/ir/index.d.ts +0 -1
  118. package/dist-cjs/ir/index.js +0 -3
  119. package/dist-cjs/ir/types.d.ts +0 -71
  120. package/dist-cjs/ir/types.js +0 -10
  121. package/dist-cjs/mandate/index.d.ts +0 -2
  122. package/dist-cjs/mandate/index.js +0 -6
  123. package/dist-cjs/mandate/to-ir.d.ts +0 -3
  124. package/dist-cjs/mandate/to-ir.js +0 -63
  125. package/dist-cjs/mandate/types.d.ts +0 -20
  126. package/dist-cjs/mandate/types.js +0 -9
  127. package/dist-cjs/seams/index.d.ts +0 -1
  128. package/dist-cjs/seams/index.js +0 -3
  129. package/dist-cjs/seams/types.d.ts +0 -64
  130. package/dist-cjs/seams/types.js +0 -12
  131. package/dist-cjs/synth/deny-cases.d.ts +0 -28
  132. package/dist-cjs/synth/deny-cases.js +0 -217
  133. package/dist-cjs/synth/evaluate.d.ts +0 -18
  134. package/dist-cjs/synth/evaluate.js +0 -274
  135. package/dist-cjs/synth/harness.d.ts +0 -28
  136. package/dist-cjs/synth/harness.js +0 -50
  137. package/dist-cjs/synth/minimize.d.ts +0 -4
  138. package/dist-cjs/synth/minimize.js +0 -41
  139. package/dist-cjs/synth/permit-context.d.ts +0 -8
  140. package/dist-cjs/synth/permit-context.js +0 -38
  141. package/dist-cjs/synth/predicate-literals.d.ts +0 -5
  142. package/dist-cjs/synth/predicate-literals.js +0 -28
  143. package/dist-cjs/synth/synthesize-from-mandate.d.ts +0 -20
  144. package/dist-cjs/synth/synthesize-from-mandate.js +0 -62
  145. package/dist-cjs/verify/envelope.d.ts +0 -15
  146. package/dist-cjs/verify/envelope.js +0 -23
  147. package/dist-cjs/verify/index.d.ts +0 -3
  148. package/dist-cjs/verify/index.js +0 -8
  149. package/dist-cjs/verify/simulate.d.ts +0 -18
  150. package/dist-cjs/verify/simulate.js +0 -175
  151. package/dist-cjs/verify/verify.d.ts +0 -15
  152. package/dist-cjs/verify/verify.js +0 -184
@@ -0,0 +1,312 @@
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 'eq': {
204
+ const sel = selectorFromEq(node.left, node.right)
205
+ return sel === null ? [WILDCARD] : [sel]
206
+ }
207
+ case 'in': {
208
+ // Set membership over the selector halves: `call_fn in {a, b}` permits
209
+ // both. A haystack element that is not the matching literal kind makes
210
+ // the node uninformative rather than narrower.
211
+ if (node.needle.kind === 'call_contract') {
212
+ const addrs = node.haystack.map(literalAddress)
213
+ if (addrs.some((a) => a === null)) return [WILDCARD]
214
+ return dedupe((addrs as string[]).map((a) => ({ contract: a, fn: ANY })))
215
+ }
216
+ if (node.needle.kind === 'call_fn') {
217
+ const syms = node.haystack.map(literalSymbol)
218
+ if (syms.some((s) => s === null)) return [WILDCARD]
219
+ return dedupe((syms as string[]).map((s) => ({ contract: ANY, fn: s })))
220
+ }
221
+ return [WILDCARD]
222
+ }
223
+ default:
224
+ // `lte` binds an amount, never the selector.
225
+ return [WILDCARD]
226
+ }
227
+ }
228
+
229
+ /** Selectors a context type admits, before the predicate narrows them. */
230
+ export function selectorsForContextType(ct: ContextType): Selector[] {
231
+ switch (ct.kind) {
232
+ case 'default':
233
+ return [WILDCARD]
234
+ case 'call_contract':
235
+ return [{ contract: ct.contract, fn: ANY }]
236
+ case 'create_contract':
237
+ // A contract-creation context is a different `Context` shape. The
238
+ // interpreter refuses anything that is not `Context::Contract`, and a
239
+ // creation rule can never serve a call, so it shares no selector.
240
+ return []
241
+ }
242
+ }
243
+
244
+ /** What a rule can actually authorise: its context type narrowed by its
245
+ * predicate. An unpoliced or unreadable rule contributes no narrowing. */
246
+ export function effectiveSelectors(rule: ObservedRule): Selector[] {
247
+ const fromType = selectorsForContextType(rule.contextType)
248
+ if (!rule.predicate) return fromType
249
+ return intersectSelectors(fromType, permittedSelectors(rule.predicate))
250
+ }
251
+
252
+ function classifyRule(rule: ObservedRule): RuleClass {
253
+ if (rule.policyAddresses.length === 0) return 'unpoliced'
254
+ return rule.predicate ? 'interpreter' : 'foreign'
255
+ }
256
+
257
+ function adviceFor(cls: RuleClass, ruleId: number): string {
258
+ switch (cls) {
259
+ case 'unpoliced':
260
+ 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.`
261
+ case 'foreign':
262
+ 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.`
263
+ case 'interpreter':
264
+ 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.`
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Every existing rule a signer of the intended install could name instead.
270
+ *
271
+ * A rule collides when it shares at least one signer AND at least one selector.
272
+ * Both are needed for the signer to have a choice: same signer but disjoint
273
+ * calls means no call can be rerouted, and same calls but no shared signer
274
+ * means nobody can reroute them.
275
+ */
276
+ export function findAuthorityOverlaps(args: {
277
+ intended: IntendedInstall
278
+ existing: ObservedRule[]
279
+ }): AuthorityOverlap[] {
280
+ const intendedSelectors = intersectSelectors(
281
+ selectorsForContextType(args.intended.contextType),
282
+ permittedSelectors(args.intended.predicate)
283
+ )
284
+ const out: AuthorityOverlap[] = []
285
+
286
+ for (const rule of args.existing) {
287
+ if (rule.id === args.intended.ruleId) continue
288
+
289
+ const shared = sharedSigners(args.intended.signers, rule.signers)
290
+ if (shared.length === 0) continue
291
+
292
+ const sharedSelectors = intersectSelectors(intendedSelectors, effectiveSelectors(rule))
293
+ if (sharedSelectors.length === 0) continue
294
+
295
+ const ruleClass = classifyRule(rule)
296
+ out.push({
297
+ ruleId: rule.id,
298
+ ruleClass,
299
+ severity:
300
+ ruleClass === 'unpoliced'
301
+ ? 'bypass'
302
+ : ruleClass === 'foreign'
303
+ ? 'unknown'
304
+ : 'not-restricting',
305
+ sharedSigners: shared,
306
+ sharedSelectors,
307
+ advice: adviceFor(ruleClass, rule.id),
308
+ })
309
+ }
310
+
311
+ return out
312
+ }
@@ -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,
@@ -52,17 +52,23 @@ export function isBelowThreshold(c: ParseConfidence): boolean {
52
52
  * under-confidence recording.
53
53
  *
54
54
  * The remediation text branches on which diagnostic bucket is non-empty:
55
- * - unknownContracts only -> user MUST supply an ABI (or re-capture
56
- * against a known protocol version). The contract is real, the
57
- * recorder just cannot decode it.
58
- * - opaqueScVals only -> the recorder encountered a value shape it
59
- * should support but did not decode. The user cannot supply an ABI
60
- * to fix a decoder bug; the guidance explicitly says so and points at
61
- * re-running after a tool upgrade / reporting the path.
62
- * - both -> list both diagnostics AND chain the right remediation for
63
- * each (the user supplies an ABI AND reports the decoder gap).
64
- * - neither (the "denom === 0" path) -> unchanged from the pre-fix
65
- * text; the user did not actually hit a code-level barrier. */
55
+ * - unknownContracts only -> the contract is not in the compiled-in
56
+ * registry. NOT "the contract has no interface": most Soroban
57
+ * contracts publish a typed spec on chain, this package just never
58
+ * reads one. The reason code stays `no-abi` because it is a published
59
+ * enum value, so the TEXT has to carry the correction.
60
+ * - opaqueScVals only -> the recorder met a value shape it should
61
+ * support but did not decode. That is a decoder bug, and the guidance
62
+ * says so rather than implying the caller can fix it.
63
+ * - both -> list both diagnostics and chain the right remediation.
64
+ * - neither (the "denom === 0" path) -> the caller did not actually hit
65
+ * a code-level barrier.
66
+ *
67
+ * Do NOT tell the caller to supply an ABI. There is no input for one:
68
+ * recognition runs off the compiled-in registry plus a known-contract set
69
+ * that no public tool boundary exposes. The old text named an action
70
+ * nobody could take and sent users looking for a file they do not have and
71
+ * would not need. */
66
72
  export function buildLowConfidenceQuestion(c: ParseConfidence): string {
67
73
  const hasUnknown = c.unknownContracts.length > 0
68
74
  const hasOpaque = c.opaqueScVals.length > 0
@@ -75,18 +81,23 @@ export function buildLowConfidenceQuestion(c: ParseConfidence): string {
75
81
  }
76
82
  const why = reasons.length === 0 ? 'no diagnostic reason available' : reasons.join('; ')
77
83
  const header = `Recording refused: parseConfidence ${c.overall.toFixed(3)} is below the threshold ${c.thresholdUsed.toFixed(3)}. Diagnostic: ${why}.`
78
- // The two remediation branches are explicit so the user (or the agent)
79
- // can dispatch on the verb. The "unknown contract" branch is the only
80
- // one that asks for an ABI; the "opaque ScVal" branch asks the user to
81
- // report the path and re-run after a tool upgrade.
84
+ // The remediation branches are explicit so the user (or the agent) can
85
+ // dispatch on the verb. Neither branch asks for an ABI: there is no input
86
+ // that accepts one, and for the unknown-contract branch the contract's
87
+ // own spec is usually on chain already - it is this package that does not
88
+ // read it.
89
+ const unknownAdvice =
90
+ `The contract is not in this package's built-in registry - "no-abi" means unrecognised here, not that the contract lacks an interface. ` +
91
+ `You cannot supply one: the registry is compiled in. Either build the policy without deriving it from this transaction, or re-capture against a protocol the registry covers. ` +
92
+ `Passing confidenceOverride accepts the recording as it stands, including the arguments this tool could not attribute - do that only if you have checked them yourself.`
82
93
  if (hasUnknown && hasOpaque) {
83
94
  return (
84
- `${header} Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version; ` +
85
- `also file the opaque ScVal path against the recorder (decoder limitation) and re-run record_transaction after a tool upgrade.`
95
+ `${header} ${unknownAdvice} Separately, file the opaque ScVal path against the recorder (a decoder limitation) ` +
96
+ `and re-run record_transaction after a tool upgrade.`
86
97
  )
87
98
  }
88
99
  if (hasUnknown) {
89
- return `${header} Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version, then re-run record_transaction.`
100
+ return `${header} ${unknownAdvice}`
90
101
  }
91
102
  if (hasOpaque) {
92
103
  return `${header} This is a recorder decoder limitation (the value shape is supported in principle but not yet decoded) - the agent cannot fix it by supplying an ABI. Report the opaque ScVal path above against the recorder and re-run record_transaction after a tool upgrade.`
@@ -17,8 +17,13 @@
17
17
  // Returns ToolResponse<RecordedTransaction> per the canonical envelope
18
18
  // defined in src/errors.ts.
19
19
 
20
- import type { xdr } from '@stellar/stellar-sdk'
20
+ import { Networks, type xdr } from '@stellar/stellar-sdk'
21
21
  import type { ToolError, ToolResponse } from '../errors.ts'
22
+ import {
23
+ resolveContractsByOnChainSpec,
24
+ type SpecFetcher,
25
+ specFetcherFromRpc,
26
+ } from '../registry/on-chain-spec.ts'
22
27
  import type { Network, ParseConfidence, RecordedTransaction } from '../types.ts'
23
28
  import type { DecodedTransaction } from './decode.ts'
24
29
  import {
@@ -34,7 +39,7 @@ import {
34
39
  isBelowThreshold,
35
40
  } from './freshness.ts'
36
41
  import { extractTokenMovements } from './movements.ts'
37
- import { createRpcServer, type RpcFetcher } from './rpc.ts'
42
+ import { createRpcServer, PUBLIC_RPC_URLS, type RpcFetcher } from './rpc.ts'
38
43
  import { validateAgainstEvents } from './validate.ts'
39
44
 
40
45
  /** Public input shape. The brief pins:
@@ -63,10 +68,56 @@ export interface RecordInput {
63
68
  * automatically; tests can pass a deterministic stub. */
64
69
  crossNetworkFetcher?: RpcFetcher
65
70
  confidenceOverride?: number
71
+ /** Read a contract's own interface off chain when the compiled-in registry
72
+ * does not recognise it. Default ON: the registry covers the protocols we
73
+ * pinned by hand, and refusing everything else reported `no-abi` for
74
+ * contracts that publish a full typed spec. Set false to record against
75
+ * the registry alone (no extra RPC). */
76
+ resolveContractSpecs?: boolean
77
+ /** Test seam for the spec lookup. Unset in production, where it is built
78
+ * from the network's pinned RPC URL. */
79
+ specFetcher?: SpecFetcher
66
80
  }
67
81
 
68
82
  export type RecordResult = ToolResponse<RecordedTransaction>
69
83
 
84
+ /** Second pass over the contracts the compiled-in registry did not recognise.
85
+ *
86
+ * Each candidate's own interface is read off chain and every call it received
87
+ * is checked against it; the ones that verify are fed back through the decoder
88
+ * as known. Re-decoding rather than patching the first result keeps ONE code
89
+ * path computing parseConfidence - a hand-adjusted count here would be a
90
+ * second implementation of the gate, free to drift from the real one.
91
+ *
92
+ * Only ever ADDS recognition. A missing spec, an unreachable RPC or a call the
93
+ * interface does not describe all leave the recording exactly as it was. */
94
+ async function resolveByOnChainSpec(
95
+ input: RecordInput,
96
+ decoded: DecodedTransaction,
97
+ redecode: (known: ReadonlySet<string>) => DecodedTransaction
98
+ ): Promise<DecodedTransaction> {
99
+ if (input.resolveContractSpecs === false) return decoded
100
+ if (decoded.unknownContracts.length === 0) return decoded
101
+ const fetcher =
102
+ input.specFetcher ??
103
+ specFetcherFromRpc(
104
+ PUBLIC_RPC_URLS[input.network],
105
+ input.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET
106
+ )
107
+ let resolved: ReadonlySet<string>
108
+ try {
109
+ resolved = await resolveContractsByOnChainSpec(
110
+ decoded.invocations,
111
+ decoded.unknownContracts.map((u) => u.contract),
112
+ fetcher
113
+ )
114
+ } catch {
115
+ // A lookup failure must not fail the recording that already succeeded.
116
+ return decoded
117
+ }
118
+ return resolved.size === 0 ? decoded : redecode(resolved)
119
+ }
120
+
70
121
  export async function recordTransaction(input: RecordInput): Promise<RecordResult> {
71
122
  if (!input.network) {
72
123
  return err('RECORDING_FAILED', 'network required', false)
@@ -138,6 +189,9 @@ export async function recordTransaction(input: RecordInput): Promise<RecordResul
138
189
  if (e instanceof DecodeError) return err('RECORDING_FAILED', e.message, false)
139
190
  throw e
140
191
  }
192
+ decoded = await resolveByOnChainSpec(input, decoded, (known) =>
193
+ decodeEnvelope(fetched.envelopeXdr, events, [], fetched.ledger, known, input.network)
194
+ )
141
195
  return finish(input.network, decoded, input.confidenceOverride)
142
196
  }
143
197
 
@@ -157,6 +211,9 @@ export async function recordTransaction(input: RecordInput): Promise<RecordResul
157
211
  false
158
212
  )
159
213
  }
214
+ decoded = await resolveByOnChainSpec(input, decoded, (known) =>
215
+ decodeEnvelopeXdr(xdrStr, [], [], 0, known, input.network)
216
+ )
160
217
  return finish(input.network, decoded, input.confidenceOverride)
161
218
  }
162
219
 
package/src/record/rpc.ts CHANGED
@@ -23,7 +23,10 @@ export interface SorobanTxResponse {
23
23
 
24
24
  export type RpcFetcher = (hash: string) => Promise<SorobanTxResponse | null>
25
25
 
26
- const PUBLIC_RPC_URLS: Record<Network, string> = {
26
+ /** Exported so the on-chain spec lookup reads from the SAME endpoint the
27
+ * recorder fetched the transaction from. Two different endpoints could
28
+ * disagree about what a contract is. */
29
+ export const PUBLIC_RPC_URLS: Record<Network, string> = {
27
30
  testnet: 'https://soroban-testnet.stellar.org',
28
31
  // The brief pins testnet; mainnet is left to the caller via injection. We keep
29
32
  // a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
@@ -107,7 +107,10 @@ export function identifyProtocol(
107
107
  * declared type. `other` is intentionally NOT a valid ABI match - it means
108
108
  * the decoder couldn't classify the value, which is exactly the signal
109
109
  * fail-closed should refuse. */
110
- function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean {
110
+ /** Exported so the on-chain-spec path checks a call the SAME way a pinned
111
+ * protocol does. Reimplementing it there would let the two drift, and a
112
+ * looser copy would be the fail-OPEN direction. */
113
+ export function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean {
111
114
  if (expected.length !== actual.length) return false
112
115
  for (let i = 0; i < expected.length; i += 1) {
113
116
  const want = expected[i]