Class: BSV::Transaction::Tx
- Inherits:
-
Object
- Object
- BSV::Transaction::Tx
- Defined in:
- lib/bsv/transaction/tx.rb
Overview
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.
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
-
#inputs ⇒ Array<TransactionInput>
readonly
Transaction inputs.
-
#lock_time ⇒ Integer
readonly
Lock time (block height or Unix timestamp).
-
#merkle_path ⇒ MerklePath?
BRC-74 merkle path (for BEEF serialisation).
-
#outputs ⇒ Array<TransactionOutput>
readonly
Transaction outputs.
-
#version ⇒ Integer
readonly
Transaction version number.
Class Method Summary collapse
-
.from_beef(data) ⇒ Transaction::Tx?
Parse a BEEF binary bundle and return the subject transaction with full ancestry wired, including late-bound BUMP attachment.
-
.from_beef_hex(hex) ⇒ Transaction::Tx?
Parse a BEEF hex string and return the subject transaction.
-
.from_binary(data) ⇒ Transaction::Tx
Deserialise a transaction from binary data.
-
.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.
-
.from_ef(data) ⇒ Transaction::Tx
Deserialise a transaction from Extended Format (BRC-30) binary data.
-
.from_ef_hex(hex) ⇒ Transaction::Tx
Deserialise a transaction from an Extended Format hex string.
-
.from_hex(hex) ⇒ Transaction::Tx
Deserialise a transaction from a hex string.
Instance Method Summary collapse
-
#add_input(input) ⇒ self
Append a transaction input.
-
#add_output(output) ⇒ self
Append a transaction output.
-
#estimated_fee(satoshis_per_byte: 0.1) ⇒ Integer
deprecated
Deprecated.
Use FeeModels::SatoshisPerKilobyte#compute_fee instead. This method delegates through
SatoshisPerKilobyteinternally and will be removed in 1.0. -
#estimated_size ⇒ Integer
Estimate the serialised transaction size in bytes.
-
#fee(model_or_fee = nil, change_distribution: :equal) ⇒ self
Compute the fee and distribute change across change outputs.
-
#initialize(version: 1, lock_time: 0) ⇒ Tx
constructor
A new instance of Tx.
-
#initialize_copy(other) ⇒ Object
Called by
#dupand#clone. -
#invalidate_caches ⇒ self
Invalidate all cached state on this transaction (sighash components, wire serialisation, etc.) AND restore the
@owning_txbackref on every current input and output. -
#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).
-
#sighash_preimage(input_index, sighash_type = Sighash::ALL_FORK_ID, subscript: nil) ⇒ String
Build the BIP-143 sighash preimage for an input.
-
#sign(input_index, private_key, sighash_type = Sighash::ALL_FORK_ID) ⇒ self
Sign a single input with a private key (P2PKH).
-
#sign_all(private_key = nil, sighash_type = Sighash::ALL_FORK_ID) ⇒ self
Sign all unsigned inputs.
-
#to_beef ⇒ String
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.
-
#to_beef_hex ⇒ String
Serialise this transaction to a BEEF V2 hex string.
-
#to_binary ⇒ String
Serialise the transaction to its binary wire format.
-
#to_ef ⇒ String
Serialise the transaction in Extended Format (BRC-30).
-
#to_ef_hex ⇒ String
Serialise the transaction in Extended Format as a hex string.
-
#to_hex ⇒ String
Serialise the transaction to a hex string.
-
#total_input_satoshis ⇒ Integer
Sum of all input source satoshi values.
-
#total_output_satoshis ⇒ Integer
Sum of all output satoshi values.
-
#txid_hex ⇒ String
(also: #dtxid, #dtxid_hex)
The transaction ID as a hex string (display byte order).
-
#verify(chain_tracker:, fee_model: nil, verified: nil) ⇒ true
Perform full SPV verification of this transaction and its ancestry.
-
#verify_input(index) ⇒ Boolean
Verify the scripts of a single input using the interpreter.
-
#wtxid ⇒ String
Wire-order transaction ID (raw SHA-256d of the serialised tx).
Constructor Details
#initialize(version: 1, lock_time: 0) ⇒ Tx
Returns a new instance of Tx.
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
#inputs ⇒ Array<TransactionInput> (readonly)
Returns transaction inputs.
49 50 51 |
# File 'lib/bsv/transaction/tx.rb', line 49 def inputs @inputs end |
#lock_time ⇒ Integer (readonly)
Returns 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_path ⇒ MerklePath?
Returns BRC-74 merkle path (for BEEF serialisation).
55 56 57 |
# File 'lib/bsv/transaction/tx.rb', line 55 def merkle_path @merkle_path end |
#outputs ⇒ Array<TransactionOutput> (readonly)
Returns transaction outputs.
52 53 54 |
# File 'lib/bsv/transaction/tx.rb', line 52 def outputs @outputs end |
#version ⇒ Integer (readonly)
Returns 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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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_size ⇒ Integer
Estimate the serialised transaction size in bytes.
Uses actual unlocking script size for signed inputs and template estimated length for unsigned inputs.
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.
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_caches ⇒ self
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.
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).
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).
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.
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.
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_beef ⇒ String
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.
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_hex ⇒ String
Serialise this transaction to a BEEF V2 hex string.
479 480 481 |
# File 'lib/bsv/transaction/tx.rb', line 479 def to_beef_hex to_beef.unpack1('H*') end |
#to_binary ⇒ String
Memoised; see sighash-cache for the invalidation contract.
Serialise the transaction to its binary wire format.
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_ef ⇒ String
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:
- Explicit
source_satoshis/source_locking_scripton the input. - 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.
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_hex ⇒ String
Serialise the transaction in Extended Format as a hex string.
263 264 265 |
# File 'lib/bsv/transaction/tx.rb', line 263 def to_ef_hex to_ef.unpack1('H*') end |
#to_hex ⇒ String
Serialise the transaction to a hex string.
219 220 221 |
# File 'lib/bsv/transaction/tx.rb', line 219 def to_hex to_binary.unpack1('H*') end |
#total_input_satoshis ⇒ Integer
Sum of all input source satoshi values.
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_satoshis ⇒ Integer
Sum of all output satoshi values.
919 920 921 |
# File 'lib/bsv/transaction/tx.rb', line 919 def total_output_satoshis @outputs.sum(&:satoshis) end |
#txid_hex ⇒ String Also known as: dtxid, dtxid_hex
The transaction ID as a hex string (display byte 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
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.
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:
- If a transaction has a merkle path that validates against the chain tracker, it is marked verified (inputs are not re-checked).
- Otherwise, each input's scripts are executed via the interpreter, and source transactions are enqueued for verification.
- Optionally validates that the root transaction's fee meets the provided fee model.
- 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 returnfalse; Go omits the check:script_failure— Ruby raises; TS/Python returnfalse; 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 afterverifyreturns (populate a persistent verification cache). See verify-kwarg. The reference SDKs repeat the full walk every timeverifyis called.
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.}") 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.}" ) 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.
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 |
#wtxid ⇒ String
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.
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 |