@crediolabs/policy-synth 0.3.1 → 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.
- package/dist/install/authority-overlap.d.ts +101 -0
- package/dist/install/authority-overlap.js +227 -0
- package/dist/install/index.d.ts +1 -0
- package/dist/install/index.js +4 -0
- package/dist/record/index.d.ts +10 -0
- package/dist/record/index.js +32 -1
- package/dist/record/rpc.d.ts +4 -0
- package/dist/record/rpc.js +4 -1
- package/dist/registry/identify.d.ts +10 -1
- package/dist/registry/identify.js +4 -1
- package/dist/registry/on-chain-spec.d.ts +37 -0
- package/dist/registry/on-chain-spec.js +152 -0
- package/dist/run/index.d.ts +25 -4
- package/dist/run/index.js +65 -4
- package/dist/run/schemas.d.ts +313 -0
- package/dist/run/schemas.js +52 -0
- package/dist/synth/declare.d.ts +30 -0
- package/dist/synth/declare.js +98 -0
- package/dist/synth/index.d.ts +1 -0
- package/dist/synth/index.js +1 -0
- package/dist-cjs/install/authority-overlap.d.ts +101 -0
- package/dist-cjs/install/authority-overlap.js +236 -0
- package/dist-cjs/install/index.d.ts +1 -0
- package/dist-cjs/install/index.js +13 -2
- package/dist-cjs/record/index.d.ts +10 -0
- package/dist-cjs/record/index.js +31 -0
- package/dist-cjs/record/rpc.d.ts +4 -0
- package/dist-cjs/record/rpc.js +7 -3
- package/dist-cjs/registry/identify.d.ts +10 -1
- package/dist-cjs/registry/identify.js +4 -0
- package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
- package/dist-cjs/registry/on-chain-spec.js +159 -0
- package/dist-cjs/run/index.d.ts +25 -4
- package/dist-cjs/run/index.js +65 -2
- package/dist-cjs/run/schemas.d.ts +313 -0
- package/dist-cjs/run/schemas.js +53 -1
- package/dist-cjs/synth/declare.d.ts +30 -0
- package/dist-cjs/synth/declare.js +101 -0
- package/dist-cjs/synth/index.d.ts +1 -0
- package/dist-cjs/synth/index.js +3 -1
- package/package.json +1 -1
- package/src/install/authority-overlap.ts +312 -0
- package/src/install/index.ts +20 -0
- package/src/record/index.ts +59 -2
- package/src/record/rpc.ts +4 -1
- package/src/registry/identify.ts +4 -1
- package/src/registry/on-chain-spec.ts +168 -0
- package/src/run/index.ts +79 -2
- package/src/run/schemas.ts +57 -0
- package/src/synth/declare.ts +157 -0
- package/src/synth/index.ts +5 -0
|
@@ -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
|
+
}
|
package/src/install/index.ts
CHANGED
|
@@ -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,
|
package/src/record/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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").
|
package/src/registry/identify.ts
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/registry/on-chain-spec.ts - read a contract's own interface off chain.
|
|
2
|
+
//
|
|
3
|
+
// The compiled-in registry covers the protocols we pinned by hand. Everything
|
|
4
|
+
// else was reported as `no-abi`, which read as "this contract does not
|
|
5
|
+
// describe itself" when it almost always does: a Soroban contract embeds a
|
|
6
|
+
// typed spec in its wasm, and the network will hand it over. The recorder was
|
|
7
|
+
// refusing calls whose interface was one RPC round-trip away.
|
|
8
|
+
//
|
|
9
|
+
// What this buys is NARROW and deliberately so. A fetched spec says what the
|
|
10
|
+
// contract's arguments ARE, not what they MEAN. So a call verified against it
|
|
11
|
+
// is recognised - the decode is trustworthy, the confidence gate stops
|
|
12
|
+
// refusing it - but the arguments carry the contract's own parameter NAMES
|
|
13
|
+
// rather than a curated meaning, and nothing here infers which argument is a
|
|
14
|
+
// spend or a recipient. A pinned protocol still outranks a fetched spec for
|
|
15
|
+
// exactly that reason, and `identifyProtocol` is consulted first.
|
|
16
|
+
|
|
17
|
+
import { contract as sdkContract } from '@stellar/stellar-sdk'
|
|
18
|
+
import type { ContractInvocation, ScVal } from '../types.ts'
|
|
19
|
+
import { argsMatchAbi } from './identify.ts'
|
|
20
|
+
import type { AbiArg, AbiArgType, ProtocolAbi } from './protocols.ts'
|
|
21
|
+
|
|
22
|
+
/** Map an XDR spec type to the ScVal subset vocabulary the matcher uses.
|
|
23
|
+
*
|
|
24
|
+
* Returns null for a type the recorder's ScVal subset cannot represent. That
|
|
25
|
+
* is deliberate: an argument we cannot type is an argument we cannot check,
|
|
26
|
+
* and claiming a match on it would be the fail-OPEN direction. A function
|
|
27
|
+
* with any such argument is dropped from the derived ABI, so a call to it
|
|
28
|
+
* stays unrecognised rather than being waved through. */
|
|
29
|
+
export function abiTypeFromSpecType(specTypeName: string): AbiArgType | null {
|
|
30
|
+
switch (specTypeName) {
|
|
31
|
+
case 'scSpecTypeAddress':
|
|
32
|
+
return 'address'
|
|
33
|
+
case 'scSpecTypeI128':
|
|
34
|
+
return 'i128'
|
|
35
|
+
case 'scSpecTypeU64':
|
|
36
|
+
case 'scSpecTypeI64':
|
|
37
|
+
return 'u64'
|
|
38
|
+
case 'scSpecTypeU32':
|
|
39
|
+
case 'scSpecTypeI32':
|
|
40
|
+
return 'u32'
|
|
41
|
+
case 'scSpecTypeSymbol':
|
|
42
|
+
return 'symbol'
|
|
43
|
+
case 'scSpecTypeVec':
|
|
44
|
+
return 'vec'
|
|
45
|
+
case 'scSpecTypeBytes':
|
|
46
|
+
case 'scSpecTypeBytesN':
|
|
47
|
+
return 'bytes'
|
|
48
|
+
case 'scSpecTypeMap':
|
|
49
|
+
return 'map'
|
|
50
|
+
default:
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The RPC surface this module needs. Narrowed to one method so a test can
|
|
56
|
+
* supply a stub without standing up a server, and so the recorder's existing
|
|
57
|
+
* RPC client can be passed straight in. */
|
|
58
|
+
export interface SpecFetcher {
|
|
59
|
+
contractSpec(contractId: string): Promise<sdkContract.Spec | null>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build a `SpecFetcher` over the SDK's contract client. */
|
|
63
|
+
export function specFetcherFromRpc(rpcUrl: string, networkPassphrase: string): SpecFetcher {
|
|
64
|
+
return {
|
|
65
|
+
async contractSpec(contractId: string): Promise<sdkContract.Spec | null> {
|
|
66
|
+
try {
|
|
67
|
+
const client = await sdkContract.Client.from({ contractId, networkPassphrase, rpcUrl })
|
|
68
|
+
return (client as unknown as { spec: sdkContract.Spec }).spec ?? null
|
|
69
|
+
} catch {
|
|
70
|
+
// A contract with no spec, an unreachable RPC and a bad address all
|
|
71
|
+
// land here and all mean the same thing to the caller: no interface
|
|
72
|
+
// was obtained, so the contract stays unrecognised. Fail closed.
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Convert a fetched spec into the ABI shape `identifyProtocol` already
|
|
80
|
+
* matches against. Functions with an argument outside the ScVal subset are
|
|
81
|
+
* OMITTED rather than partially typed. */
|
|
82
|
+
export function abiFromSpec(spec: sdkContract.Spec): ProtocolAbi {
|
|
83
|
+
const abi: ProtocolAbi = {}
|
|
84
|
+
for (const fn of spec.funcs()) {
|
|
85
|
+
const name = fn.name().toString()
|
|
86
|
+
// The constructor is not callable after deployment, so a recorded
|
|
87
|
+
// invocation can never be one. Keeping it would only widen the surface.
|
|
88
|
+
if (name === '__constructor') continue
|
|
89
|
+
const args: AbiArg[] = []
|
|
90
|
+
let usable = true
|
|
91
|
+
for (const input of fn.inputs()) {
|
|
92
|
+
const type = abiTypeFromSpecType(input.type().switch().name)
|
|
93
|
+
if (type === null) {
|
|
94
|
+
usable = false
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
const argName = input.name().toString()
|
|
98
|
+
args.push({
|
|
99
|
+
name: argName,
|
|
100
|
+
type,
|
|
101
|
+
// The contract's own parameter name is the honest description. It is
|
|
102
|
+
// NOT a curated meaning - nothing here knows whether `value` is a
|
|
103
|
+
// spend - so downstream must not treat it as one.
|
|
104
|
+
meaning: `${argName} (from the contract's on-chain interface)`,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
if (usable) abi[name] = { args }
|
|
108
|
+
}
|
|
109
|
+
return abi
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Fetch and convert in one step. Returns null when no usable interface was
|
|
113
|
+
* obtained, which the caller must treat as "unrecognised". */
|
|
114
|
+
export async function fetchContractAbi(
|
|
115
|
+
contractId: string,
|
|
116
|
+
fetcher: SpecFetcher
|
|
117
|
+
): Promise<ProtocolAbi | null> {
|
|
118
|
+
const spec = await fetcher.contractSpec(contractId)
|
|
119
|
+
if (spec === null) return null
|
|
120
|
+
const abi = abiFromSpec(spec)
|
|
121
|
+
return Object.keys(abi).length === 0 ? null : abi
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Every contract invoked anywhere in the tree, with the calls made on it. */
|
|
125
|
+
function callsByContract(
|
|
126
|
+
invocations: ReadonlyArray<ContractInvocation>
|
|
127
|
+
): Map<string, Array<{ fn: string; args: ScVal[] }>> {
|
|
128
|
+
const out = new Map<string, Array<{ fn: string; args: ScVal[] }>>()
|
|
129
|
+
const walk = (inv: ContractInvocation): void => {
|
|
130
|
+
const list = out.get(inv.contract) ?? []
|
|
131
|
+
list.push({ fn: inv.fn, args: inv.args })
|
|
132
|
+
out.set(inv.contract, list)
|
|
133
|
+
for (const sub of inv.subInvocations) walk(sub)
|
|
134
|
+
}
|
|
135
|
+
for (const inv of invocations) walk(inv)
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Contracts whose EVERY recorded call matches their own published interface.
|
|
140
|
+
*
|
|
141
|
+
* All-or-nothing per contract, deliberately. A contract where one call
|
|
142
|
+
* verifies and another does not is a contract we do not understand, and
|
|
143
|
+
* marking it recognised would raise confidence on the strength of the call
|
|
144
|
+
* we happened to check. The unverified call is the one that matters.
|
|
145
|
+
*
|
|
146
|
+
* A contract with no fetchable spec, or a call naming a function absent from
|
|
147
|
+
* it, simply stays unknown - this only ever ADDS recognition, so a failure
|
|
148
|
+
* here degrades to today's behaviour rather than to a wrong answer. */
|
|
149
|
+
export async function resolveContractsByOnChainSpec(
|
|
150
|
+
invocations: ReadonlyArray<ContractInvocation>,
|
|
151
|
+
candidates: ReadonlyArray<string>,
|
|
152
|
+
fetcher: SpecFetcher
|
|
153
|
+
): Promise<Set<string>> {
|
|
154
|
+
const resolved = new Set<string>()
|
|
155
|
+
const calls = callsByContract(invocations)
|
|
156
|
+
for (const contract of new Set(candidates)) {
|
|
157
|
+
const made = calls.get(contract)
|
|
158
|
+
if (!made || made.length === 0) continue
|
|
159
|
+
const abi = await fetchContractAbi(contract, fetcher)
|
|
160
|
+
if (abi === null) continue
|
|
161
|
+
const everyCallVerifies = made.every((c) => {
|
|
162
|
+
const entry = abi[c.fn]
|
|
163
|
+
return entry !== undefined && argsMatchAbi(entry.args, c.args)
|
|
164
|
+
})
|
|
165
|
+
if (everyCallVerifies) resolved.add(contract)
|
|
166
|
+
}
|
|
167
|
+
return resolved
|
|
168
|
+
}
|