Class: BSV::Transaction::Tx

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

Overview

Note:

Not thread-safe. Direct mutation of #inputs / #outputs arrays (e.g. tx.inputs << input) bypasses the cache-invalidation contract and may produce silently invalid sighashes. Use #add_input / #add_output and the documented setter surface, or call #invalidate_caches after direct mutation. See sighash-cache.

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::Tx.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) ⇒ Tx

Returns a new instance of Tx.

Parameters:

  • version (Integer) (defaults to: 1)

    transaction version (default: 1)

  • lock_time (Integer) (defaults to: 0)

    lock time (default: 0)



59
60
61
62
63
64
65
# File 'lib/bsv/transaction/tx.rb', line 59

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:



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

def inputs
  @inputs
end

#lock_timeInteger (readonly)

Returns lock time (block height or Unix timestamp).

Returns:

  • (Integer)

    lock time (block height or Unix timestamp)



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

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)



55
56
57
# File 'lib/bsv/transaction/tx.rb', line 55

def merkle_path
  @merkle_path
end

#outputsArray<TransactionOutput> (readonly)

Returns transaction outputs.

Returns:



52
53
54
# File 'lib/bsv/transaction/tx.rb', line 52

def outputs
  @outputs
end

#versionInteger (readonly)

Returns transaction version number.

Returns:

  • (Integer)

    transaction version number



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

def version
  @version
end

Class Method Details

.from_beef(data) ⇒ Transaction::Tx?

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_wtxid 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 wtxid 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::Tx, nil)

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



498
499
500
501
502
503
504
505
# File 'lib/bsv/transaction/tx.rb', line 498

def self.from_beef(data)
  beef = Beef.from_binary(data)
  subject_wtxid = beef.subject_wtxid ||
                  beef.transactions.reverse.find { |bt| !bt.is_a?(Beef::TxidOnlyEntry) }&.wtxid
  return nil unless subject_wtxid

  beef.find_atomic_transaction(subject_wtxid)
end

.from_beef_hex(hex) ⇒ Transaction::Tx?

Parse a BEEF hex string and return the subject transaction.

Parameters:

  • hex (String)

    hex-encoded BEEF

Returns:

  • (Transaction::Tx, nil)

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



512
513
514
# File 'lib/bsv/transaction/tx.rb', line 512

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

.from_binary(data) ⇒ Transaction::Tx

Deserialise a transaction from binary data.

Parameters:

  • data (String)

    raw binary transaction

Returns:

Raises:

  • (ArgumentError)


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
301
302
303
# File 'lib/bsv/transaction/tx.rb', line 271

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::Tx, 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:



387
388
389
390
391
392
393
394
395
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
# File 'lib/bsv/transaction/tx.rb', line 387

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::Tx

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

Parameters:

  • data (String)

    raw EF binary

Returns:

Raises:

  • (ArgumentError)

    if the EF marker is invalid



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/bsv/transaction/tx.rb', line 318

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::Tx

Deserialise a transaction from an Extended Format hex string.

Parameters:

  • hex (String)

    hex-encoded EF transaction

Returns:



377
378
379
# File 'lib/bsv/transaction/tx.rb', line 377

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

.from_hex(hex) ⇒ Transaction::Tx

Deserialise a transaction from a hex string.

Parameters:

  • hex (String)

    hex-encoded transaction

Returns:



309
310
311
# File 'lib/bsv/transaction/tx.rb', line 309

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.

Idempotent on re-add: calling add_input(x) twice with the same input on the same Transaction::Tx returns self on the second call without appending a duplicate or invalidating caches. Raises ArgumentError if the input is already attached to a different Transaction::Tx.

Parameters:

Returns:

  • (self)

    for chaining

Raises:

  • (ArgumentError)

    if the input is already attached to a different Tx instance. Sharing across Tx instances is anti-idiomatic; construct a fresh instance per Tx. See sighash-cache.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/bsv/transaction/tx.rb', line 80

def add_input(input)
  existing_owner = input.instance_variable_get(:@owning_tx)
  if existing_owner
    return self if existing_owner.equal?(self)

    raise ArgumentError,
          "TransactionInput #{input.dtxid_hex}:#{input.prev_tx_out_index} is already attached to a Tx"
  end

  input.instance_variable_set(:@owning_tx, self)
  @inputs << input
  # Avoid the O(N+M) rebind walk inside invalidate_caches — the new
  # input's backref was just set above and every existing input/output
  # was rebound when it was originally added. clear_caches is O(1).
  clear_caches
  self
