bsv-sdk 0.16.0 → 0.17.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.
@@ -13,7 +13,7 @@ module BSV
13
13
  #
14
14
  # @example Parse a BEEF bundle and find a transaction
15
15
  # beef = BSV::Transaction::Beef.from_hex(beef_hex)
16
- # tx = beef.find_transaction(txid_bytes)
16
+ # tx = beef.find_transaction(wtxid)
17
17
  class Beef
18
18
  # @!group Version constants
19
19
 
@@ -44,37 +44,53 @@ module BSV
44
44
  # @return [Transaction, nil] the transaction (nil for TXID-only entries)
45
45
  attr_reader :transaction
46
46
 
47
- # @return [String, nil] 32-byte txid for TXID-only entries
48
- attr_reader :known_txid
47
+ # @return [String, nil] 32-byte wire-order wtxid for TXID-only entries
48
+ attr_reader :known_wtxid
49
49
 
50
50
  # @return [Integer, nil] index into the BEEF bumps array
51
51
  attr_reader :bump_index
52
52
 
53
53
  # @param format [Integer] format flag
54
54
  # @param transaction [Transaction, nil] the transaction
55
- # @param known_txid [String, nil] 32-byte txid for TXID-only entries
55
+ # @param known_wtxid [String, nil] 32-byte wire-order wtxid for TXID-only entries
56
56
  # @param bump_index [Integer, nil] index into the bumps array
57
57
  # @raise [ArgumentError] if format is FORMAT_RAW_TX_AND_BUMP without a bump_index
58
- def initialize(format:, transaction: nil, known_txid: nil, bump_index: nil)
58
+ def initialize(format:, transaction: nil, known_wtxid: nil, bump_index: nil)
59
59
  raise ArgumentError, 'FORMAT_RAW_TX_AND_BUMP requires a bump_index' if format == FORMAT_RAW_TX_AND_BUMP && bump_index.nil?
60
60
 
61
+ BSV::Primitives::Hex.validate_wtxid!(known_wtxid, name: 'known_wtxid') if known_wtxid
61
62
  @format = format
62
63
  @transaction = transaction
63
- @known_txid = known_txid
64
+ @known_wtxid = known_wtxid
64
65
  @bump_index = bump_index
65
66
  end
66
67
 
67
- # The transaction ID for this entry.
68
- #
69
- # @return [String, nil] 32-byte txid in display byte order
70
- def txid
68
+ # Wire-order transaction ID.
69
+ # @return [String, nil] 32-byte wtxid
70
+ def wtxid
71
71
  case @format
72
72
  when FORMAT_TXID_ONLY
73
- @known_txid
73
+ @known_wtxid
74
74
  else
75
- @transaction&.txid
75
+ @transaction&.wtxid
76
76
  end
77
77
  end
78
+
79
+ # Display-order transaction ID as binary bytes.
80
+ # @return [String, nil] 32-byte display-order txid
81
+ def txid
82
+ wtxid&.reverse
83
+ end
84
+
85
+ # Display-order transaction ID as a hex string.
86
+ #
87
+ # +dtxid+ always returns a 64-char hex string suitable for JSON
88
+ # and UI boundaries.
89
+ #
90
+ # @return [String, nil] hex-encoded transaction ID (display order)
91
+ def dtxid
92
+ wtxid&.reverse&.unpack1('H*')
93
+ end
78
94
  end
79
95
 
80
96
  # @return [Integer] BEEF version constant
@@ -86,8 +102,21 @@ module BSV
86
102
  # @return [Array<BeefTx>] the transactions in dependency order
87
103
  attr_reader :transactions
88
104
 
89
- # @return [String, nil] 32-byte subject txid (Atomic BEEF only)
90
- attr_reader :subject_txid
105
+ # @return [String, nil] 32-byte wire-order subject txid (Atomic BEEF only)
106
+ attr_reader :subject_wtxid
107
+
108
+ # Display-order subject txid as binary bytes (Atomic BEEF only).
109
+ # @return [String, nil] 32-byte display-order txid, or nil
110
+ def subject_txid
111
+ @subject_wtxid&.reverse
112
+ end
113
+
114
+ # Display-order subject txid as a hex string (Atomic BEEF only).
115
+ #
116
+ # @return [String, nil] hex-encoded display-order txid, or nil
117
+ def subject_dtxid
118
+ @subject_wtxid&.reverse&.unpack1('H*')
119
+ end
91
120
 
