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