end

#add_output(output) ⇒ self

Append a transaction output.

Idempotent on re-add: calling add_output(x) twice with the same output on the same Transaction::Tx returns self on the second call without appending a duplicate or invalidating caches. Raises ArgumentError if the output is already attached to a different Transaction::Tx.

Parameters:

Returns:

  • (self)

    for chaining

Raises:

  • (ArgumentError)

    if the output is already attached to a different Tx instance. Sharing across Tx instances is anti-idiomatic; construct a fresh instance per Tx. See sighash-cache.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/bsv/transaction/tx.rb', line 111

def add_output(output)
  existing_owner = output.instance_variable_get(:@owning_tx)
  if existing_owner
    return self if existing_owner.equal?(self)

    asm_fragment = output.locking_script&.to_asm || '<nil locking_script>'
    asm_fragment = "#{asm_fragment[0, 40]}..." if asm_fragment.length > 40
    raise ArgumentError,
          "TransactionOutput (#{asm_fragment}) is already attached to a Tx"
  end

  output.instance_variable_set(:@owning_tx, self)
  @outputs << output
  # Avoid the O(N+M) rebind walk inside invalidate_caches — the new
  # output's backref was just set above and every existing input/output
  # was rebound when it was originally added. clear_caches is O(1).
  clear_caches
  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)



932
933
934
935
936
937
938
939
# File 'lib/bsv/transaction/tx.rb', line 932

def estimated_fee(satoshis_per_byte: 0.1)
  unless self.class.instance_variable_get(:@_estimated_fee_warned)
    warn '[DEPRECATION] BSV::Transaction::Tx#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



947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
# File 'lib/bsv/transaction/tx.rb', line 947

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



992
993
994
995
996
997
998
999
1000
# File 'lib/bsv/transaction/tx.rb', line 992

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

#initialize_copy(other) ⇒ Object

Called by #dup and #clone. Deep-dups @inputs and @outputs so that the copy and the original do not share mutable input/output state. Rebinds @owning_tx on each copied struct to self (the new transaction). Resets all cache ivars directly so the dup does not share mutable cache state (especially @hash_outputs_single, a Hash) with the original.

This closes the hazard at beef.rb:703,707 where a shallow dup would leave the copied inputs/outputs pointing at the original transaction's cache.

Note: we cannot delegate to invalidate_caches here because that method calls Hash#clear on @hash_outputs_single, which would mutate the shared Hash and evict the original's per-index cache too. Direct ivar = nil assignments on self leave the original untouched.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/bsv/transaction/tx.rb', line 178

def initialize_copy(other)
  super
  @inputs = @inputs.map do |i|
    dup_input = i.dup
    dup_input.instance_variable_set(:@owning_tx, self)
    dup_input
  end
  @outputs = @outputs.map do |o|
    dup_output = o.dup
    dup_output.instance_variable_set(:@owning_tx, self)
    dup_output
  end
  @to_binary = nil
  @wtxid = nil
  @hash_prevouts = nil
  @hash_sequence = nil
  @hash_outputs_all = nil
  @hash_outputs_single = nil
end

#invalidate_cachesself

Note:

Rarely needed. Normal mutation through the documented setter surface (input.sequence=, output.satoshis=, etc.) invalidates the right cache slices automatically. This method is an escape hatch for code that mutates @inputs / @outputs through unsupported paths. See sighash-cache.

Invalidate all cached state on this transaction (sighash components, wire serialisation, etc.) AND restore the @owning_tx backref on every current input and output. This is what makes the escape hatch complete: if a caller appended an input/output via direct array mutation (bypassing add_input / add_output), the new struct's backref is nil and future sequence= / locking_script= / etc. setters would not bubble invalidation up. After invalidate_caches, the backref is rebound so subsequent setter mutations flow through correctly.

Raises ArgumentError if any input or output is already attached to a different Transaction::Tx — matches the cross-Tx rebind contract of add_input / add_output.

Examples:

After mutating the inputs array directly

tx.inputs << input             # bypasses the documented setter surface
tx.invalidate_caches           # clears all cache layers + rebinds @owning_tx
input.sequence = 42            # now invalidates correctly
tx.verify(...)                 # safe