92
121
  # @param version [Integer] BEEF version constant (default: BEEF_V1, matching to_binary's
93
122
  # default for ARC compatibility; from_binary overwrites this with the parsed version)
@@ -97,7 +126,7 @@ module BSV
97
126
  @version = version
98
127
  @bumps = bumps
99
128
  @transactions = transactions
100
- @subject_txid = nil
129
+ @subject_wtxid = nil
101
130
  end
102
131
 
103
132
  # --- Deserialisation ---
@@ -133,9 +162,9 @@ module BSV
133
162
  raise ArgumentError, "truncated Atomic BEEF: need 36 bytes at offset #{offset}, got #{remaining}"
134
163
  end
135
164
 
136
- # Atomic BEEF stores the subject txid in internal byte order (little-endian
137
- # hash order), matching JS and Go SDKs. Reverse to display order for internal use.
138
- beef.instance_variable_set(:@subject_txid, data.byteslice(offset, 32).reverse)
165
+ # Atomic BEEF stores the subject txid in wire (internal / little-endian) byte order,
166
+ # matching JS and Go SDKs. Store as-is in @subject_wtxid (wire-order).
167
+ beef.instance_variable_set(:@subject_wtxid, data.byteslice(offset, 32))
139
168
  offset += 32
140
169
  inner_version = data.byteslice(offset, 4).unpack1('V')
141
170
  offset += 4
@@ -223,13 +252,13 @@ module BSV
223
252
 
224
253
  # Serialise as Atomic BEEF (BRC-95), wrapping V2 data with a subject txid.
225
254
  #
226
- # @param subject_txid [String] 32-byte subject transaction ID
255
+ # @param subject_wtxid [String] 32-byte wire-order subject transaction ID
227
256
  # @return [String] raw Atomic BEEF binary
228
- def to_atomic_binary(subject_txid)
257
+ def to_atomic_binary(subject_wtxid)
258
+ BSV::Primitives::Hex.validate_wtxid!(subject_wtxid, name: 'subject_wtxid')
229
259
  buf = [ATOMIC_BEEF].pack('V')
230
- # Write subject txid in internal byte order (reverse of display order),
231
- # matching JS and Go SDK conventions for Bitcoin binary formats.
232
- buf << subject_txid.b.reverse
260
+ # subject_wtxid is already in wire (internal) byte order write as-is.
261
+ buf << subject_wtxid.b
233
262
  # BRC-95: inner envelope is always V2
234
263
  buf << to_binary(version: BEEF_V2)
235
264
  buf
@@ -237,42 +266,45 @@ module BSV
237
266
 
238
267
  # --- Lookup ---
239
268
 
240
- # Find a transaction in the bundle by its transaction ID.
269
+ # Find a transaction in the bundle by its wire-order transaction ID.
241
270
  #
242
- # @param txid [String] 32-byte txid in display byte order
271
+ # @param wtxid [String] 32-byte wire-order wtxid
243
272
  # @return [Transaction, nil] the matching transaction, or nil
244
- def find_transaction(txid)
273
+ def find_transaction(wtxid)
274
+ BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid')
275
+ BSV.logger&.debug { "[Beef] find_transaction: #{wtxid.reverse.unpack1('H*')} in #{@transactions.length} entries" }
245
276
  @transactions.each do |beef_tx|
246
- return beef_tx.transaction if beef_tx.transaction&.txid == txid
277
+ return beef_tx.transaction if beef_tx.wtxid == wtxid
247
278
  end
248
279
  nil
249
280
  end
250
281
 
251
- # Find the merkle path (BUMP) for a transaction by its txid.
282
+ # Find the merkle path (BUMP) for a transaction by its wire-order txid.
252
283
  #
253
284
  # First checks the transaction-table entries, then scans @bumps directly
254
- # for a BUMP whose level-0 leaves contain the txid.
285
+ # for a BUMP whose level-0 leaves contain the wtxid.
255
286
  #
256
- # @param txid [String] 32-byte txid in display byte order
287
+ # @param wtxid [String] 32-byte wire-order wtxid
257
288
  # @return [MerklePath, nil] the merkle path, or nil if not found
258
- def find_bump(txid)
289
+ def find_bump(wtxid)
290
+ BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid')
259
291
  # Check transaction-table entries first (fast path)
