bsv-sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  # Background Evaluation Extended Format (BEEF) for SPV-ready transaction
@@ -104,6 +106,11 @@ module BSV
104
106
  # @param data [String] raw BEEF binary
105
107
  # @return [Beef] the parsed BEEF bundle
106
108
  def self.from_binary(data)
109
+ if data.bytesize < 4
110
+ raise ArgumentError,
111
+ "truncated BEEF: need at least 4 bytes for version, got #{data.bytesize}"
112
+ end
113
+
107
114
  offset = 0
108
115
 
109
116
  version = data.byteslice(offset, 4).unpack1('V')
@@ -112,9 +119,13 @@ module BSV
112
119
  beef = new(version: version)
113
120
 
114
121
  if version == ATOMIC_BEEF
122
+ if data.bytesize < offset + 36
123
+ remaining = data.bytesize - offset
124
+ raise ArgumentError, "truncated Atomic BEEF: need 36 bytes at offset #{offset}, got #{remaining}"
125
+ end
126
+
115
127
  beef.instance_variable_set(:@subject_txid, data.byteslice(offset, 32))
116
128
  offset += 32
117
- # Read inner V2 version
118
129
  inner_version = data.byteslice(offset, 4).unpack1('V')
119
130
  offset += 4
120
131
  beef.instance_variable_set(:@version, inner_version)
@@ -208,6 +219,261 @@ module BSV
208
219
  nil
209
220
  end
210
221
 
