Class: BSV::Transaction::Transaction

Inherits:
Object
  • Object
show all
Defined in:
lib/bsv/transaction/transaction.rb

Overview

A Bitcoin transaction: a collection of inputs consuming previous outputs and producing new outputs.

Supports construction, binary/hex serialisation, BIP-143 sighash computation (with FORKID), signing, script verification, and fee estimation.

Examples:

Build, sign, and serialise a transaction

tx = BSV::Transaction::Transaction.new
tx.add_input(input)
tx.add_output(output)
tx.sign(0, private_key)
tx.to_hex #=> "0100000001..."

Constant Summary collapse

UNSIGNED_P2PKH_INPUT_SIZE =

Estimated size of an unsigned P2PKH input in bytes.

148
LOG10_RECIPROCAL_D_VALUES_1TO9 =

Lookup table for benford_number calculation. we want float values for log10(1 + (1.0 / i)) for the 9 integers 0 < i < 10 in ruby this becomes: (1..9).to_a.collect{|d| Math.log10(1 + (1.0 / d)) }

[0.3010299956639812,
0.17609125905568124,
0.12493873660829993,
0.09691001300805642,
0.07918124604762482,
0.06694678963061322,
0.05799194697768673,
0.05115252244738129,
0.04575749056067514].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(version: 1, lock_time: 0) ⇒ Transaction

Returns a new instance of Transaction.

Parameters:

  • version (Integer) (defaults to: 1)

    transaction version (default: 1)

  • lock_time (Integer) (defaults to: 0)

    lock time (default: 0)



52
53
54
55
56
57
58
# File 'lib/bsv/transaction/transaction.rb', line 52

def initialize(version: 1, lock_time: 0)
  @version = version
  @lock_time = lock_time
  @inputs = []
  @outputs = []
  @merkle_path = nil
end

Instance Attribute Details

#inputsArray<TransactionInput> (readonly)

Returns transaction inputs.

Returns:



42
43
44
# File 'lib/bsv/transaction/transaction.rb', line 42

def inputs
  @inputs
end

#lock_timeInteger (readonly)

Returns lock time (block height or Unix timestamp).

Returns:

  • (Integer)

    lock time (block height or Unix timestamp)



39
40
41
# File 'lib/bsv/transaction/transaction.rb', line 39

def lock_time
  @lock_time
end

#merkle_pathMerklePath?

Returns BRC-74 merkle path (for BEEF serialisation).

Returns:

  • (MerklePath, nil)

    BRC-74 merkle path (for BEEF serialisation)



48
49
50
# File 'lib/bsv/transaction/transaction.rb', line 48

def merkle_path
  @merkle_path
end

#outputsArray<TransactionOutput> (readonly)

Returns transaction outputs.

Returns:



45
46
47
# File 'lib/bsv/transaction/transaction.rb', line 45

def outputs
  @outputs
end

#versionInteger (readonly)

Returns transaction version number.

Returns:

  • (Integer)

    transaction version number



36
37
38
# File 'lib/bsv/transaction/transaction.rb', line 36

def version
  @version
end

Class Method Details

.from_beef(data) ⇒ Transaction

Parse a BEEF binary bundle and return the subject transaction (the last transaction in the bundle).

Parameters:

  • data (String)

    raw BEEF binary

Returns:

  • (Transaction)

    the subject transaction with ancestry wired



351
352
353
354
355
# File 'lib/bsv/transaction/transaction.rb', line 351

def self.from_beef(data)
  beef = Beef.from_binary(data)
  last_tx_entry = beef.transactions.reverse.find(&:transaction)
  last_tx_entry&.transaction
end

.from_beef_hex(hex) ⇒ Transaction

Parse a BEEF hex string and return the subject transaction.

Parameters:

  • hex (String)

    hex-encoded BEEF

Returns:

  • (Transaction)

    the subject transaction with ancestry wired



361
362
363
# File 'lib/bsv/transaction/transaction.rb', line 361

def self.from_beef_hex(hex)
  from_beef(BSV::Primitives::Hex.decode(hex, name: 'BEEF hex'))
end

.from_binary(data) ⇒ Transaction

Deserialise a transaction from binary data.

Parameters:

  • data (String)

    raw binary transaction

Returns:

Raises:

  • (ArgumentError)


138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/bsv/transaction/transaction.rb', line 138

def self.from_binary(data)
  raise ArgumentError, "truncated transaction: need at least 10 bytes, got #{data.bytesize}" if data.bytesize < 10

  offset = 0

  version = data.byteslice(offset, 4).unpack1('V')
  offset += 4

  tx = new(version: version)

  input_count, vi_size = VarInt.decode(data, offset)
  offset += vi_size
  input_count.times do
    input, consumed = TransactionInput.from_binary(data, offset)
    tx.add_input(input)
    offset += consumed
  end

  output_count, vi_size = VarInt.decode(data, offset)
  offset += vi_size
  output_count.times do
    output, consumed = TransactionOutput.from_binary(data, offset)
    tx.add_output(output)
    offset += consumed
  end

  if data.bytesize < offset + 4
    raise ArgumentError, "truncated transaction: need 4 bytes for lock_time at offset #{offset}, got #{data.bytesize - offset}"
  end

  tx.instance_variable_set(:@lock_time, data.byteslice(offset, 4).unpack1('V'))
  tx
end

.from_binary_with_offset(data, offset = 0) ⇒ Array(Transaction, Integer)

Deserialise a transaction from binary data at a given offset, returning the transaction and the number of bytes consumed.

Parameters:

  • data (String)

    binary data containing the transaction

  • offset (Integer) (defaults to: 0)

    byte offset to start reading from

Returns:

  • (Array(Transaction, Integer))

    the transaction and bytes consumed



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/bsv/transaction/transaction.rb', line 254

def self.from_binary_with_offset(data, offset = 0)
  if data.bytesize < offset + 10
    raise ArgumentError, "truncated transaction: need at least 10 bytes at offset #{offset}, got #{data.bytesize - offset}"
  end

  start = offset

  version = data.byteslice(offset, 4).unpack1('V')
  offset += 4

  tx = new(version: version)

  input_count, vi_size = VarInt.decode(data, offset)
  offset += vi_size
  input_count.times do
    input, consumed = TransactionInput.from_binary(data, offset)
    tx.add_input(input)
    offset += consumed
  end

  output_count, vi_size = VarInt.decode(data, offset)
  offset += vi_size
  output_count.times do
    output, consumed = TransactionOutput.from_binary(data, offset)
    tx.add_output(output)
    offset += consumed
  end

  if data.bytesize < offset + 4
    raise ArgumentError, "truncated transaction: need 4 bytes for lock_time at offset #{offset}, got #{data.bytesize - offset}"
  end

  tx.instance_variable_set(:@lock_time, data.byteslice(offset, 4).unpack1('V'))
  offset += 4

  [tx, offset - start]
end

.from_ef(data) ⇒ Transaction

Deserialise a transaction from Extended Format (BRC-30) binary data.

Parameters:

  • data (String)

    raw EF binary

Returns:

  • (Transaction)

    the parsed transaction with source data on inputs

Raises:

  • (ArgumentError)

    if the EF marker is invalid



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/bsv/transaction/transaction.rb', line 185

def self.from_ef(data)
  raise ArgumentError, "truncated EF transaction: need at least 10 bytes, got #{data.bytesize}" if data.bytesize < 10

  offset = 0

  version = data.byteslice(offset, 4).unpack1('V')
  offset += 4

  marker = data.byteslice(offset, 6)
  raise ArgumentError, 'invalid EF marker' unless marker == "\x00\x00\x00\x00\x00\xEF".b

  offset += 6

  tx = new(version: version)

  input_count, vi_size = VarInt.decode(data, offset)
  offset += vi_size
  input_count.times do
    input, consumed = TransactionInput.from_binary(data, offset)
    tx.add_input(input)
    offset += consumed

    if data.bytesize < offset + 8
      remaining = data.bytesize - offset
      raise ArgumentError, "truncated EF input: need 8 bytes for source_satoshis at offset #{offset}, got #{remaining}"
    end

    input.source_satoshis = data.byteslice(offset, 8).unpack1('Q<')
    offset += 8

    lock_len, vi_size = VarInt.decode(data, offset)
    offset += vi_size
    if lock_len.positive?
      input.source_locking_script = BSV::Script::Script.from_binary(data.byteslice(offset, lock_len))
      offset += lock_len
    end
  end

  output_count, vi_size = VarInt.decode(data, offset)
  offset += vi_size
  output_count.times do
    output, consumed = TransactionOutput.from_binary(data, offset)
    tx.add_output(output)
    offset += consumed
  end

  if data.bytesize < offset + 4
    remaining = data.bytesize - offset
    raise ArgumentError, "truncated EF transaction: need 4 bytes for lock_time at offset #{offset}, got #{remaining}"
  end

  tx.instance_variable_set(:@lock_time, data.byteslice(offset, 4).unpack1('V'))
  tx
end

.from_ef_hex(hex) ⇒ Transaction

Deserialise a transaction from an Extended Format hex string.

Parameters:

  • hex (String)

    hex-encoded EF transaction

Returns:

  • (Transaction)

    the parsed transaction with source data on inputs



244
245
246
# File 'lib/bsv/transaction/transaction.rb', line 244

def self.from_ef_hex(hex)
  from_ef(BSV::Primitives::Hex.decode(hex, name: 'EF transaction hex'))
end

.from_hex(hex) ⇒ Transaction

Deserialise a transaction from a hex string.

Parameters:

  • hex (String)

    hex-encoded transaction

Returns:



176
177
178
# File 'lib/bsv/transaction/transaction.rb', line 176

def self.from_hex(hex)
  from_binary(BSV::Primitives::Hex.decode(hex, name: 'transaction hex'))
end

Instance Method Details

#add_input(input) ⇒ self

Append a transaction input.

Parameters:

Returns:

  • (self)

    for chaining



64
65
66
67
# File 'lib/bsv/transaction/transaction.rb', line 64

def add_input(input)
  @inputs << input
  self
end

#add_output(output) ⇒ self

Append a transaction output.

Parameters:

Returns:

  • (self)

    for chaining



73
74
75
76
# File 'lib/bsv/transaction/transaction.rb', line 73

def add_output(output)
  @outputs << output
  self
end

#estimated_fee(satoshis_per_byte: 0.1) ⇒ Integer

Deprecated.

Use FeeModels::SatoshisPerKilobyte#compute_fee instead. This method delegates through SatoshisPerKilobyte internally and will be removed in 1.0.

Estimate the mining fee based on the estimated transaction size.

Parameters:

  • satoshis_per_byte (Numeric) (defaults to: 0.1)

    fee rate (default: 0.1 sat/byte = 100 sat/kB, matching the SatoshisPerKilobyte default)

Returns:

  • (Integer)

    estimated fee in satoshis (rounded up)



614
615
616
617
618
619
620
621
# File 'lib/bsv/transaction/transaction.rb', line 614

def estimated_fee(satoshis_per_byte: 0.1)
  unless self.class.instance_variable_get(:@_estimated_fee_warned)
    warn '[DEPRECATION] BSV::Transaction::Transaction#estimated_fee is deprecated. ' \
         'Use BSV::Transaction::FeeModels::SatoshisPerKilobyte.new.compute_fee(tx) instead.', uplevel: 1
    self.class.instance_variable_set(:@_estimated_fee_warned, true)
  end
  FeeModels::SatoshisPerKilobyte.new(value: satoshis_per_byte * 1000).compute_fee(self)
end

#estimated_sizeInteger

Estimate the serialised transaction size in bytes.

Uses actual unlocking script size for signed inputs and template estimated length for unsigned inputs.

Returns:

  • (Integer)

    estimated size in bytes



629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
# File 'lib/bsv/transaction/transaction.rb', line 629

def estimated_size
  size = 4 # version
  size += VarInt.encode(@inputs.length).bytesize
  @inputs.each_with_index do |input, index|
    size += if input.unlocking_script
              input.to_binary.bytesize
            elsif input.unlocking_script_template
              script_len = input.unlocking_script_template.estimated_length(self, index)
              32 + 4 + VarInt.encode(script_len).bytesize + script_len + 4
            else
              # F4.3: raise instead of silently assuming 148-byte P2PKH.
              # Matches TS/Go which require either an unlocking script or
              # a template for size estimation.
              raise ArgumentError,
                    "input #{index} has no unlocking script or template — " \
                    'cannot estimate size (set unlocking_script_template first)'
            end
  end
  size += VarInt.encode(@outputs.length).bytesize
  @outputs.each { |o| size += o.to_binary.bytesize }
  size += 4 # lock_time
  size
end

#fee(model_or_fee = nil, change_distribution: :equal) ⇒ self

Compute the fee and distribute change across change outputs.

Accepts a FeeModel instance, a numeric fee in satoshis, or nil (defaults to FeeModels::SatoshisPerKilobyte at 50 sat/kB).

After computing the fee, distributes remaining satoshis across outputs marked as change. The distribution strategy is controlled by the change_distribution: keyword argument:

  • :equal (default) — divides change equally across all change outputs, matching TS SDK default behaviour.

  • :random — Benford-inspired distribution that biases amounts towards the lower end of the available range, improving privacy by producing varied change amounts.

If insufficient change remains, all change outputs are removed.

Parameters:

  • model_or_fee (FeeModel, Integer, nil) (defaults to: nil)

    fee model, fixed fee, or nil for default

  • change_distribution (Symbol) (defaults to: :equal)

    :equal or :random (default: :equal)

Returns:

  • (self)

    for chaining

Raises:

  • (ArgumentError)

    if change_distribution is not :random or :equal



674
675
676
677
678
679
680
681
682
# File 'lib/bsv/transaction/transaction.rb', line 674

def fee(model_or_fee = nil, change_distribution: :equal)
  unless %i[random equal].include?(change_distribution)
    raise ArgumentError, "invalid change_distribution #{change_distribution.inspect}; expected :random or :equal"
  end

  fee_sats = compute_fee_sats(model_or_fee)
  distribute_change(fee_sats, change_distribution)
  self
end

#sighash(input_index, sighash_type = Sighash::ALL_FORK_ID, subscript: nil) ⇒ String

Compute the BIP-143 sighash digest for an input (double-SHA-256 of the preimage).

Parameters:

  • input_index (Integer)

    the input to compute the sighash for

  • sighash_type (Integer) (defaults to: Sighash::ALL_FORK_ID)

    sighash flags (default: ALL|FORKID)

  • subscript (Script::Script, nil) (defaults to: nil)

    override locking script for the input

Returns:

  • (String)

    32-byte sighash digest



444
445
446
# File 'lib/bsv/transaction/transaction.rb', line 444

def sighash(input_index, sighash_type = Sighash::ALL_FORK_ID, subscript: nil)
  BSV::Primitives::Digest.sha256d(sighash_preimage(input_index, sighash_type, subscript: subscript))
end

#sighash_preimage(input_index, sighash_type = Sighash::ALL_FORK_ID, subscript: nil) ⇒ String

Build the BIP-143 sighash preimage for an input.

Only SIGHASH_FORKID types are supported (BSV requirement).

Parameters:

  • input_index (Integer)

    the input to compute the preimage for

  • sighash_type (Integer) (defaults to: Sighash::ALL_FORK_ID)

    sighash flags (default: ALL|FORKID)

  • subscript (Script::Script, nil) (defaults to: nil)

    override locking script for the input

Returns:

  • (String)

    the raw preimage bytes (hash this to get the sighash)

Raises:

  • (ArgumentError)

    if sighash_type does not include FORKID



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/bsv/transaction/transaction.rb', line 396

def sighash_preimage(input_index, sighash_type = Sighash::ALL_FORK_ID, subscript: nil)
  raise ArgumentError, 'only SIGHASH_FORKID types are supported' unless sighash_type & Sighash::FORK_ID != 0

  input = @inputs[input_index]
  base_type = sighash_type & Sighash::MASK
  anyone = sighash_type.anybits?(Sighash::ANYONE_CAN_PAY)

  # 1. nVersion (4 LE)
  buf = [@version].pack('V')

  # 2. hashPrevouts
  buf << hash_prevouts(anyone)

  # 3. hashSequence
  buf << hash_sequence(anyone, base_type)

  # 4. outpoint of this input (32 + 4)
  buf << input.outpoint_binary

  # 5. scriptCode of this input (varint + script)
  script_bytes = (subscript || input.source_locking_script).to_binary
  buf << VarInt.encode(script_bytes.bytesize)
  buf << script_bytes

  # 6. value of this input (8 LE)
  buf << [input.source_satoshis].pack('Q<')

  # 7. nSequence of this input (4 LE)
  buf << [input.sequence].pack('V')

  # 8. hashOutputs
  buf << hash_outputs(base_type, input_index)

  # 9. nLockTime (4 LE)
  buf << [@lock_time].pack('V')

  # 10. sighash type (4 LE) — includes FORKID flag
  buf << [sighash_type].pack('V')

  buf
end

#sign(input_index, private_key, sighash_type = Sighash::ALL_FORK_ID) ⇒ self

Sign a single input with a private key (P2PKH).

Computes the sighash, signs it, and sets the unlocking script on the input.

Parameters:

  • input_index (Integer)

    the input to sign

  • private_key (Primitives::PrivateKey)

    the signing key

  • sighash_type (Integer) (defaults to: Sighash::ALL_FORK_ID)

    sighash flags (default: ALL|FORKID)

Returns:

  • (self)

    for chaining



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/bsv/transaction/transaction.rb', line 458

def sign(input_index, private_key, sighash_type = Sighash::ALL_FORK_ID)
  # F4.9: validate outputs have satoshis before signing — a nil satoshis
  # value would produce a corrupt sighash preimage.
  @outputs.each_with_index do |output, idx|
    raise ArgumentError, "output #{idx} has nil satoshis — set before signing" if output.satoshis.nil?
  end

  hash = sighash(input_index, sighash_type)
  signature = private_key.sign(hash)
  sig_with_hashtype = signature.to_der + [sighash_type].pack('C')
  pubkey_bytes = private_key.public_key.compressed

  @inputs[input_index].unlocking_script =
    BSV::Script::Script.p2pkh_unlock(sig_with_hashtype, pubkey_bytes)
  self
end

#sign_all(private_key = nil, sighash_type = Sighash::ALL_FORK_ID) ⇒ self

Sign all unsigned inputs.

For each input without an unlocking script: if the input has an UnlockingScriptTemplate, delegates to it; otherwise falls back to P2PKH signing with the given private key.

Parameters:

  • private_key (Primitives::PrivateKey, nil) (defaults to: nil)

    fallback signing key

  • sighash_type (Integer) (defaults to: Sighash::ALL_FORK_ID)

    sighash flags (default: ALL|FORKID)

Returns:

  • (self)

    for chaining



484
485
486
487
488
489
490
491
492
493
494
495
# File 'lib/bsv/transaction/transaction.rb', line 484

def sign_all(private_key = nil, sighash_type = Sighash::ALL_FORK_ID)
  @inputs.each_with_index do |input, index|
    next if input.unlocking_script

    if input.unlocking_script_template
      input.unlocking_script = input.unlocking_script_template.sign(self, index)
    elsif private_key
      sign(index, private_key, sighash_type)
    end
  end
  self
end

#to_beefString

Serialise this transaction (with its ancestry chain and merkle proofs) into a BEEF V1 binary bundle (BRC-62), the default format for ARC and the reference TS SDK.

Walks the ‘source_transaction` references on inputs to collect ancestors. Transactions with a `merkle_path` are treated as proven leaves — their ancestors are not traversed further.

Proven ancestors that share a block are combined into a single BUMP per block, then trimmed via MerklePath#extract so the serialised bundle carries only the txid: true-flagged leaves that correspond to transactions in this BEEF. This prevents “phantom” txid leaves carried over from a shared LocalProofStore entry (issue #302) and also shrinks the BEEF by dropping intermediate sibling hashes that are no longer needed.

Ancestor merkle_path objects are not mutated: paths are deep-copied before any combine/trim work.

Returns:

  • (String)

    raw BEEF V1 binary

Raises:

  • (ArgumentError)

    if an ancestor’s merkle_path does not actually contain that transaction’s txid, or if the cleaned BUMP’s root does not match the source root (both indicate corrupt proof data)



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/bsv/transaction/transaction.rb', line 317

def to_beef
  beef = Beef.new
  ancestors = collect_ancestors

  bump_index_by_height = build_beef_bumps(beef, ancestors)

  ancestors.each do |tx|
    entry = if tx.merkle_path
              Beef::BeefTx.new(
                format: Beef::FORMAT_RAW_TX_AND_BUMP,
                transaction: tx,
                bump_index: bump_index_by_height.fetch(tx.merkle_path.block_height)
              )
            else
              Beef::BeefTx.new(format: Beef::FORMAT_RAW_TX, transaction: tx)
            end
    beef.transactions << entry
  end

  beef.to_binary
end

#to_beef_hexString

Serialise this transaction to a BEEF V2 hex string.

Returns:

  • (String)

    hex-encoded BEEF



342
343
344
# File 'lib/bsv/transaction/transaction.rb', line 342

def to_beef_hex
  to_beef.unpack1('H*')
end

#to_binaryString

Serialise the transaction to its binary wire format.

Returns:

  • (String)

    raw transaction bytes



83
84
85
86
87
88
89
90
91
# File 'lib/bsv/transaction/transaction.rb', line 83

def to_binary
  buf = [@version].pack('V')
  buf << VarInt.encode(@inputs.length)
  @inputs.each { |i| buf << i.to_binary }
  buf << VarInt.encode(@outputs.length)
  @outputs.each { |o| buf << o.to_binary }
  buf << [@lock_time].pack('V')
  buf
end

#to_efString

Serialise the transaction in Extended Format (BRC-30).

EF embeds source satoshis and source locking scripts in each input, allowing ARC to validate sighashes without fetching parent transactions.

Returns:

  • (String)

    raw EF transaction bytes

Raises:

  • (ArgumentError)

    if any input is missing source_satoshis or source_locking_script



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/bsv/transaction/transaction.rb', line 107

def to_ef
  buf = [@version].pack('V')
  buf << "\x00\x00\x00\x00\x00\xEF".b
  buf << VarInt.encode(@inputs.length)
  @inputs.each do |input|
    raise ArgumentError, 'inputs must have source_satoshis for EF' if input.source_satoshis.nil?
    raise ArgumentError, 'inputs must have source_locking_script for EF' if input.source_locking_script.nil?

    buf << input.to_binary
    buf << [input.source_satoshis].pack('Q<')
    lock_bytes = input.source_locking_script.to_binary
    buf << VarInt.encode(lock_bytes.bytesize)
    buf << lock_bytes
  end
  buf << VarInt.encode(@outputs.length)
  @outputs.each { |o| buf << o.to_binary }
  buf << [@lock_time].pack('V')
  buf
end

#to_ef_hexString

Serialise the transaction in Extended Format as a hex string.

Returns:

  • (String)

    hex-encoded EF transaction



130
131
132
# File 'lib/bsv/transaction/transaction.rb', line 130

def to_ef_hex
  to_ef.unpack1('H*')
end

#to_hexString

Serialise the transaction to a hex string.

Returns:

  • (String)

    hex-encoded transaction



96
97
98
# File 'lib/bsv/transaction/transaction.rb', line 96

def to_hex
  to_binary.unpack1('H*')
end

#total_input_satoshisInteger

Sum of all input source satoshi values.

Returns:

  • (Integer)

    total input value in satoshis



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
# File 'lib/bsv/transaction/transaction.rb', line 582

def total_input_satoshis
  @inputs.each_with_index do |input, idx|
    # F4.4: fall back through source_transaction if source_satoshis is nil.
    if input.source_satoshis.nil? && input.source_transaction
      output = input.source_transaction.outputs[input.prev_tx_out_index]
      input.source_satoshis = output.satoshis if output
    end
    next unless input.source_satoshis.nil?

    raise ArgumentError,
          "input #{idx} has nil source_satoshis — " \
          'set it or wire source_transaction before computing totals'
  end
  @inputs.sum(&:source_satoshis)
end

#total_output_satoshisInteger

Sum of all output satoshi values.

Returns:

  • (Integer)

    total output value in satoshis



601
602
603
# File 'lib/bsv/transaction/transaction.rb', line 601

def total_output_satoshis
  @outputs.sum(&:satoshis)
end

#txidString

Compute the transaction ID (double-SHA-256 of the serialised tx, byte-reversed).

Returns display byte order (reversed from the natural hash). Compare with BSV::Transaction::TransactionInput#prev_tx_id which stores wire byte order (natural hash). Use .reverse to convert between the two.

Returns:

  • (String)

    32-byte transaction ID in display byte order



374
375
376
# File 'lib/bsv/transaction/transaction.rb', line 374

def txid
  BSV::Primitives::Digest.sha256d(to_binary).reverse
end

#txid_hexString

The transaction ID as a hex string (display byte order).

Returns:

  • (String)

    hex-encoded transaction ID



381
382
383
# File 'lib/bsv/transaction/transaction.rb', line 381

def txid_hex
  txid.unpack1('H*')
end

#verify(chain_tracker:, fee_model: nil) ⇒ true

Perform full SPV verification of this transaction and its ancestry.

Uses a queue-based approach (matching TS/Go SDKs) to walk the transaction ancestry chain:

  1. If a transaction has a merkle path that validates against the chain tracker, it is marked verified (inputs are not re-checked).

  2. Otherwise, each input’s scripts are executed via the interpreter, and source transactions are enqueued for verification.

  3. Optionally validates that the root transaction’s fee meets the provided fee model.

  4. Checks that total outputs do not exceed total inputs.

Parameters:

  • chain_tracker (ChainTracker)

    chain tracker for merkle root validation

  • fee_model (FeeModel, nil) (defaults to: nil)

    optional fee model to validate the root transaction’s fee

Returns:

  • (true)

    on successful verification

Raises:

  • (ArgumentError)

    if a source transaction or unlocking script is missing

  • (BSV::Script::ScriptError)

    if script execution fails

  • (VerificationError)

    for merkle path failures, fee validation, or output overflow



535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
# File 'lib/bsv/transaction/transaction.rb', line 535

def verify(chain_tracker:, fee_model: nil)
  verified = {}
  queue = [self]

  until queue.empty?
    tx = queue.shift
    tx_id = tx.txid_hex
    next if verified[tx_id]

    # Merkle path short-circuit: proven transaction needs no input verification
    if tx.merkle_path
      unless tx.merkle_path.verify(tx_id, chain_tracker)
        raise VerificationError.new(:invalid_merkle_proof,
                                    "invalid merkle proof for transaction #{tx_id}")
      end

      verified[tx_id] = true
      next
    end

    # Fee validation (root transaction only)
    verify_fee(fee_model) if tx.equal?(self) && fee_model

    # Verify each input
    tx.inputs.each_with_index do |input, index|
      verify_input_requirements(tx, input, index)
      tx.verify_input(index)

      # Enqueue source transaction for verification if not yet verified
      source_tx = input.source_transaction
      queue << source_tx if source_tx && !verified[source_tx.txid_hex]
    end

    # Output ≤ input check
    verify_output_constraint(tx)

    verified[tx_id] = true
  end

  true
end

#verify_input(index) ⇒ Boolean

Verify the scripts of a single input using the interpreter.

Parameters:

  • index (Integer)

    the input index to verify

Returns:

  • (Boolean)

    true if the scripts evaluate successfully



503
504
505
506
507
508
509
510
511
512
# File 'lib/bsv/transaction/transaction.rb', line 503

def verify_input(index)
  input = @inputs[index]
  BSV::Script::Interpreter.verify(
    tx: self,
    input_index: index,
    unlock_script: input.unlocking_script,
    lock_script: input.source_locking_script,
    satoshis: input.source_satoshis
  )
end