bsv-sdk 0.8.0 → 0.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efdb265c4e97396d1769a3652cbe04c57ad154035d2d990f118e343bbe7fafa8
4
- data.tar.gz: c955e289d5b4316db586980bc02287d29d2e973ac071b5be5ca33298f6e647a5
3
+ metadata.gz: d6ca8a4c5f04ec956d46b46d18286a7dad889496c1ae2683ff79210c01e10529
4
+ data.tar.gz: d2c327e4b61a979909dd31f4755f852c3d0c6153f00c135c446ec64386f5709a
5
5
  SHA512:
6
- metadata.gz: a2ca1b5d8d35586daa3da201ebf137e5aa01034452b931185b7804add1537c35db0f1d95463676dbb060b1475a41b236fa178dee2bdcd748051b27a6ce9c40ea
7
- data.tar.gz: fa8c3e8f7b4a1ad2b5b9870fbccc65c0fbbc85c9b55607f00068dfcf008c4bfee403df05777106ac54c3c7ee0384841bc258f0f8e918a852578fb65309415502
6
+ metadata.gz: 015f6727214704a6a972f1fd6f8cc7c9597f21df21ab794698d9862f7c9a4df702f33be01cec402dc5726240137ae22137f74e08cfc820dd25204ffa92b32768
7
+ data.tar.gz: 53517f95c64d850fa7b164f4b785020d81866525d9bda7623e22eeb89ba7a55d61f9d8c17abadf8cadcee9a5a4469516bbe9f87102f50bbc2f32559963ff5fd0
data/CHANGELOG.md CHANGED
@@ -21,6 +21,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
21
21
  and each gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
22
22
  independently.
23
23
 
24
+ ## sdk-0.8.1 — 2026-04-08
25
+
26
+ ### Fixed
27
+
28
+ - [sdk] **`Transaction#to_beef` strips phantom `txid: true` leaves** —
29
+ when a proof loaded from a shared `LocalProofStore` carries txid flags
30
+ for transactions that are not part of the bundle being constructed,
31
+ `to_beef` now rebuilds each per-block BUMP from only the bundle's own
32
+ txids instead of propagating the phantoms into the serialised output.
33
+ ARC previously rejected such BEEFs with misleading parser errors,
34
+ blocking any wallet workflow that received a BEEF via
35
+ `internalize_action` and then spent the internalised UTXOs.
36
+ Closes #302.
37
+
38
+ ### Added
39
+
40
+ - [sdk] **`MerklePath#extract(txid_hashes)`** — returns a new trimmed
41
+ compound path covering only the requested txids, reconstructing the
42
+ minimum set of sibling hashes at each tree level. Raises
43
+ `ArgumentError` on empty input, unknown txid, or root mismatch.
44
+ Ported from the TypeScript SDK. Used internally by
45
+ `Transaction#to_beef` and available for direct use.
46
+ - [sdk] **`MerklePath#trim`** — removes internal nodes not required by
47
+ level-zero txid leaves. Called implicitly by `#combine` and `#extract`
48
+ and rarely needs to be invoked directly. Ported from the TypeScript
49
+ SDK.
50
+ - [sdk] **`MerklePath#initialize_copy`** — `.dup` now produces a new
51
+ MerklePath whose outer and level arrays are independent of the
52
+ source, so the copy can be freely mutated via `#combine`, `#trim`,
53
+ or `#extract` without affecting the original. `PathElement`s
54
+ remain immutable and are shared between source and copy.
55
+
56
+ ### Changed
57
+
58
+ - [sdk] **`MerklePath#combine`** now calls `#trim` at the end so merged
59
+ paths stay minimal across repeated merges, matching the TypeScript
60
+ SDK. Combined paths are strictly smaller than before — external
61
+ callers that inspected `mp.path` after `#combine` may see fewer
62
+ nodes, though every txid leaf's merkle proof is preserved.
63
+ - [sdk] **`MerklePath#combine`** also preserves `txid: true` flags when
64
+ the incoming leaf is flagged and the existing leaf at the same offset
65
+ isn't, so merging an ancestor's single-leaf proof into a compound
66
+ that already contains the same offset as a sibling no longer loses
67
+ the txid flag.
68
+ - [sdk] **`Transaction#to_beef`** now raises `ArgumentError` if an
69
+ ancestor's merkle path doesn't actually contain that transaction's
70
+ txid, or if the rebuilt BUMP's root doesn't match the source root.
71
+ Previously such corrupt proof data would silently emit a broken BEEF.
72
+ Callers relying on `to_beef` not raising on valid data are
73
+ unaffected; the new exception only triggers on corrupt proof stores.
74
+
75
+ ### Internal
76
+
77
+ - [sdk] **`Beef#merge_transaction`** indirectly benefits from the
78
+ tighter `#combine` + `#trim` behaviour: compound BUMPs no longer
79
+ accumulate dead sibling hashes across repeated merges.
80
+ - [sdk] On the real-world `#302` regression fixture, the cleaned BUMP
81
+ shrinks from 2476 B to 1300 B (47% reduction) as a side effect of
82
+ `#extract` removing intermediate siblings that are no longer needed
83
+ once phantom leaves are gone.
84
+
24
85
  ## sdk-0.8.0 — 2026-04-08