222
+ # Find the merkle path (BUMP) for a transaction by its txid.
223
+ #
224
+ # @param txid [String] 32-byte txid in internal byte order
225
+ # @return [MerklePath, nil] the merkle path, or nil if not found
226
+ def find_bump(txid)
227
+ bt = @transactions.find { |entry| entry.txid == txid && entry.format == FORMAT_RAW_TX_AND_BUMP }
228
+ return unless bt
229
+
230
+ bt.transaction&.merkle_path || (bt.bump_index && @bumps[bt.bump_index])
231
+ end
232
+
233
+ # Find a transaction with all source_transactions wired for signing.
234
+ #
235
+ # @param txid [String] 32-byte txid in internal byte order
236
+ # @return [Transaction, nil] the transaction with wired inputs, or nil
237
+ def find_transaction_for_signing(txid)
238
+ tx = find_transaction(txid)
239
+ return unless tx
240
+
241
+ wire_inputs(tx)
242
+ tx
243
+ end
244
+
245
+ # Find a transaction and recursively wire its ancestry (source transactions
246
+ # and merkle paths) for atomic proof validation.
247
+ #
248
+ # @param txid [String] 32-byte txid in internal byte order
249
+ # @return [Transaction, nil] the transaction with full proof tree, or nil
250
+ def find_atomic_transaction(txid)
251
+ tx = find_transaction(txid)
252
+ return unless tx
253
+
254
+ wire_ancestry(tx)
255
+ tx
256
+ end
257
+
258
+ # Serialise as Atomic BEEF (BRC-95) hex string.
259
+ #
260
+ # @param subject_txid [String] 32-byte subject transaction ID
261
+ # @return [String] hex-encoded Atomic BEEF
262
+ def to_atomic_hex(subject_txid)
263
+ to_atomic_binary(subject_txid).unpack1('H*')
264
+ end
265
+
266
+ # --- Merge operations ---
267
+
268
+ # Add or deduplicate a merkle path (BUMP) in this BEEF bundle.
269
+ #
270
+ # If an existing BUMP shares the same block_height and merkle root,
271
+ # it is combined (via MerklePath#combine) and the existing index is
272
+ # returned. Otherwise the BUMP is appended.
273
+ #
274
+ # @param merkle_path [MerklePath] the BUMP to merge
275
+ # @return [Integer] the index of the (possibly merged) BUMP
276
+ def merge_bump(merkle_path)
277
+ root = merkle_path.compute_root
278
+ @bumps.each_with_index do |existing, idx|
279
+ next unless existing.block_height == merkle_path.block_height
280
+
281
+ if existing.compute_root == root
282
+ existing.combine(merkle_path)
283
+ return idx
284
+ end
285
+ end
286
+
287
+ @bumps << merkle_path
288
+ @bumps.length - 1
289
+ end
290
+
291
+ # Add a transaction to this BEEF bundle.
292
+ #
293
+ # Recursively merges the transaction's ancestors (via source_transaction
294
+ # references on inputs) and their merkle paths. Duplicate transactions
295
+ # (same txid) are not re-added.
296
+ #
297
+ # @param tx [Transaction] the transaction to merge
298
+ # @return [BeefTx] the (possibly existing) BeefTx entry
299
+ def merge_transaction(tx)
300
+ txid = tx.txid
301
+
302
+ # Check for existing entry
303
+ existing = @transactions.find { |bt| bt.txid == txid }
304
+ return existing if existing
305
+
306
+ # Recursively merge ancestors first (dependency order)
307
+ tx.inputs.each do |input|
308
+ merge_transaction(input.source_transaction) if input.source_transaction
309
+ end
310
+
311
+ # Merge this transaction's BUMP if it has one
312
+ entry = if tx.merkle_path
313
+ bump_idx = merge_bump(tx.merkle_path)
314
+ BeefTx.new(format: FORMAT_RAW_TX_AND_BUMP, transaction: tx, bump_index: bump_idx)
315
+ else
316
+ BeefTx.new(format: FORMAT_RAW_TX, transaction: tx)
317
+ end
318
+ @transactions << entry
319
+ entry
320
+ end
321
+
322
+ # Add a transaction from raw binary data.
323
+ #
324
+ # @param raw_bytes [String] raw transaction binary
325
+ # @param bump_index [Integer, nil] optional BUMP index
326
+ # @return [BeefTx] the new BeefTx entry
327
+ def merge_raw_tx(raw_bytes, bump_index: nil)
328
+ tx = Transaction.from_binary(raw_bytes)
329
+ existing = @transactions.find { |bt| bt.txid == tx.txid }
330
+ return existing if existing
331
+
332
+ entry = if bump_index
333
+ tx.merkle_path = @bumps[bump_index] if bump_index < @bumps.length
334
+ BeefTx.new(format: FORMAT_RAW_TX_AND_BUMP, transaction: tx, bump_index: bump_index)
335
+ else
336
+ BeefTx.new(format: FORMAT_RAW_TX, transaction: tx)
337
+ end
338
+ @transactions << entry
339
+ entry
340
+ end
341
+
342
+ # Merge all BUMPs and transactions from another BEEF bundle.
343
+ #
344
+ # BUMP indices are remapped during merge.
345
+ #
346
+ # @param other [Beef] the BEEF bundle to merge from
347
+ # @return [self]
348
+ def merge(other)
349
+ # Build index remap for BUMPs
350
+ bump_remap = {}
351
+ other.bumps.each_with_index do |bump, old_idx|
352
+ bump_remap[old_idx] = merge_bump(bump)
353
+ end
354
+
355
+ # Merge transactions with remapped BUMP indices
356
+ other.transactions.each do |beef_tx|
357
+ case beef_tx.format
358
+ when FORMAT_TXID_ONLY
359
+ next if @transactions.any? { |bt| bt.txid == beef_tx.known_txid }
360
+
361
+ @transactions << BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: beef_tx.known_txid)
362
+ else
363
+ next if @transactions.any? { |bt| bt.txid == beef_tx.txid }
364
+
365
+ if beef_tx.format == FORMAT_RAW_TX_AND_BUMP && beef_tx.bump_index
366
+ new_idx = bump_remap[beef_tx.bump_index] || beef_tx.bump_index
367
+ beef_tx.transaction.merkle_path = @bumps[new_idx]
368
+ @transactions << BeefTx.new(
369
+ format: FORMAT_RAW_TX_AND_BUMP,
370
+ transaction: beef_tx.transaction,
371
+ bump_index: new_idx
372
+ )
373
+ else
374
+ @transactions << BeefTx.new(format: FORMAT_RAW_TX, transaction: beef_tx.transaction)
375
+ end
376
+ end
377
+ end
378
+
379
+ self
380
+ end
381
+
382
+ # Convert a transaction entry to TXID-only format.
383
+ #
384
+ # @param txid [String] 32-byte txid in internal byte order
385
+ # @return [BeefTx, nil] the converted entry, or nil if not found
386
+ def make_txid_only(txid)
387
+ idx = @transactions.index { |bt| bt.txid == txid }
388
+ return unless idx
389
+
390
+ @transactions[idx] = BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: txid)
391
+ end
392
+
393
+ # --- Validation ---
394
+
395
+ # Check structural validity of the BEEF bundle.
396
+ #
397
+ # A valid BEEF has every transaction either:
398
+ # - proven (has a BUMP / merkle_path), or
399
+ # - all its inputs reference transactions that are themselves valid
400
+ # within this bundle.
401
+ #
402
+ # @param allow_txid_only [Boolean] whether TXID-only entries count as valid (default: false)
403
+ # @return [Boolean] true if structurally valid
404
+ def valid?(allow_txid_only: false)
405
+ known_txids = build_known_txids(allow_txid_only)
406
+
407
+ # TXID-only entries are invalid unless explicitly allowed
408
+ has_txid_only = @transactions.any? { |bt| bt.format == FORMAT_TXID_ONLY }
409
+ return false if has_txid_only && !allow_txid_only
410
+
411
+ pending = @transactions.select { |bt| bt.transaction && !known_txids.include?(bt.txid) }
412
+
413
+ # Iteratively resolve: if all inputs of a tx are known, it becomes known
414
+ changed = true
415
+ while changed
416
+ changed = false
417
+ pending.reject! do |bt|
418
+ all_inputs_known = bt.transaction.inputs.all? do |input|
419
+ known_txids.include?(input.prev_tx_id.reverse)
420
+ end
421
+ if all_inputs_known
422
+ known_txids.add(bt.txid)
423
+ changed = true
424
+ end
425
+ all_inputs_known
426
+ end
427
+ end
428
+
429
+ pending.empty?
430
+ end
431
+
432
+ # Sort transactions in topological (dependency) order in place.
433
+ #
434
+ # After sorting, every transaction's input ancestors appear before it
435
+ # in the array. This is required for correct BEEF serialisation.
436
+ #
437
+ # @return [self]
438
+ def sort_transactions!
439
+ return self if @transactions.length <= 1
440
+
441
+ txid_index = {}
442
+ @transactions.each_with_index { |bt, i| txid_index[bt.txid] = i }
443
+
444
+ # Build adjacency: for each tx, which other txs must come before it?
445
+ in_degree = Array.new(@transactions.length, 0)
446
+ dependents = Array.new(@transactions.length) { [] }
447
+
448
+ @transactions.each_with_index do |bt, i|
449
+ next unless bt.transaction
450
+
451
+ bt.transaction.inputs.each do |input|
452
+ dep_idx = txid_index[input.prev_tx_id.reverse]
453
+ next unless dep_idx
454
+
455
+ dependents[dep_idx] << i
456
+ in_degree[i] += 1
457
+ end
458
+ end
459
+
460
+ # Kahn's algorithm
461
+ queue = (0...@transactions.length).select { |i| in_degree[i].zero? }
462
+ sorted = []
463
+
464
+ until queue.empty?
465
+ idx = queue.shift
466
+ sorted << @transactions[idx]
467
+ dependents[idx].each do |dep|
468
+ in_degree[dep] -= 1
469
+ queue << dep if in_degree[dep].zero?
470
+ end
471
+ end
472
+
473
+ @transactions = sorted
474
+ self
475
+ end
476
+
211
477
  # --- Private class methods for deserialisation ---