Returns:

  • (self)

    for chaining

Raises:

  • (ArgumentError)

    if any input or output is attached to a different Tx instance



159
160
161
162
163
# File 'lib/bsv/transaction/tx.rb', line 159

def invalidate_caches
  rebind_owning_tx!
  clear_caches
  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



648
649
650
651
652
# File 'lib/bsv/transaction/tx.rb', line 648

def sighash(input_index, sighash_type = Sighash::ALL_FORK_ID, subscript: nil)
  digest = BSV::Primitives::Digest.sha256d(sighash_preimage(input_index, sighash_type, subscript: subscript))
  BSV.logger&.debug { "[Sighash] digest=#{digest.unpack1('H*')}" }
  digest
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



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
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
# File 'lib/bsv/transaction/tx.rb', line 564

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]
  raise ArgumentError, "no input at index #{input_index}" if input.nil?

  # Resolve source data from wired source_transaction when not explicitly set.
  if input.source_transaction
    source_output = input.source_transaction.outputs[input.prev_tx_out_index]
    if source_output
      input.source_satoshis       ||= source_output.satoshis
      input.source_locking_script ||= source_output.locking_script
    end
  end

  if input.source_satoshis.nil?
    raise ArgumentError,
          "input #{input_index} has nil source_satoshis — " \
          'set it or wire source_transaction before computing sighash'
  end

  unless subscript || input.source_locking_script
    raise ArgumentError,
          "input #{input_index} has nil source_locking_script — " \
          'set it or wire source_transaction before computing sighash'
  end

  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')

  BSV.logger&.debug do
    hp = buf.byteslice(4, 32).unpack1('H*')
    hs = buf.byteslice(36, 32).unpack1('H*')
    op = input.outpoint_binary.unpack1('H*')
    sc = script_bytes.unpack1('H*')
    ho = buf.byteslice(-40, 32).unpack1('H*')
    "[Sighash] input=#{input_index} type=0x#{format('%02x', sighash_type)} " \
      "version=#{@version} hashPrevouts=#{hp} hashSequence=#{hs} " \
      "outpoint=#{op} scriptCode=#{sc[0, 40]}#{'...' if sc.length > 40} " \
      "value=#{input.source_satoshis} seq=#{input.sequence} " \
      "hashOutputs=#{ho} locktime=#{@lock_time}"
  end

  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



664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/bsv/transaction/tx.rb', line 664

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



690
691
692
693
694
695
696
697
698
699
700
701
# File 'lib/bsv/transaction/tx.rb', line 690

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)



450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/bsv/transaction/tx.rb', line 450

