bsv-sdk 0.1.0 → 0.2.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.
@@ -84,11 +84,47 @@ module BSV
84
84
  to_binary.unpack1('H*')
85
85
  end
86
86
 
87
+ # Serialise the transaction in Extended Format (BRC-30).
88
+ #
89
+ # EF embeds source satoshis and source locking scripts in each input,
90
+ # allowing ARC to validate sighashes without fetching parent transactions.
91
+ #
92
+ # @return [String] raw EF transaction bytes
93
+ # @raise [ArgumentError] if any input is missing source_satoshis or source_locking_script
94
+ def to_ef
95
+ buf = [@version].pack('V')
96
+ buf << "\x00\x00\x00\x00\x00\xEF".b
97
+ buf << VarInt.encode(@inputs.length)
98
+ @inputs.each do |input|
99
+ raise ArgumentError, 'inputs must have source_satoshis for EF' if input.source_satoshis.nil?
100
+ raise ArgumentError, 'inputs must have source_locking_script for EF' if input.source_locking_script.nil?
101
+
102
+ buf << input.to_binary
103
+ buf << [input.source_satoshis].pack('Q<')
104
+ lock_bytes = input.source_locking_script.to_binary
105
+ buf << VarInt.encode(lock_bytes.bytesize)
106
+ buf << lock_bytes
107
+ end
108
+ buf << VarInt.encode(@outputs.length)
109
+ @outputs.each { |o| buf << o.to_binary }
110
+ buf << [@lock_time].pack('V')
111
+ buf
112
+ end
113
+
114
+ # Serialise the transaction in Extended Format as a hex string.
115
+ #
116
+ # @return [String] hex-encoded EF transaction
117
+ def to_ef_hex
118
+ to_ef.unpack1('H*')
119
+ end
120
+
87
121
  # Deserialise a transaction from binary data.
88
122
  #
89
123
  # @param data [String] raw binary transaction
90
124
  # @return [Transaction] the parsed transaction
91
125
  def self.from_binary(data)
126
+ raise ArgumentError, "truncated transaction: need at least 10 bytes, got #{data.bytesize}" if data.bytesize < 10
127
+
92
128
  offset = 0
93
129
 
94
130
  version = data.byteslice(offset, 4).unpack1('V')
@@ -112,6 +148,11 @@ module BSV
112
148
  offset += consumed
113
149
  end
114
150
 
151
+ if data.bytesize < offset + 4
152
+ raise ArgumentError,
153
+ "truncated transaction: need 4 bytes for lock_time at offset #{offset}, got #{data.bytesize - offset}"
154
+ end
155
+
115
156
  tx.instance_variable_set(:@lock_time, data.byteslice(offset, 4).unpack1('V'))
116
157
  tx
117
158
  end
@@ -124,6 +165,79 @@ module BSV
124
165
  from_binary([hex].pack('H*'))
125
166
  end
126
167
 