212
478
 
213
479
  class << self
@@ -303,6 +569,42 @@ module BSV
303
569
 
304
570
  private
305
571
 
572
+ # Build a set of txids that are "known" (proven or txid-only).
573
+ def build_known_txids(allow_txid_only)
574
+ known = Set.new
575
+ @transactions.each do |bt|
576
+ case bt.format
577
+ when FORMAT_RAW_TX_AND_BUMP
578
+ known.add(bt.txid)
579
+ when FORMAT_TXID_ONLY
580
+ known.add(bt.txid) if allow_txid_only
581
+ end
582
+ end
583
+ known
584
+ end
585
+
586
+ # Wire source_transaction references on a transaction's inputs.
587
+ def wire_inputs(tx)
588
+ tx.inputs.each do |input|
589
+ next if input.source_transaction
590
+
591
+ source = find_transaction(input.prev_tx_id.reverse)
592
+ input.source_transaction = source if source
593
+ end
594
+ end
595
+
596
+ # Recursively wire source_transactions and merkle_paths.
597
+ def wire_ancestry(tx)
598
+ wire_inputs(tx)
599
+ tx.inputs.each do |input|
600
+ next unless input.source_transaction
601
+
602
+ source = input.source_transaction
603
+ source.merkle_path ||= find_bump(source.txid)
604
+ wire_ancestry(source)
605
+ end
606
+ end
607
+
306
608
  def build_bump_map
307
609
  map = {}.compare_by_identity
308
610
  idx = 0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Transaction