260
- bt = @transactions.find { |entry| entry.txid == txid && entry.format == FORMAT_RAW_TX_AND_BUMP }
292
+ bt = @transactions.find { |entry| entry.wtxid == wtxid && entry.format == FORMAT_RAW_TX_AND_BUMP }
261
293
  return bt.transaction&.merkle_path || (bt.bump_index && @bumps[bt.bump_index]) if bt
262
294
 
263
- # F5.8: also scan @bumps directly for a path containing the txid leaf
264
- txid_internal = txid.reverse
295
+ # F5.8: also scan @bumps directly for a path containing the wtxid leaf
265
296
  @bumps.find do |bump|
266
- bump.path[0]&.any? { |leaf| leaf.hash == txid_internal }
297
+ bump.path[0]&.any? { |leaf| leaf.hash == wtxid }
267
298
  end
268
299
  end
269
300
 
270
301
  # Find a transaction with all source_transactions wired for signing.
271
302
  #
272
- # @param txid [String] 32-byte txid in display byte order
303
+ # @param wtxid [String] 32-byte wire-order wtxid
273
304
  # @return [Transaction, nil] the transaction with wired inputs, or nil
274
- def find_transaction_for_signing(txid)
275
- tx = find_transaction(txid)
305
+ def find_transaction_for_signing(wtxid)
306
+ BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid')
307
+ tx = find_transaction(wtxid)
276
308
  return unless tx
277
309
 
278
310
  wire_inputs(tx)
@@ -282,10 +314,11 @@ module BSV
282
314
  # Find a transaction and recursively wire its ancestry (source transactions
283
315
  # and merkle paths) for atomic proof validation.
284
316
  #
285
- # @param txid [String] 32-byte txid in display byte order
317
+ # @param wtxid [String] 32-byte wire-order wtxid
286
318
  # @return [Transaction, nil] the transaction with full proof tree, or nil
287
- def find_atomic_transaction(txid)
288
- tx = find_transaction(txid)
319
+ def find_atomic_transaction(wtxid)
320
+ BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid')
321
+ tx = find_transaction(wtxid)
289
322
  return unless tx
290
323
 
291
324
  wire_ancestry(tx)
@@ -294,10 +327,10 @@ module BSV
294
327
 
295
328
  # Serialise as Atomic BEEF (BRC-95) hex string.
296
329
  #
297
- # @param subject_txid [String] 32-byte subject transaction ID
330
+ # @param subject_wtxid [String] 32-byte wire-order subject transaction ID
298
331
  # @return [String] hex-encoded Atomic BEEF
299
- def to_atomic_hex(subject_txid)
300
- to_atomic_binary(subject_txid).unpack1('H*')
332
+ def to_atomic_hex(subject_wtxid)
333
+ to_atomic_binary(subject_wtxid).unpack1('H*')
301
334
  end
302
335
 
303
336
  # --- Merge operations ---
@@ -339,7 +372,7 @@ module BSV
339
372
  level0_internal = level0_leaves.map(&:hash).compact.to_set
340
373
  @transactions.each_with_index do |bt, i|
341
374
  next unless bt.format == FORMAT_RAW_TX && bt.transaction
342
- next unless level0_internal.include?(bt.transaction.txid.reverse)
375
+ next unless level0_internal.include?(bt.transaction.wtxid)
343
376
 
344
377
  bt.transaction.merkle_path ||= bump
345
378
  @transactions[i] = BeefTx.new(
@@ -362,10 +395,10 @@ module BSV
362
395
  # @param tx [Transaction] the transaction to merge
363
396
  # @return [BeefTx] the (possibly existing or upgraded) BeefTx entry
364
397
  def merge_transaction(tx)
365
- txid = tx.txid
398
+ wtxid = tx.wtxid
366
399
 
367
400
  # Check for existing entry and upgrade if a stronger format is available
368
- existing_idx = @transactions.index { |bt| bt.txid == txid }
401
+ existing_idx = @transactions.index { |bt| bt.wtxid == wtxid }
369
402
  if existing_idx
370
403
  existing = @transactions[existing_idx]
371
404
  upgraded = upgrade_beef_tx(existing, tx)
@@ -409,7 +442,7 @@ module BSV
409
442
  tx.merkle_path = @bumps[bump_index]
410
443
  end
411
444
 
412
- existing_idx = @transactions.index { |bt| bt.txid == tx.txid }
445
+ existing_idx = @transactions.index { |bt| bt.wtxid == tx.wtxid }
413
446
  if existing_idx
414
447
  existing = @transactions[existing_idx]
415
448
  upgraded = upgrade_beef_tx(existing, tx, bump_index: bump_index)
@@ -448,11 +481,11 @@ module BSV
448
481
  other.transactions.each do |beef_tx|
449
482
  case beef_tx.format
450
483
  when FORMAT_TXID_ONLY
451
- next if @transactions.any? { |bt| bt.txid == beef_tx.known_txid }
484
+ next if @transactions.any? { |bt| bt.wtxid == beef_tx.known_wtxid }
452
485
 
453
- @transactions << BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: beef_tx.known_txid)
486
+ @transactions << BeefTx.new(format: FORMAT_TXID_ONLY, known_wtxid: beef_tx.known_wtxid)
454
487
  else
