Skip to content

Commit b1e1fdf

Browse files
authored
Merge pull request #473 from sgbett/feat/466-beef-ancestry-persistence
fix: persist BEEF ancestry through the wallet lifecycle
2 parents 2908393 + ba74f77 commit b1e1fdf

File tree

10 files changed

+994
-17
lines changed

10 files changed

+994
-17
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Draft comment for bsv-blockchain/wallet-toolbox#149
2+
3+
**Not yet posted.** Review before posting. Attribution note: same reporter
4+
as upstream issue (sgbett / Simon Bettison), so tone can be direct and
5+
technical.
6+
7+
---
8+
9+
## Comment body
10+
11+
Hi — just a note that I hit the same issue while porting the wallet to Ruby (sgbett/bsv-ruby-sdk, HLR #466), and the investigation turned up a slightly different shape to what I originally thought when I filed this.
12+
13+
My initial instinct (and the workaround in the fork) was that `internaliseAction` wasn't persisting BEEF ancestors. After going deeper, that turned out not to be quite right — `storeProofsFromBeef` was already walking the full BEEF and storing every ancestor transaction. The storage side was fine.
14+
15+
The real issue was in two other places:
16+
17+
**1. EF serialisation (`toHexEF` / `writeEF`)**
18+
19+
When a transaction is built using inputs from `fromBEEF`, those inputs have their `sourceTransaction` wired up but `sourceSatoshis` and `sourceLockingScript` aren't set as explicit properties on the input object. In the Ruby port, `toEF` wasn't falling back to `input.sourceTransaction.outputs[i]` when those explicit fields were missing — it just raised and the caller silently fell back to broadcasting plain hex. ARC then rejected that hex because the parent was unconfirmed.
20+
21+
I had a look at the TS `writeEF` (Transaction.ts ~L672–713) and it accesses `i.sourceTransaction.outputs[i.sourceOutputIndex]` directly, so it should handle this correctly. The Ruby implementation was the divergent one here — the fix was to derive from `sourceTransaction` when explicit fields aren't set, non-mutating, matching the TS behaviour.
22+
23+
**2. `fromBEEF` ancestry wiring**
24+
25+
The second, subtler issue: `Transaction.fromBEEF` (Ruby's `from_beef`) wasn't routing through `Beef#findAtomicTransaction` (our `find_atomic_transaction`). That method does the extra step of attaching late-bound BUMPs to `FORMAT_RAW_TX` ancestors whose txid appears as a leaf elsewhere in the BUMP tree — a pass that `wireSourceTransactions` alone doesn't cover. So the returned transaction had some ancestors without `merklePath` attached even when the BEEF contained the proof.
26+
27+
The fix was to go through `findAtomicTransaction` / `find_atomic_transaction` rather than the simpler path, so every ancestor in the returned tx has its proof wired before you try to broadcast.
28+
29+
---
30+
31+
The end-to-end symptom is: `internaliseAction` runs fine, the UTXO lands in the wallet, but when you try to spend it in a later `createAction`, the broadcast silently falls back to raw hex (or fails to build EF), and ARC rejects it with "must be valid transaction on chain".
32+
33+
The fix in the Ruby SDK is in [sgbett/bsv-ruby-sdk#473](https://github.com/sgbett/bsv-ruby-sdk/pull/473) if it's useful as a reference.
34+
35+
Whether the TS SDK has the same gap in `fromBEEF` / `fromAtomicBEEF` is worth a look — if `writeEF` already handles the `sourceTransaction` fallback correctly (which it looks like it does), the TS issue might be narrower than "persist ancestors in `internaliseAction`". Happy to dig into it further if that's useful.
36+

docs/gems/wallet.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ wallet.internalize_action({
148148
})
149149
```
150150

151+
The full internalize → create → broadcast round-trip is exercised by the integration
152+
spec suite. `internalize_action` persists every transaction in the BEEF (not just the
153+
subject), so ancestor chain data is available when the wallet later builds a new
154+
transaction spending the internalised UTXO. This ensures `to_ef_hex` can serialise the
155+
spending transaction correctly for ARC broadcast, even when the parent is unconfirmed.
156+
151157
## Status meanings
152158

153159
Each action stored by the wallet carries a `status` field that reflects its

docs/sdk/transaction.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,20 @@ tx = beef.find_transaction(txid_bytes)
193193

194194
BEEF automatically wires source transactions: inputs that reference other transactions in the bundle will have their `source_transaction` set.
195195

196+
### Extracting a transaction from a BEEF
197+
198+
`Transaction.from_beef` returns the subject transaction with full ancestry wired, including late-bound BUMP attachment for ancestors stored as raw transactions alongside a separately-bundled merkle proof:
199+
200+
```ruby
201+
tx = BSV::Transaction::Transaction.from_beef(beef_bytes)
202+
203+
# Source data is derived from the wired ancestry — no need to set
204+
# source_satoshis or source_locking_script explicitly.
205+
ef_bytes = tx.to_ef_hex # ready for ARC broadcast
206+
```
207+
208+
This is the canonical flow for re-broadcasting a received BEEF (e.g. after `internalize_action`). `to_ef_hex` resolves source satoshis and locking scripts directly from `source_transaction.outputs[prev_tx_out_index]` when the explicit fields are not set on an input.
209+
196210
### Transaction Entries
197211

198212
Each entry in a BEEF bundle has a format:

gem/bsv-sdk/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to the `bsv-sdk` gem are documented here.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66
and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- `Transaction#to_ef` now derives `source_satoshis` and `source_locking_script`
13+
from `input.source_transaction.outputs[input.prev_tx_out_index]` when the
14+
explicit fields are unset. Previously `to_ef` raised `ArgumentError` on inputs
15+
wired via `Transaction.from_beef`, causing `ARC#broadcast` to silently fall back
16+
to raw hex — which ARC rejects when parent transactions are unconfirmed.
17+
Consumer impact: recently-received UTXOs that previously could not be
18+
re-broadcast now can. Matches TS SDK `writeEF` behaviour. (#467, HLR #466)
19+
- `Transaction.from_beef` and `from_beef_hex` now use `Beef#find_atomic_transaction`
20+
to locate the subject transaction, ensuring that `FORMAT_RAW_TX` ancestors whose
21+
txid appears as a leaf in a separately-stored BUMP have their `merkle_path`
22+
attached. Covers a late-bound BUMP attachment gap not handled by the initial
23+
`wire_source_transactions` pass. Matches `Transaction.fromAtomicBEEF` semantics
24+
in the TS SDK. (#468, HLR #466)
25+
26+
**Related upstream issue:** [wallet-toolbox#149](https://github.com/bsv-blockchain/wallet-toolbox/issues/149) — same architectural gap in the TS SDK.
27+
828
## 0.12.0 — 2026-04-15
929

1030
### Added

gem/bsv-sdk/lib/bsv/transaction/transaction.rb

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,29 @@ def to_hex
102102
# EF embeds source satoshis and source locking scripts in each input,
103103
# allowing ARC to validate sighashes without fetching parent transactions.
104104
#
105+
# Source data is resolved in priority order:
106+
# 1. Explicit +source_satoshis+ / +source_locking_script+ on the input.
107+
# 2. Derived from +input.source_transaction.outputs[input.prev_tx_out_index]+.
108+
#
109+
# Source fields on input objects are never mutated — derivation happens on
110+
# each call, so calling +to_ef+ twice produces identical output.
111+
#
105112
# @return [String] raw EF transaction bytes
106-
# @raise [ArgumentError] if any input is missing source_satoshis or source_locking_script
113+
# @raise [ArgumentError] if any input cannot supply source_satoshis or
114+
# source_locking_script via either mechanism
107115
def to_ef
108116
buf = [@version].pack('V')
109117
buf << "\x00\x00\x00\x00\x00\xEF".b
110118
buf << VarInt.encode(@inputs.length)
111-
@inputs.each do |input|
112-
raise ArgumentError, 'inputs must have source_satoshis for EF' if input.source_satoshis.nil?
113-
raise ArgumentError, 'inputs must have source_locking_script for EF' if input.source_locking_script.nil?
119+
@inputs.each_with_index do |input, idx|
120+
source_output = ef_source_output(input, idx)
121+
122+
satoshis = input.source_satoshis || source_output.satoshis
123+
locking_script = input.source_locking_script || source_output.locking_script
114124

115125
buf << input.to_binary
116-
buf << [input.source_satoshis].pack('Q<')
117-
lock_bytes = input.source_locking_script.to_binary
126+
buf << [satoshis].pack('Q<')
127+
lock_bytes = locking_script.to_binary
118128
buf << VarInt.encode(lock_bytes.bytesize)
119129
buf << lock_bytes
120130
end
@@ -343,21 +353,35 @@ def to_beef_hex
343353
to_beef.unpack1('H*')
344354
end
345355

346-
# Parse a BEEF binary bundle and return the subject transaction
347-
# (the last transaction in the bundle).
356+
# Parse a BEEF binary bundle and return the subject transaction with
357+
# full ancestry wired, including late-bound BUMP attachment.
358+
#
359+
# For Atomic BEEFs (BRC-95), the subject transaction is identified by
360+
# the embedded subject_txid field. For plain BEEFs, the last transaction
361+
# with a raw tx entry is used as the subject.
362+
#
363+
# Uses +find_atomic_transaction+ so that FORMAT_RAW_TX ancestors whose
364+
# txid appears as a leaf in a separately-stored BUMP get their
365+
# +merkle_path+ wired correctly — a gap not covered by the initial
366+
# +wire_source_transactions+ pass in +Beef.from_binary+.
348367
#
349368
# @param data [String] raw BEEF binary
350-
# @return [Transaction] the subject transaction with ancestry wired
369+
# @return [Transaction, nil] the subject transaction with ancestry wired,
370+
# or nil if the BEEF is empty or contains no raw transaction entries
351371
def self.from_beef(data)
352372
beef = Beef.from_binary(data)
353-
last_tx_entry = beef.transactions.reverse.find(&:transaction)
354-
last_tx_entry&.transaction
373+
subject_txid = beef.subject_txid ||
374+
beef.transactions.reverse.find(&:transaction)&.transaction&.txid
375+
return nil unless subject_txid
376+
377+
beef.find_atomic_transaction(subject_txid)
355378
end
356379

357380
# Parse a BEEF hex string and return the subject transaction.
358381
#
359382
# @param hex [String] hex-encoded BEEF
360-
# @return [Transaction] the subject transaction with ancestry wired
383+
# @return [Transaction, nil] the subject transaction with ancestry wired,
384+
# or nil if the BEEF is empty or contains no raw transaction entries
361385
def self.from_beef_hex(hex)
362386
from_beef(BSV::Primitives::Hex.decode(hex, name: 'BEEF hex'))
363387
end
@@ -683,6 +707,39 @@ def fee(model_or_fee = nil, change_distribution: :equal)
683707

684708
private
685709

710+
# Resolve the source TransactionOutput for EF serialisation.
711+
#
712+
# Returns nil when both explicit fields (+source_satoshis+ and
713+
# +source_locking_script+) are already set on the input, so no derivation
714+
# is required. Otherwise, requires a wired +source_transaction+ and a
715+
# valid output at +prev_tx_out_index+.
716+
#
717+
# @param input [TransactionInput]
718+
# @param idx [Integer] input index, used in error messages
719+
# @return [TransactionOutput, nil]
720+
def ef_source_output(input, idx)
721+
return nil if input.source_satoshis && input.source_locking_script
722+
723+
unless input.source_transaction
724+
missing = []
725+
missing << 'source_satoshis' unless input.source_satoshis
726+
missing << 'source_locking_script' unless input.source_locking_script
727+
raise ArgumentError,
728+
"input #{idx} is missing #{missing.join(' and ')} and has no wired source_transaction"
729+
end
730+
731+
if input.prev_tx_out_index.nil?
732+
raise ArgumentError,
733+
"input #{idx} has no prev_tx_out_index — cannot derive EF source data"
734+
end
735+
736+
output = input.source_transaction.outputs[input.prev_tx_out_index]
737+
return output if output
738+
739+
raise ArgumentError,
740+
"input #{idx} source_transaction has no output at index #{input.prev_tx_out_index}"
741+
end
742+
686743
def verify_input_requirements(tx, input, index)
687744
tx_id = tx.txid_hex
688745
raise ArgumentError, "input #{index} of transaction #{tx_id} has no unlocking script" if input.unlocking_script.nil?

0 commit comments

Comments
 (0)