5
+ # Base class for chain trackers that verify merkle roots against the blockchain.
6
+ #
7
+ # Chain trackers confirm that a given merkle root corresponds to a valid block
8
+ # at a specific height. This is essential for SPV verification — without it,
9
+ # merkle proofs cannot be validated against the actual blockchain.
10
+ #
11
+ # Subclasses must implement {#valid_root_for_height?} and {#current_height}.
12
+ #
13
+ # @example Implementing a custom chain tracker
14
+ # class MyTracker < BSV::Transaction::ChainTracker
15
+ # def valid_root_for_height?(root, height)
16
+ # # query your block header source
17
+ # end
18
+ #
19
+ # def current_height
20
+ # # return current chain tip height
21
+ # end
22
+ # end
23
+ class ChainTracker
24
+ # Verify that a merkle root is valid for the given block height.
25
+ #
26
+ # @param root [String] merkle root as a hex string
27
+ # @param height [Integer] block height
28
+ # @return [Boolean] true if the root matches the block at the given height
29
+ # @raise [NotImplementedError] if not overridden by a subclass
30
+ def valid_root_for_height?(_root, _height)
31
+ raise NotImplementedError, "#{self.class}#valid_root_for_height? must be implemented"
32
+ end
33
+
34
+ # Return the current blockchain height.
35
+ #
36
+ # @return [Integer] the height of the chain tip
37
+ # @raise [NotImplementedError] if not overridden by a subclass
38
+ def current_height
39
+ raise NotImplementedError, "#{self.class}#current_height must be implemented"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module BSV
8
+ module Transaction
9
+ module ChainTrackers
10
+ # Chain tracker that verifies merkle roots using the WhatsOnChain API.
11
+ #
12
+ # Queries the WoC block header endpoint to retrieve the merkle root for a
13
+ # given block height and compares it with the provided root.
14
+ #
15
+ # @example
16
+ # tracker = BSV::Transaction::ChainTrackers::WhatsOnChain.new
17
+ # tracker.valid_root_for_height?('abcd...', 800_000)
18
+ class WhatsOnChain < ChainTracker
19
+ BASE_URL = 'https://api.whatsonchain.com'
20
+
21
+ NETWORKS = {
22
+ main: 'main',
23
+ mainnet: 'main',
24
+ test: 'test',
25
+ testnet: 'test',
26
+ stn: 'stn'
27
+ }.freeze
28
+
29
+ # @param network [Symbol] :main, :mainnet, :test, :testnet, or :stn
30
+ # @param api_key [String, nil] optional WoC API key
31
+ # @param http_client [#request, nil] injectable HTTP client for testing
32
+ def initialize(network: :main, api_key: nil, http_client: nil)
33
+ super()
34
+ @network = NETWORKS.fetch(network) { raise ArgumentError, "unknown network: #{network}" }
35
+ @api_key = api_key
36
+ @http_client = http_client
37
+ end
38
+
39
+ # Verify that a merkle root is valid for the given block height.
40
+ #
41
+ # @param root [String] merkle root as a hex string
42
+ # @param height [Integer] block height
43
+ # @return [Boolean]
44
+ def valid_root_for_height?(root, height)
45
+ response = get("/v1/bsv/#{@network}/block/#{height}/header")
46
+ return false if response.nil?
47
+
48
+ data = JSON.parse(response.body)
49
+ data['merkleroot'].downcase == root.downcase
50
+ end
51
+
52
+ # Return the current blockchain height.
53
+ #
54
+ # @return [Integer]
55
+ def current_height
56
+ response = get("/v1/bsv/#{@network}/chain/info", not_found_returns_nil: false)
57
+ data = JSON.parse(response.body)
58
+ data['blocks']
59
+ end
60
+
61
+ private
62
+
63
+ # @param path [String] API path
64
+ # @param not_found_returns_nil [Boolean] if true, return nil on 404 instead of raising
65
+ # @return [Net::HTTPResponse, nil]
66
+ def get(path, not_found_returns_nil: true)
67
+ uri = URI("#{BASE_URL}#{path}")
68
+ request = Net::HTTP::Get.new(uri)
69
+ request['Authorization'] = @api_key if @api_key
70
+
71
+ response = execute(uri, request)
72
+ code = response.code.to_i
73
+
74
+ return nil if not_found_returns_nil && code == 404
75
+ return response if (200..299).cover?(code)
76
+
77
+ raise BSV::Network::ChainProviderError.new(
78
+ response.body || "HTTP #{code}",
79
+ status_code: code
80
+ )
81
+ end
82
+
83
+ def execute(uri, request)
84
+ if @http_client
85
+ @http_client.request(uri, request)
86
+ else
87
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
88
+ http.request(request)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Transaction
5
+ # Namespace for chain tracker implementations.
6
+ module ChainTrackers
7
+ autoload :WhatsOnChain, 'bsv/transaction/chain_trackers/whats_on_chain'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Transaction
5
+ # Base class for fee models that compute transaction fees.
6
+ #
7
+ # Fee models determine how many satoshis a transaction should pay in fees
8
+ # based on its size or other properties. Subclasses must implement
9
+ # {#compute_fee}.
10
+ #
11
+ # @example Implementing a custom fee model
12
+ # class FixedFee < BSV::Transaction::FeeModel
13
+ # def compute_fee(_transaction)
14
+ # 1000
15
+ # end
16
+ # end
17
+ class FeeModel
18
+ # Compute the fee for a transaction.
19
+ #
20
+ # @param transaction [Transaction] the transaction to compute the fee for
21
+ # @return [Integer] the fee in satoshis
22
+ # @raise [NotImplementedError] if not overridden by a subclass
23
+ def compute_fee(_transaction)
24
+ raise NotImplementedError, "#{self.class}#compute_fee must be implemented"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Transaction
5
+ module FeeModels
6
+ # Fee model that charges a configurable number of satoshis per kilobyte.
7
+ #
8
+ # Uses the transaction's estimated size (from templates for unsigned inputs,
9
+ # actual size for signed inputs) to compute the fee.
10
+ #
11
+ # @example
12
+ # model = BSV::Transaction::FeeModels::SatoshisPerKilobyte.new(value: 100)
13
+ # fee = model.compute_fee(transaction) # => 25 (for a ~250 byte tx)
14
+ class SatoshisPerKilobyte < FeeModel
15
+ # @return [Integer] satoshis per kilobyte rate
16
+ attr_reader :value
17
+
18
+ # @param value [Integer] satoshis per kilobyte (default: 50)
19
+ def initialize(value: 50)
20
+ super()
21
+ @value = value
22
+ end
23
+
24
+ # Compute the fee for a transaction based on its estimated size.
25
+ #
26
+ # @param transaction [Transaction] the transaction to compute the fee for
27
+ # @return [Integer] the fee in satoshis
28
+ def compute_fee(transaction)
29
+ size = transaction.estimated_size
30
+ (size / 1000.0 * @value).ceil
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Transaction
5
+ # Namespace for fee model implementations.
6
+ module FeeModels
7
+ autoload :SatoshisPerKilobyte, 'bsv/transaction/fee_models/satoshis_per_kilobyte'
8
+ end
9
+ end
10
+ end
@@ -186,6 +186,21 @@ module BSV
186
186
  compute_root(txid).reverse.unpack1('H*')
