tbc-tx-parser 0.1.10 → 0.3.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 (62) hide show
  1. package/README.md +93 -9
  2. package/dist/core/asset-identity.d.ts +3 -0
  3. package/dist/core/asset-identity.d.ts.map +1 -0
  4. package/dist/core/asset-identity.js +3 -0
  5. package/dist/core/asset-identity.js.map +1 -0
  6. package/dist/core/input-utxos.d.ts.map +1 -1
  7. package/dist/core/input-utxos.js +4 -1
  8. package/dist/core/input-utxos.js.map +1 -1
  9. package/dist/core/public-script.d.ts.map +1 -1
  10. package/dist/core/public-script.js +7 -2
  11. package/dist/core/public-script.js.map +1 -1
  12. package/dist/decode-tx-raw.d.ts.map +1 -1
  13. package/dist/decode-tx-raw.js +8 -2
  14. package/dist/decode-tx-raw.js.map +1 -1
  15. package/dist/protocols/ft/index.d.ts.map +1 -1
  16. package/dist/protocols/ft/index.js +11 -0
  17. package/dist/protocols/ft/index.js.map +1 -1
  18. package/dist/protocols/modern/amm.d.ts +32 -0
  19. package/dist/protocols/modern/amm.d.ts.map +1 -0
  20. package/dist/protocols/modern/amm.js +79 -0
  21. package/dist/protocols/modern/amm.js.map +1 -0
  22. package/dist/protocols/modern/analyze.d.ts +7 -0
  23. package/dist/protocols/modern/analyze.d.ts.map +1 -0
  24. package/dist/protocols/modern/analyze.js +887 -0
  25. package/dist/protocols/modern/analyze.js.map +1 -0
  26. package/dist/protocols/modern/codecs.d.ts +87 -0
  27. package/dist/protocols/modern/codecs.d.ts.map +1 -0
  28. package/dist/protocols/modern/codecs.js +251 -0
  29. package/dist/protocols/modern/codecs.js.map +1 -0
  30. package/dist/protocols/nft/index.d.ts.map +1 -1
  31. package/dist/protocols/nft/index.js +2 -1
  32. package/dist/protocols/nft/index.js.map +1 -1
  33. package/dist/protocols/piggy-bank/index.js +1 -1
  34. package/dist/protocols/piggy-bank/index.js.map +1 -1
  35. package/dist/protocols/pool-nft/facts.js +1 -1
  36. package/dist/protocols/pool-nft/facts.js.map +1 -1
  37. package/dist/protocols/pool-nft/fees.d.ts.map +1 -1
  38. package/dist/protocols/pool-nft/fees.js +2 -1
  39. package/dist/protocols/pool-nft/fees.js.map +1 -1
  40. package/dist/result/classification-description.d.ts.map +1 -1
  41. package/dist/result/classification-description.js +12 -1
  42. package/dist/result/classification-description.js.map +1 -1
  43. package/dist/result/classification.d.ts.map +1 -1
  44. package/dist/result/classification.js +4 -0
  45. package/dist/result/classification.js.map +1 -1
  46. package/dist/result/index.d.ts.map +1 -1
  47. package/dist/result/index.js +2 -2
  48. package/dist/result/index.js.map +1 -1
  49. package/dist/script-parser/contracts.js +1 -1
  50. package/dist/script-parser/contracts.js.map +1 -1
  51. package/dist/script-parser.d.ts.map +1 -1
  52. package/dist/script-parser.js +34 -1
  53. package/dist/script-parser.js.map +1 -1
  54. package/dist/types/protocol.d.ts +11 -2
  55. package/dist/types/protocol.d.ts.map +1 -1
  56. package/dist/types/result.d.ts +14 -5
  57. package/dist/types/result.d.ts.map +1 -1
  58. package/dist/types/script.d.ts +10 -2
  59. package/dist/types/script.d.ts.map +1 -1
  60. package/dist/types/semantic.d.ts +9 -0
  61. package/dist/types/semantic.d.ts.map +1 -1
  62. package/package.json +11 -6
