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 +4 -4
- data/CHANGELOG.md +61 -0
- data/lib/bsv/transaction/merkle_path.rb +187 -2
- data/lib/bsv/transaction/transaction.rb +47 -6
- data/lib/bsv/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6ca8a4c5f04ec956d46b46d18286a7dad889496c1ae2683ff79210c01e10529
|
|
4
|
+
data.tar.gz: d2c327e4b61a979909dd31f4755f852c3d0c6153f00c135c446ec64386f5709a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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:
|
|
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