168
+ # Deserialise a transaction from Extended Format (BRC-30) binary data.
169
+ #
170
+ # @param data [String] raw EF binary
171
+ # @return [Transaction] the parsed transaction with source data on inputs
172
+ # @raise [ArgumentError] if the EF marker is invalid
173
+ def self.from_ef(data)
174
+ if data.bytesize < 10
175
+ raise ArgumentError,
176
+ "truncated EF transaction: need at least 10 bytes, got #{data.bytesize}"
177
+ end
178
+
179
+ offset = 0
180
+
181
+ version = data.byteslice(offset, 4).unpack1('V')
182
+ offset += 4
183
+
184
+ marker = data.byteslice(offset, 6)
185
+ raise ArgumentError, 'invalid EF marker' unless marker == "\x00\x00\x00\x00\x00\xEF".b
186
+
187
+ offset += 6
188
+
189
+ tx = new(version: version)
190
+
191
+ input_count, vi_size = VarInt.decode(data, offset)
192
+ offset += vi_size
193
+ input_count.times do
194
+ input, consumed = TransactionInput.from_binary(data, offset)
195
+ tx.add_input(input)
196
+ offset += consumed
197
+
198
+ if data.bytesize < offset + 8
199
+ remaining = data.bytesize - offset
200
+ raise ArgumentError,
201
+ "truncated EF input: need 8 bytes for source_satoshis at offset #{offset}, got #{remaining}"
202
+ end
203
+
204
+ input.source_satoshis = data.byteslice(offset, 8).unpack1('Q<')
205
+ offset += 8
206
+
207
+ lock_len, vi_size = VarInt.decode(data, offset)
208
+ offset += vi_size
209
+ if lock_len.positive?
210
+ input.source_locking_script = BSV::Script::Script.from_binary(data.byteslice(offset, lock_len))
211
+ offset += lock_len
212
+ end
213
+ end
214
+
215
+ output_count, vi_size = VarInt.decode(data, offset)
216
+ offset += vi_size
217
+ output_count.times do
218
+ output, consumed = TransactionOutput.from_binary(data, offset)
219
+ tx.add_output(output)
220
+ offset += consumed
221
+ end
222
+
223
+ if data.bytesize < offset + 4
224
+ remaining = data.bytesize - offset
225
+ raise ArgumentError,
226
+ "truncated EF transaction: need 4 bytes for lock_time at offset #{offset}, got #{remaining}"
227
+ end
228
+
229
+ tx.instance_variable_set(:@lock_time, data.byteslice(offset, 4).unpack1('V'))
230
+ tx
231
+ end
232
+
233
+ # Deserialise a transaction from an Extended Format hex string.
234
+ #
235
+ # @param hex [String] hex-encoded EF transaction
236
+ # @return [Transaction] the parsed transaction with source data on inputs
237
+ def self.from_ef_hex(hex)
238
+ from_ef([hex].pack('H*'))
239
+ end
240
+
127
241
  # Deserialise a transaction from binary data at a given offset,
128
242
  # returning the transaction and the number of bytes consumed.
129
243
  #
@@ -131,6 +245,11 @@ module BSV
131
245
  # @param offset [Integer] byte offset to start reading from
132
246
  # @return [Array(Transaction, Integer)] the transaction and bytes consumed
133
247
  def self.from_binary_with_offset(data, offset = 0)
248
+ if data.bytesize < offset + 10
249
+ raise ArgumentError,
250
+ "truncated transaction: need at least 10 bytes at offset #{offset}, got #{data.bytesize - offset}"
251
+ end
252
+
134
253
  start = offset
135
254
 
136
255
  version = data.byteslice(offset, 4).unpack1('V')
@@ -154,17 +273,86 @@ module BSV
154
273
  offset += consumed
155
274
  end
156
275
 
276
+ if data.bytesize < offset + 4
277
+ raise ArgumentError,
278
+ "truncated transaction: need 4 bytes for lock_time at offset #{offset}, got #{data.bytesize - offset}"
279
+ end
280
+
157
281
  tx.instance_variable_set(:@lock_time, data.byteslice(offset, 4).unpack1('V'))
158
282
  offset += 4
159
283
 
160
284
  [tx, offset - start]
161
285
  end
162
286
 