@@ -0,0 +1,887 @@
1
+ import { assetIdentities } from '../../core/asset-identity.js';
2
+ import { tbc } from '../../adapters/tbc-lib.js';
3
+ import { address, controllerHash, htlcCode, nftCode, orderCode, outpoint, poolCode, poolTape, poolMath, poolFeePolicy, poolMetadata, poolConfigurationIssues, sha256, tokenCode, tokenState, } from './codecs.js';
4
+ const sum = (items) => items.reduce((n, t) => n + t.state.balance, 0n);
5
+ const owner = (s) => s.controller[20] === 0
6
+ ? { address: address(s.controller.subarray(0, 20)) }
7
+ : { hash: s.controller.subarray(0, 20).toString('hex') };
8
+ const sameOwner = (a, b) => a.controller.equals(b.controller);
9
+ function exactDisplay(n, decimal) {
10
+ const v = n.toString().padStart(decimal + 1, '0');
11
+ return decimal ? `${v.slice(0, -decimal)}.${v.slice(-decimal)}`.replace(/\.?0+$/, '') : v;
12
+ }
13
+ function classification(protocol, action, issues, description) {
14
+ return {
15
+ protocol,
16
+ action,
17
+ conformance: !issues.length
18
+ ? 'standard'
19
+ : issues.some((issue) => /MISSING|UNRESOLVED|UNVERIFIED|AMBIGUOUS|UNKNOWN|UNRECOGNIZED/.test(issue))
20
+ ? 'indeterminate'
21
+ : 'nonstandard',
22
+ description,
23
+ ...(issues.length ? { issues: [...new Set(issues)] } : {}),
24
+ };
25
+ }
26
+ function checkCertificate(inputs, outputs, s, amount) {
27
+ const cert = inputs.find((i) => i.prevout?.script.hex &&
28
+ sha256(Buffer.from(i.prevout.script.hex, 'hex')).toString('hex') === s.coinNftCodeHash);
29
+ if (!cert?.prevout)
30
+ return false;
31
+ const code = nftCode(cert.prevout.script.hex);
32
+ if (!code)
33
+ return false;
34
+ const before = cert.prevout.associatedTape;
35
+ if (!before ||
36
+ before.txid !== cert.prevTxid ||
37
+ before.outputIndex !== cert.vout + 2 ||
38
+ before.satoshis !== 0)
39
+ return false;
40
+ const old = before.script.data?.nftTape;
41
+ const newCode = outputs.find((o) => o.script.hex === cert.prevout.script.hex);
42
+ const next = newCode && outputs[newCode.index + 2]?.script.data?.nftTape;
43
+ const hold = inputs.find((i) => i.prevTxid === cert.prevTxid && i.vout === cert.vout + 1);
44
+ const newHold = newCode && outputs[newCode.index + 1];
45
+ if (!old || !next || !hold?.prevout || !newHold || hold.prevout.script.hex !== newHold.script.hex)
46
+ return false;
47
+ const h = new tbc.Script(newHold.script.hex);
48
+ if (h.chunks[2]?.buf?.toString('hex') !== s.adminPubKeyHash ||
49
+ !newHold.script.hex?.includes(Buffer.from('For Coin ').toString('hex')))
50
+ return false;
51
+ try {
52
+ return (BigInt(String(next.coinTotalSupply)) - BigInt(String(old.coinTotalSupply)) === amount &&
53
+ next.nftName === old.nftName &&
54
+ next.nftSymbol === old.nftSymbol &&
55
+ next.coinDecimal === old.coinDecimal);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ /** Interpret modern versions within the established families, keeping the complete output ledger. */
62
+ export function analyzeModern(inputs, outputs, txid, locktime, baseFlows, baseClassification) {
63
+ const issues = [];
64
+ const ins = [], outs = [];
65
+ for (const i of inputs) {
66
+ const p = i.prevout, hex = p?.script.hex;
67
+ if (!hex || !tokenCode(hex))
68
+ continue;
69
+ const tape = p.associatedTape;
70
+ if (!tape ||
71
+ tape.txid !== i.prevTxid ||
72
+ tape.outputIndex !== i.vout + 1 ||
73
+ tape.satoshis !== 0 ||
74
+ !tape.script.hex) {
75
+ issues.push('MISSING_OR_MISMATCHED_TOKEN_TAPE');
76
+ continue;
77
+ }
78
+ const state = tokenState(hex, tape.script.hex);
79
+ if (!state || p.satoshis !== 500) {
80
+ issues.push('INVALID_TOKEN_CODE_TAPE');
81
+ continue;
82
+ }
83
+ ins.push({
84
+ state,
85
+ index: i.index,
86
+ hex,
87
+ satoshis: p.satoshis,
88
+ id: p.contractId,
89
+ });
90
+ }
91
+ for (const o of outputs) {
92
+ const hex = o.script.hex;
93
+ if (!hex || !tokenCode(hex))
94
+ continue;
95
+ const tape = outputs[o.index + 1];
96
+ const state = tape?.script.hex && tape.satoshis === 0 && o.satoshis === 500
97
+ ? tokenState(hex, tape.script.hex)
98
+ : undefined;
99
+ if (!state) {
100
+ issues.push('INVALID_TOKEN_CODE_TAPE');
101
+ continue;
102
+ }
103
+ outs.push({ state, index: o.index, hex, satoshis: o.satoshis });
104
+ }
105
+ const poolInputs = inputs.flatMap((i) => {
106
+ const hex = i.prevout?.script.hex, p = hex && poolCode(hex);
107
+ return p
108
+ ? [
109
+ {
110
+ index: i.index,
111
+ hex: hex,
112
+ code: p,
113
+ tape: i.prevout.associatedTape,
114
+ value: i.prevout.satoshis,
115
+ },
116
+ ]
117
+ : [];
118
+ });
119
+ const poolOutputs = outputs.flatMap((o) => {
120
+ const hex = o.script.hex, p = hex && poolCode(hex);
121
+ return p
122
+ ? [
123
+ {
124
+ index: o.index,
125
+ hex: hex,
126
+ code: p,
127
+ tape: outputs[o.index + 1],
128
+ value: o.satoshis,
129
+ },
130
+ ]
131
+ : [];
132
+ });
133
+ const orderInputs = inputs.flatMap((i) => {
134
+ const p = i.prevout?.script.hex && orderCode(i.prevout.script.hex);
135
+ return p ? [{ ...p, index: i.index, hex: i.prevout.script.hex }] : [];
136
+ });
137
+ const orderOutputs = outputs.flatMap((o) => {
138
+ const p = o.script.hex && orderCode(o.script.hex);
139
+ return p ? [{ ...p, index: o.index, hex: o.script.hex }] : [];
140
+ });
141
+ const htlcInputs = inputs.flatMap((i) => {
142
+ const p = i.prevout?.script.hex && htlcCode(i.prevout.script.hex);
143
+ return p ? [{ ...p, index: i.index, hex: i.prevout.script.hex }] : [];
144
+ });
145
+ const htlcOutputs = outputs.flatMap((o) => {
146
+ const p = o.script.hex && htlcCode(o.script.hex);
147
+ return p ? [{ ...p, index: o.index, hex: o.script.hex }] : [];
148
+ });
149
+ const certOutput = outputs.find((o) => nftCode(o.script.hex ?? '') &&
150
+ outputs[o.index + 1]?.script.hex?.includes(Buffer.from('For Coin ').toString('hex')) &&
151
+ outputs[o.index + 2]?.script.data?.nftTape?.coinTotalSupply === '0');
152
+ const modernPresent = certOutput ||
153
+ ins.length ||
154
+ outs.length ||
155
+ issues.length ||
156
+ poolInputs.length ||
157
+ poolOutputs.length ||
158
+ htlcInputs.length ||
159
+ htlcOutputs.length ||
160
+ orderOutputs.some((o) => o.modern) ||
161
+ orderInputs.some((o) => o.modern);
162
+ if (!modernPresent)
163
+ return undefined;
164
+ const tokenFlows = [];
165
+ const flowOutputs = new Map();
166
+ const nativeSources = new Map();
167
+ function bindNativePayout(amount, hash, destination) {
168
+ const output = outputs.find((o) => o.script.type === 'P2PKH' &&
169
+ BigInt(o.satoshis) === amount &&
170
+ (!destination || o.address === destination));
171
+ if (output)
172
+ nativeSources.set(output.index, { hash });
173
+ else
174
+ issues.push('CONTRACT_NATIVE_PAYOUT_MISMATCH');
175
+ }
176
+ const tokenOutputIndexes = new Set(outs.map((t) => t.index));
177
+ let protocol = outs.some((t) => t.state.descriptor.family === 'stablecoin')
178
+ ? 'stablecoin'
179
+ : 'ft';
180
+ let action = 'transfer';
181
+ const identities = new Set([...ins, ...outs].map((t) => t.state.identity));
182
+ for (const identity of identities) {
183
+ const from = ins.filter((t) => t.state.identity === identity), to = outs.filter((t) => t.state.identity === identity), s = to[0]?.state ?? from[0].state;
184
+ const delta = sum(to) - sum(from);
185
+ const ids = [...new Set(from.map((t) => t.id).filter((v) => !!v))];
186
+ if (ids.length > 1)
187
+ issues.push('CONTRADICTORY_TOKEN_IDENTITY');
188
+ let id = ids.length === 1 ? ids[0] : undefined;
189
+ for (const t of [...from, ...to])
190
+ if (t.state.decimal !== s.decimal || t.state.name !== s.name || t.state.symbol !== s.symbol)
191
+ issues.push('TOKEN_METADATA_CHANGED');
192
+ for (const i of from) {
193
+ if (i.state.controller[20] === 1 &&
194
+ !inputs.some((p) => p.prevout?.script.hex && controllerHash(p.prevout.script.hex) === owner(i.state).hash))
195
+ issues.push('MISSING_TOKEN_CONTROLLER');
196
+ }
197
+ const origin = s.origin;
198
+ const source = origin &&
199
+ inputs.find((i) => i.prevTxid === origin.txid &&
200
+ i.vout === origin.vout &&
201
+ i.prevout?.script.type === 'P2PKH');
202
+ const standardMint = s.descriptor.family === 'ft' &&
203
+ !from.length &&
204
+ !!source &&
205
+ delta > 0n &&
206
+ (!s.declaredSupplyRaw || delta === s.declaredSupplyRaw);
207
+ const stableMint = s.descriptor.family === 'stablecoin' &&
208
+ delta > 0n &&
209
+ checkCertificate(inputs, outputs, s, delta);
210
+ const lpMint = s.descriptor.family === 'ftlp' &&
211
+ poolOutputs.some((p) => sha256(Buffer.from(p.hex, 'hex')).toString('hex') === s.poolCodeHash) &&
212
+ delta > 0n;
213
+ if (standardMint)
214
+ id = txid;
215
+ if (stableMint) {
216
+ const cert = inputs.find((i) => i.prevout?.script.hex &&
217
+ sha256(Buffer.from(i.prevout.script.hex, 'hex')).toString('hex') === s.coinNftCodeHash);
218
+ // The certificate creation txid is available when supplied by caller; never substitute each mint txid.
219
+ id =
220
+ cert?.prevout?.contractId ??
221
+ (cert?.prevout?.associatedTape?.script.data?.nftTape?.coinTotalSupply === '0'
222
+ ? cert.prevTxid
223
+ : undefined);
224
+ }
225
+ const mint = standardMint || stableMint || lpMint;
226
+ if (delta !== 0n && !mint)
227
+ issues.push('TOKEN_AMOUNT_NOT_CONSERVED');
228
+ if (mint)
229
+ action = 'mint';
230
+ else if (from.length > to.length && to.length === 1)
231
+ action = 'merge';
232
+ if (!from.length && !mint && !(delta === 0n && poolOutputs.length))
233
+ issues.push('UNRESOLVED_TOKEN_ORIGIN');
234
+ if (s.descriptor.family === 'stablecoin' && from.length && to.length && delta === 0n) {
235
+ const maxBefore = Math.max(...from.map((t) => t.state.lockTime));
236
+ const maxAfter = Math.max(...to.map((t) => t.state.lockTime));
237
+ if (maxAfter > maxBefore)
238
+ action = 'freeze';
239
+ else if (maxAfter < maxBefore)
240
+ action = 'unfreeze';
241
+ if (maxAfter !== maxBefore &&
242
+ from.some((i) => to.some((o) => o.state.amounts[i.index] > 0n && !sameOwner(i.state, o.state))))
243
+ issues.push('STABLECOIN_ADMIN_CHANGED_OWNER');
244
+ }
245
+ for (const o of to) {
246
+ if (o.state.balance === 0n)
247
+ continue;
248
+ const endpoint = owner(o.state);
249
+ const sources = from.filter((t) => o.state.amounts[t.index] > 0n);
250
+ const owners = [
251
+ ...new Set((sources.length ? sources : from).map((t) => JSON.stringify(owner(t.state)))),
252
+ ].map((v) => JSON.parse(v));
253
+ const originEndpoint = owners.length === 1
254
+ ? owners[0]
255
+ : {
256
+ outpoints: from.map((t) => ({
257
+ txid: inputs[t.index].prevTxid,
258
+ vout: inputs[t.index].vout,
259
+ })),
260
+ };
261
+ const isSelf = (sources.length ? sources : from).length > 0 &&
262
+ (sources.length ? sources : from).every((t) => sameOwner(t.state, o.state));
263
+ const burned = endpoint.address === '1BitcoinEaterAddressDontSendf59kuE';
264
+ const context = {
265
+ protocol: s.descriptor.family === 'ftlp' ? 'poolNft' : s.descriptor.family,
266
+ recognized: issues.length === 0,
267
+ action,
268
+ };
269
+ const asset = {
270
+ type: s.descriptor.family,
271
+ symbol: s.symbol ?? (s.descriptor.family === 'stablecoin' ? 'StableCoin' : 'FT'),
272
+ ...(s.name ? { name: s.name } : {}),
273
+ ...(id ? { contractId: id } : {}),
274
+ ...(s.decimal !== undefined ? { decimal: s.decimal } : {}),
275
+ };
276
+ assetIdentities.set(asset, s.identity);
277
+ const amount = {
278
+ raw: o.state.balance.toString(),
279
+ ...(s.decimal !== undefined
280
+ ? {
281
+ decimal: s.decimal,
282
+ display: exactDisplay(o.state.balance, s.decimal),
283
+ }
284
+ : {}),
285
+ };
286
+ if (burned && from.length)
287
+ tokenFlows.push({
288
+ asset,
289
+ amount,
290
+ purpose: 'burn',
291
+ direction: 'out',
292
+ from: originEndpoint,
293
+ context,
294
+ });
295
+ else if (mint && !from.length)
296
+ tokenFlows.push({
297
+ asset,
298
+ amount,
299
+ purpose: 'mint',
300
+ direction: 'in',
301
+ to: endpoint,
302
+ context,
303
+ });
304
+ else if (isSelf)
305
+ tokenFlows.push({
306
+ asset,
307
+ amount,
308
+ purpose: 'self',
309
+ direction: 'self',
310
+ from: originEndpoint,
311
+ to: endpoint,
312
+ context,
313
+ });
314
+ else if (from.length)
315
+ tokenFlows.push({
316
+ asset,
317
+ amount,
318
+ purpose: 'transfer',
319
+ direction: 'out',
320
+ from: originEndpoint,
321
+ to: endpoint,
322
+ context,
323
+ });
324
+ else
325
+ tokenFlows.push({
326
+ asset,
327
+ amount,
328
+ purpose: 'unknown',
329
+ direction: 'unknown',
330
+ to: endpoint,
331
+ context: { ...context, recognized: false },
332
+ });
333
+ flowOutputs.set(tokenFlows.at(-1), o);
334
+ }
335
+ // Per-input slot attribution is independently checked for ordinary conservation paths.
336
+ if (delta === 0n && from.length)
337
+ for (const i of from) {
338
+ if (to.reduce((n, o) => n + (o.state.amounts[i.index] ?? 0n), 0n) !== i.state.balance)
339
+ issues.push('TOKEN_INPUT_SLOT_MISMATCH');
340
+ }
341
+ }
342
+ if (issues.length && !ins.length && !outs.length)
343
+ protocol =
344
+ inputs.some((i) => i.prevout?.script.contract?.family === 'stablecoin') ||
345
+ outputs.some((o) => o.script.contract?.family === 'stablecoin')
346
+ ? 'stablecoin'
347
+ : 'ft';
348
+ if (outs.length && outs.every((t) => t.state.descriptor.family === 'ftlp')) {
349
+ protocol = 'poolNft';
350
+ action = tokenFlows.some((f) => f.purpose === 'burn')
351
+ ? 'burnFtLp'
352
+ : ins.length > outs.length
353
+ ? 'mergeFtLp'
354
+ : 'transferFtLp';
355
+ }
356
+ if (certOutput && !outs.length && !ins.length) {
357
+ protocol = 'stablecoin';
358
+ action = 'bootstrap';
359
+ const origin = nftCode(certOutput.script.hex).origin;
360
+ if (!inputs.some((i) => i.prevTxid === origin.txid && i.vout === origin.vout) ||
361
+ certOutput.satoshis !== 200 ||
362
+ outputs[certOutput.index + 1]?.satoshis !== 100 ||
363
+ outputs[certOutput.index + 2]?.satoshis !== 0)
364
+ issues.push('INVALID_STABLECOIN_CERTIFICATE');
365
+ }
366
+ if (poolInputs.length || poolOutputs.length) {
367
+ protocol = 'poolNft';
368
+ action = 'unknown';
369
+ const p = poolInputs[0], q = poolOutputs[0];
370
+ if (poolInputs.length > 1 || poolOutputs.length !== 1 || !q)
371
+ issues.push('AMBIGUOUS_POOL_STATE');
372
+ const next = q?.tape?.script.hex && poolTape(q.tape.script.hex);
373
+ const previous = p?.tape?.script.hex && poolTape(p.tape.script.hex);
374
+ if (!next || q?.tape?.satoshis !== 0 || q.value < 1500)
375
+ issues.push('INVALID_POOL_TAPE');
376
+ if (p &&
377
+ (!previous ||
378
+ p.tape?.txid !== inputs[p.index].prevTxid ||
379
+ p.tape.outputIndex !== inputs[p.index].vout + 1 ||
380
+ p.hex !== q?.hex))
381
+ issues.push('INVALID_POOL_CONTINUITY');
382
+ if (next && q) {
383
+ issues.push(...poolConfigurationIssues(q.code, next));
384
+ if (p && previous)
385
+ issues.push(...poolConfigurationIssues(p.code, previous));
386
+ const expectedOrigin = outpoint(q.code.originalUTXO);
387
+ if (!p) {
388
+ if (inputs.some((i) => i.prevTxid === expectedOrigin.txid && i.vout === expectedOrigin.vout) &&
389
+ next.ftAAmount === 0n &&
390
+ next.ftLpAmount === 0n &&
391
+ next.tbcAmount === 0n)
392
+ action = 'create';
393
+ else
394
+ issues.push('UNVERIFIED_POOL_CREATION');
395
+ }
396
+ else if (previous) {
397
+ const config = (v) => JSON.stringify({ ...v, ftAAmount: '', ftLpAmount: '', tbcAmount: '' }, (_, v) => typeof v === 'bigint' ? v.toString() : v);
398
+ if (config(previous) !== config(next))
399
+ issues.push('POOL_CONFIGURATION_CHANGED');
400
+ const dl = next.ftLpAmount - previous.ftLpAmount, df = next.ftAAmount - previous.ftAAmount, dt = next.tbcAmount - previous.tbcAmount;
401
+ if (dl > 0n && df > 0n && dt > 0n)
402
+ action = previous.ftLpAmount === 0n ? 'init' : 'increaseLiquidity';
403
+ else if (dl < 0n && df < 0n && dt < 0n)
404
+ action = 'consumeLiquidity';
405
+ else if (dl === 0n && df < 0n && dt > 0n)
406
+ action = 'swapTbcToFt';
407
+ else if (dl === 0n && df > 0n && dt < 0n)
408
+ action = 'swapFtToTbc';
409
+ else if (dl === 0n && df === 0n && dt === 0n)
410
+ action = 'mergeFtInPool';
411
+ else
412
+ issues.push('UNRECOGNIZED_POOL_TRANSITION');
413
+ try {
414
+ const state = {
415
+ ftLpAmount: previous.ftLpAmount,
416
+ ftAAmount: previous.ftAAmount,
417
+ tbcAmount: previous.tbcAmount,
418
+ poolValue: BigInt(p.value),
419
+ };
420
+ const policy = poolFeePolicy(next);
421
+ const matches = (v) => v.ftLpAmount === next.ftLpAmount &&
422
+ v.ftAAmount === next.ftAAmount &&
423
+ v.tbcAmount === next.tbcAmount &&
424
+ v.poolValue === BigInt(q.value);
425
+ let valid = false;
426
+ if (action === 'init' || action === 'increaseLiquidity')
427
+ valid = matches(poolMath.quoteAddLP(state, BigInt(q.value - p.value), action === 'init' ? df : undefined).nextState);
428
+ else if (action === 'consumeLiquidity') {
429
+ const quote = poolMath.quoteRemoveLP(state, -dl);
430
+ valid = matches(quote.nextState);
431
+ bindNativePayout(quote.poolValueDecrementSat, controllerHash(p.hex));
432
+ }
433
+ else if (action === 'swapFtToTbc') {
434
+ const quote = poolMath.quoteSwapTBC(state, df, policy);
435
+ valid = matches(quote.nextState);
436
+ bindNativePayout(quote.tbcOutSat, controllerHash(p.hex));
437
+ if (quote.fees.serviceFeePaidSat > 0n &&
438
+ !outputs.some((o) => BigInt(o.satoshis) === quote.fees.serviceFeePaidSat &&
439
+ sha256(Buffer.from(o.script.hex, 'hex')).equals(q.code.tbcFeeScriptHash)))
440
+ valid = false;
441
+ }
442
+ else if (action === 'swapTbcToFt') {
443
+ const paid = [
444
+ 0,
445
+ ...outputs
446
+ .filter((o) => sha256(Buffer.from(o.script.hex, 'hex')).equals(q.code.tbcFeeScriptHash))
447
+ .map((o) => o.satoshis),
448
+ ];
449
+ valid = paid.some((fee) => {
450
+ try {
451
+ const quote = poolMath.quoteSwapFT(state, BigInt(q.value - p.value + fee), policy);
452
+ return matches(quote.nextState) && quote.fees.serviceFeePaidSat === BigInt(fee);
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ });
458
+ }
459
+ else if (action === 'mergeFtInPool')
460
+ valid = p.value === q.value;
461
+ if (!valid)
462
+ issues.push('POOL_AMOUNT_OR_FEE_MISMATCH');
463
+ }
464
+ catch {
465
+ issues.push('POOL_AMOUNT_OR_FEE_MISMATCH');
466
+ }
467
+ }
468
+ const identity = next.ftAPartialHash.toString('hex');
469
+ const all = [...ins, ...outs];
470
+ if (all.some((t) => t.state.controller[20] === 1 &&
471
+ t.state.controller.subarray(0, 20).toString('hex') === controllerHash(q.hex) &&
472
+ t.state.identity.slice(0, 64) !== identity))
473
+ issues.push('POOL_TOKEN_IDENTITY_MISMATCH');
474
+ const lp = all.filter((t) => t.state.descriptor.family === 'ftlp');
475
+ if (lp.some((t) => t.state.poolCodeHash !== sha256(Buffer.from(q.hex, 'hex')).toString('hex') ||
476
+ t.state.identity.slice(0, 64) !== next.ftLpPartialHash.toString('hex')))
477
+ issues.push('POOL_LP_IDENTITY_MISMATCH');
478
+ for (const f of tokenFlows)
479
+ if (assetIdentities.get(f.asset)?.slice(0, 64) === identity)
480
+ Object.assign(f.asset, { contractId: next.ftAContractId });
481
+ const poolOwner = controllerHash(q.hex);
482
+ const reserve = (list) => sum(list.filter((t) => t.state.controller[20] === 1 &&
483
+ t.state.controller.subarray(0, 20).toString('hex') === poolOwner &&
484
+ t.state.identity.slice(0, 64) === identity));
485
+ if (reserve(outs) !== next.ftAAmount || (previous && reserve(ins) !== previous.ftAAmount))
486
+ issues.push('POOL_TOKEN_RESERVE_MISMATCH');
487
+ if (previous) {
488
+ const lpInputs = ins.filter((t) => t.state.descriptor.family === 'ftlp'), lpOutputs = outs.filter((t) => t.state.descriptor.family === 'ftlp');
489
+ const burned = sum(lpOutputs.filter((t) => owner(t.state).address === '1BitcoinEaterAddressDontSendf59kuE'));
490
+ if (sum(lpOutputs) - sum(lpInputs) - burned !== next.ftLpAmount - previous.ftLpAmount)
491
+ issues.push('POOL_LP_SUPPLY_MISMATCH');
492
+ }
493
+ }
494
+ }
495
+ if (orderInputs.length || orderOutputs.length) {
496
+ protocol = 'orderBook';
497
+ action = 'unknown';
498
+ for (const o of [...orderInputs, ...orderOutputs]) {
499
+ for (const token of [o.tokenA, ...(o.tokenB ? [o.tokenB] : [])]) {
500
+ const relevant = [...ins, ...outs].filter((t) => t.state.identity.slice(0, 64) === token.partialHash);
501
+ const lockedToken = o.tokenB
502
+ ? o.side === 'sell'
503
+ ? o.tokenA
504
+ : o.tokenB
505
+ : o.side === 'buy'
506
+ ? o.tokenA
507
+ : undefined;
508
+ const needsToken = orderInputs.length === 2 || lockedToken === token;
509
+ if (!relevant.length && needsToken)
510
+ issues.push('ORDER_TOKEN_EVIDENCE_MISSING');
511
+ // StableCoin orders can refer to the first issuance txid, while the indexed
512
+ // asset ID names its certificate. The immutable partial hash binds the asset;
513
+ // preserve the caller's verified StableCoin ID instead of replacing it.
514
+ if (relevant.some((t) => t.state.descriptor.family !== 'stablecoin' && t.id && t.id !== token.id))
515
+ issues.push('ORDER_TOKEN_IDENTITY_MISMATCH');
516
+ for (const f of tokenFlows) {
517
+ if (assetIdentities.get(f.asset)?.slice(0, 64) === token.partialHash &&
518
+ !('contractId' in f.asset && f.asset.contractId))
519
+ Object.assign(f.asset, { contractId: token.id });
520
+ }
521
+ }
522
+ }
523
+ if (!orderInputs.length && orderOutputs.length === 1)
524
+ action = orderOutputs[0].side === 'buy' ? 'placeBuy' : 'placeSell';
525
+ else if (orderInputs.length === 1 && !orderOutputs.length) {
526
+ const i = inputs[orderInputs[0].index];
527
+ const chunks = new tbc.Script(i.unlockingScript.hex ?? '').chunks;
528
+ if (chunks.at(-1)?.opcodenum === tbc.Opcode.OP_2)
529
+ action = orderInputs[0].side === 'buy' ? 'cancelBuy' : 'cancelSell';
530
+ else
531
+ issues.push('UNVERIFIED_ORDER_CANCEL');
532
+ const order = orderInputs[0];
533
+ const controlled = ins.filter((t) => owner(t.state).hash === controllerHash(order.hex));
534
+ if (controlled.length) {
535
+ for (const token of controlled)
536
+ if (!outs.some((t) => t.state.identity === token.state.identity &&
537
+ owner(t.state).address === order.owner &&
538
+ t.state.balance === token.state.balance))
539
+ issues.push('ORDER_CANCEL_RECIPIENT_MISMATCH');
540
+ }
541
+ else {
542
+ bindNativePayout(order.volume, controllerHash(order.hex), order.owner);
543
+ }
544
+ }
545
+ else if (orderInputs.length === 2) {
546
+ const buy = orderInputs.find((o) => o.side === 'buy'), sell = orderInputs.find((o) => o.side === 'sell');
547
+ if (buy &&
548
+ sell &&
549
+ buy.tokenA.id === sell.tokenA.id &&
550
+ buy.tokenA.partialHash === sell.tokenA.partialHash &&
551
+ buy.tokenB?.id === sell.tokenB?.id &&
552
+ buy.tokenB?.partialHash === sell.tokenB?.partialHash &&
553
+ buy.price === sell.price) {
554
+ action = 'match';
555
+ if (orderOutputs.some((o) => !orderInputs.some((i) => i.owner === o.owner &&
556
+ i.side === o.side &&
557
+ i.price === o.price &&
558
+ i.feeRate === o.feeRate &&
559
+ i.taxAddress === o.taxAddress &&
560
+ i.tokenA.id === o.tokenA.id &&
561
+ i.tokenB?.id === o.tokenB?.id &&
562
+ o.volume < i.volume)))
563
+ issues.push('INVALID_ORDER_REMAINDER');
564
+ const matched = buy.volume < sell.volume ? buy.volume : sell.volume;
565
+ for (const original of [buy, sell]) {
566
+ const remaining = original.volume - matched;
567
+ const continuations = orderOutputs.filter((o) => o.side === original.side);
568
+ if (remaining === 0n
569
+ ? continuations.length !== 0
570
+ : continuations.length !== 1 || continuations[0].volume !== remaining)
571
+ issues.push('INVALID_ORDER_REMAINDER');
572
+ }
573
+ const aNet = matched - (matched * buy.feeRate) / 1000000n;
574
+ const payment = (matched * sell.price) / 1000000n;
575
+ const bNet = payment - (payment * sell.feeRate) / 1000000n;
576
+ const paid = (hash, to, n) => outs.some((t) => t.state.identity.slice(0, 64) === hash &&
577
+ owner(t.state).address === to &&
578
+ t.state.balance === n);
579
+ if (!buy.tokenB)
580
+ bindNativePayout(aNet, controllerHash(sell.hex), buy.owner);
581
+ const aFee = matched - aNet, bFee = payment - bNet;
582
+ if (!buy.tokenB && aFee > 0n)
583
+ bindNativePayout(aFee, controllerHash(sell.hex), buy.taxAddress);
584
+ if (bFee > 0n &&
585
+ !paid(buy.tokenB?.partialHash ?? buy.tokenA.partialHash, sell.taxAddress, bFee))
586
+ issues.push('ORDER_FEE_MISMATCH');
587
+ if (aFee > 0n &&
588
+ (buy.tokenB
589
+ ? !paid(buy.tokenA.partialHash, buy.taxAddress, aFee)
590
+ : !outputs.some((o) => o.address === buy.taxAddress && BigInt(o.satoshis) === aFee)))
591
+ issues.push('ORDER_FEE_MISMATCH');
592
+ if (buy.tokenB) {
593
+ if (!paid(buy.tokenA.partialHash, buy.owner, aNet) ||
594
+ !paid(buy.tokenB.partialHash, sell.owner, bNet))
595
+ issues.push('ORDER_SETTLEMENT_MISMATCH');
596
+ }
597
+ else if (!outputs.some((o) => o.address === buy.owner && BigInt(o.satoshis) === aNet) ||
598
+ !paid(buy.tokenA.partialHash, sell.owner, bNet))
599
+ issues.push('ORDER_SETTLEMENT_MISMATCH');
600
+ }
601
+ else
602
+ issues.push('ORDER_PAIR_MISMATCH');
603
+ }
604
+ else
605
+ issues.push('AMBIGUOUS_ORDER_OPERATION');
606
+ }
607
+ for (const o of [
608
+ ...orderInputs.map((o) => ({ ...o, source: 'input' })),
609
+ ...orderOutputs.map((o) => ({ ...o, source: 'output' })),
610
+ ]) {
611
+ const locked = o.tokenB
612
+ ? o.side === 'sell'
613
+ ? o.tokenA
614
+ : o.tokenB
615
+ : o.side === 'buy'
616
+ ? o.tokenA
617
+ : undefined;
618
+ if (!locked)
619
+ continue;
620
+ const list = o.source === 'input' ? ins : outs;
621
+ const n = sum(list.filter((t) => t.state.identity.slice(0, 64) === locked.partialHash &&
622
+ owner(t.state).hash === controllerHash(o.hex)));
623
+ const needed = o.side === 'buy' ? (o.volume * o.price) / 1000000n : o.volume;
624
+ if (n < needed)
625
+ issues.push('ORDER_LOCKED_BALANCE_MISMATCH');
626
+ }
627
+ if (htlcInputs.length || htlcOutputs.length) {
628
+ protocol = 'htlc';
629
+ action = 'deploy';
630
+ if (htlcInputs.length === 1) {
631
+ const h = htlcInputs[0], i = inputs[h.index], chunks = new tbc.Script(i.unlockingScript.hex ?? '').chunks;
632
+ const branch = chunks.at(-1)?.opcodenum;
633
+ if (branch === tbc.Opcode.OP_1) {
634
+ const secret = chunks.at(-2)?.buf;
635
+ if (secret && sha256(secret).toString('hex') === h.hashlock)
636
+ action = 'withdraw';
637
+ else
638
+ issues.push('HTLC_PREIMAGE_MISMATCH');
639
+ }
640
+ else if (branch === tbc.Opcode.OP_0) {
641
+ action = 'refund';
642
+ if (i.sequence === 0xffffffff ||
643
+ locktime < h.timelock ||
644
+ locktime < 500000000 !== h.timelock < 500000000)
645
+ issues.push('HTLC_LOCK_UNSATISFIED');
646
+ }
647
+ else {
648
+ action = 'unknown';
649
+ issues.push('HTLC_UNKNOWN_BRANCH');
650
+ }
651
+ const receiver = action === 'refund' ? h.sender : h.receiver;
652
+ const controlled = ins.filter((t) => owner(t.state).hash === controllerHash(h.hex));
653
+ if (controlled.length) {
654
+ for (const t of controlled)
655
+ if (!outs.some((o) => o.state.identity === t.state.identity &&
656
+ owner(o.state).address === receiver &&
657
+ o.state.balance === t.state.balance))
658
+ issues.push('HTLC_TOKEN_RECIPIENT_MISMATCH');
659
+ }
660
+ else if (!outputs.some((o) => o.address === receiver && o.satoshis > 0))
661
+ issues.push('HTLC_RECIPIENT_MISMATCH');
662
+ }
663
+ else if (htlcInputs.length > 1)
664
+ issues.push('AMBIGUOUS_HTLC_OPERATION');
665
+ }
666
+ if ((protocol === 'ft' || protocol === 'stablecoin') && action === 'transfer') {
667
+ const senders = inputs
668
+ .filter((i) => i.prevout?.script.type === 'P2PKH')
669
+ .map((i) => i.prevout?.address);
670
+ if (outputs.some((o) => o.script.type === 'P2PKH' && o.satoshis > 0 && o.address && !senders.includes(o.address)))
671
+ action = 'transferWithTbc';
672
+ }
673
+ for (const token of [...ins, ...outs]) {
674
+ const tape = ins.includes(token)
675
+ ? inputs[token.index].prevout?.associatedTape?.script
676
+ : outputs[token.index + 1]?.script;
677
+ const state = token.state;
678
+ if (!tape || state.decimal === undefined)
679
+ continue;
680
+ tape.data = { ...tape.data, ftTape: {
681
+ name: state.name ?? '', symbol: state.symbol ?? '', decimal: state.decimal,
682
+ rawAmount: state.balance.toString(), amountSlots: state.amounts.map(String),
683
+ displayAmount: exactDisplay(state.balance, state.decimal),
684
+ amount: Number(state.balance) / 10 ** state.decimal,
685
+ lockTime: state.lockTime, isStableCoin: state.descriptor.family === 'stablecoin',
686
+ } };
687
+ }
688
+ // Preserve the established OrderBook state fields on Code outputs and prevouts.
689
+ // Continuation outputs are state, not a second asset movement.
690
+ for (const order of orderInputs) {
691
+ const script = inputs[order.index].prevout.script;
692
+ script.data = { ...script.data, orderBook: {
693
+ side: order.side, holdAddress: order.owner, saleVolumeSatoshis: Number(order.volume),
694
+ feeRateRaw: order.feeRate.toString(), unitPriceRaw: order.price.toString(),
695
+ ftPartialHash: order.tokenA.partialHash, ftID: order.tokenA.id,
696
+ } };
697
+ }
698
+ for (const order of orderOutputs) {
699
+ const script = outputs[order.index].script;
700
+ script.data = { ...script.data, orderBook: {
701
+ side: order.side, holdAddress: order.owner, saleVolumeSatoshis: Number(order.volume),
702
+ feeRateRaw: order.feeRate.toString(), unitPriceRaw: order.price.toString(),
703
+ ftPartialHash: order.tokenA.partialHash, ftID: order.tokenA.id,
704
+ } };
705
+ }
706
+ // Public flows describe economic movements. Output locations and reserve/carrier
707
+ // balances stay in chain, using the same conventions as earlier contract generations.
708
+ const flows = baseFlows.filter((f) => f.asset.type !== 'tbc');
709
+ const nativeSenders = [...new Set(inputs
710
+ .filter((i) => i.prevout?.script.type === 'P2PKH')
711
+ .map((i) => i.prevout?.address).filter((a) => !!a))];
712
+ const nativeFrom = nativeSenders.length === 1
713
+ ? { address: nativeSenders[0] }
714
+ : nativeSenders.length ? { addresses: nativeSenders }
715
+ : { outpoints: inputs.map((i) => ({ txid: i.prevTxid, vout: i.vout })) };
716
+ const pool = poolOutputs[0];
717
+ const poolHash = pool && controllerHash(pool.hex);
718
+ const poolId = poolInputs[0] && inputs[poolInputs[0].index].prevout?.contractId;
719
+ const poolEndpoint = poolHash
720
+ ? poolId ? { poolId } : { hash: poolHash } : undefined;
721
+ const publicOwner = (endpoint) => {
722
+ if (endpoint.hash === poolHash && poolEndpoint)
723
+ return poolEndpoint;
724
+ const order = orderInputs.find((o) => controllerHash(o.hex) === endpoint.hash);
725
+ return order && action === 'match' ? { address: order.owner } : endpoint;
726
+ };
727
+ const sourceOf = (tokens) => {
728
+ const owners = [...new Map(tokens.map((t) => {
729
+ const endpoint = publicOwner(owner(t.state));
730
+ return [JSON.stringify(endpoint), endpoint];
731
+ })).values()];
732
+ return owners.length === 1 ? owners[0] : {
733
+ outpoints: tokens.map((t) => ({ txid: inputs[t.index].prevTxid, vout: inputs[t.index].vout })),
734
+ };
735
+ };
736
+ for (let flow of tokenFlows) {
737
+ const output = flowOutputs.get(flow);
738
+ const identity = assetIdentities.get(flow.asset);
739
+ const sources = ins.filter((t) => t.state.identity === identity && output.state.amounts[t.index] > 0n);
740
+ const destination = owner(output.state);
741
+ // A pool reserve output combines old reserves and a deposit. Expose only the deposit.
742
+ if (poolHash && destination.hash === poolHash && action !== 'mergeFtInPool') {
743
+ const deposits = sources.filter((t) => owner(t.state).hash !== poolHash);
744
+ const amount = deposits.reduce((n, t) => n + output.state.amounts[t.index], 0n);
745
+ if (!amount)
746
+ continue;
747
+ flow.amount = { raw: amount.toString(), ...(output.state.decimal !== undefined
748
+ ? { decimal: output.state.decimal, display: exactDisplay(amount, output.state.decimal) } : {}) };
749
+ flow.from = sourceOf(deposits);
750
+ flow.purpose = 'transfer';
751
+ flow.direction = 'out';
752
+ }
753
+ if (flow.from)
754
+ flow.from = publicOwner(flow.from);
755
+ if (flow.to)
756
+ flow.to = publicOwner(flow.to);
757
+ delete flow.context;
758
+ if (protocol === 'poolNft' && action === 'consumeLiquidity' && flow.asset.type === 'ftlp') {
759
+ if (flow.purpose !== 'burn')
760
+ continue; // LP change is part of the redemption, not another payment.
761
+ if (!poolEndpoint)
762
+ continue;
763
+ flow = { ...flow, purpose: 'transfer', direction: 'out', to: poolEndpoint };
764
+ }
765
+ const destinationOrder = orderOutputs.find((o) => controllerHash(o.hex) === destination.hash);
766
+ if (destinationOrder) {
767
+ if (action === 'match')
768
+ continue; // Remaining locked order state is not a second placement.
769
+ flow.purpose = 'lock';
770
+ flow.direction = 'out';
771
+ flow.context = { protocol: 'orderBook', action, recognized: issues.length === 0 };
772
+ }
773
+ else if (protocol === 'orderBook' && action.startsWith('cancel') &&
774
+ sources.some((t) => orderInputs.some((o) => controllerHash(o.hex) === owner(t.state).hash))) {
775
+ flow.purpose = 'release';
776
+ flow.direction = 'in';
777
+ }
778
+ if (htlcOutputs.some((h) => controllerHash(h.hex) === destination.hash)) {
779
+ flow.purpose = 'lock';
780
+ flow.direction = 'out';
781
+ flow.context = { protocol: 'htlc', action, recognized: issues.length === 0 };
782
+ }
783
+ else if (htlcInputs.some((h) => sources.some((t) => owner(t.state).hash === controllerHash(h.hex)))) {
784
+ flow.purpose = 'release';
785
+ flow.direction = 'in';
786
+ flow.context = { protocol: 'htlc', action, recognized: issues.length === 0 };
787
+ }
788
+ if (issues.length)
789
+ flow.context = { protocol, action, recognized: false };
790
+ flows.push(flow);
791
+ }
792
+ if (pool && poolEndpoint) {
793
+ const next = pool.tape?.script.hex && poolTape(pool.tape.script.hex);
794
+ const previous = poolInputs[0]?.tape?.script.hex && poolTape(poolInputs[0].tape.script.hex);
795
+ if (next && action === 'create')
796
+ flows.push({
797
+ asset: { type: 'nft', subtype: 'poolNft', contractId: txid, poolNft: {
798
+ ...poolMetadata(pool.code, next),
799
+ } }, purpose: 'mint', direction: 'in',
800
+ });
801
+ if (next && previous && next.tbcAmount > previous.tbcAmount)
802
+ flows.push({
803
+ asset: { type: 'tbc', symbol: 'TBC' },
804
+ amount: { satoshis: Number(next.tbcAmount - previous.tbcAmount) },
805
+ purpose: 'transfer', direction: 'out', from: nativeFrom, to: poolEndpoint,
806
+ });
807
+ // Keep AMM state under the existing PoolNFT tape schema, without a public generation field.
808
+ for (const p of [...poolInputs, ...poolOutputs]) {
809
+ const tape = p.tape, state = tape?.script.hex && poolTape(tape.script.hex);
810
+ if (!state || !tape)
811
+ continue;
812
+ tape.script.data = { ...tape.script.data, poolNft: {
813
+ version: 2, combinedPartialHash: state.ftLpPartialHash.toString('hex') + state.ftAPartialHash.toString('hex'),
814
+ ftLpPartialHash: state.ftLpPartialHash.toString('hex'), ftAPartialHash: state.ftAPartialHash.toString('hex'),
815
+ ftLpAmountRaw: state.ftLpAmount.toString(), ftLpDisplayAmount: exactDisplay(state.ftLpAmount, 6),
816
+ ftAAmountRaw: state.ftAAmount.toString(), tbcSatoshis: Number(state.tbcAmount),
817
+ ftContractTxid: state.ftAContractId, ...poolMetadata(p.code, state),
818
+ } };
819
+ }
820
+ }
821
+ for (const o of outputs) {
822
+ if (o.satoshis <= 0)
823
+ continue;
824
+ const order = orderOutputs.find((p) => p.index === o.index);
825
+ const htlc = htlcOutputs.find((p) => p.index === o.index);
826
+ const controlledTokens = (hex) => outs.some((t) => owner(t.state).hash === controllerHash(hex));
827
+ const nftCarrier = o.script.contract?.family === 'nft' ||
828
+ outputs.some((code) => nftCode(code.script.hex ?? '') && code.index + 1 === o.index);
829
+ if (poolOutputs.some((p) => p.index === o.index) ||
830
+ (tokenOutputIndexes.has(o.index) || nftCarrier) && o.satoshis < 5000 ||
831
+ order && (controlledTokens(order.hex) || action === 'match') ||
832
+ htlc && controlledTokens(htlc.hex))
833
+ continue;
834
+ const to = o.address ? { address: o.address }
835
+ : o.script.data?.hash ? { hash: o.script.data.hash } : { script: { hex: o.script.hex } };
836
+ const amount = { satoshis: o.satoshis };
837
+ const asset = { type: 'tbc', symbol: 'TBC' };
838
+ if (order || htlc) {
839
+ flows.push({ asset, amount, purpose: 'lock', direction: 'out', from: nativeFrom,
840
+ to: { hash: controllerHash((order ?? htlc).hex) },
841
+ context: { protocol, action, recognized: issues.length === 0 } });
842
+ }
843
+ else if (o.script.type === 'P2PKH') {
844
+ const h = htlcInputs[0];
845
+ const nativeHtlc = h && !ins.some((t) => owner(t.state).hash === controllerHash(h.hex));
846
+ const htlcPayout = nativeHtlc && o.address === (action === 'refund' ? h.sender : h.receiver);
847
+ const bound = nativeSources.get(o.index);
848
+ const contractSource = htlcPayout ? { hash: controllerHash(h.hex) } : bound && publicOwner(bound);
849
+ const release = !!htlcPayout || !!bound && action.startsWith('cancel');
850
+ const self = !contractSource && !!o.address && nativeSenders.includes(o.address);
851
+ flows.push({ asset, amount,
852
+ ...(release ? { purpose: 'release', direction: 'in' }
853
+ : self ? { purpose: 'self', direction: 'self' }
854
+ : { purpose: 'transfer', direction: 'out' }),
855
+ from: contractSource ?? nativeFrom, to,
856
+ ...(htlcPayout ? { context: { protocol, action, recognized: issues.length === 0 } } : {}), });
857
+ }
858
+ else {
859
+ flows.push({ asset, amount, purpose: 'transfer', direction: 'out', from: nativeFrom, to,
860
+ context: { protocol: 'unknown', recognized: false } });
861
+ issues.push('UNKNOWN_NONZERO_OUTPUT');
862
+ }
863
+ }
864
+ if (outputs.reduce((n, o) => n + BigInt(o.satoshis), 0n) >
865
+ inputs.reduce((n, i) => n + BigInt(i.prevout?.satoshis ?? 0), 0n))
866
+ issues.push('NATIVE_VALUE_NOT_CONSERVED');
867
+ for (const f of flows)
868
+ if (f.context && f.context.protocol !== 'unknown')
869
+ f.context = { protocol, action, recognized: issues.length === 0 };
870
+ // An independent NFT action retains the legacy classifier when no fungible or controller family wins.
871
+ if (!issues.length &&
872
+ !certOutput &&
873
+ !ins.length &&
874
+ !outs.length &&
875
+ !poolInputs.length &&
876
+ !poolOutputs.length &&
877
+ !orderInputs.length &&
878
+ !orderOutputs.length &&
879
+ !htlcInputs.length &&
880
+ !htlcOutputs.length)
881
+ return { assetFlows: flows, classification: baseClassification };
882
+ return {
883
+ assetFlows: flows,
884
+ classification: classification(protocol, action, issues, `${protocol} ${action}${issues.length ? ' (incomplete or inconsistent evidence)' : ''}.`),
885
+ };
886
+ }
887
+ //# sourceMappingURL=analyze.js.map