455
- next if @transactions.any? { |bt| bt.txid == beef_tx.txid }
488
+ next if @transactions.any? { |bt| bt.wtxid == beef_tx.wtxid }
456
489
 
457
490
  if beef_tx.format == FORMAT_RAW_TX_AND_BUMP && beef_tx.bump_index
458
491
  new_idx = bump_remap[beef_tx.bump_index]
@@ -482,13 +515,14 @@ module BSV
482
515
 
483
516
  # Convert a transaction entry to TXID-only format.
484
517
  #
485
- # @param txid [String] 32-byte txid in display byte order
518
+ # @param wtxid [String] 32-byte wire-order wtxid
486
519
  # @return [BeefTx, nil] the converted entry, or nil if not found
487
- def make_txid_only(txid)
488
- idx = @transactions.index { |bt| bt.txid == txid }
520
+ def make_txid_only(wtxid)
521
+ BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid')
522
+ idx = @transactions.index { |bt| bt.wtxid == wtxid }
489
523
  return unless idx
490
524
 
491
- @transactions[idx] = BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: txid)
525
+ @transactions[idx] = BeefTx.new(format: FORMAT_TXID_ONLY, known_wtxid: wtxid)
492
526
  end
493
527
 
494
528
  # --- Validation ---
@@ -520,15 +554,15 @@ module BSV
520
554
 
521
555
  # The txid must appear as a leaf in the BUMP and compute a valid root
522
556
  begin
523
- bump.compute_root(bt.transaction.txid.reverse)
557
+ bump.compute_root(bt.transaction.wtxid)
524
558
  rescue ArgumentError
525
559
  return false
526
560
  end
527
561
  end
528
562
 
529
- known_txids = build_known_txids(allow_txid_only)
563
+ known_wtxids = build_known_wtxids(allow_txid_only)
530
564
 
531
- pending = @transactions.select { |bt| bt.transaction && !known_txids.include?(bt.txid) }
565
+ pending = @transactions.select { |bt| bt.transaction && !known_wtxids.include?(bt.wtxid) }
532
566
 
533
567
  # Iteratively resolve: if all inputs of a tx are known, it becomes known
534
568
  changed = true
@@ -536,10 +570,10 @@ module BSV
536
570
  changed = false
537
571
  pending.reject! do |bt|
538
572
  all_inputs_known = bt.transaction.inputs.all? do |input|
539
- known_txids.include?(input.prev_tx_id.reverse)
573
+ known_wtxids.include?(input.prev_wtxid)
540
574
  end
541
575
  if all_inputs_known
542
- known_txids.add(bt.txid)
576
+ known_wtxids.add(bt.wtxid)
543
577
  changed = true
544
578
  end
545
579
  all_inputs_known
@@ -584,8 +618,8 @@ module BSV
584
618
  def sort_transactions!
585
619
  return self if @transactions.length <= 1
586
620
 
587
- txid_index = {}
588
- @transactions.each_with_index { |bt, i| txid_index[bt.txid] = i }
621
+ wtxid_index = {}
622
+ @transactions.each_with_index { |bt, i| wtxid_index[bt.wtxid] = i }
589
623
 
590
624
  # Build adjacency: for each tx, which other txs must come before it?
591
625
  in_degree = Array.new(@transactions.length, 0)
@@ -595,7 +629,7 @@ module BSV
595
629
  next unless bt.transaction
596
630
 
597
631
  bt.transaction.inputs.each do |input|