287
+ # --- BEEF convenience methods ---
288
+
289
+ # Serialise this transaction (with its ancestry chain and merkle proofs)
290
+ # into a BEEF V2 binary bundle.
291
+ #
292
+ # Walks the `source_transaction` references on inputs to collect ancestors.
293
+ # Transactions with a `merkle_path` are treated as proven leaves — their
294
+ # ancestors are not traversed further.
295
+ #
296
+ # @return [String] raw BEEF V2 binary
297
+ def to_beef
298
+ beef = Beef.new
299
+ ancestors = collect_ancestors
300
+
301
+ ancestors.each do |tx|
302
+ entry = if tx.merkle_path
303
+ bump_idx = beef.merge_bump(tx.merkle_path)
304
+ Beef::BeefTx.new(
305
+ format: Beef::FORMAT_RAW_TX_AND_BUMP,
306
+ transaction: tx,
307
+ bump_index: bump_idx
308
+ )
309
+ else
310
+ Beef::BeefTx.new(
311
+ format: Beef::FORMAT_RAW_TX,
312
+ transaction: tx
313
+ )
314
+ end
315
+ beef.transactions << entry
316
+ end
317
+
318
+ beef.to_binary
319
+ end
320
+
321
+ # Serialise this transaction to a BEEF V2 hex string.
322
+ #
323
+ # @return [String] hex-encoded BEEF
324
+ def to_beef_hex
325
+ to_beef.unpack1('H*')
326
+ end
327
+
328
+ # Parse a BEEF binary bundle and return the subject transaction
329
+ # (the last transaction in the bundle).
330
+ #
331
+ # @param data [String] raw BEEF binary
332
+ # @return [Transaction] the subject transaction with ancestry wired
333
+ def self.from_beef(data)
334
+ beef = Beef.from_binary(data)
335
+ last_tx_entry = beef.transactions.reverse.find(&:transaction)
336
+ last_tx_entry&.transaction
337
+ end
338
+
339
+ # Parse a BEEF hex string and return the subject transaction.
340
+ #
341
+ # @param hex [String] hex-encoded BEEF
342
+ # @return [Transaction] the subject transaction with ancestry wired
343
+ def self.from_beef_hex(hex)
344
+ from_beef([hex].pack('H*'))
345
+ end
346
+
163
347
  # --- Transaction ID ---
164
348
 
165
- # Compute the transaction ID (double-SHA-256 of the serialised tx, reversed).
349
+ # Compute the transaction ID (double-SHA-256 of the serialised tx, byte-reversed).
350
+ #
351
+ # Returns display byte order (reversed from the natural hash).
352
+ # Compare with {TransactionInput#prev_tx_id} which stores wire byte
353
+ # order (natural hash). Use +.reverse+ to convert between the two.
166
354
  #
167
- # @return [String] 32-byte transaction ID in internal byte order
355
+ # @return [String] 32-byte transaction ID in display byte order
168
356
  def txid
169
357
  BSV::Primitives::Digest.sha256d(to_binary).reverse
170
358
  end
@@ -299,13 +487,82 @@ module BSV
299
487
  )
300
488
  end
301
489
 
490
+ # --- SPV verification ---
491
+
492
+ # Perform full SPV verification of this transaction and its ancestry.
493
+ #
494
+ # Uses a queue-based approach (matching TS/Go SDKs) to walk the
495
+ # transaction ancestry chain:
496
+ #
497
+ # 1. If a transaction has a merkle path that validates against the chain
498
+ # tracker, it is marked verified (inputs are not re-checked).
499
+ # 2. Otherwise, each input's scripts are executed via the interpreter,
500
+ # and source transactions are enqueued for verification.
501
+ # 3. Optionally validates that the root transaction's fee meets the
502
+ # provided fee model.
503
+ # 4. Checks that total outputs do not exceed total inputs.
504
+ #
505
+ # @param chain_tracker [ChainTracker] chain tracker for merkle root validation
506
+ # @param fee_model [FeeModel, nil] optional fee model to validate the root transaction's fee
507
+ # @return [true] on successful verification
508
+ # @raise [ArgumentError] if a source transaction or unlocking script is missing
509
+ # @raise [BSV::Script::ScriptError] if script execution fails
510
+ # @raise [VerificationError] for merkle path failures, fee validation, or output overflow
511
+ def verify(chain_tracker:, fee_model: nil)
512
+ verified = {}
513
+ queue = [self]
514
+
515
+ until queue.empty?
516
+ tx = queue.shift
517
+ tx_id = tx.txid_hex
518
+ next if verified[tx_id]
519
+
520
+ # Merkle path short-circuit: proven transaction needs no input verification
521
+ if tx.merkle_path
522
+ unless tx.merkle_path.verify(tx_id, chain_tracker)
523
+ raise VerificationError.new(:invalid_merkle_proof,
524
+ "invalid merkle proof for transaction #{tx_id}")
525
+ end
526
+
527
+ verified[tx_id] = true
528
+ next
529
+ end
530
+
531
+ # Fee validation (root transaction only)
532
+ verify_fee(fee_model) if tx.equal?(self) && fee_model
533
+
534
+ # Verify each input
535
+ tx.inputs.each_with_index do |input, index|
536
+ verify_input_requirements(tx, input, index)
537
+ tx.verify_input(index)
538
+
539
+ # Enqueue source transaction for verification if not yet verified
540
+ source_tx = input.source_transaction
541
+ queue << source_tx if source_tx && !verified[source_tx.txid_hex]
542
+ end
543
+
544
+ # Output ≤ input check
545
+ verify_output_constraint(tx)
546
+
547
+ verified[tx_id] = true
548
+ end
549
+
550
+ true
551
+ end
552
+
302
553
  # --- Fee estimation ---
