Skip to content

fix(sdk): parse AtomicBEEF in hex_to_bsv_tx to preserve source transactions#103

Open
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/atomicbeef-unwrap
Open

fix(sdk): parse AtomicBEEF in hex_to_bsv_tx to preserve source transactions#103
E-Jacko wants to merge 1 commit into
icellan:mainfrom
E-Jacko:fix/atomicbeef-unwrap

Conversation

@E-Jacko

@E-Jacko E-Jacko commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Detects AtomicBEEF / BEEF magic bytes at the start of the hex stream in hex_to_bsv_tx and parses via Beef::from_hex so that wallet-toolbox's CreateActionResult.tx field — which is AtomicBEEF binary (bsv-blockchain/ts-stack/packages/wallet/wallet-toolbox/src/signer/methods/createAction.ts:66: r.tx = beef.toBinaryAtomic(r.txid)) — is unwrapped with source_transaction populated on every input.

Without this fix, the SDK treats AtomicBEEF bytes as raw tx hex, the parser runs out of buffer mid-parse, and the broadcast loop downstream fetches every input's parent over HTTP individually — even when the parent was just produced by the wallet in the same session.

Problem

Upstream HEAD packages/runar-rs/src/sdk/contract.rs:23-25:

fn hex_to_bsv_tx(hex: &str) -> Result<BsvTransaction, String> {
    BsvTransaction::from_hex(hex).map_err(|e| format!("hex_to_bsv_tx: {}", e))
}

BsvTransaction::from_hex parses ONLY the raw transaction wire format. When called with the hex of an AtomicBEEF envelope (prefix 01010101 + 32-byte txid + BEEF V1/V2 body + per-tx framing + merklePath chain), the inner reader walks into the BEEF framing bytes, mis-interprets them as tx fields, and either errors with I/O error: failed to fill whole buffer or returns a malformed Transaction with no source_transaction on any input.

This isn't a corner case — it's the hot path. Every successful wallet_toolbox.create_action(...) call returns result.tx as AtomicBEEF (TS canonical verified at createAction.ts:62-67 and signAction.ts:35). Treating those bytes as raw tx hex either fails outright or strips the parent context, forcing every downstream broadcaster to re-fetch parents over HTTP for inputs the wallet itself just supplied.

How this surfaced

Surfaced while integrating wallet-toolbox-returned AtomicBEEF into the SDK's broadcast pipeline. A result.tx field returned from create_action was passed through hex_to_bsv_tx (via the SDK's standard deploy/call path), the parser failed mid-way through the AtomicBEEF prefix, and the downstream broadcast loop saw every input with source_transaction = None. The broadcaster then tried to fetch each parent via overlay/HTTP — including parents the wallet had created moments earlier — to reconstruct the EF/BEEF envelope it needs to send to ARC. Both the parse failure (when the raw-tx parser errors) and the parent-fetch storm (when it returns a malformed but non-erroring tx) were visible in production.

Fix

/// Convert a raw transaction hex string to a BSV SDK Transaction object for broadcasting.
///
/// Detects AtomicBEEF / BEEF magic bytes at the start of the hex stream and
/// parses via `Beef::from_hex` so that wallet-toolbox's `CreateActionResult.tx`
/// (which is AtomicBEEF — see TS canonical at packages/wallet/wallet-toolbox/src/signer/
/// methods/createAction.ts:66 `r.tx = beef.toBinaryAtomic(r.txid)`) is unwrapped
/// with `source_transaction` populated on every input. Treating those bytes as
/// raw tx hex either fails or produces a transaction with empty parent context,
/// forcing every downstream broadcaster to re-fetch parents over HTTP.
///
/// Mirrors TS engine behaviour: `Transaction.fromAtomicBEEF(result.tx)`.
/// Magic bytes (first 4 bytes little-endian u32):
///   - ATOMIC_BEEF = 0x01010101 → hex "01010101"
///   - BEEF_V1     = 0x0100BEEF → hex "efbe0001"
///   - BEEF_V2     = 0x0200BEEF → hex "efbe0002"
/// Raw tx hex starts with the tx version ("01000000" / "02000000"), so the
/// discriminator is unambiguous.
pub fn hex_to_bsv_tx(hex: &str) -> Result<BsvTransaction, String> {
    if hex.len() >= 8 {
        let prefix = hex[..8].to_ascii_lowercase();
        if prefix == "01010101" || prefix == "efbe0001" || prefix == "efbe0002" {
            let beef = Beef::from_hex(hex).map_err(|e| {
                format!("hex_to_bsv_tx: BEEF magic detected but Beef::from_hex failed: {}", e)
            })?;
            return beef.into_transaction().map_err(|e| {
                format!("hex_to_bsv_tx: BEEF parsed but into_transaction failed: {}", e)
            });
        }
    }
    BsvTransaction::from_hex(hex).map_err(|e| format!("hex_to_bsv_tx: {}", e))
}