25
86
 
26
87
  ### Added
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module BSV
4
6
  module Transaction
5
7
  # A BRC-74 merkle path (BUMP — Bitcoin Unified Merkle Path).
@@ -50,6 +52,19 @@ module BSV
50
52
  @path = path
51
53
  end
52
54
 
55
+ # Produce an independent copy: a new MerklePath whose outer +path+
56
+ # array and each inner level array can be mutated (via {#combine},
57
+ # {#trim}, {#extract}) without affecting the original. PathElements
58
+ # themselves are immutable and are shared between the original and
59
+ # the copy.
60
+ #
61
+ # @param source [MerklePath] the MerklePath being copied from
62
+ # @return [void]
63
+ def initialize_copy(source)
64
+ super
65
+ @path = source.path.map(&:dup)
66
+ end
67
+
53
68
  # --- Binary serialisation (BRC-74) ---
54
69
 
55
70
  # Deserialise a merkle path from BRC-74 binary format.
@@ -270,7 +285,11 @@ module BSV
270
285
  # Merge another merkle path into this one.
271
286
  #
272
287
  # Both paths must share the same block height and merkle root.
273
- # After combining, this path contains the union of all leaves.
288
+ # After combining, this path contains the union of all leaves,
289
+ # trimmed to the minimum set required to prove every txid-flagged
290
+ # leaf. The trim matches the TS SDK's +combine+ behaviour and
291
+ # prevents accumulation of unnecessary sibling hashes across
292
+ # repeated merges.
274
293
  #
275
294
  # @param other [MerklePath] the path to merge in
276
295
  # @return [self] for chaining
@@ -289,16 +308,182 @@ module BSV
289
308
 
290
309
  existing = @path[h].to_h { |e| [e.offset, e] }
291
310
  other.path[h].each do |elem|
292
- existing[elem.offset] ||= elem
311
+ # Preserve txid flag when combining: if the incoming leaf is
312
+ # flagged, never downgrade an existing entry.
313
+ if existing.key?(elem.offset)
314
+ existing_elem = existing[elem.offset]
315
+ if elem.txid && !existing_elem.txid
316
+ existing[elem.offset] = PathElement.new(
317
+ offset: existing_elem.offset,
318
+ hash: existing_elem.hash,
319
+ txid: true,
320
+ duplicate: existing_elem.duplicate
321
+ )
322
+ end
323
+ else
324
+ existing[elem.offset] = elem
325
+ end
293
326
  end
294
327
  @path[h] = existing.values.sort_by(&:offset)
295
328
  end
296
329
 
330
+ trim
297
331
  self
298
332
  end
299
333
 