303
554
 
304
555
  # Sum of all input source satoshi values.
305
556
  #
306
557
  # @return [Integer] total input value in satoshis
307
558
  def total_input_satoshis
308
- @inputs.sum { |i| i.source_satoshis || 0 }
559
+ @inputs.each_with_index do |input, idx|
560
+ if input.source_satoshis.nil?
561
+ raise ArgumentError,
562
+ "input #{idx} has nil source_satoshis — set it before computing totals"
563
+ end
564
+ end
565
+ @inputs.sum(&:source_satoshis)
309
566
  end
310
567
 
311
568
  # Sum of all output satoshi values.
@@ -324,8 +581,86 @@ module BSV
324
581
  (size * satoshis_per_byte).ceil
325
582
  end
326
583
 
584
+ # Estimate the serialised transaction size in bytes.
585
+ #
586
+ # Uses actual unlocking script size for signed inputs and template
587
+ # estimated length for unsigned inputs.
588
+ #
589
+ # @return [Integer] estimated size in bytes
590
+ def estimated_size
591
+ size = 4 # version
592
+ size += VarInt.encode(@inputs.length).bytesize
593
+ @inputs.each_with_index do |input, index|
594
+ size += if input.unlocking_script
595
+ input.to_binary.bytesize
596
+ elsif input.unlocking_script_template
597
+ script_len = input.unlocking_script_template.estimated_length(self, index)
598
+ 32 + 4 + VarInt.encode(script_len).bytesize + script_len + 4
599
+ else
600
+ UNSIGNED_P2PKH_INPUT_SIZE
601
+ end
602
+ end
603
+ size += VarInt.encode(@outputs.length).bytesize
604
+ @outputs.each { |o| size += o.to_binary.bytesize }
605
+ size += 4 # lock_time
606
+ size
607
+ end
608
+
609
+ # Compute the fee and distribute change across change outputs.
610
+ #
611
+ # Accepts a {FeeModel} instance, a numeric fee in satoshis, or nil
612
+ # (defaults to {FeeModels::SatoshisPerKilobyte} at 50 sat/kB).
613
+ #
614
+ # After computing the fee, distributes remaining satoshis equally
615
+ # across outputs marked as change. If insufficient change, removes
616
+ # all change outputs (excess goes to miners).
617
+ #
618
+ # @param model_or_fee [FeeModel, Integer, nil] fee model, fixed fee, or nil for default
619
+ # @return [self] for chaining
620
+ def fee(model_or_fee = nil)
621
+ fee_sats = compute_fee_sats(model_or_fee)
622
+ distribute_change(fee_sats)
623
+ self
624
+ end
625
+
327
626
  private
328
627
 
628
+ def verify_input_requirements(tx, input, index)
629
+ tx_id = tx.txid_hex
630
+ if input.unlocking_script.nil?
631
+ raise ArgumentError,
632
+ "input #{index} of transaction #{tx_id} has no unlocking script"
633
+ end
634
+ if input.source_locking_script.nil?
635
+ raise ArgumentError,
636
+ "input #{index} of transaction #{tx_id} has no source locking script"
637
+ end
638
+ return unless input.source_satoshis.nil?
639
+
640
+ raise ArgumentError,
641
+ "input #{index} of transaction #{tx_id} has no source satoshis"
642
+ end
643
+
644
+ def verify_fee(fee_model)
645
+ required_fee = fee_model.compute_fee(self)
646
+ actual_fee = total_input_satoshis - total_output_satoshis
647
+ return if actual_fee >= required_fee
648
+
649
+ raise VerificationError.new(:insufficient_fee,
650
+ "insufficient fee: transaction pays #{actual_fee} sat " \
651
+ "but fee model requires #{required_fee} sat")
652
+ end
653
+
654
+ def verify_output_constraint(tx)
655
+ input_total = tx.total_input_satoshis
656
+ output_total = tx.total_output_satoshis
657
+ return if output_total <= input_total
658
+
659
+ raise VerificationError.new(:output_overflow,
660
+ "outputs (#{output_total}) exceed inputs (#{input_total}) " \
661
+ "for transaction #{tx.txid_hex}")
662
+ end
663
+
329
664
  ZERO_HASH = "\x00".b * 32
330
665
  private_constant :ZERO_HASH
331
666
 
@@ -357,23 +692,62 @@ module BSV
357
692
  end
358
693
  end
359
694
 
360
- def estimated_size
361
- size = 4 # version
362
- size += VarInt.encode(@inputs.length).bytesize
363
- @inputs.each_with_index do |input, index|
364
- size += if input.unlocking_script
365
- input.to_binary.bytesize
366
- elsif input.unlocking_script_template
367
- script_len = input.unlocking_script_template.estimated_length(self, index)
368
- 32 + 4 + VarInt.encode(script_len).bytesize + script_len + 4
369
- else
370
- UNSIGNED_P2PKH_INPUT_SIZE
371
- end
695
+ # Collect this transaction and all its ancestors in dependency order
696
+ # (ancestors first, self last). Stops recursion at transactions with
697
+ # a merkle_path (proven leaves). Deduplicates by txid.
698
+ def collect_ancestors
699
+ seen = {}
700
+ result = []
701
+ collect_ancestors_recursive(self, seen, result)
702
+ result
703
+ end
704
+
705
+ def collect_ancestors_recursive(tx, seen, result)
706
+ txid = tx.txid
707
+ return if seen.key?(txid)
708
+
709
+ unless tx.merkle_path
710
+ tx.inputs.each do |input|
711
+ next unless input.source_transaction
712
+
713
+ collect_ancestors_recursive(input.source_transaction, seen, result)
714
+ end
715
+ end
716
+
717
+ seen[txid] = true
718
+ result << tx
719
+ end
720
+
721
+ def compute_fee_sats(model_or_fee)
722
+ case model_or_fee
723
+ when nil
724
+ FeeModels::SatoshisPerKilobyte.new.compute_fee(self)
725
+ when FeeModel
726
+ model_or_fee.compute_fee(self)
727
+ when Numeric
728
+ model_or_fee.ceil
729
+ else
730
+ raise ArgumentError, "expected FeeModel, Numeric, or nil; got #{model_or_fee.class}"
731
+ end
732
+ end
733
+
734
+ def distribute_change(fee_sats)
735
+ change_outputs = @outputs.select(&:change)
736
+ return if change_outputs.empty?
737
+
738
+ input_sats = total_input_satoshis
739
+ non_change_sats = @outputs.reject(&:change).sum(&:satoshis)
740
+ available = input_sats - non_change_sats - fee_sats
741
+
742
+ if available <= change_outputs.length
743
+ @outputs.reject!(&:change)
744
+ else
745
+ per_output = available / change_outputs.length
746
+ remainder = available % change_outputs.length
747
+ change_outputs.each_with_index do |output, i|
748
+ output.satoshis = per_output + (i < remainder ? 1 : 0)
749
+ end
372
750
  end
373
- size += VarInt.encode(@outputs.length).bytesize
374
- @outputs.each { |o| size += o.to_binary.bytesize }
375
- size += 4 # lock_time
376
- size
377
751
  end
378
752
  end
379
753
  end
@@ -61,6 +61,11 @@ module BSV
61
61
  # @param offset [Integer] byte offset to start reading from
62
62
  # @return [Array(TransactionInput, Integer)] the input and bytes consumed
63
63
  def self.from_binary(data, offset = 0)
64
+ if data.bytesize < offset + 36
65
+ raise ArgumentError,
66
+ "truncated input: need 36 bytes for outpoint at offset #{offset}, got #{data.bytesize - offset}"
67
+ end
68
+
64
69
  prev_tx_id = data.byteslice(offset, 32)
65
70
  prev_tx_out_index = data.byteslice(offset + 32, 4).unpack1('V')
66
71
  offset += 36
@@ -68,9 +73,20 @@ module BSV
68
73
  script_len, vi_size = VarInt.decode(data, offset)
69
74
  offset += vi_size
70
75
 
76
+ if data.bytesize < offset + script_len
77
+ remaining = data.bytesize - offset
78
+ raise ArgumentError,
79
+ "truncated input: need #{script_len} bytes for script at offset #{offset}, got #{remaining}"
80
+ end
81
+
71
82
  unlocking_script = (BSV::Script::Script.from_binary(data.byteslice(offset, script_len)) if script_len.positive?)
72
83
  offset += script_len
73
84
 
85
+ if data.bytesize < offset + 4
86
+ raise ArgumentError,
87
+ "truncated input: need 4 bytes for sequence at offset #{offset}, got #{data.bytesize - offset}"
88
+ end
89
+
74
90
  sequence = data.byteslice(offset, 4).unpack1('V')
75
91
 
76
92
  total = 36 + vi_size + script_len + 4
@@ -9,16 +9,21 @@ module BSV
9
9
  # unlocking scripts.
10
10
  class TransactionOutput
11
11
  # @return [Integer] the output value in satoshis
12
- attr_reader :satoshis
12
+ attr_accessor :satoshis
13
13
 
14
14
  # @return [Script::Script] the locking script (spending conditions)
15
15
  attr_reader :locking_script
16
16
 
17
+ # @return [Boolean] whether this output receives change
18
+ attr_accessor :change
19
+
17
20
  # @param satoshis [Integer] output value in satoshis
18
21
  # @param locking_script [Script::Script] the locking script
19
- def initialize(satoshis:, locking_script:)
22
+ # @param change [Boolean] whether this is a change output (default: false)
23
+ def initialize(satoshis:, locking_script:, change: false)
20
24
  @satoshis = satoshis
21
25
  @locking_script = locking_script
26
+ @change = change
22
27
  end
23
28
 
24
29
  # Serialise the output to its binary wire format.
@@ -35,12 +40,23 @@ module BSV
35
40
  # @param offset [Integer] byte offset to start reading from
36
41
  # @return [Array(TransactionOutput, Integer)] the output and bytes consumed
37
42
  def self.from_binary(data, offset = 0)
43
+ if data.bytesize < offset + 8
44
+ raise ArgumentError,
45
+ "truncated output: need 8 bytes for satoshis at offset #{offset}, got #{data.bytesize - offset}"
46
+ end
47
+
38
48
  satoshis = data.byteslice(offset, 8).unpack1('Q<')
39
49
  offset += 8
40
50
 
41
51
  script_len, vi_size = VarInt.decode(data, offset)
42
52
  offset += vi_size
43
53
 
54
+ if data.bytesize < offset + script_len
55
+ remaining = data.bytesize - offset
56
+ raise ArgumentError,
57
+ "truncated output: need #{script_len} bytes for script at offset #{offset}, got #{remaining}"
58
+ end
59
+
44
60
  script_bytes = data.byteslice(offset, script_len)
45
61
  locking_script = BSV::Script::Script.from_binary(script_bytes)
46
62
 
@@ -32,16 +32,36 @@ module BSV
32
32
  # @param offset [Integer] byte offset to start reading from
33
33
  # @return [Array(Integer, Integer)] the decoded value and number of bytes consumed
34
34
  def decode(data, offset = 0)
35
+ if offset >= data.bytesize
36
+ raise ArgumentError,
37
+ "truncated varint: need 1 byte at offset #{offset}, got end of data"
38
+ end
39
+
35
40
  first = data.getbyte(offset)
36
41
 
37
42
  case first
38
43
  when 0..0xFC
39
44
  [first, 1]
40
45
  when 0xFD
