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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/lib/bsv/primitives/curve.rb +3 -3
- data/lib/bsv/primitives/ecies.rb +5 -5
- data/lib/bsv/primitives/private_key.rb +34 -0
- data/lib/bsv/primitives/public_key.rb +36 -0
- data/lib/bsv/primitives/signature.rb +2 -2
- data/lib/bsv/script/chunk.rb +15 -19
- data/lib/bsv/script/interpreter/interpreter.rb +13 -3
- data/lib/bsv/script/interpreter/operations/arithmetic.rb +1 -1
- data/lib/bsv/script/interpreter/operations/crypto.rb +1 -1
- data/lib/bsv/script/interpreter/operations/flow_control.rb +15 -8
- data/lib/bsv/script/interpreter/script_number.rb +1 -1
- data/lib/bsv/script/interpreter/stack.rb +2 -2
- data/lib/bsv/script/script.rb +4 -0
- data/lib/bsv/transaction/beef.rb +306 -4
- data/lib/bsv/transaction/chain_tracker.rb +43 -0
- data/lib/bsv/transaction/chain_trackers/whats_on_chain.rb +95 -0
- data/lib/bsv/transaction/chain_trackers.rb +10 -0
- data/lib/bsv/transaction/fee_model.rb +28 -0
- data/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb +35 -0
- data/lib/bsv/transaction/fee_models.rb +10 -0
- data/lib/bsv/transaction/merkle_path.rb +17 -2
- data/lib/bsv/transaction/transaction.rb +393 -19
- data/lib/bsv/transaction/transaction_input.rb +16 -0
- data/lib/bsv/transaction/transaction_output.rb +18 -2
- data/lib/bsv/transaction/var_int.rb +20 -0
- data/lib/bsv/transaction/verification_error.rb +26 -0
- data/lib/bsv/transaction.rb +5 -0
- data/lib/bsv/version.rb +1 -1
- metadata +9 -2
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/bsv/transaction.rb
CHANGED
|
@@ -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
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
|
|
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-
|
|
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
|