334
+ # --- Trim ---
335
+
336
+ # Remove all internal nodes that are not required by level zero
337
+ # txid-flagged leaves. Assumes the path has at least the minimum
338
+ # set of sibling hashes needed to prove every txid leaf. Leaves
339
+ # each level sorted by increasing offset.
340
+ #
341
+ # This is the Ruby port of the TypeScript SDK's +MerklePath.trim+.
342
+ # It is called implicitly by {#combine} and {#extract} and rarely
343
+ # needs to be invoked directly.
344
+ #
345
+ # @return [self] for chaining
346
+ def trim
347
+ @path.each { |level| level.sort_by!(&:offset) }
348
+
349
+ computed_offsets = []
350
+ drop_offsets = []
351
+
352
+ @path[0].each_with_index do |node, i|
353
+ if node.txid
354
+ # level 0 must enable computing level 1 for every txid node
355
+ trim_push_if_new(computed_offsets, node.offset >> 1)
356
+ else
357
+ # Array-index peer — works for well-formed compound BUMPs
358
+ # where level 0 is a sequence of adjacent (txid, sibling) pairs.
359
+ peer_index = node.offset.odd? ? i - 1 : i + 1
360
+ peer = @path[0][peer_index] if peer_index.between?(0, @path[0].length - 1)
361
+ # Drop non-txid level 0 nodes whose peer is also non-txid
362
+ trim_push_if_new(drop_offsets, peer.offset) if peer && !peer.txid
363
+ end
364
+ end
365
+
366
+ trim_drop_offsets_from_level(drop_offsets, 0)
367
+
368
+ (1...@path.length).each do |h|
369
+ drop_offsets = computed_offsets
370
+ computed_offsets = trim_next_computed_offsets(computed_offsets)
371
+ trim_drop_offsets_from_level(drop_offsets, h)
372
+ end
373
+
374
+ self
375
+ end
376
+
377
+ # --- Extract ---
378
+
379
+ # Extract a minimal compound MerklePath covering only the specified
380
+ # transaction IDs.
381
+ #
382
+ # Given a compound path (e.g. one merged from multiple single-leaf
383
+ # proofs in the same block), this method reconstructs the minimum
384
+ # set of sibling hashes at each tree level for every requested txid,
385
+ # assembles them into a new trimmed compound path, and verifies
386
+ # that the extracted path computes the same merkle root as the
387
+ # source.
388
+ #
389
+ # The primary use case is +Transaction#to_beef+: when a BUMP loaded
390
+ # from a proof store carries +txid: true+ flags for transactions
391
+ # that are not part of the current BEEF bundle, extracting only the
392
+ # bundled txids strips the phantom flags (and the now-unneeded
393
+ # sibling nodes) from the serialised output. See issue #302 for
394
+ # background.
395
+ #
396
+ # Matches the TS SDK's +MerklePath.extract+ behaviour.
397
+ #
398
+ # @param txid_hashes [Array<String>] 32-byte txids in internal byte
399
+ # order (reverse of display order). To pass hex strings, use
400
+ # +txid_hexes.map { |h| [h].pack('H*').reverse }+.
401
+ # @return [MerklePath] a new trimmed compound path proving only the
402
+ # requested txids
403
+ # @raise [ArgumentError] if +txid_hashes+ is empty, any requested
404
+ # txid is not present in the source path's level 0, or the
405
+ # extracted path's root does not match the source root
406
+ def extract(txid_hashes)
407
+ raise ArgumentError, 'at least one txid must be provided to extract' if txid_hashes.empty?
408
+
409
+ original_root = compute_root
410
+ indexed = build_indexed_path
411
+
412
+ # Build a level-0 hash → offset lookup
413
+ txid_to_offset = {}
414
+ @path[0].each do |leaf|
415
+ txid_to_offset[leaf.hash] = leaf.offset if leaf.hash
416
+ end
417
+
418
+ max_offset = @path[0].map(&:offset).max || 0
419
+ tree_height = [@path.length, max_offset.bit_length].max
420
+
421
+ needed = Array.new(tree_height) { {} }
422
+
423
+ txid_hashes.each do |txid|
424
+ tx_offset = txid_to_offset[txid]
425
+ if tx_offset.nil?
426
+ raise ArgumentError,
427
+ "transaction ID #{txid.reverse.unpack1('H*')} not found in the Merkle Path"
428
+ end
429
+
430
+ # Level 0: the txid leaf itself + its tree sibling
431
+ needed[0][tx_offset] = PathElement.new(offset: tx_offset, hash: txid, txid: true)
432
+ sib0_offset = tx_offset ^ 1
433
+ unless needed[0].key?(sib0_offset)
434
+ sib = offset_leaf(indexed, 0, sib0_offset)
435
+ needed[0][sib0_offset] = sib if sib
436
+ end
437
+
438
+ # Higher levels: just the sibling at each height
439
+ (1...tree_height).each do |h|
440
+ sib_offset = (tx_offset >> h) ^ 1
441
+ next if needed[h].key?(sib_offset)
442
+
443
+ sib = offset_leaf(indexed, h, sib_offset)
444
+ if sib
445
+ needed[h][sib_offset] = sib
446
+ elsif (tx_offset >> h) == (max_offset >> h)
447
+ # Rightmost path in a tree whose last leaf has no real sibling —
448
+ # BRC-74 represents this as a duplicate marker.
449
+ needed[h][sib_offset] = PathElement.new(offset: sib_offset, duplicate: true)
450
+ end
451
+ end
452
+ end
453
+
454
+ compound_path = needed.map { |level| level.values.sort_by(&:offset) }
455
+ compound = self.class.new(block_height: @block_height, path: compound_path)
456
+ compound.trim
457
+
458
+ extracted_root = compound.compute_root
459
+ unless extracted_root == original_root
460
+ raise ArgumentError,
461
+ "extracted path root #{extracted_root.reverse.unpack1('H*')} " \
462
+ "does not match source root #{original_root.reverse.unpack1('H*')}"
463
+ end
464
+
465
+ compound
466
+ end
467
+
300
468
  private
