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
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_txid ⇒ String?
readonly
32-byte 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(txid) ⇒ Transaction?
Find a transaction and recursively wire its ancestry (source transactions and merkle paths) for atomic proof validation.
-
#find_bump(txid) ⇒ MerklePath?
Find the merkle path (BUMP) for a transaction by its txid.
-
#find_transaction(txid) ⇒ Transaction?
Find a transaction in the bundle by its transaction ID.
-
#find_transaction_for_signing(txid) ⇒ 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(txid) ⇒ 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.
-
#to_atomic_binary(subject_txid) ⇒ String
Serialise as Atomic BEEF (BRC-95), wrapping V2 data with a subject txid.
-
#to_atomic_hex(subject_txid) ⇒ 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.
96 97 98 99 100 101 |
# File 'lib/bsv/transaction/beef.rb', line 96 def initialize(version: BEEF_V1, bumps: [], transactions: []) @version = version @bumps = bumps @transactions = transactions @subject_txid = nil end |
Instance Attribute Details
#bumps ⇒ Array<MerklePath> (readonly)
Returns merkle proofs (BUMPs) referenced by transactions.
84 85 86 |
# File 'lib/bsv/transaction/beef.rb', line 84 def bumps @bumps end |
#subject_txid ⇒ String? (readonly)
Returns 32-byte subject txid (Atomic BEEF only).
90 91 92 |
# File 'lib/bsv/transaction/beef.rb', line 90 def subject_txid @subject_txid end |
#transactions ⇒ Array<BeefTx> (readonly)
Returns the transactions in dependency order.
87 88 89 |
# File 'lib/bsv/transaction/beef.rb', line 87 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).
634 635 636 |
# File 'lib/bsv/transaction/beef.rb', line 634 def txs_not_valid @txs_not_valid end |
#version ⇒ Integer
Returns BEEF version constant.
81 82 83 |
# File 'lib/bsv/transaction/beef.rb', line 81 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.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/bsv/transaction/beef.rb', line 112 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 internal byte order (little-endian # hash order), matching JS and Go SDKs. Reverse to display order for internal use. beef.instance_variable_set(:@subject_txid, data.byteslice(offset, 32).reverse) 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.
171 172 173 |
# File 'lib/bsv/transaction/beef.rb', line 171 def self.from_hex(hex) from_binary(BSV::Primitives::Hex.decode(hex, name: 'BEEF hex')) end |
Instance Method Details
#find_atomic_transaction(txid) ⇒ Transaction?
Find a transaction and recursively wire its ancestry (source transactions and merkle paths) for atomic proof validation.
287 288 289 290 291 292 293 |
# File 'lib/bsv/transaction/beef.rb', line 287 def find_atomic_transaction(txid) tx = find_transaction(txid) return unless tx wire_ancestry(tx) tx end |
#find_bump(txid) ⇒ MerklePath?
Find the merkle path (BUMP) for a transaction by its txid.
First checks the transaction-table entries, then scans @bumps directly for a BUMP whose level-0 leaves contain the txid.
258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/bsv/transaction/beef.rb', line 258 def find_bump(txid) # Check transaction-table entries first (fast path) bt = @transactions.find { |entry| entry.txid == txid && entry.format == FORMAT_RAW_TX_AND_BUMP } return bt.transaction&.merkle_path || (bt.bump_index && @bumps[bt.bump_index]) if bt # F5.8: also scan @bumps directly for a path containing the txid leaf txid_internal = txid.reverse @bumps.find do |bump| bump.path[0]&.any? { |leaf| leaf.hash == txid_internal } end end |
#find_transaction(txid) ⇒ Transaction?
Find a transaction in the bundle by its transaction ID.
244 245 246 247 248 249 |
# File 'lib/bsv/transaction/beef.rb', line 244 def find_transaction(txid) @transactions.each do |beef_tx| return beef_tx.transaction if beef_tx.transaction&.txid == txid end nil end |
#find_transaction_for_signing(txid) ⇒ Transaction?
Find a transaction with all source_transactions wired for signing.
274 275 276 277 278 279 280 |
# File 'lib/bsv/transaction/beef.rb', line 274 def find_transaction_for_signing(txid) tx = find_transaction(txid) return unless tx wire_inputs(tx) tx end |
#make_txid_only(txid) ⇒ BeefTx?
Convert a transaction entry to TXID-only format.
487 488 489 490 491 492 |
# File 'lib/bsv/transaction/beef.rb', line 487 def make_txid_only(txid) idx = @transactions.index { |bt| bt.txid == txid } return unless idx @transactions[idx] = BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: txid) 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).
439 440 441 442 443 444 445 446 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 474 475 476 477 478 479 480 481 |
# File 'lib/bsv/transaction/beef.rb', line 439 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.format when FORMAT_TXID_ONLY next if @transactions.any? { |bt| bt.txid == beef_tx.known_txid } @transactions << BeefTx.new(format: FORMAT_TXID_ONLY, known_txid: beef_tx.known_txid) else next if @transactions.any? { |bt| bt.txid == beef_tx.txid } if beef_tx.format == FORMAT_RAW_TX_AND_BUMP && 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 << BeefTx.new( format: FORMAT_RAW_TX_AND_BUMP, transaction: tx, bump_index: new_idx ) else @transactions << BeefTx.new(format: FORMAT_RAW_TX, 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).
317 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 |
# File 'lib/bsv/transaction/beef.rb', line 317 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.format == FORMAT_RAW_TX && bt.transaction next unless level0_internal.include?(bt.transaction.txid.reverse) bt.transaction.merkle_path ||= bump @transactions[i] = BeefTx.new( format: FORMAT_RAW_TX_AND_BUMP, 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).
400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 |
# File 'lib/bsv/transaction/beef.rb', line 400 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.txid == tx.txid } 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 BeefTx.new(format: FORMAT_RAW_TX_AND_BUMP, transaction: tx, bump_index: bump_index) else BeefTx.new(format: FORMAT_RAW_TX, 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.
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
# File 'lib/bsv/transaction/beef.rb', line 364 def merge_transaction(tx) txid = tx.txid # Check for existing entry and upgrade if a stronger format is available existing_idx = @transactions.index { |bt| bt.txid == txid } 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) BeefTx.new(format: FORMAT_RAW_TX_AND_BUMP, transaction: tx, bump_index: bump_idx) else BeefTx.new(format: FORMAT_RAW_TX, 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).
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 |
# File 'lib/bsv/transaction/beef.rb', line 584 def sort_transactions! return self if @transactions.length <= 1 txid_index = {} @transactions.each_with_index { |bt, i| txid_index[bt.txid] = 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 unless bt.transaction bt.transaction.inputs.each do |input| dep_idx = txid_index[input.prev_tx_id.reverse] 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(&:txid) @txs_not_valid = @transactions.reject { |bt| sorted_set.include?(bt.txid) } end @transactions = sorted self end |
#to_atomic_binary(subject_txid) ⇒ String
Serialise as Atomic BEEF (BRC-95), wrapping V2 data with a subject txid.
228 229 230 231 232 233 234 235 236 |
# File 'lib/bsv/transaction/beef.rb', line 228 def to_atomic_binary(subject_txid) buf = [ATOMIC_BEEF].pack('V') # Write subject txid in internal byte order (reverse of display order), # matching JS and Go SDK conventions for Bitcoin binary formats. buf << subject_txid.b.reverse # BRC-95: inner envelope is always V2 buf << to_binary(version: BEEF_V2) buf end |
#to_atomic_hex(subject_txid) ⇒ String
Serialise as Atomic BEEF (BRC-95) hex string.
299 300 301 |
# File 'lib/bsv/transaction/beef.rb', line 299 def to_atomic_hex(subject_txid) to_atomic_binary(subject_txid).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.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/bsv/transaction/beef.rb', line 187 def to_binary(version: BEEF_V1) if version == BEEF_V1 && @transactions.any? { |bt| bt.format == FORMAT_TXID_ONLY } 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.
220 221 222 |
# File 'lib/bsv/transaction/beef.rb', line 220 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).
508 509 510 511 512 513 514 515 516 517 518 519 520 521 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 |
# File 'lib/bsv/transaction/beef.rb', line 508 def valid?(allow_txid_only: false) # TXID-only entries are invalid unless explicitly allowed has_txid_only = @transactions.any? { |bt| bt.format == FORMAT_TXID_ONLY } 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.format == FORMAT_RAW_TX_AND_BUMP # Must have a BUMP bump = bt.transaction&.merkle_path || (bt.bump_index && @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.transaction.txid.reverse) rescue ArgumentError return false end end known_txids = build_known_txids(allow_txid_only) pending = @transactions.select { |bt| bt.transaction && !known_txids.include?(bt.txid) } # 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_txids.include?(input.prev_tx_id.reverse) end if all_inputs_known known_txids.add(bt.txid) 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).
563 564 565 566 567 568 569 570 571 572 573 |
# File 'lib/bsv/transaction/beef.rb', line 563 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 |