@crediolabs/policy-synth 0.1.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 (85) hide show
  1. package/dist/adapters/oz/adapter.d.ts +20 -0
  2. package/dist/adapters/oz/adapter.js +282 -0
  3. package/dist/adapters/oz/index.d.ts +1 -0
  4. package/dist/adapters/oz/index.js +2 -0
  5. package/dist/errors.d.ts +37 -0
  6. package/dist/errors.js +2 -0
  7. package/dist/index.d.ts +9 -0
  8. package/dist/index.js +9 -0
  9. package/dist/ir/index.d.ts +1 -0
  10. package/dist/ir/index.js +2 -0
  11. package/dist/ir/types.d.ts +97 -0
  12. package/dist/ir/types.js +11 -0
  13. package/dist/mandate/index.d.ts +2 -0
  14. package/dist/mandate/index.js +2 -0
  15. package/dist/mandate/to-ir.d.ts +3 -0
  16. package/dist/mandate/to-ir.js +60 -0
  17. package/dist/mandate/types.d.ts +20 -0
  18. package/dist/mandate/types.js +8 -0
  19. package/dist/record/decode.d.ts +76 -0
  20. package/dist/record/decode.js +372 -0
  21. package/dist/record/freshness.d.ts +17 -0
  22. package/dist/record/freshness.js +50 -0
  23. package/dist/record/index.d.ts +21 -0
  24. package/dist/record/index.js +163 -0
  25. package/dist/record/movements.d.ts +20 -0
  26. package/dist/record/movements.js +187 -0
  27. package/dist/record/rpc.d.ts +22 -0
  28. package/dist/record/rpc.js +70 -0
  29. package/dist/record/validate.d.ts +22 -0
  30. package/dist/record/validate.js +60 -0
  31. package/dist/registry/identify.d.ts +11 -0
  32. package/dist/registry/identify.js +84 -0
  33. package/dist/registry/index.d.ts +3 -0
  34. package/dist/registry/index.js +4 -0
  35. package/dist/registry/known-addresses.d.ts +16 -0
  36. package/dist/registry/known-addresses.js +49 -0
  37. package/dist/registry/protocols.d.ts +38 -0
  38. package/dist/registry/protocols.js +149 -0
  39. package/dist/seams/index.d.ts +1 -0
  40. package/dist/seams/index.js +2 -0
  41. package/dist/seams/types.d.ts +66 -0
  42. package/dist/seams/types.js +11 -0
  43. package/dist/synth/compose-from-recording.d.ts +36 -0
  44. package/dist/synth/compose-from-recording.js +162 -0
  45. package/dist/synth/index.d.ts +5 -0
  46. package/dist/synth/index.js +6 -0
  47. package/dist/synth/lower.d.ts +23 -0
  48. package/dist/synth/lower.js +116 -0
  49. package/dist/synth/scope.d.ts +26 -0
  50. package/dist/synth/scope.js +77 -0
  51. package/dist/synth/synthesize-from-mandate.d.ts +5 -0
  52. package/dist/synth/synthesize-from-mandate.js +34 -0
  53. package/dist/synth/synthesize-from-recording.d.ts +17 -0
  54. package/dist/synth/synthesize-from-recording.js +178 -0
  55. package/dist/types.d.ts +249 -0
  56. package/dist/types.js +35 -0
  57. package/package.json +37 -0
  58. package/src/adapters/oz/adapter.ts +363 -0
  59. package/src/adapters/oz/index.ts +9 -0
  60. package/src/errors.ts +79 -0
  61. package/src/index.ts +9 -0
  62. package/src/ir/index.ts +13 -0
  63. package/src/ir/types.ts +94 -0
  64. package/src/mandate/index.ts +4 -0
  65. package/src/mandate/to-ir.ts +71 -0
  66. package/src/mandate/types.ts +21 -0
  67. package/src/record/decode.ts +500 -0
  68. package/src/record/freshness.ts +63 -0
  69. package/src/record/index.ts +224 -0
  70. package/src/record/movements.ts +188 -0
  71. package/src/record/rpc.ts +88 -0
  72. package/src/record/validate.ts +75 -0
  73. package/src/registry/identify.ts +99 -0
  74. package/src/registry/index.ts +24 -0
  75. package/src/registry/known-addresses.ts +71 -0
  76. package/src/registry/protocols.ts +176 -0
  77. package/src/seams/index.ts +11 -0
  78. package/src/seams/types.ts +81 -0
  79. package/src/synth/compose-from-recording.ts +226 -0
  80. package/src/synth/index.ts +19 -0
  81. package/src/synth/lower.ts +144 -0
  82. package/src/synth/scope.ts +108 -0
  83. package/src/synth/synthesize-from-mandate.ts +46 -0
  84. package/src/synth/synthesize-from-recording.ts +226 -0
  85. package/src/types.ts +218 -0
@@ -0,0 +1,500 @@
1
+ // src/record/decode.ts - pure XDR -> invocation tree decoder.
2
+ //
3
+ // Inputs: a base64-encoded TransactionEnvelope XDR (or one already decoded),
4
+ // the raw transaction events (used only to identify contract emitters), and
5
+ // (optionally) the auth-entry set.
6
+ //
7
+ // Outputs:
8
+ // - top-level ContractInvocation (the single Context `Policy::enforce` receives)
9
+ // - subInvocations captured on each ContractInvocation from
10
+ // SorobanAuthorizedInvocation.subInvocations - DIAGNOSTIC ONLY;
11
+ // v1 grammar does NOT walk sub-invocations (see types.ts notes).
12
+ // - decoded args vector mapped to the normalised `ScVal` subset
13
+ // - sourceAccount, signers
14
+ // - raw event list surfaced for downstream validation
15
+ // - auth entries surfaced for downstream validation
16
+ //
17
+ // No network calls. No randomness. No globals.
18
+
19
+ import { Address, StrKey, xdr } from '@stellar/stellar-sdk'
20
+ import { identifyProtocol } from '../registry/identify.ts'
21
+ import type {
22
+ ContractInvocation,
23
+ Network,
24
+ OnChainEvent,
25
+ ParseConfidence,
26
+ ScVal,
27
+ TokenMovement,
28
+ } from '../types.ts'
29
+
30
+ /** Result of decoding an envelope XDR plus the surrounding metadata from
31
+ * `getTransaction` (events + auth entries). All fields are plain JSON; the
32
+ * caller decides how to interpret them. */
33
+ export interface SerializableAuthEntry {
34
+ authorizingAddress: string | null
35
+ contract: string
36
+ fn: string
37
+ }
38
+
39
+ export interface DecodedTransaction {
40
+ sourceAccount: string
41
+ /** Authorising signers from the envelope signatures (hex-encoded ed25519
42
+ * pubkey hints expanded to G... strkeys). The recorder cannot recover the
43
+ * strkey from the 4-byte hint alone; we record the hint bytes hex instead
44
+ * when no full strkey is available. */
45
+ signers: string[]
46
+ invocations: ContractInvocation[]
47
+ /** Raw on-chain events (from getTransaction.events). Used by validate.ts to
48
+ * cross-check the parsed TokenMovement[] and ContractInvocation[]. */
49
+ events: OnChainEvent[]
50
+ /** Plain JSON projection of InvokeHostFunctionOp.auth(). */
51
+ authEntries: SerializableAuthEntry[]
52
+ /** Records that include at least one `ContractInvocation` whose decoded args
53
+ * vector or whose auth-tree contained an opaque ScVal. Empty list = full
54
+ * parseConfidence = 1.0 (modulo unknown contracts). */
55
+ opaqueScVals: ParseConfidence['opaqueScVals']
56
+ /** Contracts referenced in invocations whose (fn, args) did not match any
57
+ * known protocol ABI. Each unrecognised invocation is recorded separately
58
+ * so the freshness gate stays fail-closed regardless of operation order. */
59
+ unknownContracts: ParseConfidence['unknownContracts']
60
+ knownContracts: string[]
61
+ ledgerSequence: number
62
+ }
63
+
64
+ /** Decode a TransactionEnvelope XDR (base64) into a fully parsed invocation tree. */
65
+ export function decodeEnvelopeXdr(
66
+ envelopeXdrB64: string,
67
+ events: OnChainEvent[] = [],
68
+ _authEntries: unknown[] = [],
69
+ ledgerSequence = 0,
70
+ knownContracts: ReadonlySet<string> = new Set(),
71
+ network: Network | null = null
72
+ ): DecodedTransaction {
73
+ const envelope = xdr.TransactionEnvelope.fromXDR(envelopeXdrB64, 'base64')
74
+ return decodeEnvelope(envelope, events, _authEntries, ledgerSequence, knownContracts, network)
75
+ }
76
+
77
+ /** Same as `decodeEnvelopeXdr` but accepts an already-decoded envelope. Useful
78
+ * for tests that build the envelope via SDK helpers (no XDR round-trip). */
79
+ export function decodeEnvelope(
80
+ envelope: xdr.TransactionEnvelope,
81
+ events: OnChainEvent[] = [],
82
+ _authEntries: unknown[] = [],
83
+ ledgerSequence = 0,
84
+ knownContracts: ReadonlySet<string> = new Set(),
85
+ network: Network | null = null
86
+ ): DecodedTransaction {
87
+ const envType = envelope.switch().name
88
+ if (envType === 'envelopeTypeTxFeeBump') {
89
+ // Fee-bump wraps a normal inner v1 transaction (a different account
90
+ // pays the fee). The real operations + their authorizers live on the
91
+ // INNER v1 envelope, so decode that and discard the outer fee-bump
92
+ // shell. The fee-bump envelope switch arm `v0` is intentionally not
93
+ // routed here - fee-bump v0 does not exist.
94
+ const innerV1 = envelope.feeBump().tx().innerTx().v1()
95
+ return decodeV1Envelope(innerV1, events, ledgerSequence, knownContracts, network)
96
+ }
97
+ if (envType !== 'envelopeTypeTx') {
98
+ // TransactionV0 is out of scope - v1 protocol only. Fee-bump is now
99
+ // handled above; legacy envelopes reach this branch.
100
+ throw new DecodeError(`unsupported envelope: ${envType}`)
101
+ }
102
+ return decodeV1Envelope(envelope.v1(), events, ledgerSequence, knownContracts, network)
103
+ }
104
+
105
+ /** Decode a v1 TransactionEnvelope (either a top-level v1 envelope or the
106
+ * inner v1 envelope of a fee-bump wrapper). Source, operations, auth
107
+ * entries, signers all come from this v1 envelope. */
108
+ function decodeV1Envelope(
109
+ v1: xdr.TransactionV1Envelope,
110
+ events: OnChainEvent[],
111
+ ledgerSequence: number,
112
+ knownContracts: ReadonlySet<string>,
113
+ network: Network | null
114
+ ): DecodedTransaction {
115
+ const tx = v1.tx()
116
+ const sourceAccount = decodeMuxedOrEd25519(tx.sourceAccount())
117
+
118
+ const signers = extractSigners(v1, tx)
119
+
120
+ const invocations: ContractInvocation[] = []
121
+ const projectedAuthEntries: SerializableAuthEntry[] = []
122
+ const opaqueScVals: ParseConfidence['opaqueScVals'] = []
123
+ const knownContractSet = new Set<string>()
124
+ const unknownContracts: ParseConfidence['unknownContracts'] = []
125
+ const knownContractsOut: string[] = []
126
+
127
+ for (const op of tx.operations()) {
128
+ const body = op.body()
129
+ if (body.switch().name !== 'invokeHostFunction') continue
130
+ const invokeOp = body.invokeHostFunctionOp()
131
+ const auth = invokeOp.auth()
132
+ projectedAuthEntries.push(...auth.map(projectAuthEntry))
133
+ const hostFn = invokeOp.hostFunction()
134
+ if (hostFn.switch().name !== 'hostFunctionTypeInvokeContract') continue
135
+ const invokeArgs = hostFn.invokeContract()
136
+ const contract = decodeScAddressToC(invokeArgs.contractAddress())
137
+ const fn = symbolValueFromBufferOrString(invokeArgs.functionName())
138
+ const argsScval = invokeArgs.args()
139
+ const args: ScVal[] = []
140
+ argsScval.forEach((a: xdr.ScVal, i: number) => {
141
+ args.push(scValToSubset(a, `args[${i}]`, opaqueScVals))
142
+ })
143
+
144
+ const subInvocations: ContractInvocation[] = decodeSubInvocations(auth, opaqueScVals)
145
+
146
+ invocations.push({ contract, fn, args, subInvocations })
147
+
148
+ recordInvocation(
149
+ contract,
150
+ fn,
151
+ args,
152
+ network,
153
+ knownContracts,
154
+ knownContractSet,
155
+ knownContractsOut,
156
+ unknownContracts
157
+ )
158
+ }
159
+
160
+ return {
161
+ sourceAccount,
162
+ signers,
163
+ invocations,
164
+ events,
165
+ authEntries: projectedAuthEntries,
166
+ opaqueScVals,
167
+ unknownContracts,
168
+ knownContracts: knownContractsOut,
169
+ ledgerSequence,
170
+ }
171
+ }
172
+
173
+ /** Extract the union of signers from:
174
+ * - envelope-level signatures (the tx-level signers), and
175
+ * - Soroban auth entries that are `SorobanCredentials.sorobanCredentialsAddress`
176
+ * (the contract-Caller authorised via ed25519/address credentials).
177
+ * Returns G... strkeys (or address strkeys for non-account credentials). */
178
+ function extractSigners(v1: xdr.TransactionV1Envelope, tx: xdr.Transaction): string[] {
179
+ const set = new Set<string>()
180
+ for (const sig of v1.signatures()) {
181
+ // Each signature has a 4-byte hint. We cannot recover the full strkey from
182
+ // a 4-byte hint alone - so we record it as `hint:<hex>` to preserve signal
183
+ // for the downstream reviewer without claiming a match we cannot prove.
184
+ const hintBytes = sig.hint()
185
+ set.add(`hint:${Buffer.from(hintBytes).toString('hex')}`)
186
+ }
187
+ // Soroban address credentials - recover the strkey address.
188
+ for (const op of tx.operations()) {
189
+ const body = op.body()
190
+ if (body.switch().name !== 'invokeHostFunction') continue
191
+ const invokeOp = body.invokeHostFunctionOp()
192
+ for (const entry of invokeOp.auth()) {
193
+ const creds = entry.credentials()
194
+ const kind = creds.switch().name
195
+ if (kind === 'sorobanCredentialsAddress') {
196
+ const addrCreds = creds.address()
197
+ const scAddr = addrCreds.address()
198
+ try {
199
+ const strkey = decodeScAddressToAnyStrkey(scAddr)
200
+ if (strkey) set.add(strkey)
201
+ } catch {
202
+ // ignore - we record it as opaque via freshness
203
+ }
204
+ }
205
+ // sorobanCredentialsSourceAccount -> pulled from tx.sourceAccount() above
206
+ }
207
+ }
208
+ return Array.from(set)
209
+ }
210
+
211
+ const MAX_AUTH_TREE_DEPTH = 16
212
+
213
+ function projectAuthEntry(entry: xdr.SorobanAuthorizationEntry): SerializableAuthEntry {
214
+ const credentials = entry.credentials()
215
+ const authorizingAddress =
216
+ credentials.switch().name === 'sorobanCredentialsAddress'
217
+ ? decodeScAddressToAnyStrkey(credentials.address().address())
218
+ : null
219
+ const fn = entry.rootInvocation().function()
220
+ if (fn.switch().name === 'sorobanAuthorizedFunctionTypeContractFn') {
221
+ const contractFn = fn.contractFn()
222
+ return {
223
+ authorizingAddress,
224
+ contract: decodeScAddressToC(contractFn.contractAddress()),
225
+ fn: symbolValueFromBufferOrString(contractFn.functionName()),
226
+ }
227
+ }
228
+ return { authorizingAddress, contract: '', fn: '__create_contract__' }
229
+ }
230
+
231
+ function decodeSubInvocations(
232
+ authEntries: xdr.SorobanAuthorizationEntry[],
233
+ opaqueScVals: ParseConfidence['opaqueScVals']
234
+ ): ContractInvocation[] {
235
+ return authEntries.flatMap((entry, index) => {
236
+ const decoded = decodeAuthorizedInvocation(
237
+ entry.rootInvocation(),
238
+ opaqueScVals,
239
+ `auth[${index}].root`,
240
+ 0
241
+ )
242
+ return decoded ? [decoded] : []
243
+ })
244
+ }
245
+
246
+ function decodeAuthorizedInvocation(
247
+ inv: xdr.SorobanAuthorizedInvocation,
248
+ opaqueScVals: ParseConfidence['opaqueScVals'],
249
+ path: string,
250
+ depth: number
251
+ ): ContractInvocation | null {
252
+ if (depth >= MAX_AUTH_TREE_DEPTH) {
253
+ opaqueScVals.push({ path, type: 'auth-tree-depth-exceeded' })
254
+ return null
255
+ }
256
+ const fn = inv.function()
257
+ if (fn.switch().name !== 'sorobanAuthorizedFunctionTypeContractFn') return null
258
+
259
+ const contractFn = fn.contractFn()
260
+ const contract = decodeScAddressToC(contractFn.contractAddress())
261
+ const fnName = symbolValueFromBufferOrString(contractFn.functionName())
262
+ const args = contractFn
263
+ .args()
264
+ .map((arg: xdr.ScVal, index: number) =>
265
+ scValToSubset(arg, `${path}.args[${index}]`, opaqueScVals)
266
+ )
267
+ const subInvocations = inv.subInvocations().flatMap((child, index) => {
268
+ const decoded = decodeAuthorizedInvocation(
269
+ child,
270
+ opaqueScVals,
271
+ `${path}.subInvocations[${index}]`,
272
+ depth + 1
273
+ )
274
+ return decoded ? [decoded] : []
275
+ })
276
+ return { contract, fn: fnName, args, subInvocations }
277
+ }
278
+
279
+ /** Decode an ScAddress (either account or contract) to its strkey form. */
280
+ export function decodeScAddressToAnyStrkey(scAddr: xdr.ScAddress): string | null {
281
+ const kind = scAddr.switch().name
282
+ if (kind === 'scAddressTypeAccount') {
283
+ return decodePublicKeyToStrkey(scAddr.accountId())
284
+ }
285
+ if (kind === 'scAddressTypeContract') {
286
+ const hashBytes = scAddr.contractId() as unknown as Uint8Array
287
+ return Address.contract(Buffer.from(hashBytes)).toString()
288
+ }
289
+ return null
290
+ }
291
+
292
+ /** Decode an ScAddress to a C... contract strkey. */
293
+ export function decodeScAddressToC(scAddr: xdr.ScAddress): string {
294
+ if (scAddr.switch().name === 'scAddressTypeContract') {
295
+ const hashBytes = scAddr.contractId() as unknown as Uint8Array
296
+ return Address.contract(Buffer.from(hashBytes)).toString()
297
+ }
298
+ if (scAddr.switch().name === 'scAddressTypeAccount') {
299
+ const strkey = decodePublicKeyToStrkey(scAddr.accountId())
300
+ if (strkey) return strkey
301
+ }
302
+ throw new DecodeError(`cannot convert ScAddress ${scAddr.switch().name} to contract strkey`)
303
+ }
304
+
305
+ /** Decode a tx source account (MuxedEd25519Account OR AccountId OR PublicKey).
306
+ * - muxedAccount -> underlying ed25519 account as G...
307
+ * - publicKeyTypeEd25519 -> G...
308
+ */
309
+ function decodeMuxedOrEd25519(src: xdr.MuxedAccount): string {
310
+ const kind = src.switch().name
311
+ if (kind === 'keyTypeMuxedEd25519') {
312
+ const ed = src.med25519().ed25519()
313
+ return StrKey.encodeEd25519PublicKey(ed)
314
+ }
315
+ const ed = src.ed25519()
316
+ return StrKey.encodeEd25519PublicKey(ed)
317
+ }
318
+
319
+ /** AccountId is a type alias for PublicKey in this SDK; extract the ed25519
320
+ * strkey from a PublicKey union. */
321
+ function decodePublicKeyToStrkey(pubKey: xdr.PublicKey): string | null {
322
+ if (pubKey.switch().name === 'publicKeyTypeEd25519') {
323
+ return StrKey.encodeEd25519PublicKey(pubKey.ed25519())
324
+ }
325
+ return null
326
+ }
327
+
328
+ /** `functionName()` returns `string | Buffer` per the SDK type definition; we
329
+ * accept both and normalise. */
330
+ function symbolValueFromBufferOrString(s: string | Buffer): string {
331
+ return Buffer.isBuffer(s) ? s.toString('utf8') : s
332
+ }
333
+
334
+ /** Map an arbitrary ScVal into the normalised `ScVal` subset defined in
335
+ * types.ts. Anything outside the subset is wrapped as `{type:'other', value}`
336
+ * AND recorded in `opaqueScVals` so the freshness module can reduce
337
+ * parseConfidence. */
338
+ export function scValToSubset(
339
+ val: xdr.ScVal,
340
+ path: string,
341
+ opaqueScVals: ParseConfidence['opaqueScVals']
342
+ ): ScVal {
343
+ const kind = val.switch().name
344
+ switch (kind) {
345
+ case 'scvAddress': {
346
+ const strkey = decodeScAddressToAnyStrkey(val.address())
347
+ if (strkey === null) {
348
+ opaqueScVals.push({ path, type: kind })
349
+ return { type: 'other', value: kind }
350
+ }
351
+ return { type: 'address', value: strkey }
352
+ }
353
+ case 'scvI128': {
354
+ const parts = val.i128()
355
+ return { type: 'i128', value: i128PartsToBigInt(parts).toString() }
356
+ }
357
+ case 'scvU64': {
358
+ return { type: 'u64', value: u64PartsToBigInt(val.u64()).toString() }
359
+ }
360
+ case 'scvU32': {
361
+ return { type: 'u32', value: val.u32().toString() }
362
+ }
363
+ case 'scvSymbol': {
364
+ return { type: 'symbol', value: val.sym().toString() }
365
+ }
366
+ case 'scvVec': {
367
+ const arr = (val.vec() ?? []) as xdr.ScVal[]
368
+ return {
369
+ type: 'vec',
370
+ value: arr.map((v: xdr.ScVal, i: number) =>
371
+ scValToSubset(v, `${path}[${i}]`, opaqueScVals)
372
+ ),
373
+ }
374
+ }
375
+ case 'scvBytes': {
376
+ return { type: 'bytes', value: Buffer.from(val.bytes() as Uint8Array).toString('hex') }
377
+ }
378
+ default: {
379
+ opaqueScVals.push({ path, type: kind })
380
+ return { type: 'other', value: kind }
381
+ }
382
+ }
383
+ }
384
+
385
+ /** Convert Int128Parts (hi: Int64, lo: Uint64) to a BigInt.
386
+ * hi is a signed 64-bit value; lo is unsigned. We use the SDK-provided
387
+ * `toString()` form which gives the signed decimal representation of the 64-bit
388
+ * Int64 directly. */
389
+ export function i128PartsToBigInt(parts: xdr.Int128Parts): bigint {
390
+ const hiStr = parts.hi().toString()
391
+ const loStr = parts.lo().toString()
392
+ const hi = BigInt(hiStr)
393
+ const lo = BigInt(loStr)
394
+ return (hi << 64n) + lo
395
+ }
396
+
397
+ /** Convert UInt128Parts (hi: Uint64, lo: Uint64) to an unsigned BigInt. */
398
+ export function u128PartsToBigInt(parts: xdr.UInt128Parts): bigint {
399
+ const hi = BigInt(parts.hi().toString())
400
+ const lo = BigInt(parts.lo().toString())
401
+ return (hi << 64n) + lo
402
+ }
403
+
404
+ export function u64PartsToBigInt(h: xdr.UnsignedHyper | xdr.Hyper): bigint {
405
+ return BigInt(h.toString())
406
+ }
407
+
408
+ function recordInvocation(
409
+ contract: string,
410
+ fn: string,
411
+ args: ScVal[],
412
+ network: Network | null,
413
+ knownSet: ReadonlySet<string>,
414
+ knownContractSet: Set<string>,
415
+ knownOut: string[],
416
+ unknownOut: ParseConfidence['unknownContracts']
417
+ ): void {
418
+ // Recognition is evaluated PER INVOCATION using the (contract, fn, args)
419
+ // triple. The first call to a contract is no more authoritative than the
420
+ // second: an unrecognized call always contributes to the unknown count
421
+ // regardless of whether another invocation to the same contract was
422
+ // recognised. knownOut is deduplicated by contract (so the
423
+ // parseConfidence denominator isn't inflated by repeat calls), but each
424
+ // unrecognised invocation is recorded separately so the numerator
425
+ // reflects every fail-closed hit.
426
+ if (knownSet.has(contract)) {
427
+ if (!knownContractSet.has(contract)) {
428
+ knownContractSet.add(contract)
429
+ knownOut.push(contract)
430
+ }
431
+ return
432
+ }
433
+ if (identifyProtocol(contract, fn, args, network ?? undefined)) {
434
+ if (!knownContractSet.has(contract)) {
435
+ knownContractSet.add(contract)
436
+ knownOut.push(contract)
437
+ }
438
+ return
439
+ }
440
+ unknownOut.push({ contract, reason: 'no-abi' })
441
+ }
442
+
443
+ /** Convert a ScVal to a friendly string for use in OnChainEvent.topics
444
+ * (always-string scalar representation; vectors/bytes are stringified as JSON
445
+ * to keep the field string-typed). */
446
+ export function scValToTopicString(val: xdr.ScVal): string {
447
+ const kind = val.switch().name
448
+ if (kind === 'scvSymbol') return val.sym().toString()
449
+ if (kind === 'scvString') return val.str().toString()
450
+ if (kind === 'scvAddress') {
451
+ const strkey = decodeScAddressToAnyStrkey(val.address())
452
+ return strkey ?? `<unparsed-address:${kind}>`
453
+ }
454
+ if (kind === 'scvU64') return u64PartsToBigInt(val.u64()).toString()
455
+ if (kind === 'scvU32') return val.u32().toString()
456
+ if (kind === 'scvI128') return i128PartsToBigInt(val.i128()).toString()
457
+ if (kind === 'scvBytes') return Buffer.from(val.bytes() as Uint8Array).toString('hex')
458
+ return `<${kind}>`
459
+ }
460
+
461
+ /** Build the OnChainEvent[] view from raw contract events. Diagnostic-only:
462
+ * captures the topics + data + emitter contract for the downstream
463
+ * validator. */
464
+ export function contractEventsToOnChainEvents(
465
+ contractEvents: xdr.ContractEvent[][]
466
+ ): OnChainEvent[] {
467
+ const out: OnChainEvent[] = []
468
+ for (const group of contractEvents) {
469
+ for (const evt of group) {
470
+ const body = evt.body().v0()
471
+ const emitter = evt.contractId()
472
+ ? Address.contract(Buffer.from(evt.contractId() as unknown as Uint8Array)).toString()
473
+ : ''
474
+ const topics = body.topics().map((t: xdr.ScVal) => scValToTopicString(t))
475
+ const data = scValToSubset(body.data(), 'event.data', [])
476
+ out.push({ contract: emitter, topics, data })
477
+ }
478
+ }
479
+ return out
480
+ }
481
+
482
+ /** Build the OnChainEvent[] view from TransactionEvent[] (which wraps
483
+ * ContractEvent with a stage). Same shape as above. */
484
+ export function transactionEventsToOnChainEvents(txEvents: xdr.TransactionEvent[]): OnChainEvent[] {
485
+ const contractEvents = txEvents.map((e) => [e.event()])
486
+ return contractEventsToOnChainEvents(contractEvents)
487
+ }
488
+
489
+ /** Tiny typed error so callers can `instanceof DecodeError` without leaking
490
+ * SDK internals. */
491
+ export class DecodeError extends Error {
492
+ override readonly name = 'DecodeError'
493
+ }
494
+
495
+ /** Helper used by validate.ts to map a TokenMovement back to a search key
496
+ * without re-parsing (the validate module must cross-check against the raw
497
+ * events, NOT against the parse). */
498
+ export function tokenMovementKey(m: TokenMovement): string {
499
+ return `${m.token}|${m.from}|${m.to}|${m.amount}`
500
+ }
@@ -0,0 +1,63 @@
1
+ // src/record/freshness.ts - compute parseConfidence for a DecodedTransaction.
2
+ //
3
+ // The pinned rule (see ParseConfidence in src/types.ts):
4
+ // overall = 1 - (unknownContracts + opaqueScVals) / total
5
+ // where total = unique contracts referenced + opaque ScVal count.
6
+ // In v1 every contract is "unknown" by default (no adjacency ABI hint is
7
+ // bundled with the recorder); the freshness module still computes the value
8
+ // honestly so callers can see why the gate fires.
9
+ //
10
+ // thresholdUsed defaults to 1.0 - a stricter gate. Callers can opt to lower
11
+ // it via confidence_override but v1 ships only the default.
12
+
13
+ import type { ParseConfidence } from '../types.ts'
14
+
15
+ /** Inputs to the freshness computation. The decode module produces the raw
16
+ * counters; this module applies the rule. */
17
+ export interface FreshnessInput {
18
+ knownContracts: string[]
19
+ unknownContracts: ParseConfidence['unknownContracts']
20
+ opaqueScVals: ParseConfidence['opaqueScVals']
21
+ }
22
+
23
+ /** Compute parseConfidence per the pinned rule. Pure: no IO. */
24
+ export function computeParseConfidence(input: FreshnessInput): ParseConfidence {
25
+ const unknown = input.unknownContracts.length
26
+ const opaque = input.opaqueScVals.length
27
+ const known = input.knownContracts.length
28
+ // Pinned rule: overall = 1 - (unknown + opaque) / (known + unknown + opaque).
29
+ // Only the empty-decode case (nothing referenced) is guarded to avoid 0/0; it
30
+ // means "nothing to decode", so full confidence. A fully-unknown tx therefore
31
+ // scores 0, not an inflated value.
32
+ const denom = known + unknown + opaque
33
+ const total = denom === 0 ? 1 : denom
34
+ const overall = Math.max(0, Math.min(1, 1 - (unknown + opaque) / total))
35
+ return {
36
+ overall,
37
+ knownContracts: [...input.knownContracts],
38
+ unknownContracts: [...input.unknownContracts],
39
+ opaqueScVals: [...input.opaqueScVals],
40
+ thresholdUsed: 1.0,
41
+ }
42
+ }
43
+
44
+ /** Refuse-on-low-confidence gate. Returns true when the recording must be
45
+ * refused (overall < thresholdUsed). The orchestrator wraps this in a
46
+ * ToolError with the ParseConfidence payload attached as `details`. */
47
+ export function isBelowThreshold(c: ParseConfidence): boolean {
48
+ return c.overall < c.thresholdUsed
49
+ }
50
+
51
+ /** Convenience: build the user-facing remediation question for an
52
+ * under-confidence recording. */
53
+ export function buildLowConfidenceQuestion(c: ParseConfidence): string {
54
+ const reasons: string[] = []
55
+ for (const u of c.unknownContracts) {
56
+ reasons.push(`unknown contract ${u.contract} (${u.reason})`)
57
+ }
58
+ for (const o of c.opaqueScVals) {
59
+ reasons.push(`opaque ScVal at ${o.path} (${o.type})`)
60
+ }
61
+ const why = reasons.length === 0 ? 'no diagnostic reason available' : reasons.join('; ')
62
+ return `Recording refused: parseConfidence ${c.overall.toFixed(3)} is below the threshold ${c.thresholdUsed.toFixed(3)}. Diagnostic: ${why}. Supply an ABI for the unknown contract(s) or re-capture the transaction against a known protocol version, then re-run record_transaction.`
63
+ }