598
- dep_idx = txid_index[input.prev_tx_id.reverse]
632
+ dep_idx = wtxid_index[input.prev_wtxid]
599
633
  next unless dep_idx
600
634
 
601
635
  dependents[dep_idx] << i
@@ -618,8 +652,8 @@ module BSV
618
652
 
619
653
  # F5.5: preserve unsortable (cyclic) transactions rather than silently dropping them
620
654
  if sorted.length < @transactions.length
621
- sorted_set = sorted.to_set(&:txid)
622
- @txs_not_valid = @transactions.reject { |bt| sorted_set.include?(bt.txid) }
655
+ sorted_set = sorted.to_set(&:wtxid)
656
+ @txs_not_valid = @transactions.reject { |bt| sorted_set.include?(bt.wtxid) }
623
657
  end
624
658
 
625
659
  @transactions = sorted
@@ -661,14 +695,13 @@ module BSV
661
695
 
662
696
  case format
663
697
  when FORMAT_TXID_ONLY
664
- # Wire stores txid in internal (little-endian) byte order;
665
- # reverse to display order so BeefTx#txid is consistent with
666
- # Transaction#txid across all format types.
698
+ # Wire stores txid in internal (little-endian / wire) byte order;
699
+ # store as-is in known_wtxid so it matches Transaction#wtxid.
667
700
  raise ArgumentError, 'truncated BEEF: not enough bytes for TXID_ONLY entry' if offset + 32 > data.bytesize
668
701
 
669
- known_txid = data.byteslice(offset, 32).reverse
702
+ known_wtxid = data.byteslice(offset, 32)
670
703
  offset += 32
671
- beef.transactions << BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: known_txid)
704
+ beef.transactions << BeefTx.new(format: FORMAT_TXID_ONLY, known_wtxid: known_wtxid)
672
705
  when FORMAT_RAW_TX_AND_BUMP
673
706
  bump_index, vi_size = VarInt.decode(data, offset)
674
707
  offset += vi_size
@@ -719,14 +752,20 @@ module BSV
719
752
  beef.transactions.each do |beef_tx|
720
753
  next unless beef_tx.transaction
721
754
 
722
- # Wire inputs to ancestors already in the map (BEEF is dependency-ordered)
755
+ # Wire inputs to ancestors already in the map (BEEF is dependency-ordered).
756
+ # Both prev_wtxid and wtxid are wire-order — no conversion needed.
723
757
  beef_tx.transaction.inputs.each do |input|
724
- # prev_tx_id is wire byte order; txid keys are display byte order (reversed)
725
- source = tx_map[input.prev_tx_id.reverse]
726
- input.source_transaction = source if source
758
+ source = tx_map[input.prev_wtxid]
759
+ next unless source
760
+
761
+ input.source_transaction = source
762
+ BSV.logger&.debug do
763
+ "[Beef] wired input #{input.prev_wtxid.reverse.unpack1('H*')}:#{input.prev_tx_out_index} " \
764
+ "-> source #{source.wtxid.reverse.unpack1('H*')}"
765
+ end
727
766
  end
728
767
 
729
- tx_map[beef_tx.transaction.txid] = beef_tx.transaction
768
+ tx_map[beef_tx.transaction.wtxid] = beef_tx.transaction
730
769
  end
731
770
  end
732
771
  end
@@ -768,15 +807,15 @@ module BSV
768
807
  # FORMAT_RAW_TX_AND_BUMP is already the strongest — no upgrade needed
769
808
  end
770
809
 
771
- # Build a set of txids that are "known" (proven or txid-only).
772
- def build_known_txids(allow_txid_only)
810
+ # Build a set of wire-order wtxids that are "known" (proven or txid-only).
811
+ def build_known_wtxids(allow_txid_only)
773
812
  known = Set.new
774
813
  @transactions.each do |bt|
775
814
  case bt.format
776
815
  when FORMAT_RAW_TX_AND_BUMP
777
- known.add(bt.txid)
816
+ known.add(bt.wtxid)
778
817
  when FORMAT_TXID_ONLY
779
- known.add(bt.txid) if allow_txid_only
818
+ known.add(bt.wtxid) if allow_txid_only
780
819
  end
781
820
  end
782
821
  known
@@ -787,7 +826,7 @@ module BSV
787
826
  tx.inputs.each do |input|
788
827
  next if input.source_transaction
