One-directional optical air-gap transport for arbitrary bytes — the reference implementation of the experimental BRC-141 wire protocol: fountain-coded QR parts that survive missed camera frames with no back-channel, zero runtime dependencies, session locking against stray frames, and CRC32 payload integrity.
npm install @bsv/air-gapNo peer dependencies. Browsers and Node.js 22 or newer.
import { AirGapDecoder, AirGapEncoder } from '@bsv/air-gap'
// Sender: display parts. The application owns the sequence number and cadence.
const encoder = new AirGapEncoder(payloadBytes)
const cycle = encoder.blockCount * 64
let seq = 0
setInterval(() => {
renderQrCode(encoder.partAt(seq))
seq = (seq + 1) % cycle // loop — the repeating systematic prefix guarantees recovery
}, 200)
// Receiver: feed every scan in.
const decoder = new AirGapDecoder()
onBarcodeScan(text => {
const { ok, done, have, total } = decoder.accept(text)
if (ok) showProgress(have, total)
if (!done) return
const payload = decoder.message() // verified bytes, or null
if (payload) finish(payload)
})K source blocks and renders part seq as a wire string; pure function of (message, blockBytes, sessionId, seq)K parts are the source blocks verbatim, so one clean camera cycle decodes with zero overheadK + ε distinct parts reconstruct the message, and a looping sender makes recovery certainaccept never throws, never emits partial or unverified bytes, and rejects oversize strings before doing any work@bsv/sdk, no polyfills beyond btoa / atob / crypto.getRandomValuesThe package root provides matching typed entry points for Node.js ESM and
CommonJS consumers, and the published tarball is additionally verified as a
browser artifact: exact-tarball bundling with Vite and esbuild against a
governed size budget (browser-budget.json), plus publint, strict
@arethetypeswrong/core resolution, and clean installs that import and
require every public export. Node.js 22 or newer, evergreen browsers.
const encoder = new AirGapEncoder(payload, { blockBytes: 1200 })
const cycle = encoder.blockCount * 64
let seq = 0
const timer = setInterval(() => renderQrCode(encoder.partAt(seq++ % cycle)), 200)
// Loop until the receiver signals success out-of-band; seq is a finite u32,
// and re-running the systematic prefix is what makes recovery deterministic.const encoder = new AirGapEncoder(shortPayload)
if (encoder.blockCount === 1) renderQrCode(encoder.partAt(0)) // no animation neededimport { isAirGapPart } from '@bsv/air-gap'
onBarcodeScan(text => {
if (!isAirGapPart(text)) return handleOtherQr(text)
decoder.accept(text)
})import { estimatePartCharLength } from '@bsv/air-gap'
estimatePartCharLength(1200) // 1639 characters, exactly
// Compare against BYTE-mode QR capacity (base64url rules out alphanumeric
// mode): version 40 holds 2953 bytes at EC L, 1663 at EC Q.onCancel(() => decoder.reset())seq alone, seeded with the exact u32 product seq × 0x9e3779b1 (Math.imul — float multiplication diverges from seq = 3,393,265)air-gap: + unpadded base64url of a 23-byte big-endian header (ver u8 = 1, sessionId 8 bytes, seq u32, K u16, msgLen u32, crc32 u32) and exactly one blockMAX_BLOCK_BYTES (2048)(sessionId, K, msgLen, crc32); the decoder locks on and only switches after SESSION_SWITCH_PARTS (3) consecutive parts of one new sessionMAX_MESSAGE_BYTES (64 KiB), realistically a few hundred bytes to a few KiBur:), or the legacy bsvpayf2: prefix — none share this wire formatspecs/transport/air-gap-optical.md (normative), registered publicly as BRC-141conformance/vectors/transport/air-gap-optical.json, executed by both the package test suite and the cross-language conformance runner; includes seed-precision boundary vectors at seq = 3,393,265, 0x7fffffff and 0xffffffff0xedb88320, check value 0xCBF43926 for ASCII 123456789PW1, Vault Manager CHUNK and legacy bsvpayf2: (shared optical layer, common wallet-state envelope above it, explicit adapters) is tracked in ts-stack issue #408done before reading — message() returns null until every block is recovered; check done firstmessage() === null as fatal — After a CRC failure it means "keep scanning"; the decoder has already reset itselfseq upward forever — seq is a finite u32 and a receiver may stall on linearly dependent mixes; loop seq over a few multiples of blockCount insteadblockBytes mid-stream — The decoder pins the first accepted payload length and rejects the rest of the sessionestimatePartCharLength against byte-mode tablesAirGapError; validate before constructing