301
469
 
470
+ def trim_push_if_new(arr, value)
471
+ arr << value if arr.empty? || arr.last != value
472
+ end
473
+
474
+ def trim_drop_offsets_from_level(drop_offsets, level)
475
+ return if drop_offsets.empty?
476
+
477
+ drop_set = drop_offsets.to_set
478
+ @path[level].reject! { |node| drop_set.include?(node.offset) }
479
+ end
480
+
481
+ def trim_next_computed_offsets(offsets)
482
+ next_offsets = []
483
+ offsets.each { |o| trim_push_if_new(next_offsets, o >> 1) }
484
+ next_offsets
485
+ end
486
+
302
487
  def build_indexed_path
303
488
  @path.map do |level|
304
489
  level.to_h { |elem| [elem.offset, elem] }
@@ -299,24 +299,36 @@ module BSV
299
299
  # Transactions with a `merkle_path` are treated as proven leaves — their
300
300
  # ancestors are not traversed further.
301
301
  #
302
+ # Proven ancestors that share a block are combined into a single BUMP per
303
+ # block, then trimmed via {MerklePath#extract} so the serialised bundle
304
+ # carries only the +txid: true+-flagged leaves that correspond to
305
+ # transactions in this BEEF. This prevents "phantom" txid leaves carried
306
+ # over from a shared {LocalProofStore} entry (issue #302) and also
307
+ # shrinks the BEEF by dropping intermediate sibling hashes that are no
308
+ # longer needed.
309
+ #
310
+ # Ancestor +merkle_path+ objects are not mutated: paths are deep-copied
311
+ # before any combine/trim work.
312
+ #
302
313
  # @return [String] raw BEEF V1 binary
314
+ # @raise [ArgumentError] if an ancestor's merkle_path does not actually
315
+ # contain that transaction's txid, or if the cleaned BUMP's root does
316
+ # not match the source root (both indicate corrupt proof data)
303
317
  def to_beef
304
318
  beef = Beef.new
305
319
  ancestors = collect_ancestors
306
320
 
321
+ bump_index_by_height = build_beef_bumps(beef, ancestors)
322
+
307
323
  ancestors.each do |tx|
308
324
  entry = if tx.merkle_path
309
- bump_idx = beef.merge_bump(tx.merkle_path)
310
325
  Beef::BeefTx.new(
311
326
  format: Beef::FORMAT_RAW_TX_AND_BUMP,
312
327
  transaction: tx,
313
- bump_index: bump_idx
328
+ bump_index: bump_index_by_height.fetch(tx.merkle_path.block_height)
314
329
  )
315
330
  else
316
- Beef::BeefTx.new(
317
- format: Beef::FORMAT_RAW_TX,
318
- transaction: tx
319
- )
331
+ Beef::BeefTx.new(format: Beef::FORMAT_RAW_TX, transaction: tx)
320
332
  end
321
333
  beef.transactions << entry
322
334
  end
@@ -724,6 +736,35 @@ module BSV
724
736
  result << tx
725
737
  end
726
738
 
739
+ # Group proven ancestors by block height, combine each group into a
740
+ # single compound merkle path (without mutating the source paths), then
741
+ # extract just the txids actually in the bundle. The resulting clean
742
+ # BUMPs are appended to +beef.bumps+, one per block height.
743
+ #
744
+ # @return [Hash{Integer => Integer}] block height → bump index mapping
745
+ def build_beef_bumps(beef, ancestors)
746
+ proven_by_height = ancestors.each_with_object({}) do |tx, h|
747
+ next unless tx.merkle_path
748
+
749
+ (h[tx.merkle_path.block_height] ||= []) << tx
750
+ end
751
+
752
+ bump_index_by_height = {}
753
+ proven_by_height.each do |height, txs|
754
+ # Deep-dup the first source so combine/trim can't mutate caller state
755
+ merged = txs.first.merkle_path.dup
756
+ txs.drop(1).each { |t| merged.combine(t.merkle_path) }
757
+
758
+ txid_hashes = txs.map { |t| t.txid.reverse }
759
+ clean = merged.extract(txid_hashes)
760
+
761
+ bump_index_by_height[height] = beef.bumps.length
762
+ beef.bumps << clean
763
+ end
764
+
765
+ bump_index_by_height
766
+ end
767
+
727
768
  def compute_fee_sats(model_or_fee)
728
769
  case model_or_fee
729
770
  when nil
data/lib/bsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BSV
4
- VERSION = '0.8.0'
4
+ VERSION = '0.8.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison