@stellar-expert/tx-meta-effects-parser 5.0.0-beta10

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.
@@ -0,0 +1,308 @@
1
+ const {StrKey} = require('@stellar/stellar-base')
2
+ const {xdrParseScVal} = require('./tx-xdr-parser-utils')
3
+ const effectTypes = require('./effect-types')
4
+
5
+ const EVENT_TYPES = {
6
+ SYSTEM: 0,
7
+ CONTRACT: 1,
8
+ DIAGNOSTIC: 2
9
+ }
10
+
11
+ class EventsAnalyzer {
12
+ constructor(effectAnalyzer) {
13
+ this.effectAnalyzer = effectAnalyzer
14
+ this.callStack = []
15
+ }
16
+
17
+ /**
18
+ * @type {[]}
19
+ * @private
20
+ */
21
+ callStack
22
+
23
+ analyze() {
24
+ this.analyzeDiagnosticEvents()
25
+ this.analyzeEvents()
26
+ }
27
+
28
+ /**
29
+ * @private
30
+ */
31
+ analyzeEvents() {
32
+ const {events} = this.effectAnalyzer
33
+ if (!events)
34
+ return
35
+ //contract-generated events
36
+ for (const evt of events) {
37
+ const body = evt.body().value()
38
+ const rawTopics = body.topics()
39
+ const topics = rawTopics.map(xdrParseScVal)
40
+ if (topics[0] === 'DATA' && topics[1] === 'set')
41
+ continue //skip data entries modifications
42
+ const rawData = body.data()
43
+ //add event to the pipeline
44
+ this.effectAnalyzer.addEffect({
45
+ type: effectTypes.contractEvent,
46
+ contract: StrKey.encodeContract(evt.contractId()),
47
+ topics,
48
+ rawTopics: rawTopics.map(v => v.toXDR('base64')),
49
+ data: processEventBodyValue(rawData),
50
+ rawData: rawData.toXDR('base64')
51
+ })
52
+ }
53
+ }
54
+
55
+
56
+ /**
57
+ * @private
58
+ */
59
+ analyzeDiagnosticEvents() {
60
+ const {diagnosticEvents} = this.effectAnalyzer
61
+ if (!diagnosticEvents)
62
+ return
63
+ //diagnostic events
64
+ for (const evt of diagnosticEvents) {
65
+ if (!evt.inSuccessfulContractCall())
66
+ return //throw new UnexpectedTxMetaChangeError({type: 'diagnostic_event', action: 'failed'})
67
+ //parse event
68
+ const event = evt.event()
69
+ const contractId = event.contractId()
70
+ this.processDiagnosticEvent(event.body().value(), event.type().value, contractId ? StrKey.encodeContract(contractId) : null)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {xdr.ContractEventV0} body
76
+ * @param {Number} type
77
+ * @param {String} contractId
78
+ * @private
79
+ */
80
+ processDiagnosticEvent(body, type, contractId) {
81
+ const topics = body.topics()
82
+ switch (xdrParseScVal(topics[0])) {
83
+ case 'fn_call': // contract call
84
+ const rawArgs = body.data()
85
+ const parsedEvent = {
86
+ type: effectTypes.contractInvoked,
87
+ contract: xdrParseScVal(topics[1], true),
88
+ function: xdrParseScVal(topics[2]),
89
+ args: processEventBodyValue(rawArgs),
90
+ rawArgs: rawArgs.toXDR('base64')
91
+ }
92
+ //add the invocation to the call stack
93
+ if (this.callStack.length) {
94
+ parsedEvent.depth = this.callStack.length
95
+ }
96
+ this.callStack.push(parsedEvent)
97
+ this.effectAnalyzer.addEffect(parsedEvent)
98
+ break
99
+ case 'fn_return':
100
+ if (type !== EVENT_TYPES.DIAGNOSTIC)
101
+ return // skip non-diagnostic events
102
+ //attach execution result to the contract invocation event
103
+ const funcCall = this.callStack.pop()
104
+ const result = body.data()
105
+ if (result.switch().name !== 'scvVoid') {
106
+ funcCall.result = result.toXDR('base64')
107
+ }
108
+ break
109
+ //handle standard token contract events
110
+ //see https://github.com/stellar/rs-soroban-sdk/blob/71170fba76e1aa4d50224316f1157f0fb10e6d79/soroban-sdk/src/token.rs
111
+ case 'transfer': {
112
+ if (!matchEventTopicsShape(topics, ['address', 'address', 'str?']))
113
+ return
114
+ const from = xdrParseScVal(topics[1])
115
+ const to = xdrParseScVal(topics[2])
116
+ const asset = contractId //topics[3]? xdrParseScVal(topics[3]) || contractId
117
+ const amount = processEventBodyValue(body.data())
118
+ if (!this.matchInvocationEffect(e =>
119
+ (e.function === 'transfer' && matchArrays([from, to, amount], e.args)) ||
120
+ (e.function === 'transferFrom' && matchArrays([undefined, from, to, amount], e.args))
121
+ ))
122
+ return
123
+ const isSorobanAsset = isContractAddress(asset)
124
+ if (!isSorobanAsset || isContractAddress(from)) {
125
+ this.debit(from, asset, amount)
126
+ }
127
+ if (!isSorobanAsset || isContractAddress(to)) {
128
+ this.credit(to, asset, amount)
129
+ }
130
+ }
131
+ break
132
+ case 'mint': {
133
+ if (!matchEventTopicsShape(topics, ['address', 'address', 'str?']))
134
+ return //throw new Error('Non-standard event')
135
+ const to = xdrParseScVal(topics[1])
136
+ const amount = processEventBodyValue(body.data())
137
+ if (!this.matchInvocationEffect(e => e.function === 'mint' && matchArrays([to, amount], e.args)))
138
+ return
139
+ this.effectAnalyzer.addEffect({
140
+ type: effectTypes.assetMinted,
141
+ asset: contractId,
142
+ amount
143
+ })
144
+ this.credit(to, contractId, amount)
145
+ }
146
+ break
147
+ case 'burn': {
148
+ if (!matchEventTopicsShape(topics, ['address', 'str?']))
149
+ return //throw new Error('Non-standard event')
150
+ const from = xdrParseScVal(topics[1])
151
+ const amount = processEventBodyValue(body.data())
152
+ if (!this.matchInvocationEffect(e =>
153
+ (e.function === 'burn' && matchArrays([from, amount], e.args)) ||
154
+ (e.function === 'burn_from' && matchArrays([undefined, from, amount], e.args))
155
+ ))
156
+ return
157
+ this.debit(from, contractId, amount)
158
+ this.effectAnalyzer.addEffect({
159
+ type: effectTypes.assetBurned,
160
+ asset: contractId,
161
+ amount
162
+ })
163
+ }
164
+ break
165
+ case 'clawback': {
166
+ if (!matchEventTopicsShape(topics, ['address', 'address', 'str?']))
167
+ return //throw new Error('Non-standard event')
168
+ const admin = xdrParseScVal(topics[1])
169
+ const from = xdrParseScVal(topics[2])
170
+ const amount = processEventBodyValue(body.data())
171
+ if (!this.matchInvocationEffect(e => e.function === 'clawback' && matchArrays([from, amount], e.args)))
172
+ return
173
+ this.debit(from, contractId, amount)
174
+ this.effectAnalyzer.addEffect({
175
+ type: effectTypes.assetBurned,
176
+ asset: contractId,
177
+ amount
178
+ })
179
+ }
180
+ break
181
+ //TODO: process token allowance, authorization approval, and admin modification for SAC contracts
182
+ /*case 'approve': {
183
+ if (!matchEventTopicsShape(topics, ['address', 'address', 'str?']))
184
+ throw new Error('Non-standard event')
185
+ const from = xdrParseScVal(topics[1])
186
+ const spender = xdrParseScVal(topics[2])
187
+ }
188
+ break
189
+
190
+ case 'set_authorized': {
191
+ throw new Error('Not implemented')
192
+ //trustlineAuthorizationUpdated
193
+ if (!matchEventTopicsShape(topics, ['address', 'address', 'bool', 'str?']))
194
+ throw new Error('Non-standard event')
195
+ const admin = xdrParseScVal(topics[1])
196
+ const id = xdrParseScVal(topics[2])
197
+ const authorize = xdrParseScVal(topics[3])
198
+ }
199
+ break
200
+ case 'set_admin': {
201
+ throw new Error('Not implemented')
202
+ if (!matchEventTopicsShape(topics, ['address']))
203
+ throw new Error('Non-standard event')
204
+ const prevAdmin = xdrParseScVal(topics[1])
205
+ const newAdmin = processEventBodyValue(topics[2])
206
+ }
207
+ break*/
208
+ default:
209
+ //console.log(`Event ` + xdrParseScVal(topics[0]))
210
+ break
211
+ }
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * @param {String} from
217
+ * @param {String} asset
218
+ * @param {String} amount
219
+ * @private
220
+ */
221
+ debit(from, asset, amount) {
222
+ this.effectAnalyzer.debit(amount, asset, from)
223
+
224
+ //debit from account
225
+ //TODO: check debits of Soroban assets from account
226
+ //if (token.anchoredAsset)
227
+ //return //skip processing changes for classic assets - they are processed elsewhere
228
+ /*this.effectAnalyzer.addEffect({
229
+ type: effectTypes.accountDebited,
230
+ source: from,
231
+ asset: token.asset,
232
+ amount
233
+ })*/
234
+ }
235
+
236
+ /**
237
+ * @param {String} to
238
+ * @param {String} asset
239
+ * @param {String} amount
240
+ * @private
241
+ */
242
+ credit(to, asset, amount) {
243
+ this.effectAnalyzer.credit(amount, asset, to)
244
+
245
+ //credit account
246
+ //TODO: check credits of Soroban assets
247
+ //if (token.anchoredAsset)
248
+ //return //skip processing changes for classic assets - they are processed elsewhere
249
+ /*this.effectAnalyzer.addEffect({
250
+ type: effectTypes.accountCredited,
251
+ source: to,
252
+ asset: token.asset,
253
+ amount
254
+ })*/
255
+ }
256
+
257
+ matchInvocationEffect(cb) {
258
+ return this.effectAnalyzer.effects.find(e => e.type === effectTypes.contractInvoked && cb(e))
259
+ }
260
+ }
261
+
262
+ function matchEventTopicsShape(topics, shape) {
263
+ if (topics.length > shape.length + 1)
264
+ return false
265
+ //we ignore the first topic because it's an event name
266
+ for (let i = 0; i < shape.length; i++) {
267
+ let match = shape[i]
268
+ let optional = false
269
+ if (match.endsWith('?')) {
270
+ match = match.substring(0, match.length - 1)
271
+ optional = true
272
+ }
273
+ const topic = topics[i + 1]
274
+ if (topic) {
275
+ if (topic._arm !== match)
276
+ return false
277
+ } else if (!optional)
278
+ return false
279
+ }
280
+ return true
281
+ }
282
+
283
+ function matchArrays(a, b) {
284
+ if (!a || !b)
285
+ return false
286
+ if (a.length !== b.length)
287
+ return false
288
+ for (let i = a.length; i--;) {
289
+ if (a[i] !== undefined && a[i] !== b[i]) //undefined serves as * substitution
290
+ return false
291
+ }
292
+ return true
293
+ }
294
+
295
+ function processEventBodyValue(value) {
296
+ const innerValue = value.value()
297
+ /*if (innerValue instanceof Array) //handle simple JS arrays
298
+ return innerValue.map(xdrParseScVal)*/
299
+ if (!innerValue) //scVoid
300
+ return undefined
301
+ return xdrParseScVal(value) //other scValue
302
+ }
303
+
304
+ function isContractAddress(address) {
305
+ return address.length === 56 && address[0] === 'C'
306
+ }
307
+
308
+ module.exports = EventsAnalyzer
package/src/index.js ADDED
@@ -0,0 +1,161 @@
1
+ const {TransactionBuilder, xdr} = require('@stellar/stellar-base')
2
+ const {processFeeChargedEffect, analyzeOperationEffects, EffectsAnalyzer} = require('./tx-effects-analyzer')
3
+ const {parseTxResult} = require('./tx-result-parser')
4
+ const {parseLedgerEntryChanges} = require('./ledger-entry-changes-parser')
5
+ const {parseTxMetaChanges} = require('./tx-meta-changes-parser')
6
+ const {analyzeSignerChanges} = require('./signer-changes-analyzer')
7
+ const contractPreimageEncoder = require('./contract-preimage-encoder')
8
+ const xdrParserUtils = require('./tx-xdr-parser-utils')
9
+ const effectTypes = require('./effect-types')
10
+ const {TxMetaEffectParserError, UnexpectedTxMetaChangeError} = require('./errors')
11
+
12
+ /**
13
+ * Retrieve effects from transaction execution result metadata
14
+ * @param {String} network - Network passphrase
15
+ * @param {String|Buffer|xdr.TransactionEnvelope} tx - Base64-encoded tx envelope xdr
16
+ * @param {String|Buffer|xdr.TransactionResult} result? - Base64-encoded tx envelope result
17
+ * @param {String|Buffer|xdr.TransactionMeta} meta? - Base64-encoded tx envelope meta
18
+ * @return {ParsedTxOperationsMetadata}
19
+ */
20
+ function parseTxOperationsMeta({network, tx, result, meta}) {
21
+ if (!network)
22
+ throw new TypeError(`Network passphrase argument is required.`)
23
+ if (typeof network !== 'string')
24
+ throw new TypeError(`Invalid network passphrase or identifier: "${network}".`)
25
+ if (!tx)
26
+ throw new TypeError(`Transaction envelope argument is required.`)
27
+ const isEphemeral = !meta
28
+ //parse tx, result, and meta xdr
29
+ try {
30
+ tx = ensureXdrInputType(tx, xdr.TransactionEnvelope)
31
+ } catch (e) {
32
+ throw new TxMetaEffectParserError('Invalid transaction envelope XDR. ' + e.message)
33
+ }
34
+ try {
35
+ result = ensureXdrInputType(result, xdr.TransactionResult)
36
+ } catch (e) {
37
+ try {
38
+ const pair = ensureXdrInputType(result, xdr.TransactionResultPair)
39
+ result = pair.result()
40
+ } catch {
41
+ throw new TxMetaEffectParserError('Invalid transaction result XDR. ' + e.message)
42
+ }
43
+ }
44
+
45
+ tx = TransactionBuilder.fromXDR(tx, network)
46
+
47
+ let parsedTx = tx
48
+ let parsedResult = result
49
+
50
+ const isFeeBump = !!parsedTx.innerTransaction
51
+ let feeBumpSuccess
52
+
53
+ const res = {
54
+ tx,
55
+ isEphemeral
56
+ }
57
+
58
+ //take inner transaction if parsed tx is a fee bump tx
59
+ if (isFeeBump) {
60
+ parsedTx = parsedTx.innerTransaction
61
+ if (parsedTx.innerTransaction)
62
+ throw new TxMetaEffectParserError('Failed to process FeeBumpTransaction wrapped with another FeeBumpTransaction')
63
+ if (!isEphemeral) {
64
+ parsedResult = result.result().innerResultPair().result()
65
+ feeBumpSuccess = parsedResult.result().switch().value >= 0
66
+ }
67
+ }
68
+
69
+ //normalize operation source and effects container
70
+ if (parsedTx.operations) {
71
+ res.operations = parsedTx.operations
72
+
73
+ for (const op of parsedTx.operations) {
74
+ if (!op.source) {
75
+ op.source = parsedTx.source
76
+ }
77
+ op.effects = []
78
+ }
79
+ }
80
+
81
+ //process fee charge
82
+ res.effects = [processFeeChargedEffect(tx, tx.feeSource || parsedTx.source, result.feeCharged().toString(), isFeeBump)]
83
+
84
+ //check execution result
85
+ const {success, opResults} = parseTxResult(parsedResult)
86
+ if (!success || isFeeBump && !feeBumpSuccess) {
87
+ res.failed = true
88
+ return res
89
+ }
90
+
91
+ //do not parse meta for unsubmitted/rejected transactions
92
+ if (isEphemeral)
93
+ return res
94
+
95
+ //retrieve operations result metadata
96
+ try {
97
+ meta = ensureXdrInputType(meta, xdr.TransactionMeta)
98
+ } catch {
99
+ throw new TxMetaEffectParserError('Invalid transaction metadata XDR. ' + e.message)
100
+ }
101
+
102
+ //add tx-level effects
103
+ for (const {before, after} of parseTxMetaChanges(meta)) {
104
+ if (before.entry !== 'account')
105
+ throw new UnexpectedTxMetaChangeError({type: before.entry, action: 'update'})
106
+ for (const effect of analyzeSignerChanges(before, after)) {
107
+ effect.source = (before || after).address
108
+ res.effects.push(effect)
109
+ }
110
+ }
111
+ const metaValue = meta.value()
112
+ const opMeta = metaValue.operations()
113
+
114
+ //analyze operation effects for each operation
115
+ for (let i = 0; i < parsedTx.operations.length; i++) {
116
+ const operation = parsedTx.operations[i]
117
+ if (success) {
118
+ const params = {
119
+ operation,
120
+ meta:opMeta[i]?.changes(),
121
+ result:opResults[i],network
122
+ }
123
+ //only for Soroban contract invocation
124
+ if (operation.type === 'invokeHostFunction') {
125
+ const sorobanMeta = metaValue.sorobanMeta()
126
+ params.events = sorobanMeta.events()
127
+ params.diagnosticEvents = sorobanMeta.diagnosticEvents()
128
+ }
129
+ const analyzer = new EffectsAnalyzer(params)
130
+ operation.effects = analyzer.analyze()
131
+ }
132
+ }
133
+ return res
134
+ }
135
+
136
+ /**
137
+ * Convert base64/raw XDR representation to XDR type
138
+ * @param {String|Buffer|Uint8Array|xdrType} value
139
+ * @param xdrType
140
+ * @return {xdrType|*}
141
+ * @internal
142
+ */
143
+ function ensureXdrInputType(value, xdrType) {
144
+ if (value?.toXDR) // duck-typing check XDR types
145
+ return value
146
+
147
+ if (!value || (typeof value !== 'string' && !(value instanceof Uint8Array)))
148
+ throw new TypeError(`Invalid input format. Expected xdr.${xdrType.name} (raw, buffer, or bas64-encoded).`)
149
+ return xdrType.fromXDR(value, typeof value === 'string' ? 'base64' : 'raw')
150
+ }
151
+
152
+ /**
153
+ * @typedef {{}} ParsedTxOperationsMetadata
154
+ * @property {Transaction|FeeBumpTransaction} tx
155
+ * @property {BaseOperation[]} operations
156
+ * @property {Boolean} isEphemeral
157
+ * @property {Boolean} [failed]
158
+ * @property {{}[]} [effects]
159
+ */
160
+
161
+ module.exports = {parseTxOperationsMeta, parseTxResult, analyzeOperationEffects, parseLedgerEntryChanges, parseTxMetaChanges, effectTypes, xdrParserUtils, contractPreimageEncoder}