789
828
 
790
- source = find_transaction(input.prev_tx_id.reverse)
829
+ source = find_transaction(input.prev_wtxid)
791
830
  input.source_transaction = source if source
792
831
  end
793
832
  end
@@ -799,7 +838,7 @@ module BSV
799
838
  next unless input.source_transaction
800
839
 
801
840
  source = input.source_transaction
802
- source.merkle_path ||= find_bump(source.txid)
841
+ source.merkle_path ||= find_bump(source.wtxid)
803
842
  wire_ancestry(source)
804
843
  end
805
844
  end
@@ -820,8 +859,8 @@ module BSV
820
859
  case beef_tx.format
821
860
  when FORMAT_TXID_ONLY
822
861
  buf << [FORMAT_TXID_ONLY].pack('C')
823
- # Reverse display-order txid back to wire (internal) byte order.
824
- buf << beef_tx.known_txid.reverse
862
+ # known_wtxid is already wire (internal) byte order.
863
+ buf << beef_tx.known_wtxid
825
864
  when FORMAT_RAW_TX_AND_BUMP
826
865
  buf << [FORMAT_RAW_TX_AND_BUMP].pack('C')
827
866
  buf << VarInt.encode(beef_tx.bump_index)
@@ -21,7 +21,8 @@ module BSV
21
21
  # @!attribute [r] hash
22
22
  # @return [String, nil] 32-byte hash (nil when duplicate)
23
23
  # @!attribute [r] txid
24
- # @return [Boolean] whether this leaf is a transaction ID
24
+ # @return [Boolean] BRC-74 flag — whether this leaf represents a transaction in
25
+ # the merkle tree (the 0x02 bit in the BRC-74 serialisation). Not a txid value.
25
26
  # @!attribute [r] duplicate
26
27
  # @return [Boolean] whether this leaf duplicates its sibling
27
28
  class PathElement
@@ -29,7 +30,9 @@ module BSV
29
30
 
30
31
  # @param offset [Integer] position index within the tree level
31
32
  # @param hash [String, nil] 32-byte hash (nil when duplicate)
32
- # @param txid [Boolean] whether this leaf is a transaction ID
33
+ # @param txid [Boolean] BRC-74 flag — true when this leaf represents a transaction
34
+ # in the tree (the 0x02 bit in the BRC-74 serialisation). This is NOT a txid
35
+ # value; it is a boolean presence flag mandated by the BRC-74 specification.
33
36
  # @param duplicate [Boolean] whether this leaf duplicates its sibling
34
37
  def initialize(offset:, hash: nil, txid: false, duplicate: false)
35
38
  @offset = offset
@@ -147,25 +150,26 @@ module BSV
147
150
  # @example Convert a WoC TSC proof
148
151
  # tsc = JSON.parse(woc_response).first
149
152
  # mp = BSV::Transaction::MerklePath.from_tsc(
150
- # txid: tsc['txOrId'],
153
+ # dtxid_hex: tsc['txOrId'],
151
154
  # index: tsc['index'],
152
155
  # nodes: tsc['nodes'],
153
156
  # block_height: 612_251
154
157
  # )
155
158
  # mp.compute_root_hex(tsc['txOrId']) #=> the block's merkle root
156
159
  #
157
- # @param txid [String] hex-encoded transaction ID in display byte order
160
+ # @param dtxid_hex [String] hex-encoded transaction ID in display byte order
158
161
  # @param index [Integer] the transaction's position in the block
159
162
  # @param nodes [Array<String>] sibling hashes leaf-to-root, each a 32-byte
160
163
  # hex string in display byte order, or +"*"+ for a duplicate node
161
164
  # @param block_height [Integer] the block's height (TSC carries the block
162
165
  # hash; the caller must look up the height separately)
163
166
  # @return [MerklePath] a BRC-74 merkle path equivalent to the TSC proof
164
- def self.from_tsc(txid:, index:, nodes:, block_height:)
165
- txid_bytes = [txid].pack('H*').reverse
167
+ def self.from_tsc(dtxid_hex:, index:, nodes:, block_height:)
168
+ BSV::Primitives::Hex.validate_dtxid_hex!(dtxid_hex, name: 'dtxid_hex')
169
+ wtxid = [dtxid_hex].pack('H*').reverse
166
170
 
167
171
  # Level 0 always contains the txid leaf.
168
- level0 = [PathElement.new(offset: index, hash: txid_bytes, txid: true)]
172
+ level0 = [PathElement.new(offset: index, hash: wtxid, txid: true)]
169
173
 
170
174
  # A single-tx block has no siblings — the txid IS the merkle root.
171
175
  return new(block_height: block_height, path: [level0]) if nodes.empty?
@@ -193,6 +197,7 @@ module BSV
193
197
  if node == '*'
194
198
  PathElement.new(offset: offset, duplicate: true)
195
199
  else
200
+ BSV::Primitives::Hex.validate_dtxid_hex!(node, name: "TSC merkle node at offset #{offset}")
196
201
  PathElement.new(offset: offset, hash: [node].pack('H*').reverse)
197
202
  end
198
203
  end
@@ -240,17 +245,22 @@ module BSV
240
245
 
241
246
  # Recompute the merkle root from this path and a transaction ID.
242
247
  #
243
- # @param txid [String, nil] 32-byte txid in internal byte order (auto-detected if nil)
244
- # @return [String] 32-byte merkle root in internal byte order
245
- # @raise [ArgumentError] if the txid is not found in the path
246
- def compute_root(txid = nil)
247
- txid ||= @path[0].find(&:hash)&.hash
248
- return txid if @path.length == 1 && @path[0].length == 1
248
+ # @param wtxid [String, nil] 32-byte txid in wire byte order (auto-detected if nil)
249
+ # @return [String] 32-byte merkle root in wire byte order
250
+ # @raise [ArgumentError] if the wtxid is not found in the path
251
+ def compute_root(wtxid = nil)
252
+ wtxid ||= @path[0].find(&:hash)&.hash
253
+ BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid') if wtxid
254
+ BSV.logger&.debug do
255
+ dtxid = wtxid&.reverse&.unpack1('H*')
256
+ "[MerklePath] compute_root: dtxid=#{dtxid} block_height=#{@block_height} levels=#{@path.length}"
257
+ end
258
+ return wtxid if @path.length == 1 && @path[0].length == 1
249
259
 
250
260
  indexed = build_indexed_path
251
261
 
252
- tx_leaf = @path[0].find { |l| l.hash == txid }
253
- raise ArgumentError, 'the BUMP does not contain the txid' unless tx_leaf
262
+ tx_leaf = @path[0].find { |l| l.hash == wtxid }
263
+ raise ArgumentError, 'the BUMP does not contain the given wtxid' unless tx_leaf
254
264
 
255
265
  working = tx_leaf.hash
256
266
  index = tx_leaf.offset
@@ -289,11 +299,12 @@ module BSV
289
299
 
290
300
  # Recompute the merkle root and return it as a hex string.
291
301
  #
292
- # @param txid_hex [String, nil] hex-encoded txid (display order)
302
+ # @param dtxid_hex [String, nil] hex-encoded txid (display order)
293
303
  # @return [String] hex-encoded merkle root (display order)
294
- def compute_root_hex(txid_hex = nil)
295
- txid = txid_hex ? [txid_hex].pack('H*').reverse : nil
296
- compute_root(txid).reverse.unpack1('H*')
304
+ def compute_root_hex(dtxid_hex = nil)
305
+ BSV::Primitives::Hex.validate_dtxid_hex!(dtxid_hex, name: 'compute_root_hex dtxid_hex') if dtxid_hex
306
+ wtxid = dtxid_hex ? [dtxid_hex].pack('H*').reverse : nil
307
+ compute_root(wtxid).reverse.unpack1('H*')
297
308
  end
298
309
 
299
310
  # --- Verification ---
@@ -312,22 +323,27 @@ module BSV
312
323
  # and accepts immature ones — the opposite of the intended behaviour. The correct
313
324
  # logic is: reject when `current_height - block_height < 100` (immature).
314
325
  #
315
- # @param txid_hex [String] hex-encoded transaction ID (display order)
326
+ # @param dtxid_hex [String] hex-encoded transaction ID (display order)
316
327
  # @param chain_tracker [ChainTracker] chain tracker to verify the root against
317
328
  # @return [Boolean] true if the computed root matches the block at this height
318
- def verify(txid_hex, chain_tracker)
319
- txid_bytes = [txid_hex].pack('H*').reverse
320
- txid_leaf = @path[0].find { |l| l.hash == txid_bytes }
329
+ def verify(dtxid_hex, chain_tracker)
330
+ BSV::Primitives::Hex.validate_dtxid_hex!(dtxid_hex, name: 'dtxid_hex')
331
+ wtxid = [dtxid_hex].pack('H*').reverse
332
+ tx_leaf = @path[0].find { |l| l.hash == wtxid }
321
333
 
322
334
  # Offset 0 in a block's merkle tree is always the coinbase transaction —
323
335
  # a Bitcoin protocol invariant. Apply the 100-block maturity check.
324
- if txid_leaf&.offset&.zero?
336
+ if tx_leaf&.offset&.zero?
325
337
  current = chain_tracker.current_height
326
338
  return false if current - @block_height < 100
327
339
  end
328
340
 
329
- root_hex = compute_root_hex(txid_hex)
330
- chain_tracker.valid_root_for_height?(root_hex, @block_height)
341
+ root_hex = compute_root_hex(dtxid_hex)
342
+ valid = chain_tracker.valid_root_for_height?(root_hex, @block_height)
343
+ BSV.logger&.debug do
344
+ "[MerklePath] verify: dtxid=#{dtxid_hex} height=#{@block_height} root=#{root_hex} valid=#{valid}"
345
+ end
346
+ valid
331
347
  end
332
348
 
333
349
  # --- Combine ---
@@ -445,24 +461,24 @@ module BSV
445
461
  #
446
462
  # Matches the TS SDK's +MerklePath.extract+ behaviour.
447
463
  #
448
- # @param txid_hashes [Array<String>] 32-byte txids in internal byte
464
+ # @param wtxid_hashes [Array<String>] 32-byte txids in wire byte
449
465
  # order (reverse of display order). To pass hex strings, use
450
- # +txid_hexes.map { |h| [h].pack('H*').reverse }+.
466
+ # +dtxid_hexes.map { |h| [h].pack('H*').reverse }+.
451
467
  # @return [MerklePath] a new trimmed compound path proving only the
452
468
  # requested txids
453
- # @raise [ArgumentError] if +txid_hashes+ is empty, any requested
469
+ # @raise [ArgumentError] if +wtxid_hashes+ is empty, any requested
454
470
  # txid is not present in the source path's level 0, or the
455
471
  # extracted path's root does not match the source root
456
- def extract(txid_hashes)
457
- raise ArgumentError, 'at least one txid must be provided to extract' if txid_hashes.empty?
472
+ def extract(wtxid_hashes)
473
+ raise ArgumentError, 'at least one wtxid must be provided to extract' if wtxid_hashes.empty?
458
474
 
459
475
  original_root = compute_root
460
476
  indexed = build_indexed_path
461
477
 
462
478
  # Build a level-0 hash → offset lookup
463
- txid_to_offset = {}
479
+ wtxid_to_offset = {}
464
480
  @path[0].each do |leaf|
465
- txid_to_offset[leaf.hash] = leaf.offset if leaf.hash
481
+ wtxid_to_offset[leaf.hash] = leaf.offset if leaf.hash
466
482
  end
467
483
 
468
484
  max_offset = @path[0].map(&:offset).max || 0
@@ -470,15 +486,15 @@ module BSV
470
486
 
471
487
  needed = Array.new(tree_height) { {} }
472
488
 
473
- txid_hashes.each do |txid|
474
- tx_offset = txid_to_offset[txid]
489
+ wtxid_hashes.each do |wtxid_hash|
490
+ tx_offset = wtxid_to_offset[wtxid_hash]
475
491
  if tx_offset.nil?
476
492
  raise ArgumentError,
477
- "transaction ID #{txid.reverse.unpack1('H*')} not found in the Merkle Path"
493
+ "wtxid #{wtxid_hash.reverse.unpack1('H*')} not found in the Merkle Path"
478
494
  end
479
495
 
480
- # Level 0: the txid leaf itself + its tree sibling
481
- needed[0][tx_offset] = PathElement.new(offset: tx_offset, hash: txid, txid: true)
496
+ # Level 0: the transaction leaf itself + its tree sibling
497
+ needed[0][tx_offset] = PathElement.new(offset: tx_offset, hash: wtxid_hash, txid: true)
482
498
  sib0_offset = tx_offset ^ 1
483
499
  unless needed[0].key?(sib0_offset)
484
500
  sib = offset_leaf(indexed, 0, sib0_offset)