bsv-sdk 0.8.0 → 0.8.2

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: 534417702a1c6fe35494ff3d7bc028d302452fc21312747cd83167137fab43c7
4
+ data.tar.gz: d7aca5b47d7f853f41142d2ae9087351bf0160beb0b25959330e7e380bbb5d92
5
5
  SHA512:
6
- metadata.gz: a2ca1b5d8d35586daa3da201ebf137e5aa01034452b931185b7804add1537c35db0f1d95463676dbb060b1475a41b236fa178dee2bdcd748051b27a6ce9c40ea
7
- data.tar.gz: fa8c3e8f7b4a1ad2b5b9870fbccc65c0fbbc85c9b55607f00068dfcf008c4bfee403df05777106ac54c3c7ee0384841bc258f0f8e918a852578fb65309415502
6
+ metadata.gz: 109f3d7d92f210bb3bc5df4e2defaec5322be06b33a467023109fef27d1c9ffe8ca2f876b865b9a4e9f21ef69e9f5d19dd8fde5b02cefdd58f3ee2ac58cfae41
7
+ data.tar.gz: 70145605156016e9fbcd6a4c397a317358ad3142c97637d6ad8a849934ac8d356f40c555e55af178280693c3c4d6b1d3f76af8f10df5526e6fde6fefdef928c5
data/CHANGELOG.md CHANGED
@@ -21,6 +21,185 @@ 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.2 / wallet-0.3.4 — 2026-04-08
25
+
26
+ Paired security patch release. Three P0 findings from the
27
+ [2026-04-08 cross-SDK compliance review](.architecture/reviews/20260408-cross-sdk-compliance-review.md)
28
+ plus follow-up hardening from the PR review pass. Must be installed
29
+ together — the `bsv-wallet` gemspec now pins its `bsv-sdk` dependency
30
+ to `>= 0.8.2, < 1.0` to enforce the paired upgrade and prevent a stale
31
+ pair where one gem has its fixes and the other doesn't.
32
+
33
+ Two GitHub Security Advisories accompany this release (draft until
34
+ CVE IDs return from MITRE):
35
+
36
+ - [GHSA-hc36-c89j-5f4j](.security/advisories/2026-0001-acquire-certificate-signature-bypass.md) — F8.15 / F8.16 partial — `acquire_certificate` persists unverified certifier signatures (CWE-347, CVSS 8.1 HIGH)
37
+ - [GHSA-9hfr-gw99-8rhx](.security/advisories/2026-0002-arc-broadcaster-failure-statuses.md) — F5.13 — ARC broadcaster treats failure statuses as success (CWE-754, CVSS 7.5 HIGH)
38
+
39
+ ### Security
40
+
41
+ - [wallet] **`acquire_certificate` now verifies certifier signatures** before
42
+ persisting (BRC-52). Both the `'direct'` and `'issuance'` acquisition
43
+ paths previously wrote user-supplied `signature:` values to storage
44
+ without any verification — a caller could forge a certificate that
45
+ `list_certificates` / `prove_certificate` would later treat as
46
+ authentic. This was a credential forgery primitive masquerading as
47
+ an API finding. The new `BSV::Wallet::CertificateSignature` module
48
+ builds the canonical BRC-52 preimage (matching the TS reference
49
+ `Certificate#toBinary(false)` byte-for-byte) and delegates to
50
+ `ProtoWallet#verify_signature`. Invalid certificates raise
51
+ `BSV::Wallet::CertificateSignature::InvalidError` and are not
52
+ persisted. Closes F8.15 (and the verification aspect of F8.16).
53
+
54
+ - [sdk] **`VarInt.encode` now rejects negative integers** and values
55
+ above 2^64 − 1. Previously `VarInt.encode(-1)` fell into the single-
56
+ byte branch and emitted `0xFF` (the marker for a 9-byte encoding),
57
+ silently corrupting the transaction stream with no exception raised.
58
+ The docstring already required a non-negative integer; the
59
+ implementation did not enforce it. Closes F1.3.
60
+
61
+ - [sdk] **ARC broadcaster recognises the full failure status set**. The
62
+ previous `REJECTED_STATUSES` contained only `REJECTED` and
63
+ `DOUBLE_SPEND_ATTEMPTED`; responses with txStatus `INVALID`,
64
+ `MALFORMED`, `MINED_IN_STALE_BLOCK`, or any `ORPHAN`-containing
65
+ `txStatus` / `extraInfo` were silently treated as successful
66
+ broadcasts. Callers relying on `broadcast()` to signal failure would
67
+ trust transactions that were never actually accepted by the network.
68
+ The new failure set matches the TypeScript reference broadcaster
69
+ exactly, and case-insensitive matching defends against ARC's
70
+ documented history of emitting values outside its own OpenAPI enum
71
+ (TS issue #105). Malformed 2xx responses without a `txid` field
72
+ also raise, closing the same silent-success class for shape
73
+ corruption. Closes F5.13.
74
+
75
+ ### Changed
76
+
77
+ - [sdk] **ARC broadcaster HTTP wire format** brought into line with the
78
+ TypeScript reference:
79
+ - Content-Type is now `application/json` (was `application/octet-stream`)
80
+ - Body is `{"rawTx": hex}` — Extended Format (BRC-30) hex when every
81
+ input has `source_satoshis` / `source_locking_script` populated
82
+ (so ARC can validate sighashes without fetching parents), falling
83
+ back to plain raw-tx hex otherwise
84
+ - New `XDeployment-ID` header (default: `bsv-ruby-sdk-<random hex>`,
85
+ overridable via `deployment_id:` constructor kwarg)
86
+ - New optional `X-CallbackUrl` and `X-CallbackToken` constructor
87
+ kwargs for ARC status callbacks
88
+
89
+ - [wallet] **`bsv-wallet.gemspec` bsv-sdk dependency pinned** to
90
+ `>= 0.8.2, < 1.0`. The previous `~> 0.4` constraint was stale (wallet
91
+ hasn't been tested against bsv-sdk 0.4.x in months) and would have
92
+ let a user install `bsv-wallet 0.3.4` against an old `bsv-sdk` that
93
+ was missing F1.3 and F5.13. Technically breaking — any consumer
94
+ pinned to `bsv-sdk < 0.8.2` must upgrade — but un-breaking in
95
+ practice: it forces users to the known-good pair rather than a
96
+ silently-broken combination.
97
+
98
+ ### Internal
99
+
100
+ - [sdk] `lib/bsv/network/**/*` added to `Metrics/ClassLength` and
101
+ `Metrics/ParameterLists` RuboCop exclusion lists to match the
102
+ existing treatment of `lib/bsv/wallet_interface/**/*`. ARC is
103
+ HTTP-client boilerplate in the same shape.
104
+ - [wallet] `lib/bsv/wallet_interface/**/*` added to the
105
+ `Metrics/ModuleLength` exclusion list (was previously only excluded
106
+ from `Metrics/ClassLength`). The new `CertificateSignature` module
107
+ triggered the discrepancy.
108
+ - [sdk, wallet] Review-feedback hardening bundled into the same PR to
109
+ keep the security-patch window small: case-insensitive ARC failure
110
+ matching, `Base64.strict_decode64` on BRC-52 preimage fields,
111
+ `EncodingError` rescue in `CertificateSignature.verify!`, rejection
112
+ of mixed string / symbol duplicate field names, malformed 2xx
113
+ rejection in ARC, and even-length guard on hex signatures.
114
+
115
+ ### Migration notes
116
+
117
+ - **Existing `bsv-wallet` users** pinned to `bsv-sdk ~> 0.4` will need
118
+ to relax their constraint or upgrade. Anything installed before
119
+ `bsv-wallet 0.3.4` is vulnerable to the F8.15 certificate forgery
120
+ primitive.
121
+ - **Callers passing negative integers to `VarInt.encode`** (unlikely —
122
+ the docstring already disallowed it) will now get an `ArgumentError`
123
+ instead of silent corruption. Fix: pass non-negative values.
124
+ - **Callers relying on ARC broadcaster silently succeeding for INVALID
125
+ / MALFORMED / MINED_IN_STALE_BLOCK / ORPHAN responses** will now see
126
+ `BroadcastError` raised. Fix: handle the error — the previous
127
+ behaviour was objectively wrong and any downstream logic that
128
+ tolerated it was silently corrupt.
129
+ - **Callers of `acquire_certificate` with a fake or untrusted
130
+ `signature:` field** will now see
131
+ `BSV::Wallet::CertificateSignature::InvalidError`. Fix: ensure the
132
+ certificate has been properly signed by the declared certifier.
133
+
134
+ ### Test suite
135
+
136
+ - 3112 examples, 0 failures (up from 3080 on 0.8.1)
137
+ - 16 new regression tests for F1.3, F5.13, and F8.15
138
+ - 16 further regression tests for the review-feedback hardening
139
+ - Ruby 2.7 — 3.4 matrix green
140
+ - CodeQL clean; RuboCop clean across 266 files
141
+
142
+ ## sdk-0.8.1 — 2026-04-08
143
+
144
+ ### Fixed
145
+
146
+ - [sdk] **`Transaction#to_beef` strips phantom `txid: true` leaves** —
147
+ when a proof loaded from a shared `LocalProofStore` carries txid flags
148
+ for transactions that are not part of the bundle being constructed,
149
+ `to_beef` now rebuilds each per-block BUMP from only the bundle's own
150
+ txids instead of propagating the phantoms into the serialised output.
151
+ ARC previously rejected such BEEFs with misleading parser errors,
152
+ blocking any wallet workflow that received a BEEF via
153
+ `internalize_action` and then spent the internalised UTXOs.
154
+ Closes #302.
155
+
156
+ ### Added
157
+
158
+ - [sdk] **`MerklePath#extract(txid_hashes)`** — returns a new trimmed
159
+ compound path covering only the requested txids, reconstructing the
160
+ minimum set of sibling hashes at each tree level. Raises
161
+ `ArgumentError` on empty input, unknown txid, or root mismatch.
162
+ Ported from the TypeScript SDK. Used internally by
163
+ `Transaction#to_beef` and available for direct use.
164
+ - [sdk] **`MerklePath#trim`** — removes internal nodes not required by
165
+ level-zero txid leaves. Called implicitly by `#combine` and `#extract`
166
+ and rarely needs to be invoked directly. Ported from the TypeScript
167
+ SDK.
168
+ - [sdk] **`MerklePath#initialize_copy`** — `.dup` now produces a new
169
+ MerklePath whose outer and level arrays are independent of the
170
+ source, so the copy can be freely mutated via `#combine`, `#trim`,
171
+ or `#extract` without affecting the original. `PathElement`s
172
+ remain immutable and are shared between source and copy.
173
+
174
+ ### Changed
175
+
176
+ - [sdk] **`MerklePath#combine`** now calls `#trim` at the end so merged
177
+ paths stay minimal across repeated merges, matching the TypeScript
178
+ SDK. Combined paths are strictly smaller than before — external
179
+ callers that inspected `mp.path` after `#combine` may see fewer
180
+ nodes, though every txid leaf's merkle proof is preserved.
181
+ - [sdk] **`MerklePath#combine`** also preserves `txid: true` flags when
182
+ the incoming leaf is flagged and the existing leaf at the same offset
183
+ isn't, so merging an ancestor's single-leaf proof into a compound
184
+ that already contains the same offset as a sibling no longer loses
185
+ the txid flag.
186
+ - [sdk] **`Transaction#to_beef`** now raises `ArgumentError` if an
187
+ ancestor's merkle path doesn't actually contain that transaction's
188
+ txid, or if the rebuilt BUMP's root doesn't match the source root.
189
+ Previously such corrupt proof data would silently emit a broken BEEF.
190
+ Callers relying on `to_beef` not raising on valid data are
191
+ unaffected; the new exception only triggers on corrupt proof stores.
192
+
193
+ ### Internal
194
+
195
+ - [sdk] **`Beef#merge_transaction`** indirectly benefits from the
196
+ tighter `#combine` + `#trim` behaviour: compound BUMPs no longer
197
+ accumulate dead sibling hashes across repeated merges.
198
+ - [sdk] On the real-world `#302` regression fixture, the cleaned BUMP
199
+ shrinks from 2476 B to 1300 B (47% reduction) as a side effect of
200
+ `#extract` removing intermediate siblings that are no longer needed
201
+ once phantom leaves are gone.
202
+
24
203
  ## sdk-0.8.0 — 2026-04-08
25
204
 
26
205
  ### Added
@@ -3,6 +3,7 @@
3
3
  require 'net/http'
4
4
  require 'json'
5
5
  require 'uri'
6
+ require 'securerandom'
6
7
 
7
8
  module BSV
8
9
  module Network
@@ -14,16 +15,48 @@ module BSV
14
15
  # The HTTP client is injectable for testability. It must respond to
15
16
  # #request(uri, request) and return an object with #code and #body.
16
17
  class ARC
17
- REJECTED_STATUSES = %w[REJECTED DOUBLE_SPEND_ATTEMPTED].freeze
18
-
19
- def initialize(url, api_key: nil, http_client: nil)
18
+ # ARC response statuses that indicate the transaction was NOT accepted.
19
+ # Matches the TypeScript SDK's ARC broadcaster failure set (issue #305,
20
+ # finding F5.13). Prior to this fix, Ruby only recognised REJECTED and
21
+ # DOUBLE_SPEND_ATTEMPTED, silently treating INVALID / MALFORMED /
22
+ # MINED_IN_STALE_BLOCK responses as successful broadcasts.
23
+ REJECTED_STATUSES = %w[
24
+ REJECTED
25
+ DOUBLE_SPEND_ATTEMPTED
26
+ INVALID
27
+ MALFORMED
28
+ MINED_IN_STALE_BLOCK
29
+ ].freeze
30
+
31
+ # Substring match for orphan detection in txStatus or extraInfo fields.
32
+ ORPHAN_MARKER = 'ORPHAN'
33
+
34
+ # @param url [String] ARC base URL (without trailing slash)
35
+ # @param api_key [String, nil] optional bearer token for Authorization
36
+ # @param deployment_id [String, nil] optional deployment identifier for
37
+ # the +XDeployment-ID+ header; defaults to a per-instance random value
38
+ # @param callback_url [String, nil] optional +X-CallbackUrl+ for ARC
39
+ # status callbacks
40
+ # @param callback_token [String, nil] optional +X-CallbackToken+ for
41
+ # ARC status callback authentication
42
+ # @param http_client [#request, nil] injectable HTTP client for testing
43
+ def initialize(url, api_key: nil, deployment_id: nil, callback_url: nil,
44
+ callback_token: nil, http_client: nil)
20
45
  @url = url.chomp('/')
21
46
  @api_key = api_key
47
+ @deployment_id = deployment_id || "bsv-ruby-sdk-#{SecureRandom.hex(8)}"
48
+ @callback_url = callback_url
49
+ @callback_token = callback_token
22
50
  @http_client = http_client
23
51
  end
24
52
 
25
53
  # Submit a transaction to ARC.
26
54
  #
55
+ # The transaction is encoded as Extended Format (BRC-30) hex when every
56
+ # input has +source_satoshis+ and +source_locking_script+ populated,
57
+ # which lets ARC validate sighashes without fetching parents. Falls back
58
+ # to plain raw-tx hex when EF is unavailable.
59
+ #
27
60
  # @param tx [Transaction] the transaction to broadcast
28
61
  # @param wait_for [String, nil] ARC wait condition — one of
29
62
  # 'RECEIVED', 'STORED', 'ANNOUNCED_TO_NETWORK',
@@ -31,14 +64,18 @@ module BSV
31
64
  # connection open until the transaction reaches the requested
32
65
  # state (or times out). Defaults to nil (no wait).
33
66
  # @return [BroadcastResponse]
34
- # @raise [BroadcastError]
67
+ # @raise [BroadcastError] when ARC returns a non-2xx HTTP status or a
68
+ # rejected/orphan +txStatus+
35
69
  def broadcast(tx, wait_for: nil)
36
70
  uri = URI("#{@url}/v1/tx")
37
71
  request = Net::HTTP::Post.new(uri)
38
- request['Content-Type'] = 'application/octet-stream'
72
+ request['Content-Type'] = 'application/json'
73
+ request['XDeployment-ID'] = @deployment_id
39
74
  request['X-WaitFor'] = wait_for if wait_for
75
+ request['X-CallbackUrl'] = @callback_url if @callback_url
76
+ request['X-CallbackToken'] = @callback_token if @callback_token
40
77
  apply_auth_header(request)
41
- request.body = tx.to_binary
78
+ request.body = JSON.generate(rawTx: raw_tx_hex(tx))
42
79
 
43
80
  response = execute(uri, request)
44
81
  handle_broadcast_response(response)
@@ -49,6 +86,7 @@ module BSV
49
86
  def status(txid)
50
87
  uri = URI("#{@url}/v1/tx/#{txid}")
51
88
  request = Net::HTTP::Get.new(uri)
89
+ request['XDeployment-ID'] = @deployment_id
52
90
  apply_auth_header(request)
53
91
 
54
92
  response = execute(uri, request)
@@ -57,6 +95,15 @@ module BSV
57
95
 
58
96
  private
59
97
 
98
+ # Prefer Extended Format (BRC-30) hex so ARC can validate sighashes
99
+ # without fetching parent transactions. Falls back to plain raw-tx hex
100
+ # when any input lacks source_satoshis / source_locking_script.
101
+ def raw_tx_hex(tx)
102
+ tx.to_ef_hex
103
+ rescue ArgumentError
104
+ tx.to_hex
105
+ end
106
+
60
107
  def apply_auth_header(request)
61
108
  request['Authorization'] = "Bearer #{@api_key}" if @api_key
62
109
  end
@@ -83,20 +130,45 @@ module BSV
83
130
  )
84
131
  end
85
132
 
86
- tx_status = body['txStatus']
87
- if rejected_status?(tx_status)
133
+ if rejected_status?(body)
88
134
  raise BroadcastError.new(
89
- body['detail'] || body['title'] || tx_status,
135
+ body['detail'] || body['title'] || body['txStatus'],
90
136
  status_code: code,
91
137
  txid: body['txid']
92
138
  )
93
139
  end
94
140
 
141
+ # A 2xx response without a txid is a malformed ARC reply —
142
+ # `parse_json` falls back to `{'detail' => raw}` on non-JSON,
143
+ # which would otherwise produce a `BroadcastResponse` full of
144
+ # `nil`s and `success? => true`. That's the same silent
145
+ # success-as-failure class of bug F5.13 closed for explicit
146
+ # error statuses; closing it here for shape corruption too.
147
+ unless body['txid']
148
+ raise BroadcastError.new(
149
+ 'ARC returned a malformed 2xx response',
150
+ status_code: code
151
+ )
152
+ end
153
+
95
154
  build_response(body)
96
155
  end
97
156
 
98
- def rejected_status?(tx_status)
99
- REJECTED_STATUSES.include?(tx_status)
157
+ def rejected_status?(body)
158
+ # Case-insensitive match — the TypeScript reference
159
+ # (`ts-sdk/src/transaction/broadcasters/ARC.ts:155-166`) explicitly
160
+ # `.toUpperCase()`s both fields before membership / substring checks.
161
+ # ARC has a documented history of emitting values outside its own
162
+ # OpenAPI enum (e.g. `txStatus: "success"` for orphans in TS issue
163
+ # #105), so case normalisation is the defensive choice.
164
+ tx_status = body['txStatus'].to_s.upcase
165
+ return true if REJECTED_STATUSES.include?(tx_status)
166
+ return true if tx_status.include?(ORPHAN_MARKER)
167
+
168
+ extra_info = body['extraInfo'].to_s.upcase
169
+ return true if extra_info.include?(ORPHAN_MARKER)
170
+
171
+ false
100
172
  end
101
173
 
102
174
  def parse_json(raw)
@@ -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
@@ -10,11 +10,18 @@ module BSV
10
10
  module VarInt
11
11
  module_function
12
12
 
13
+ # Maximum value representable by a Bitcoin VarInt (unsigned 64-bit).
14
+ MAX_UINT64 = 0xFFFF_FFFF_FFFF_FFFF
15
+
13
16
  # Encode an integer as a Bitcoin VarInt.
14
17
  #
15
- # @param value [Integer] non-negative integer to encode
18
+ # @param value [Integer] non-negative integer to encode (0..2^64-1)
16
19
  # @return [String] encoded binary bytes
20
+ # @raise [ArgumentError] if +value+ is negative or exceeds 2^64-1
17
21
  def encode(value)
22
+ raise ArgumentError, "varint requires non-negative integer, got #{value}" if value.negative?
23
+ raise ArgumentError, "varint value #{value} exceeds uint64 max (#{MAX_UINT64})" if value > MAX_UINT64
24
+
18
25
  if value < 0xFD
19
26
  [value].pack('C')
20
27
  elsif value <= 0xFFFF
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.2'
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison