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.
@@ -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,12 +273,87 @@ 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
+ bump_map = {}
300
+ ancestors = collect_ancestors
301
+
302
+ ancestors.each do |tx|
303
+ # Collect BUMPs
304
+ if tx.merkle_path
305
+ height = tx.merkle_path.block_height
306
+ unless bump_map.key?(height)
307
+ bump_map[height] = beef.bumps.length
308
+ beef.bumps << tx.merkle_path
309
+ end
310
+ end
311
+
312
+ # Add transaction in dependency order
313
+ entry = if tx.merkle_path
314
+ Beef::BeefTx.new(
315
+ format: Beef::FORMAT_RAW_TX_AND_BUMP,
316
+ transaction: tx,
317
+ bump_index: bump_map[tx.merkle_path.block_height]
318
+ )
319
+ else
320
+ Beef::BeefTx.new(
321
+ format: Beef::FORMAT_RAW_TX,
322
+ transaction: tx
323
+ )
324
+ end
325
+ beef.transactions << entry
326
+ end
327
+
328
+ beef.to_binary
329
+ end
330
+
331
+ # Serialise this transaction to a BEEF V2 hex string.
332
+ #
333
+ # @return [String] hex-encoded BEEF
334
+ def to_beef_hex
335
+ to_beef.unpack1('H*')
336
+ end
337
+
338
+ # Parse a BEEF binary bundle and return the subject transaction
339
+ # (the last transaction in the bundle).
340
+ #
341
+ # @param data [String] raw BEEF binary
342
+ # @return [Transaction] the subject transaction with ancestry wired
343
+ def self.from_beef(data)
344
+ beef = Beef.from_binary(data)
345
+ last_tx_entry = beef.transactions.reverse.find(&:transaction)
346
+ last_tx_entry&.transaction
347
+ end
348
+
349
+ # Parse a BEEF hex string and return the subject transaction.
350
+ #
351
+ # @param hex [String] hex-encoded BEEF
352
+ # @return [Transaction] the subject transaction with ancestry wired
353
+ def self.from_beef_hex(hex)
354
+ from_beef([hex].pack('H*'))
355
+ end
356
+
163
357
  # --- Transaction ID ---
164
358
 
165
359
  # Compute the transaction ID (double-SHA-256 of the serialised tx, reversed).
@@ -299,13 +493,82 @@ module BSV
299
493
  )
300
494
  end
301
495
 
496
+ # --- SPV verification ---
497
+
498
+ # Perform full SPV verification of this transaction and its ancestry.
499
+ #
500
+ # Uses a queue-based approach (matching TS/Go SDKs) to walk the
501
+ # transaction ancestry chain:
502
+ #
503
+ # 1. If a transaction has a merkle path that validates against the chain
504
+ # tracker, it is marked verified (inputs are not re-checked).
505
+ # 2. Otherwise, each input's scripts are executed via the interpreter,
506
+ # and source transactions are enqueued for verification.
507
+ # 3. Optionally validates that the root transaction's fee meets the
508
+ # provided fee model.
509
+ # 4. Checks that total outputs do not exceed total inputs.
510
+ #
511
+ # @param chain_tracker [ChainTracker] chain tracker for merkle root validation
512
+ # @param fee_model [FeeModel, nil] optional fee model to validate the root transaction's fee
513
+ # @return [true] on successful verification
514
+ # @raise [ArgumentError] if a source transaction or unlocking script is missing
515
+ # @raise [BSV::Script::ScriptError] if script execution fails
516
+ # @raise [VerificationError] for merkle path failures, fee validation, or output overflow
517
+ def verify(chain_tracker:, fee_model: nil)
518
+ verified = {}
519
+ queue = [self]
520
+
521
+ until queue.empty?
522
+ tx = queue.shift
523
+ tx_id = tx.txid_hex
524
+ next if verified[tx_id]
525
+
526
+ # Merkle path short-circuit: proven transaction needs no input verification
527
+ if tx.merkle_path
528
+ unless tx.merkle_path.verify(tx_id, chain_tracker)
529
+ raise VerificationError.new(:invalid_merkle_proof,
530
+ "invalid merkle proof for transaction #{tx_id}")
531
+ end
532
+
533
+ verified[tx_id] = true
534
+ next
535
+ end
536
+
537
+ # Fee validation (root transaction only)
538
+ verify_fee(fee_model) if tx.equal?(self) && fee_model
539
+
540
+ # Verify each input
541
+ tx.inputs.each_with_index do |input, index|
542
+ verify_input_requirements(tx, input, index)
543
+ tx.verify_input(index)
544
+
545
+ # Enqueue source transaction for verification if not yet verified
546
+ source_tx = input.source_transaction
547
+ queue << source_tx if source_tx && !verified[source_tx.txid_hex]
548
+ end
549
+
550
+ # Output ≤ input check
551
+ verify_output_constraint(tx)
552
+
553
+ verified[tx_id] = true
554
+ end
555
+
556
+ true
557
+ end
558
+
302
559
  # --- Fee estimation ---
303
560
 
304
561
  # Sum of all input source satoshi values.
305
562
  #
306
563
  # @return [Integer] total input value in satoshis
307
564
  def total_input_satoshis
308
- @inputs.sum { |i| i.source_satoshis || 0 }
565
+ @inputs.each_with_index do |input, idx|
566
+ if input.source_satoshis.nil?
567
+ raise ArgumentError,
568
+ "input #{idx} has nil source_satoshis — set it before computing totals"
569
+ end
570
+ end
571
+ @inputs.sum(&:source_satoshis)
309
572
  end
310
573
 
311
574
  # Sum of all output satoshi values.
@@ -324,8 +587,86 @@ module BSV
324
587
  (size * satoshis_per_byte).ceil
325
588
  end
326
589
 
590
+ # Estimate the serialised transaction size in bytes.
591
+ #
592
+ # Uses actual unlocking script size for signed inputs and template
593
+ # estimated length for unsigned inputs.
594
+ #
595
+ # @return [Integer] estimated size in bytes
596
+ def estimated_size
597
+ size = 4 # version
598
+ size += VarInt.encode(@inputs.length).bytesize
599
+ @inputs.each_with_index do |input, index|
600
+ size += if input.unlocking_script
601
+ input.to_binary.bytesize
602
+ elsif input.unlocking_script_template
603
+ script_len = input.unlocking_script_template.estimated_length(self, index)
604
+ 32 + 4 + VarInt.encode(script_len).bytesize + script_len + 4
605
+ else
606
+ UNSIGNED_P2PKH_INPUT_SIZE
607
+ end
608
+ end
609
+ size += VarInt.encode(@outputs.length).bytesize
610
+ @outputs.each { |o| size += o.to_binary.bytesize }
611
+ size += 4 # lock_time
612
+ size
613
+ end
614
+
615
+ # Compute the fee and distribute change across change outputs.
616
+ #
617
+ # Accepts a {FeeModel} instance, a numeric fee in satoshis, or nil
618
+ # (defaults to {FeeModels::SatoshisPerKilobyte} at 50 sat/kB).
619
+ #
620
+ # After computing the fee, distributes remaining satoshis equally
621
+ # across outputs marked as change. If insufficient change, removes
622
+ # all change outputs (excess goes to miners).
623
+ #
624
+ # @param model_or_fee [FeeModel, Integer, nil] fee model, fixed fee, or nil for default
625
+ # @return [self] for chaining
626
+ def fee(model_or_fee = nil)
627
+ fee_sats = compute_fee_sats(model_or_fee)
628
+ distribute_change(fee_sats)
629
+ self
630
+ end
631
+
327
632
  private
328
633
 
634
+ def verify_input_requirements(tx, input, index)
635
+ tx_id = tx.txid_hex
636
+ if input.unlocking_script.nil?
637
+ raise ArgumentError,
638
+ "input #{index} of transaction #{tx_id} has no unlocking script"
639
+ end
640
+ if input.source_locking_script.nil?
641
+ raise ArgumentError,
642
+ "input #{index} of transaction #{tx_id} has no source locking script"
643
+ end
644
+ return unless input.source_satoshis.nil?
645
+
646
+ raise ArgumentError,
647
+ "input #{index} of transaction #{tx_id} has no source satoshis"
648
+ end
649
+
650
+ def verify_fee(fee_model)
651
+ required_fee = fee_model.compute_fee(self)
652
+ actual_fee = total_input_satoshis - total_output_satoshis
653
+ return if actual_fee >= required_fee
654
+
655
+ raise VerificationError.new(:insufficient_fee,
656
+ "insufficient fee: transaction pays #{actual_fee} sat " \
657
+ "but fee model requires #{required_fee} sat")
658
+ end
659
+
660
+ def verify_output_constraint(tx)
661
+ input_total = tx.total_input_satoshis
662
+ output_total = tx.total_output_satoshis
663
+ return if output_total <= input_total
664
+
665
+ raise VerificationError.new(:output_overflow,
666
+ "outputs (#{output_total}) exceed inputs (#{input_total}) " \
667
+ "for transaction #{tx.txid_hex}")
668
+ end
669
+
329
670
  ZERO_HASH = "\x00".b * 32
330
671
  private_constant :ZERO_HASH
331
672
 
@@ -357,23 +698,62 @@ module BSV
357
698
  end
358
699
  end
359
700
 
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
701
+ # Collect this transaction and all its ancestors in dependency order
702
+ # (ancestors first, self last). Stops recursion at transactions with
703
+ # a merkle_path (proven leaves). Deduplicates by txid.
704
+ def collect_ancestors
705
+ seen = {}
706
+ result = []
707
+ collect_ancestors_recursive(self, seen, result)
708
+ result
709
+ end
710
+
711
+ def collect_ancestors_recursive(tx, seen, result)
712
+ txid = tx.txid
713
+ return if seen.key?(txid)
714
+
715
+ unless tx.merkle_path
716
+ tx.inputs.each do |input|
717
+ next unless input.source_transaction
718
+
719
+ collect_ancestors_recursive(input.source_transaction, seen, result)
720
+ end
721
+ end
722
+
723
+ seen[txid] = true
724
+ result << tx
725
+ end
726
+
727
+ def compute_fee_sats(model_or_fee)
728
+ case model_or_fee
729
+ when nil
730
+ FeeModels::SatoshisPerKilobyte.new.compute_fee(self)
731
+ when FeeModel
732
+ model_or_fee.compute_fee(self)
733
+ when Numeric
734
+ model_or_fee.ceil
735
+ else
736
+ raise ArgumentError, "expected FeeModel, Numeric, or nil; got #{model_or_fee.class}"
737
+ end
738
+ end
739
+
740
+ def distribute_change(fee_sats)
741
+ change_outputs = @outputs.select(&:change)
742
+ return if change_outputs.empty?
743
+
744
+ input_sats = total_input_satoshis
745
+ non_change_sats = @outputs.reject(&:change).sum(&:satoshis)
746
+ available = input_sats - non_change_sats - fee_sats
747
+
748
+ if available <= change_outputs.length
749
+ @outputs.reject!(&:change)
750
+ else
751
+ per_output = available / change_outputs.length
752
+ remainder = available % change_outputs.length
753
+ change_outputs.each_with_index do |output, i|
754
+ output.satoshis = per_output + (i < remainder ? 1 : 0)
755
+ end
372
756
  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
757
  end
378
758
  end
379
759
  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.0'
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.0
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