Helper changed from fn (private) to pub fn so the regression test can exercise it through public surface. No production-side semantic change from the pub.

The magic-byte discriminator is unambiguous: raw Bitcoin transactions always start with a 4-byte little-endian version field (01000000 for v1, 02000000 for v2). None of those collide with AtomicBEEF (01010101) or BEEF V1/V2 (efbe0001 / efbe0002). Raw-tx hex input continues through the legacy BsvTransaction::from_hex path unchanged.

Cross-language parity

This is the strongest port-parity story in this batch. TS canonical (bsv-blockchain/ts-stack/packages/sdk/src/transaction/Transaction.ts:121):

static fromAtomicBEEF(beef: number[] | Uint8Array): Transaction

…is the canonical entry-point for unwrapping the same byte layout. The TS SDK's Transaction.fromAtomicBEEF() walks the same prefix + BEEF body and returns a Transaction with sourceTransaction populated on every input — exactly the shape this PR produces in Rust.

The wallet-toolbox client class uses this canonical pattern at CWIStyleWalletManager.ts:451-456:

createResult.txid || (createResult.tx ? Transaction.fromAtomicBEEF(createResult.tx).id('hex') : undefined)
...
const broadcastTx = Transaction.fromAtomicBEEF(createResult.tx!)

i.e., the canonical sequence is: createActionr.tx (AtomicBEEF bytes) → Transaction.fromAtomicBEEF(r.tx) → broadcaster. This PR makes the Rust SDK's hex_to_bsv_tx capable of the same unwrap, so the Rust broadcaster sees fully-populated inputs.

Verification

git clone --depth 1 https://github.com/icellan/runar /tmp/runar
cd /tmp/runar
git apply <runar-r-ab-hex-to-bsv-tx-atomicbeef-clean.patch>
cd packages/runar-rs
cargo test --test r_ab_atomic_beef_unwrap

Passes with patch:

running 2 tests
test r_ab_hex_to_bsv_tx_falls_back_to_raw_tx_when_no_beef_magic ... ok
test r_ab_hex_to_bsv_tx_unwraps_atomic_beef_and_links_parent ... ok

test result: ok. 2 passed; 0 failed

Fails without patch (surgical — strip the BEEF-detection block, keep pub fn):

failures:

---- r_ab_hex_to_bsv_tx_unwraps_atomic_beef_and_links_parent stdout ----
R-AB regression: hex_to_bsv_tx must parse AtomicBEEF bytes: "hex_to_bsv_tx: I/O error: failed to fill whole buffer"

failures:
    r_ab_hex_to_bsv_tx_unwraps_atomic_beef_and_links_parent

test result: FAILED. 1 passed; 1 failed

Pre-patch, hex_to_bsv_tx tries to parse the AtomicBEEF envelope bytes as a raw tx — the wire layout doesn't conform, the inner reader runs out of buffer mid-parse, and the test's assertion that the inner Transaction's inputs[0].source_transaction is Some(...) cannot even be reached. The raw-tx fallback test still passes — locking the no-regression property for non-BEEF input.

The bundled test uses only upstream-existing deps (bsv-sdk 0.1.72, the same version pinned in upstream's Cargo.lock). It constructs a parent tx P, a child tx C that spends P, wraps them in an AtomicBEEF envelope with merkle path for P, hex-encodes the envelope, and asserts hex_to_bsv_tx(hex) returns C with C.inputs[0].source_transaction == Some(P).

Backwards compatibility

  • Raw transaction hex (any input starting with 01000000 or 02000000) continues through the legacy BsvTransaction::from_hex path with byte-identical behaviour. Locked by r_ab_hex_to_bsv_tx_falls_back_to_raw_tx_when_no_beef_magic.
  • Function signature is unchanged. Only visibility went from fn to pub fn (additive — no existing call site is forced to import it).
  • No new dependencies — Beef is already imported via bsv::transaction::beef (already a dep of runar-rs).
  • The magic-byte discriminator is unambiguous against the BSV raw-tx wire format. There is no possible input that would change route between pre- and post-patch behaviour.

Related

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant