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 with full ancestry wired, including late-bound BUMP attachment.

For Atomic BEEFs (BRC-95), the subject transaction is identified by the embedded subject_txid field. For plain BEEFs, the last transaction with a raw tx entry is used as the subject.

Uses find_atomic_transaction so that FORMAT_RAW_TX ancestors whose txid appears as a leaf in a separately-stored BUMP get their merkle_path wired correctly — a gap not covered by the initial wire_source_transactions pass in Beef.from_binary.

Parameters:

  • data (String)

    raw BEEF binary

Returns:

  • (Transaction, nil)

    the subject transaction with ancestry wired, or nil if the BEEF is empty or contains no raw transaction entries



371
372
373
374
375
376
377
378
# File 'lib/bsv/transaction/transaction.rb', line 371

def self.from_beef(data)
  beef = Beef.from_binary(data)
  subject_txid = beef.subject_txid ||
                 beef.transactions.reverse.find(&:transaction)&.transaction&.txid
  return nil unless subject_txid

  beef.find_atomic_transaction(subject_txid)
end

.from_beef_hex(hex) ⇒ Transaction?

Parse a BEEF hex string and return the subject transaction.

Parameters:

  • hex (String)

    hex-encoded BEEF

Returns:

  • (Transaction, nil)

    the subject transaction with ancestry wired, or nil if the BEEF is empty or contains no raw transaction entries



385
386
387
# File 'lib/bsv/transaction/transaction.rb', line 385

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)


148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/bsv/transaction/transaction.rb', line 148

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



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
291
292
293
294
295
296
297
298
299
300
# File 'lib/bsv/transaction/transaction.rb', line 264

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



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
239
240
241
242
243
244
245
246
247
248
# File 'lib/bsv/transaction/transaction.rb', line 195

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



254
255
256
# File 'lib/bsv/transaction/transaction.rb', line 254

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:



186
187
188
# File 'lib/bsv/transaction/transaction.rb', line 186

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)



638
639
640
641
642
643
644
645
# File 'lib/bsv/transaction/transaction.rb', line 638

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



653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
# File 'lib/bsv/transaction/transaction.rb', line 653

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



698
699
700
701
702
703
704
705
706
# File 'lib/bsv/transaction/transaction.rb', line 698

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



468
469
470
# File 'lib/bsv/transaction/transaction.rb', line 468

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



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
# File 'lib/bsv/transaction/transaction.rb', line 420

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



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

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



508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/bsv/transaction/transaction.rb', line 508

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)



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/bsv/transaction/transaction.rb', line 327

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



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

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.

Source data is resolved in priority order:

  1. Explicit source_satoshis / source_locking_script on the input.

  2. Derived from input.source_transaction.outputs[input.prev_tx_out_index].

Source fields on input objects are never mutated — derivation happens on each call, so calling to_ef twice produces identical output.

Returns:

  • (String)

    raw EF transaction bytes

Raises:

  • (ArgumentError)

    if any input cannot supply source_satoshis or source_locking_script via either mechanism



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/bsv/transaction/transaction.rb', line 115

def to_ef
  buf = [@version].pack('V')
  buf << "\x00\x00\x00\x00\x00\xEF".b
  buf << VarInt.encode(@inputs.length)
  @inputs.each_with_index do |input, idx|
    source_output = ef_source_output(input, idx)

    satoshis = input.source_satoshis || source_output.satoshis
    locking_script = input.source_locking_script || source_output.locking_script

    buf << input.to_binary
    buf << [satoshis].pack('Q<')
    lock_bytes = 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



140
141
142
# File 'lib/bsv/transaction/transaction.rb', line 140

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



606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
# File 'lib/bsv/transaction/transaction.rb', line 606

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



625
626
627
# File 'lib/bsv/transaction/transaction.rb', line 625

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



398
399
400
# File 'lib/bsv/transaction/transaction.rb', line 398

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



405
406
407
# File 'lib/bsv/transaction/transaction.rb', line 405

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



559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
# File 'lib/bsv/transaction/transaction.rb', line 559

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



527
528
529
530
531
532
533
534
535
536
# File 'lib/bsv/transaction/transaction.rb', line 527

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