46
+ if data.bytesize < offset + 3
47
+ raise ArgumentError,
48
+ "truncated varint: need 3 bytes at offset #{offset}, got #{data.bytesize - offset}"
49
+ end
50
+
41
51
  [data.byteslice(offset + 1, 2).unpack1('v'), 3]
42
52
  when 0xFE
53
+ if data.bytesize < offset + 5
54
+ raise ArgumentError,
55
+ "truncated varint: need 5 bytes at offset #{offset}, got #{data.bytesize - offset}"
56
+ end
57
+
43
58
  [data.byteslice(offset + 1, 4).unpack1('V'), 5]
44
59
  when 0xFF
60
+ if data.bytesize < offset + 9
61
+ raise ArgumentError,
62
+ "truncated varint: need 9 bytes at offset #{offset}, got #{data.bytesize - offset}"
63
+ end
64
+
45
65
  [data.byteslice(offset + 1, 8).unpack1('Q<'), 9]
46
66
  end
47
67
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Transaction
5
+ # Error raised during SPV verification.
6
+ #
7
+ # Carries a machine-readable code alongside a human-readable message,
8
+ # matching the typed error pattern used by the Go SDK
9
+ # (ErrInvalidMerklePath, ErrFeeTooLow, ErrScriptVerificationFailed).
10
+ class VerificationError < StandardError
11
+ # @return [Symbol] the error code
12
+ attr_reader :code
13
+
14
+ INVALID_MERKLE_PROOF = :invalid_merkle_proof
15
+ INSUFFICIENT_FEE = :insufficient_fee
16
+ OUTPUT_OVERFLOW = :output_overflow
17
+
18
+ # @param code [Symbol] error code
19
+ # @param message [String] human-readable description
20
+ def initialize(code, message)
21
+ @code = code
22
+ super(message)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -13,6 +13,11 @@ module BSV
13
13
  autoload :TransactionInput, 'bsv/transaction/transaction_input'
14
14
  autoload :Sighash, 'bsv/transaction/sighash'
15
15
  autoload :MerklePath, 'bsv/transaction/merkle_path'
16
+ autoload :FeeModel, 'bsv/transaction/fee_model'
17
+ autoload :FeeModels, 'bsv/transaction/fee_models'
18
+ autoload :VerificationError, 'bsv/transaction/verification_error'
19
+ autoload :ChainTracker, 'bsv/transaction/chain_tracker'
20
+ autoload :ChainTrackers, 'bsv/transaction/chain_trackers'
16
21
  autoload :Beef, 'bsv/transaction/beef'
17
22
  autoload :UnlockingScriptTemplate, 'bsv/transaction/unlocking_script_template'
18
23
  autoload :P2PKH, 'bsv/transaction/p2pkh'
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.1.0'
4
+ VERSION = '0.2.1'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-14 00:00:00.000000000 Z
10
+ date: 2026-03-07 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: A Ruby library for interacting with the BSV Blockchain — keys, scripts,
13
13
  transactions, and more.
@@ -64,6 +64,12 @@ files:
64
64
  - lib/bsv/script/script.rb
65
65
  - lib/bsv/transaction.rb
66
66
  - lib/bsv/transaction/beef.rb
67
+ - lib/bsv/transaction/chain_tracker.rb
68
+ - lib/bsv/transaction/chain_trackers.rb
69
+ - lib/bsv/transaction/chain_trackers/whats_on_chain.rb
70
+ - lib/bsv/transaction/fee_model.rb
71
+ - lib/bsv/transaction/fee_models.rb
72
+ - lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb
67
73
  - lib/bsv/transaction/merkle_path.rb
68
74
  - lib/bsv/transaction/p2pkh.rb
69
75
  - lib/bsv/transaction/sighash.rb
@@ -72,6 +78,7 @@ files:
72
78
  - lib/bsv/transaction/transaction_output.rb
73
79
  - lib/bsv/transaction/unlocking_script_template.rb
74
80
  - lib/bsv/transaction/var_int.rb
81
+ - lib/bsv/transaction/verification_error.rb
75
82
  - lib/bsv/version.rb
76
83
  - lib/bsv/wallet.rb
77
84
  - lib/bsv/wallet/insufficient_funds_error.rb