Class: BSV::Transaction::Beef
- Inherits:
-
Object
- Object
- BSV::Transaction::Beef
- Defined in:
- lib/bsv/transaction/beef.rb
Overview
Background Evaluation Extended Format (BEEF) for SPV-ready transaction bundles. Encodes one or more transactions together with their merkle proofs (BUMPs), enabling recipients to verify inclusion without querying a block explorer.
Supports BRC-62 (V1), BRC-96 (V2), and BRC-95 (Atomic BEEF) formats.
Defined Under Namespace
Classes: BeefTx, ProvenTxEntry, RawTxEntry, TxidOnlyEntry
Version constants collapse
- BEEF_V1 =
Version magic bytes as LE uint32 (matching pack(‘V’) / unpack1(‘V’)). Stream bytes: 01 00 BE EF / 02 00 BE EF / 01 01 01 01
0xEFBE0001- BEEF_V2 =
BRC-62
0xEFBE0002- ATOMIC_BEEF =
BRC-96
0x01010101
Transaction format flags collapse
- FORMAT_RAW_TX =
Raw transaction without a merkle proof.
0- FORMAT_RAW_TX_AND_BUMP =
Raw transaction with an associated BUMP index.
1- FORMAT_TXID_ONLY =
Only the transaction ID (no raw data).
2
Instance Attribute Summary collapse
-
#bumps ⇒ Array<MerklePath>
readonly
Merkle proofs (BUMPs) referenced by transactions.
-
#subject_wtxid ⇒ String?
readonly
32-byte wire-order subject txid (Atomic BEEF only).
-
#transactions ⇒ Array<BeefTx>
readonly
The transactions in dependency order.
-
#txs_not_valid ⇒ Array<BeefTx>?
readonly
Returns transactions that could not be sorted due to cycles, or nil.
-
#version ⇒ Integer
BEEF version constant.
Class Method Summary collapse
-
.from_binary(data) ⇒ Beef
Deserialise a BEEF bundle from binary data.
-
.from_hex(hex) ⇒ Beef
Deserialise a BEEF bundle from a hex string.
Instance Method Summary collapse
-
#find_atomic_transaction(wtxid) ⇒ Transaction?
Find a transaction and recursively wire its ancestry (source transactions and merkle paths) for atomic proof validation.
-
#find_bump(wtxid) ⇒ MerklePath?
Find the merkle path (BUMP) for a transaction by its wire-order txid.
-
#find_transaction(wtxid) ⇒ Transaction?
Find a transaction in the bundle by its wire-order transaction ID.
-
#find_transaction_for_signing(wtxid) ⇒ Transaction?
Find a transaction with all source_transactions wired for signing.
-
#initialize(version: BEEF_V1, bumps: [], transactions: []) ⇒ Beef
constructor
A new instance of Beef.
-
#make_txid_only(wtxid) ⇒ BeefTx?
Convert a transaction entry to TXID-only format.
-
#merge(other) ⇒ self
Merge all BUMPs and transactions from another BEEF bundle.
-
#merge_bump(merkle_path) ⇒ Integer
Add or deduplicate a merkle path (BUMP) in this BEEF bundle.
-
#merge_raw_tx(raw_bytes, bump_index: nil) ⇒ BeefTx
Add a transaction from raw binary data.
-
#merge_transaction(tx) ⇒ BeefTx
Add a transaction to this BEEF bundle.
-
#sort_transactions! ⇒ self
Sort transactions in topological (dependency) order in place.
-
#subject_dtxid ⇒ String?
Display-order subject txid as a hex string (Atomic BEEF only).
-
#to_atomic_binary(subject_wtxid) ⇒ String
Serialise as Atomic BEEF (BRC-95), wrapping V2 data with a subject txid.
-
#to_atomic_hex(subject_wtxid) ⇒ String
Serialise as Atomic BEEF (BRC-95) hex string.
-
#to_binary(version: BEEF_V1) ⇒ String
Serialise the BEEF bundle to binary format.
-
#to_hex ⇒ String
Serialise the BEEF bundle to a hex string.
-
#valid?(allow_txid_only: false) ⇒ Boolean
Check structural validity of the BEEF bundle.
-
#verify(chain_tracker = nil, allow_txid_only: false) ⇒ Boolean
Verify the BEEF bundle against a chain tracker (SPV).
Constructor Details
#initialize(version: BEEF_V1, bumps: [], transactions: []) ⇒ Beef
Returns a new instance of Beef.
178 179 180 181 182 183 |
# File 'lib/bsv/transaction/beef.rb', line 178 def initialize(version: BEEF_V1, bumps: [], transactions: []) @version = version @bumps = bumps @transactions = transactions @subject_wtxid = nil end |
Instance Attribute Details
#bumps ⇒ Array<MerklePath> (readonly)
Returns merkle proofs (BUMPs) referenced by transactions.
159 160 161 |
# File 'lib/bsv/transaction/beef.rb', line 159 def bumps @bumps end |
#subject_wtxid ⇒ String? (readonly)
Returns 32-byte wire-order subject txid (Atomic BEEF only).
165 166 167 |
# File 'lib/bsv/transaction/beef.rb', line 165 def subject_wtxid @subject_wtxid end |
#transactions ⇒ Array<BeefTx> (readonly)
Returns the transactions in dependency order.
162 163 164 |
# File 'lib/bsv/transaction/beef.rb', line 162 def transactions @transactions end |
#txs_not_valid ⇒ Array<BeefTx>? (readonly)
Returns transactions that could not be sorted due to cycles, or nil.
Populated by #sort_transactions! when cycles are detected (F5.5).
714 715 716 |
# File 'lib/bsv/transaction/beef.rb', line 714 def txs_not_valid @txs_not_valid end |
#version ⇒ Integer
Returns BEEF version constant.
156 157 158 |
# File 'lib/bsv/transaction/beef.rb', line 156 def version @version end |
Class Method Details
.from_binary(data) ⇒ Beef
Deserialise a BEEF bundle from binary data.
Supports V1 (BRC-62), V2 (BRC-96), and Atomic (BRC-95) formats. After parsing, input source transactions are wired automatically.
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 |
# File 'lib/bsv/transaction/beef.rb', line 194 def self.from_binary(data) raise ArgumentError, "truncated BEEF: need at least 4 bytes for version, got #{data.bytesize}" if data.bytesize < 4 offset = 0 version = data.byteslice(offset, 4).unpack1('V') offset += 4 # F5.12: reject unknown version magic bytes unless [BEEF_V1, BEEF_V2, ATOMIC_BEEF].include?(version) raise ArgumentError, format('unknown BEEF version 0x%<ver>08X: expected BEEF_V1 (0x%<v1>08X), ' \ 'BEEF_V2 (0x%<v2>08X), or ATOMIC_BEEF (0x%<ab>08X)', ver: version, v1: BEEF_V1, v2: BEEF_V2, ab: ATOMIC_BEEF) end beef = new(version: version) if version == ATOMIC_BEEF if data.bytesize < offset + 36 remaining = data.bytesize - offset raise ArgumentError, "truncated Atomic BEEF: need 36 bytes at offset #{offset}, got #{remaining}" end # Atomic BEEF stores the subject txid in wire (internal / little-endian) byte order, # matching JS and Go SDKs. Store as-is in @subject_wtxid (wire-order). beef.instance_variable_set(:@subject_wtxid, data.byteslice(offset, 32)) offset += 32 inner_version = data.byteslice(offset, 4).unpack1('V') offset += 4 # Validate inner version — must be V1 or V2 unless [BEEF_V1, BEEF_V2].include?(inner_version) raise ArgumentError, format('unknown inner BEEF version 0x%<ver>08X inside Atomic BEEF: expected BEEF_V1 or BEEF_V2', ver: inner_version) end beef.version = inner_version end offset = read_bumps(beef, data, offset) case version == ATOMIC_BEEF ? beef.version : version when BEEF_V2 read_v2_transactions(beef, data, offset) when BEEF_V1 read_v1_transactions(beef, data, offset) end wire_source_transactions(beef) beef end |
.from_hex(hex) ⇒ Beef
Deserialise a BEEF bundle from a hex string.
253 254 255 |
# File 'lib/bsv/transaction/beef.rb', line 253 def self.from_hex(hex) from_binary(BSV::Primitives::Hex.decode(hex, name: 'BEEF hex')) end |
Instance Method Details
#find_atomic_transaction(wtxid) ⇒ Transaction?
Find a transaction and recursively wire its ancestry (source transactions and merkle paths) for atomic proof validation.
373 374 375 376 377 378 379 380 |
# File 'lib/bsv/transaction/beef.rb', line 373 def find_atomic_transaction(wtxid) BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid') tx = find_transaction(wtxid) return unless tx wire_ancestry(tx) tx end |
#find_bump(wtxid) ⇒ MerklePath?
Find the merkle path (BUMP) for a transaction by its wire-order txid.
First checks the transaction-table entries, then scans @bumps directly for a BUMP whose level-0 leaves contain the wtxid.
343 344 345 346 347 348 349 350 351 352 353 |
# File 'lib/bsv/transaction/beef.rb', line 343 def find_bump(wtxid) BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid') # Check transaction-table entries first (fast path) bt = @transactions.find { |entry| entry.wtxid == wtxid && entry.is_a?(ProvenTxEntry) } return bt.transaction.merkle_path || @bumps[bt.bump_index] if bt # F5.8: also scan @bumps directly for a path containing the wtxid leaf @bumps.find do |bump| bump.path[0]&.any? { |leaf| leaf.hash == wtxid } end end |
#find_transaction(wtxid) ⇒ Transaction?
Find a transaction in the bundle by its wire-order transaction ID.
326 327 328 329 330 331 332 333 334 |
# File 'lib/bsv/transaction/beef.rb', line 326 def find_transaction(wtxid) BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid') BSV.logger&.debug { "[Beef] find_transaction: #{wtxid.reverse.unpack1('H*')} in #{@transactions.length} entries" } @transactions.each do |beef_tx| next if beef_tx.is_a?(TxidOnlyEntry) return beef_tx.transaction if beef_tx.wtxid == wtxid end nil end |
#find_transaction_for_signing(wtxid) ⇒ Transaction?
Find a transaction with all source_transactions wired for signing.
359 360 361 362 363 364 365 366 |
# File 'lib/bsv/transaction/beef.rb', line 359 def find_transaction_for_signing(wtxid) BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid') tx = find_transaction(wtxid) return unless tx wire_inputs(tx) tx end |
#make_txid_only(wtxid) ⇒ BeefTx?
Convert a transaction entry to TXID-only format.
566 567 568 569 570 571 572 |
# File 'lib/bsv/transaction/beef.rb', line 566 def make_txid_only(wtxid) BSV::Primitives::Hex.validate_wtxid!(wtxid, name: 'wtxid') idx = @transactions.index { |bt| bt.wtxid == wtxid } return unless idx @transactions[idx] = TxidOnlyEntry.new(known_wtxid: wtxid) end |
#merge(other) ⇒ self
Merge all BUMPs and transactions from another BEEF bundle.
BUMP indices are remapped during merge. New BeefTx instances are constructed rather than sharing references with the source bundle (F5.9).
522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 |
# File 'lib/bsv/transaction/beef.rb', line 522 def merge(other) # Build index remap for BUMPs bump_remap = {} other.bumps.each_with_index do |bump, old_idx| bump_remap[old_idx] = merge_bump(bump) end # Merge transactions with remapped BUMP indices, constructing new # BeefTx instances rather than sharing source references (F5.9). other.transactions.each do |beef_tx| case beef_tx when TxidOnlyEntry next if @transactions.any? { |bt| bt.wtxid == beef_tx.known_wtxid } @transactions << TxidOnlyEntry.new(known_wtxid: beef_tx.known_wtxid) else next if @transactions.any? { |bt| bt.wtxid == beef_tx.wtxid } if beef_tx.is_a?(ProvenTxEntry) && beef_tx.bump_index new_idx = bump_remap[beef_tx.bump_index] if new_idx.nil? raise ArgumentError, "source BEEF has inconsistent bump_index #{beef_tx.bump_index} " \ "(source has #{other.bumps.length} bumps); refusing to write a stale reference" end # F5.9: construct a new BeefTx with a dup'd Transaction # so mutations to the merged bundle don't affect the source. tx = beef_tx.transaction.dup tx.merkle_path = @bumps[new_idx] @transactions << ProvenTxEntry.new(transaction: tx, bump_index: new_idx) else @transactions << RawTxEntry.new(transaction: beef_tx.transaction.dup) end end end self end |
#merge_bump(merkle_path) ⇒ Integer
Add or deduplicate a merkle path (BUMP) in this BEEF bundle.
If an existing BUMP shares the same block_height and merkle root, it is combined (via MerklePath#combine) and the existing index is returned. Otherwise the BUMP is appended.
After the BUMP is stored, any existing FORMAT_RAW_TX transactions whose txid appears in the new BUMP’s level-0 leaves are retroactively upgraded to FORMAT_RAW_TX_AND_BUMP (F5.6).
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 |
# File 'lib/bsv/transaction/beef.rb', line 404 def merge_bump(merkle_path) root = merkle_path.compute_root idx = nil @bumps.each_with_index do |existing, i| next unless existing.block_height == merkle_path.block_height next unless existing.compute_root == root existing.combine(merkle_path) idx = i break end if idx.nil? @bumps << merkle_path idx = @bumps.length - 1 end # F5.6: retroactively link existing FORMAT_RAW_TX entries whose txid # appears in the new BUMP's level-0 leaves. bump = @bumps[idx] level0_leaves = bump.path[0] || [] level0_internal = level0_leaves.map(&:hash).compact.to_set @transactions.each_with_index do |bt, i| next unless bt.is_a?(RawTxEntry) next unless level0_internal.include?(bt.wtxid) bt.transaction.merkle_path ||= bump @transactions[i] = ProvenTxEntry.new(transaction: bt.transaction, bump_index: idx) end idx end |
#merge_raw_tx(raw_bytes, bump_index: nil) ⇒ BeefTx
Add a transaction from raw binary data.
If the transaction already exists, upgrades weaker entries to stronger formats when a raw tx or bump_index is now available (F5.7).
483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 |
# File 'lib/bsv/transaction/beef.rb', line 483 def merge_raw_tx(raw_bytes, bump_index: nil) tx = Transaction.from_binary(raw_bytes) if bump_index unless bump_index.is_a?(Integer) && bump_index >= 0 && bump_index < @bumps.length raise ArgumentError, "bump_index #{bump_index.inspect} out of range (have #{@bumps.length} bumps)" end tx.merkle_path = @bumps[bump_index] end existing_idx = @transactions.index { |bt| bt.wtxid == tx.wtxid } if existing_idx existing = @transactions[existing_idx] upgraded = upgrade_beef_tx(existing, tx, bump_index: bump_index) @transactions[existing_idx] = upgraded if upgraded return @transactions[existing_idx] end entry = if bump_index ProvenTxEntry.new(transaction: tx, bump_index: bump_index) else RawTxEntry.new(transaction: tx) end @transactions << entry entry end |
#merge_transaction(tx) ⇒ BeefTx
Add a transaction to this BEEF bundle.
Recursively merges the transaction’s ancestors (via source_transaction references on inputs) and their merkle paths. Duplicate transactions (same txid) are upgraded if a stronger format is now available (F5.7): TXID_ONLY → RAW_TX or RAW_TX_AND_BUMP; RAW_TX → RAW_TX_AND_BUMP.
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/bsv/transaction/beef.rb', line 447 def merge_transaction(tx) wtxid = tx.wtxid # Check for existing entry and upgrade if a stronger format is available existing_idx = @transactions.index { |bt| bt.wtxid == wtxid } if existing_idx existing = @transactions[existing_idx] upgraded = upgrade_beef_tx(existing, tx) @transactions[existing_idx] = upgraded if upgraded return @transactions[existing_idx] end # Recursively merge ancestors first (dependency order) tx.inputs.each do |input| merge_transaction(input.source_transaction) if input.source_transaction end # Merge this transaction's BUMP if it has one entry = if tx.merkle_path bump_idx = merge_bump(tx.merkle_path) ProvenTxEntry.new(transaction: tx, bump_index: bump_idx) else RawTxEntry.new(transaction: tx) end @transactions << entry entry end |
#sort_transactions! ⇒ self
Sort transactions in topological (dependency) order in place.
After sorting, every transaction’s input ancestors appear before it in the array. This is required for correct BEEF serialisation.
Transactions that form a cycle (i.e. cannot be topologically sorted) are moved to @txs_not_valid rather than being silently dropped (F5.5).
664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 |
# File 'lib/bsv/transaction/beef.rb', line 664 def sort_transactions! return self if @transactions.length <= 1 wtxid_index = {} @transactions.each_with_index { |bt, i| wtxid_index[bt.wtxid] = i } # Build adjacency: for each tx, which other txs must come before it? in_degree = Array.new(@transactions.length, 0) dependents = Array.new(@transactions.length) { [] } @transactions.each_with_index do |bt, i| next if bt.is_a?(TxidOnlyEntry) bt.transaction.inputs.each do |input| dep_idx = wtxid_index[input.prev_wtxid] next unless dep_idx dependents[dep_idx] << i in_degree[i] += 1 end end # Kahn's algorithm queue = (0...@transactions.length).select { |i| in_degree[i].zero? } sorted = [] until queue.empty? idx = queue.shift sorted << @transactions[idx] dependents[idx].each do |dep| in_degree[dep] -= 1 queue << dep if in_degree[dep].zero? end end # F5.5: preserve unsortable (cyclic) transactions rather than silently dropping them if sorted.length < @transactions.length sorted_set = sorted.to_set(&:wtxid) @txs_not_valid = @transactions.reject { |bt| sorted_set.include?(bt.wtxid) } end @transactions = sorted self end |
#subject_dtxid ⇒ String?
Display-order subject txid as a hex string (Atomic BEEF only).
170 171 172 |
# File 'lib/bsv/transaction/beef.rb', line 170 def subject_dtxid @subject_wtxid&.reverse&.unpack1('H*') end |
#to_atomic_binary(subject_wtxid) ⇒ String
Serialise as Atomic BEEF (BRC-95), wrapping V2 data with a subject txid.
310 311 312 313 314 315 316 317 318 |
# File 'lib/bsv/transaction/beef.rb', line 310 def to_atomic_binary(subject_wtxid) BSV::Primitives::Hex.validate_wtxid!(subject_wtxid, name: 'subject_wtxid') buf = [ATOMIC_BEEF].pack('V') # subject_wtxid is already in wire (internal) byte order — write as-is. buf << subject_wtxid.b # BRC-95: inner envelope is always V2 buf << to_binary(version: BEEF_V2) buf end |
#to_atomic_hex(subject_wtxid) ⇒ String
Serialise as Atomic BEEF (BRC-95) hex string.
386 387 388 |
# File 'lib/bsv/transaction/beef.rb', line 386 def to_atomic_hex(subject_wtxid) to_atomic_binary(subject_wtxid).unpack1('H*') end |
#to_binary(version: BEEF_V1) ⇒ String
Serialise the BEEF bundle to binary format.
Defaults to V1 (BRC-62) for compatibility with ARC and the reference TS SDK. Pass version: BEEF_V2 for BRC-96 format.
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 |
# File 'lib/bsv/transaction/beef.rb', line 269 def to_binary(version: BEEF_V1) if version == BEEF_V1 && @transactions.any?(TxidOnlyEntry) raise ArgumentError, 'BEEF V1 (BRC-62) does not support FORMAT_TXID_ONLY entries; pass version: BEEF_V2 to serialise this bundle' end # F5.5/F5.20: ensure transactions are in dependency order before serialising sort_transactions! buf = [version].pack('V') buf << VarInt.encode(@bumps.length) @bumps.each { |bump| buf << bump.to_binary } buf << VarInt.encode(@transactions.length) @transactions.each do |beef_tx| if version == BEEF_V2 write_v2_tx(buf, beef_tx) else write_v1_tx(buf, beef_tx) end end buf end |
#to_hex ⇒ String
Serialise the BEEF bundle to a hex string.
Uses the bundle’s own @version, so a BEEF parsed from V2 round-trips to V2 hex, and a BEEF parsed from V1 (or freshly constructed via the default constructor) round-trips to V1 hex.
302 303 304 |
# File 'lib/bsv/transaction/beef.rb', line 302 def to_hex to_binary(version: @version).unpack1('H*') end |
#valid?(allow_txid_only: false) ⇒ Boolean
Check structural validity of the BEEF bundle.
A valid BEEF has every transaction either:
-
proven (has a BUMP / merkle_path), or
-
all its inputs reference transactions that are themselves valid within this bundle.
For FORMAT_RAW_TX_AND_BUMP entries, the BUMP linkage and computed root are also verified (F5.4).
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 |
# File 'lib/bsv/transaction/beef.rb', line 588 def valid?(allow_txid_only: false) # TXID-only entries are invalid unless explicitly allowed has_txid_only = @transactions.any?(TxidOnlyEntry) return false if has_txid_only && !allow_txid_only # F5.4: verify BUMP linkage and computed root for each proven transaction @transactions.each do |bt| next unless bt.is_a?(ProvenTxEntry) # Must have a BUMP bump = bt.transaction.merkle_path || @bumps[bt.bump_index] return false unless bump # The txid must appear as a leaf in the BUMP and compute a valid root begin bump.compute_root(bt.wtxid) rescue ArgumentError return false end end known_wtxids = build_known_wtxids(allow_txid_only) pending = @transactions.reject { |bt| bt.is_a?(TxidOnlyEntry) || known_wtxids.include?(bt.wtxid) } # Iteratively resolve: if all inputs of a tx are known, it becomes known changed = true while changed changed = false pending.reject! do |bt| all_inputs_known = bt.transaction.inputs.all? do |input| known_wtxids.include?(input.prev_wtxid) end if all_inputs_known known_wtxids.add(bt.wtxid) changed = true end all_inputs_known end end pending.empty? end |
#verify(chain_tracker = nil, allow_txid_only: false) ⇒ Boolean
Verify the BEEF bundle against a chain tracker (SPV).
Calls #valid? first for structural checks, then optionally verifies each BUMP’s computed merkle root against the chain tracker (F5.3).
643 644 645 646 647 648 649 650 651 652 653 |
# File 'lib/bsv/transaction/beef.rb', line 643 def verify(chain_tracker = nil, allow_txid_only: false) return false unless valid?(allow_txid_only: allow_txid_only) return true unless chain_tracker @bumps.each do |bump| root_hex = bump.compute_root.reverse.unpack1('H*') return false unless chain_tracker.valid_root_for_height?(root_hex, bump.block_height) end true end |