@bitfiles/bpub
v0.3.0
Published
TypeScript port of the bpub Python library: read, write, and sign BPUB data inscriptions embedded in Bitcoin transactions. Isomorphic (Node + browser); standard Bitcoin plumbing and signing are built on @scure/btc-signer.
Maintainers
Readme
bpub
A TypeScript port of the bpub Python library.
BPUB embeds arbitrary data in Bitcoin by encoding it into fake compressed secp256k1 pubkeys held inside 1-of-N P2WSH multisig redeem scripts. Each pubkey carries 31 bytes of payload plus a ground nonce; the data is revealed when the multisig outputs are spent, because the redeem scripts land in the witness.
This port covers the data format (v3.5, v4 and v5 streams), the script and transaction plumbing needed to read inscriptions back off-chain, the pure parts of the encode side, and — unlike upstream's stated scope — signing the reveal, ownership-transfer, and funding transactions.
- Runs in Node (>= 22) and modern browsers. Standard Bitcoin plumbing
(hashing, transaction (de)serialisation, bech32/segwit addresses, ECDSA
signing) is built on the audited
@scure/btc-signer/@noblestack rather than reimplemented here; DEFLATE still uses Compression Streams, since neither has a compression facility. - BPUB's own bits stay bespoke, because there's nothing to delegate to:
encoding data into valid-but-fake secp256k1 pubkeys (
curve.ts,pubkeys.ts), and the BPUB-specific script shapes and stream formats. - Verified against a real mainnet inscription (see Tests).
Install
npm install @bitfiles/bpubnpm install # dev dependencies only
npm run build # emits dist/
npm testExtract a file from a reveal transaction
By txid, letting the library fetch the transaction:
import { recoverFromTxid } from "@bitfiles/bpub";
const { meta, content, source } = await recoverFromTxid(
"c6c3710169c5d8516cb45a70d2278fdadb04f21c82840703238ae428bbf6197e",
);Or from raw hex you already have:
import { recoverFromRawTransaction, bytesToHex } from "@bitfiles/bpub";
const { meta, content, controlPubkey } = await recoverFromRawTransaction(rawTxHex);
console.log(meta.filename); // "luke.jpg"
console.log(meta.mime); // "image/jpeg"
console.log(meta.size); // 11276
console.log(bytesToHex(meta.sha)); // committed sha256, already verified
console.log(content); // Uint8Array of the original filerecoverFromRawTransaction auto-detects the control pubkey and fails if the
inputs disagree; pass { controlPubkey } (bytes or hex) to pin it. The decoder
verifies the committed length and SHA-256 before returning, so a successful
call means the bytes are intact.
resolveRawTransaction accepts either form, so UIs need only one input:
import { resolveRawTransaction, recoverFromRawTransaction } from "@bitfiles/bpub";
const { hex, source } = await resolveRawTransaction(userInput); // txid or raw hex
const { meta, content } = await recoverFromRawTransaction(hex);resolveRawTransaction and recoverFromTxid accept { chain } ("btc" or
"btcb2", defaulting to "btcb2") to pick which chain to fetch a txid from.
In a browser, hand the result straight to a Blob:
const url = URL.createObjectURL(new Blob([content], { type: meta.mime }));Runnable examples:
examples/extract.mjs— Node CLI, taking a txid, a raw hex string, or a file:node examples/extract.mjs c6c3710169c5d8516cb45a70d2278fdadb04f21c82840703238ae428bbf6197e node examples/extract.mjs --chain=btc c6c3710169c5d8516cb45a70d2278fdadb04f21c82840703238ae428bbf6197e node examples/extract.mjs data/transaction.txt out.jpg
Browser viewer (website/, deployed to https://bpub.bitfiles.io):
website/index.html— viewer with a chain dropdown (btcb2by default) and a single input that accepts a txid or raw transaction hex. It imports the library fromsrc/and is built with Vite:npm run dev # dev server with hot reload npm run build:site # production build into website/dist npm run preview # serve the production buildIt autoloads the sample txid on
btcb2and renders the image. The textbox and chain selection are mirrored into the URL's hash fragment, so any view is shareable, e.g.https://bpub.bitfiles.io/#btcb2/c6c3710169c5d8516cb45a70d2278fdadb04f21c82840703238ae428bbf6197e. "Copy link" copies the current URL.
Encode a file
import { buildInscription, buildFundingTransaction } from "@bitfiles/bpub";
// Redeem scripts + P2WSH scriptPubKeys, ready to fund however you like.
const inscription = await buildInscription(fileBytes, {
mime: "image/jpeg",
filename: "cat.jpg",
compress: true,
controlPubkey, // 33 bytes; you must hold the private key
});
// Or an unsigned single-input funding transaction (v5 requires an owner address).
const funding = await buildFundingTransaction({
data: fileBytes,
mime: "image/jpeg",
filename: "cat.jpg",
compress: true,
controlPubkey,
utxo: { txid, vout: 0, valueSats: 200_000 },
feerate: 5,
changeAddress: "bc1q…",
ownerAddress: "bc1q…",
});Multipart files
A reveal transaction has to stay under the 400,000 WU standardness limit, so one inscription holds about 217 KB of stream. Larger files are split across several part inscriptions and tied together by a manifest inscription, whose txid is the file's link. Both are ordinary v5 inscriptions, so the stream format doesn't change; readers that don't know about manifests just see a JSON file.
import {
MULTIPART_MANIFEST_MIME,
MULTIPART_PART_MIME,
buildInscription,
buildManifest,
encodeManifest,
planMultipart,
} from "@bitfiles/bpub";
// Compresses the whole file once (when that helps), then slices it so each
// part's v5 stream fits in maxStreamBytes.
const plan = await planMultipart(fileBytes, { maxStreamBytes: 217_000 });
const partTxids = [];
for (const part of plan.parts) {
const inscription = await buildInscription(part, { mime: MULTIPART_PART_MIME, controlPubkey });
partTxids.push(await fundAndReveal(inscription)); // your wallet flow
}
const manifest = buildManifest(plan, partTxids, { mime: "video/mp4", filename: "clip.mp4" });
const manifestInscription = await buildInscription(encodeManifest(manifest), {
mime: MULTIPART_MANIFEST_MIME,
compress: true,
controlPubkey,
});Reading one back:
import { isManifestMeta, recoverFromTxid, recoverMultipartFromTxid } from "@bitfiles/bpub";
const single = await recoverFromTxid(txid, { chain: "btc" });
if (isManifestMeta(single.meta)) {
const { meta, content } = await recoverMultipartFromTxid(txid, { chain: "btc" });
}recoverMultipartFromManifest does the same from an already-parsed manifest,
and parseManifest + assembleMultipart do it without any network access.
The manifest (mime application/vnd.bpub.manifest+json) is JSON:
{
"bpub_manifest": 1,
"mime": "video/mp4",
"filename": "clip.mp4",
"size": 1048576,
"sha256": "<sha256 of the reassembled, uncompressed file>",
"compression": "deflate-raw",
"parts": ["<txid>", "<txid>"]
}- Parts are inscribed with mime
application/vnd.bpub.partand no stream compression. Their contents, joined in order, are the file, raw-DEFLATE compressed whencompressionis"deflate-raw". - Each part's stream header already commits to that part's SHA-256, so the
manifest lists only txids. Readers check the reassembled file against the
manifest's
sizeandsha256. - The file's v5 bpub id,
sha256("BPUB5" || sha256 || size_be_8), is derived fromsha256andsize, so a multipart file has the same id it would have had as a single inscription.manifestFileMetareturns it in aBpubMeta. - Parts must be on the same chain as their manifest.
API
| Area | Exports |
| --- | --- |
| Recovery | recoverFromRawTransaction, recoverFromTransaction, recoverFromTxid, decodeOwnerTransfer |
| Fetching | fetchRawTransaction, resolveRawTransaction, recoverFromTxid, recoverMultipartFromTxid, recoverMultipartFromManifest, isTxid, isRawTransactionHex, isChain, CHAIN_SOURCES, DEFAULT_CHAIN |
| Streams | buildStreamV35, buildStreamV4, buildStreamV5, decodeStream, computeBpubV5Id, xorObfuscate |
| Multipart | planMultipart, buildManifest, encodeManifest, parseManifest, assembleMultipart, manifestFileMeta, isManifestMeta, isPartMeta, MULTIPART_MANIFEST_MIME, MULTIPART_PART_MIME, MULTIPART_MANIFEST_VERSION |
| Pubkey coding | encodeStreamToPubkeys, decodePubkeysToStream, chunkDataPubkeys |
| Scripts | buildMultisigScript, parseBpubMultisigScript, buildOwnerRedeemScript, decodeOwnerRedeemScript, p2wshScriptPubKey, p2wpkhScriptPubKey, parseScript, pushData, smallIntFromOp |
| Addresses | addressToScriptPubKey, scriptPubKeyToAddress, ownerH160FromAddress, addressFromOwnerH160, bech32Encode, bech32Decode, encodeSegwitAddress, decodeSegwitAddress |
| Transactions | deserializeTransaction, serializeTransaction, transactionId, prevTxidHex, txidToBytes, encodeVarInt |
| Encoding | buildInscription, buildFundingTransaction |
| Signing (uses @scure/btc-signer) | signFundingTransaction, signRevealTransaction, signOwnerTransferTransaction |
| Fees | estimateFee, estimateFeeFunding, estimateFeeReveal, estimateFeeOwnerTransfer |
| Primitives | sha256, sha256d, hash160, ripemd160, rawDeflate, rawInflate, zlibDeflate, zlibInflate, modPow, sqrtModP, isQuadraticResidue, liftX |
Everything speaks Uint8Array, never Node Buffer. Functions that hash or
compress are async, because Web Crypto and Compression Streams are.
Signing (funding, reveal, ownership transfer)
buildFundingTransaction deliberately returns an unsigned transaction.
Signing it, and building the reveal and ownership-transfer transactions, means
producing valid ECDSA signatures — exactly the kind of code this project
would rather borrow from an audited library than reimplement, which is why
@scure/btc-signer handles it via these three functions:
import {
signFundingTransaction,
signRevealTransaction,
signOwnerTransferTransaction,
} from "@bitfiles/bpub";
// 1. Fund: sign buildFundingTransaction()'s single P2WPKH input.
const funded = await signFundingTransaction({ funding, utxo, privateKey });
// 2. Reveal: spend the funding tx's BPUB multisig outputs with the control
// key, putting each redeem script into the witness.
const revealed = await signRevealTransaction({
inputs: inscription.redeemScripts.map((redeemScript, i) => ({
txid: fundingTxid,
vout: i,
valueSats: 546,
redeemScript,
})),
destinationAddress: "bc1q…",
controlPrivateKey,
feerate: 5,
});
// 3. Transfer v5 ownership to a new owner.
const transferred = await signOwnerTransferTransaction({
bpubId: inscription.bpubId!,
ownerPrivateKey,
utxo: ownerUtxo,
newOwnerAddress: "bc1q…",
feerate: 5,
});Each call returns { transaction, rawHex, txid, feeSats }, ready to
broadcast. The multisig redeem script is a standard 1-of-N bare multisig, so
signRevealTransaction and signFundingTransaction use @scure/btc-signer's
Transaction end to end. The v5 ownership redeem script is a nonstandard
shape (a P2WPKH check wrapped with an extra OP_DROP) that the library's
script recognition doesn't know about, so signOwnerTransferTransaction
computes the BIP-143 sighash and signature with its lower-level pieces and
assembles the witness by hand.
Mapping from the Python library
| bpub.py | here |
| --- | --- |
| txrecover | recoverFromRawTransaction, recoverFromTxid |
| decodetransfer | decodeOwnerTransfer |
| encode / decode | buildStreamV5 / decodeStream |
| txbuild | buildFundingTransaction |
| fundpsbt + signreveal/signrevealutxo | signFundingTransaction, signRevealTransaction |
| ownertransferpsbt | signOwnerTransferTransaction |
| encode_stream_to_pubkeys | encodeStreamToPubkeys |
| decode_pubkeys_to_stream | decodePubkeysToStream |
| build_multisig_script | buildMultisigScript |
| build_owner_redeem_script | buildOwnerRedeemScript |
| owner_h160_from_address | ownerH160FromAddress |
| bech32_to_scriptpubkey | addressToScriptPubKey |
| meta["bpub_version"], meta["bpub_id"] | meta.bpubVersion, meta.bpubId (camelCase) |
Deliberately not ported: the PSBT file format itself, the node RPC
indexer, and the interactive wizard — those need a Bitcoin node or a
human at a terminal, so they stay in the upstream CLI. Signing (fundpsbt,
revealpsbt, signreveal, ownertransferpsbt upstream) is covered, as
plain functions rather than PSBTs — see
Signing above. buildInscription
still returns bare scripts if you'd rather drive signing with your own
tooling (a hardware wallet, a PSBT-based flow, etc.).
Known differences from upstream
- Compressed streams are not byte-identical. Python uses
zlibat level 9; Compression Streams expose no level knob. Both emit valid raw DEFLATE and decode to the same bytes, and the committed SHA-256 andbpub_idare taken over the uncompressed content, so interoperability is unaffected. - v3.5 content is returned as stored. Like upstream,
decodeStreamdoes not inflate legacy v3.5 streams; the TLV header commits to the sha256 of the stored bytes, and there is no flag recording whether they were compressed. liftXfolds upstream'sis_quadratic_residue+sqrt_mod_ppair into one modular exponentiation. It selects exactly the same nonces (verified against on-chain pubkeys in the tests).
Tests
npm testThe suite runs against the real mainnet inscription in data/
(tx c6c37101…bbf6197e),
and asserts more than "it didn't throw":
- the recovered bytes are byte-identical to
data/luke.jpg, with the metadata (luke.jpg,image/jpeg, 11276 bytes) andbpub_idmatching; - re-encoding the recovered stream reproduces all 364 on-chain data pubkeys exactly, which pins down the nonce grinding and curve arithmetic;
- rebuilt redeem scripts match the on-chain witness scripts byte for byte;
- the parsed transaction re-serialises to the input hex and hashes to the published txid;
- RIPEMD-160 and BIP-173/350 reference vectors;
- txid/raw-hex classification and per-chain fetching against a stubbed
fetch(no network calls;BPUB_LIVE=1 npm testadds a live fetch from the default chain); - round-trips for v3.5 / v4 / v5 streams, and a funding transaction spent by a synthetic reveal transaction and read back;
signFundingTransaction,signRevealTransaction, andsignOwnerTransferTransactionproduce transactions thatrecoverFromTransaction/decodeOwnerTransferread back correctly, with signatures independently checked against the BIP-143 sighash with@noble/curves, not just accepted by this library's own (signature-agnostic) decoder.
website/index.html was also manually verified in Chrome on both chains,
including URL/back-button syncing.
License
MIT
