@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.
- package/dist/adapters/oz/adapter.d.ts +20 -0
- package/dist/adapters/oz/adapter.js +282 -0
- package/dist/adapters/oz/index.d.ts +1 -0
- package/dist/adapters/oz/index.js +2 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/ir/index.d.ts +1 -0
- package/dist/ir/index.js +2 -0
- package/dist/ir/types.d.ts +97 -0
- package/dist/ir/types.js +11 -0
- package/dist/mandate/index.d.ts +2 -0
- package/dist/mandate/index.js +2 -0
- package/dist/mandate/to-ir.d.ts +3 -0
- package/dist/mandate/to-ir.js +60 -0
- package/dist/mandate/types.d.ts +20 -0
- package/dist/mandate/types.js +8 -0
- package/dist/record/decode.d.ts +76 -0
- package/dist/record/decode.js +372 -0
- package/dist/record/freshness.d.ts +17 -0
- package/dist/record/freshness.js +50 -0
- package/dist/record/index.d.ts +21 -0
- package/dist/record/index.js +163 -0
- package/dist/record/movements.d.ts +20 -0
- package/dist/record/movements.js +187 -0
- package/dist/record/rpc.d.ts +22 -0
- package/dist/record/rpc.js +70 -0
- package/dist/record/validate.d.ts +22 -0
- package/dist/record/validate.js +60 -0
- package/dist/registry/identify.d.ts +11 -0
- package/dist/registry/identify.js +84 -0
- package/dist/registry/index.d.ts +3 -0
- package/dist/registry/index.js +4 -0
- package/dist/registry/known-addresses.d.ts +16 -0
- package/dist/registry/known-addresses.js +49 -0
- package/dist/registry/protocols.d.ts +38 -0
- package/dist/registry/protocols.js +149 -0
- package/dist/seams/index.d.ts +1 -0
- package/dist/seams/index.js +2 -0
- package/dist/seams/types.d.ts +66 -0
- package/dist/seams/types.js +11 -0
- package/dist/synth/compose-from-recording.d.ts +36 -0
- package/dist/synth/compose-from-recording.js +162 -0
- package/dist/synth/index.d.ts +5 -0
- package/dist/synth/index.js +6 -0
- package/dist/synth/lower.d.ts +23 -0
- package/dist/synth/lower.js +116 -0
- package/dist/synth/scope.d.ts +26 -0
- package/dist/synth/scope.js +77 -0
- package/dist/synth/synthesize-from-mandate.d.ts +5 -0
- package/dist/synth/synthesize-from-mandate.js +34 -0
- package/dist/synth/synthesize-from-recording.d.ts +17 -0
- package/dist/synth/synthesize-from-recording.js +178 -0
- package/dist/types.d.ts +249 -0
- package/dist/types.js +35 -0
- package/package.json +37 -0
- package/src/adapters/oz/adapter.ts +363 -0
- package/src/adapters/oz/index.ts +9 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +9 -0
- package/src/ir/index.ts +13 -0
- package/src/ir/types.ts +94 -0
- package/src/mandate/index.ts +4 -0
- package/src/mandate/to-ir.ts +71 -0
- package/src/mandate/types.ts +21 -0
- package/src/record/decode.ts +500 -0
- package/src/record/freshness.ts +63 -0
- package/src/record/index.ts +224 -0
- package/src/record/movements.ts +188 -0
- package/src/record/rpc.ts +88 -0
- package/src/record/validate.ts +75 -0
- package/src/registry/identify.ts +99 -0
- package/src/registry/index.ts +24 -0
- package/src/registry/known-addresses.ts +71 -0
- package/src/registry/protocols.ts +176 -0
- package/src/seams/index.ts +11 -0
- package/src/seams/types.ts +81 -0
- package/src/synth/compose-from-recording.ts +226 -0
- package/src/synth/index.ts +19 -0
- package/src/synth/lower.ts +144 -0
- package/src/synth/scope.ts +108 -0
- package/src/synth/synthesize-from-mandate.ts +46 -0
- package/src/synth/synthesize-from-recording.ts +226 -0
- 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
|
+
}
|