def to_beef
  beef = Beef.new
  ancestors = collect_ancestors

  bump_index_by_height = build_beef_bumps(beef, ancestors)
  BSV.logger&.debug do
    proven = ancestors.count(&:merkle_path)
    "[Tx] BEEF: #{ancestors.length} ancestors, #{proven} proven " \
      "across #{bump_index_by_height.length} block heights"
  end

  ancestors.each do |tx|
    entry = if tx.merkle_path
              Beef::ProvenTxEntry.new(
                transaction: tx,
                bump_index: bump_index_by_height.fetch(tx.merkle_path.block_height)
              )
            else
              Beef::RawTxEntry.new(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



479
480
481
# File 'lib/bsv/transaction/tx.rb', line 479

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

#to_binaryString

Note:

Memoised; see sighash-cache for the invalidation contract.

Serialise the transaction to its binary wire format.

Returns:

  • (String)

    raw transaction bytes (binary encoding, frozen)



204
205
206
207
208
209
210
211
212
213
214
# File 'lib/bsv/transaction/tx.rb', line 204

def to_binary
  @to_binary ||= begin
    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.b.freeze
  end
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



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/bsv/transaction/tx.rb', line 238

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



263
264
265
# File 'lib/bsv/transaction/tx.rb', line 263

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

#to_hexString

Serialise the transaction to a hex string.

Returns:

  • (String)

    hex-encoded transaction



219
220
221
# File 'lib/bsv/transaction/tx.rb', line 219

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



900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
# File 'lib/bsv/transaction/tx.rb', line 900

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



919
920
921
# File 'lib/bsv/transaction/tx.rb', line 919

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

#txid_hexString Also known as: dtxid, dtxid_hex

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

Returns:

  • (String)

    64-char hex-encoded transaction ID (display order)



536
537
538
# File 'lib/bsv/transaction/tx.rb', line 536

def txid_hex
  wtxid.reverse.unpack1('H*')
end

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

Note:

Security (trust contract): the caller warrants that every wtxid present in verified: at method entry is fully verified — script AND chain-anchor (merkle proof) AND any consensus-flag context. A seeded wtxid is treated as fully verified regardless of what proof or script data it carries. The SDK does not re-run the merkle proof for a seeded ancestor. If the caller's cache is stale or compromised, verify will return true for ancestry that has not actually been verified. Cached wtxids are pinned to the consensus flags under which they were originally verified — invalidate the caller's cache when consensus flags change, on chain reorganisation, or on any event that could invalidate a prior verification. Fee validation is not affected by this set — the fee gate is a caller-passed policy, not a cached claim, and runs once at the start of every verify call when fee_model is given.

Note:

Concurrency: the SDK writes to verified: in place without locking. Passing the same Hash to concurrent verify calls is a race — guard it externally, or give each thread its own Hash and merge the results afterwards. The class-level "Not thread-safe" note on BSV::Transaction::Tx covers direct-mutation concerns; this is the specific footgun that comes with the in-place-mutation contract of this kwarg.

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.

Semantic Divergences from Reference SDKs

This implementation raises VerificationError for all failure modes. The TypeScript and Python SDKs return false for script failures and output overflow. The Go SDK propagates errors (not booleans) for script failures, aligning with the Ruby approach.

Rationale: raising provides structured error information (+#code+, #message, #cause) that a boolean cannot convey. Consumers can rescue VerificationError and inspect #code for specifics.

Divergence summary:

  • :output_overflow — Ruby raises; TS/Python return false; Go omits the check
  • :script_failure — Ruby raises; TS/Python return false; Go also propagates errors
  • :missing_source — Ruby raises; consistent with TS/Go/Python (all raise/error)
  • verified: kwarg — Ruby-only; no equivalent seam exists in the TS, Go, or Python SDKs. This is a novel Ruby-side extension: a bidirectional wtxid-dedup Hash that the caller can seed (short-circuit ancestors already verified) and read after verify returns (populate a persistent verification cache). See verify-kwarg. The reference SDKs repeat the full walk every time verify is called.

Examples:

Pre-seed short-circuit

# wallet has already verified source_tx in a prior session
cache = { source_tx.wtxid => true }
tx.verify(chain_tracker: tracker, verified: cache)

Post-read accumulate

walked = {}
tx.verify(chain_tracker: tracker, verified: walked)
walked.keys # => [subject_wtxid, ancestor1_wtxid, ...]

Bidirectional round-trip

cache = load_persistent_verified_cache
tx.verify(chain_tracker: tracker, verified: cache)
save_persistent_verified_cache(cache) # includes newly-walked wtxids

Parameters:

  • chain_tracker (Transaction::ChainTracker)

    chain tracker for merkle root validation

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

    optional fee model to validate the root transaction's fee

  • verified (Hash{String => Boolean}, nil) (defaults to: nil)

    bidirectional wtxid dedup Hash. Keys are 32-byte wire-order binary wtxids (not hex). Values are presence-only — the SDK writes true for wtxids it walks and treats any truthy value as "already verified". A false value is treated as absent (does not short-circuit) and will be overwritten with true if the wtxid is subsequently walked. Callers use this three ways:

    • Pre-seed (short-circuit): pass a Hash pre-populated with wtxids you have verified in an earlier session. Their subtrees are skipped.
    • Post-read (accumulate): pass an empty Hash; after verify returns, read the keys to see which wtxids the SDK walked. Populates a persistent verification cache.
    • Bidirectional (both): pass a Hash that seeds the walk AND collects the walked wtxids in one call.

    The Hash is mutated in place — the caller's object identity is preserved. nil (default) means "no seed, no post-read" — the SDK uses an internal Hash the caller cannot access. Rejected at entry with ArgumentError: anything that is not nil or a mutable Hash; a frozen Hash; or a Hash with a truthy default value or any default_proc (funds risk — missing keys would return truthy and silently short-circuit verification).

Returns:

  • (true)

    on successful verification

Raises:

  • (ArgumentError)

    if verified: is (a) not nil or a Hash, (b) a frozen Hash (the SDK writes walked wtxids into it and cannot mutate a frozen one), or (c) a Hash with a truthy default value or a default_proc (missing keys would return truthy and silently short-circuit verification — funds risk)

  • (VerificationError)

    with code :invalid_merkle_proof if a merkle proof is invalid

  • (VerificationError)

    with code :insufficient_fee if the fee is below the model's threshold

  • (VerificationError)

    with code :output_overflow if outputs exceed inputs

  • (VerificationError)

    with code :script_failure if script execution fails

  • (VerificationError)

    with code :missing_source if an input is missing required source data

Since:

  • 0.11.0



817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
# File 'lib/bsv/transaction/tx.rb', line 817

def verify(chain_tracker:, fee_model: nil, verified: nil)
  verified = normalise_verified(verified)
  # Fee gate runs once at the start of every verify call. Fee is a caller-passed policy,
  # not a cached script-validity claim — seeding the subject wtxid cannot silently disable it.
  # verify_fee → total_input_satoshis raises ArgumentError when source data is missing;
  # translate that to :missing_source VerificationError so callers see a consistent error
  # taxonomy from #verify (all failure modes raise VerificationError, not ArgumentError).
  if fee_model
    begin
      verify_fee(fee_model)
    rescue ArgumentError => e
      raise VerificationError.new(:missing_source,
                                  "cannot compute fee: #{e.message}")
    end
  end

  queue = [self]

  until queue.empty?
    tx = queue.shift
    wtxid = tx.wtxid
    # Top-of-loop dedup covers both walked-during-this-call and caller-seeded wtxids.
    # A seeded wtxid short-circuits everything — including the merkle proof — because
    # the caller warrants full trust. See @note Security.
    # fetch(k, false) bypasses any custom Hash default / default_proc — critical, because
    # Hash.new(true)[missing_key] would return true and short-circuit verification silently
    # (funds risk). normalise_verified also rejects such Hashes at entry, but this is
    # defence in depth.
    next if verified.fetch(wtxid, false)

    if tx.merkle_path
      unless tx.merkle_path.verify(tx.txid_hex, chain_tracker)
        raise VerificationError.new(:invalid_merkle_proof,
                                    "invalid merkle proof for transaction #{tx.txid_hex}")
      end

      verified[wtxid] = true
      next
    end

    # Verify each input
    tx.inputs.each_with_index do |input, index|
      # Populate source data from source_transaction when not already set.
      # Matches the TS SDK pattern: sourceOutput is read directly from
      # source_transaction.outputs[sourceOutputIndex] during verify.
      if input.source_transaction
        source_output = input.source_transaction.outputs[input.prev_tx_out_index]
        if source_output
          input.source_locking_script ||= source_output.locking_script
          input.source_satoshis ||= source_output.satoshis
        end
      end

      verify_input_requirements(tx, input, index)
      begin
        tx.verify_input(index)
      rescue BSV::Script::ScriptError => e
        raise VerificationError.new(
          :script_failure,
          "script verification failed for input #{index} of transaction #{tx.txid_hex}: #{e.message}"
        )
      end

      # Enqueue source transaction unless it's already dealt with (seeded or walked).
      # fetch(k, false) bypasses any custom Hash default — see note above at top-of-loop dedup.
      source_tx = input.source_transaction
      queue << source_tx if source_tx && !verified.fetch(source_tx.wtxid, false)
    end

    # Output ≤ input check
    verify_output_constraint(tx)

    verified[wtxid] = 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



709
710
711
712
713
714
715
716
717
718
# File 'lib/bsv/transaction/tx.rb', line 709

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

#wtxidString

Note:

Memoised; see sighash-cache for the invalidation contract.

Wire-order transaction ID (raw SHA-256d of the serialised tx).

Used by BEEF, BUMPs, and merkle paths, which all work in wire byte order to match BSV::Transaction::TransactionInput#prev_wtxid.

Returns:

  • (String)

    32-byte transaction ID in wire byte order



525
526
527
528
529
530
531
# File 'lib/bsv/transaction/tx.rb', line 525

def wtxid
  @wtxid ||= begin
    id = BSV::Primitives::Digest.sha256d(to_binary)
    BSV.logger&.debug { "[Tx] wtxid computed (dtxid=#{id.reverse.unpack1('H*')})" }
    id.freeze
  end
end