187
187
  end
188
188
 
189
+ # --- Verification ---
190
+
191
+ # Verify that this merkle path is valid for a given transaction.
192
+ #
193
+ # Computes the merkle root from the path and txid, then checks it
194
+ # against the blockchain via the provided chain tracker.
195
+ #
196
+ # @param txid_hex [String] hex-encoded transaction ID (display order)
197
+ # @param chain_tracker [ChainTracker] chain tracker to verify the root against
198
+ # @return [Boolean] true if the computed root matches the block at this height
199
+ def verify(txid_hex, chain_tracker)
200
+ root_hex = compute_root_hex(txid_hex)
201
+ chain_tracker.valid_root_for_height?(root_hex, @block_height)
202
+ end
203
+
189
204
  # --- Combine ---
190
205
 
191
206
  # Merge another merkle path into this one.
@@ -208,7 +223,7 @@ module BSV
208
223
  @path << [] if h >= @path.length
209
224
  next if h >= other.path.length
210
225
 
211
- existing = @path[h].each_with_object({}) { |e, m| m[e.offset] = e }
226
+ existing = @path[h].to_h { |e| [e.offset, e] }
212
227
  other.path[h].each do |elem|
213
228
  existing[elem.offset] ||= elem
214
229
  end
@@ -222,7 +237,7 @@ module BSV
222
237
 
223
238
  def build_indexed_path
224
239
  @path.map do |level|
225
- level.each_with_object({}) { |elem, h| h[elem.offset] = elem }
240
+ level.to_h { |elem| [elem.offset, elem] }
226
241
  end
227
242
  end
228
243