Skip to content

Add base64 chunk encoding (BTC_TX64, protocol v1.1) - #11

Open
pagcoinbr wants to merge 1 commit into
eddieoz:masterfrom
pagcoinbr:feat/base64-chunking
Open

Add base64 chunk encoding (BTC_TX64, protocol v1.1)#11
pagcoinbr wants to merge 1 commit into
eddieoz:masterfrom
pagcoinbr:feat/base64-chunking

Conversation

@pagcoinbr

Copy link
Copy Markdown

Hex spends 2 characters per transaction byte; base64 spends 1.33. A Meshtastic text message is a fixed-size box (233 bytes), so the same 187-character message BTCMesh already ships carries 126 transaction bytes instead of 85.

That matters more than the ratio suggests, because stop-and-wait spends a full round trip per chunk — chunk out, BTC_CHUNK_ACK back — and at LONG_FAST each leg is seconds over a multi-hop mesh.

transaction size hex chunks base64 chunks round trips saved
1-in 1-out P2WPKH 191 B 3 2 1 (33%)
1-in 2-out P2WPKH 222 B 3 2 1 (33%)
2-in 2-out P2WPKH 372 B 5 3 2 (40%)
multi-input 600 B 8 5 3 (37%)
EXAMPLE_RAW_TX in this repo 1227 B 15 10 5 (33%)

Compatibility

New BTC_TX64| prefix alongside BTC_TX|. Since the prefix includes the pipe, "BTC_TX64|..." does not satisfy startswith("BTC_TX|") — a v1.0 relay ignores base64 chunks rather than misparsing them as hex.

The client still defaults to hex. Receivers should upgrade before senders, so this PR makes relays understand base64 without changing what any existing client puts on air. --encoding base64 opts in; flipping the default is a separate call once relays have caught up. If a v1.1 client does hit a v1.0 relay, the symptom is silence rather than a NACK, so the abort path now prints a one-line hint pointing at --encoding hex.

A session whose chunks arrive under both prefixes is rejected rather than reassembled — concatenating hex and base64 fragments would otherwise produce a silently corrupt transaction.

Design notes

  • Decoding lives inside TransactionReassembler, so add_chunk still returns hex and the broadcast path, ACK/NACK handling and history are untouched.
  • The CLI reads the encoding with getattr(args, "encoding", ENCODING_HEX), so btcmesh_gui.py and every existing caller work unchanged.
  • The whole transaction is base64-encoded once and the resulting string is split; it is not a per-chunk encoding. Chunk size is a multiple of 4 so chunks stay whole base64 quanta.
  • Chunk size is 168 base64 chars — deliberately matched to the message length v1.0 already ships rather than pushed to the 233-byte cap, since PKC-encrypted DMs spend part of the payload on the auth tag. BTCMESH_CHUNK_SIZE_B64 overrides it for calibration against a specific radio or region.
  • Drive-by: the CHUNK_SIZE comment said 170 hex chars is 100 bytes; it's 85. tests/test_btcmesh_cli.py:79 already had it right.

Tests

25 new tests in tests/test_base64_chunking.py (round trip, chunk economics, mixed-encoding rejection, corrupt and truncated base64, message length limits, and a no-radio loopback feeding CLI dry-run output straight into the reassembler).

Ran 471 tests in 26.365s
OK (skipped=20)

All 446 pre-existing tests pass unmodified.

Not verified

This has not been flown over the air. Verification was the test suite plus a loopback of CLI dry-run output through the reassembler; an end-to-end send needs two Meshtastic nodes and I have one. Worth a real send against a Heltec pair before flipping any default.

Also untouched deliberately: core/transaction_parser.py is dead code (nothing imports basic_sanity_check), so a malformed transaction still burns the full ARQ cycle before Bitcoin Core rejects it. Happy to send that as a separate PR if useful.

Hex spends 2 characters per transaction byte; base64 spends 1.33. A
Meshtastic text message is a fixed-size box, so the same 187-character
message carries 126 transaction bytes instead of 85. Under stop-and-wait
ARQ every chunk costs a full round trip, so fewer chunks is proportionally
less time on air.

The repo's own EXAMPLE_RAW_TX goes from 15 chunks to 10.

New BTC_TX64| prefix alongside BTC_TX|. Because the prefix includes the
pipe, a v1.0 relay ignores base64 chunks rather than misparsing them.
Servers accept both encodings; the client defaults to hex so relays can be
upgraded before senders. Sessions that mix encodings are rejected instead
of reassembled into a silently corrupt transaction.

Decoding happens inside TransactionReassembler, so add_chunk still returns
hex and the broadcast path is unchanged. The CLI reads the encoding via
getattr, so the GUI and existing callers need no changes.

Chunk size is 168 base64 chars (a multiple of 4, so chunks stay whole
base64 quanta), matching the message length v1.0 already ships rather than
pushing to the 233-byte radio cap, since PKC-encrypted direct messages
spend part of the payload on the auth tag. Override with
BTCMESH_CHUNK_SIZE_B64 to calibrate for a specific radio or region.

Also corrects the CHUNK_SIZE comment: 170 hex chars is 85 bytes, not 100.
@pagcoinbr

Copy link
Copy Markdown
Author

Partial over-the-air result — the "not verified" note above is now half-resolved.

Measured on a real link: two Heltec V3 / WSL V3 nodes, region ANZ, LONG_FAST, 0 hops, SNR ~5.75/6.75, firmware 2.7.11, PKC-encrypted direct messages.

I sent BTC_TX64-shaped direct messages at three sizes with wantAck=True and read the routing ACK:

message length delivered routing ACK
187 chars (what this PR generates) yes NONE (success)
200 chars yes NONE
233 chars (Constants.DATA_PAYLOAD_LEN) yes NONE

So the 187-character chunks this PR produces have real headroom, and PKC does not eat into the payload the way I assumed when picking the size.

I am deliberately not raising CHUNK_SIZE_B64 on the strength of this. The 233-byte cap is a firmware constant rather than a link property, so what actually varies between deployments is reliability, not the ceiling — and a longer packet spends more time on air, so it is likelier to be lost on a marginal link. Under stop-and-wait a lost chunk costs a full retry, which can easily exceed what the extra density buys. The conservative default plus BTCMESH_CHUNK_SIZE_B64 for operators who know their link seems like the right split, but if you would rather push the default to 208 chars (156 tx bytes/chunk, which keeps the 3-digit-counter header inside the cap), say so and I will change it — that would take the repo's own EXAMPLE_RAW_TX from 10 chunks to 8.

Still not verified: a full session end to end (chunk → reassemble → sendrawtransaction). The relay-side node here is stuck in a reboot loop — it ACKs over the air but will not hold a USB serial connection long enough to run btcmesh_server.py. I did not want to leave the impression that the whole path had been exercised when only the transport layer has.

@pagcoinbr

Copy link
Copy Markdown
Author

Correction — please disregard the over-the-air table in my previous comment. It does not show what I said it showed.

I treated the onResponse routing packet as a delivery confirmation from the destination. It was not. Inspecting the full packet, it arrives with from set to the sending node's own number, and with hopStart, hopLimit, rxSnr and rxRssi all None — the signature of a locally generated packet that never crossed the radio.

Checking the rest of the setup after that:

  • The sending node heard zero packets from any node — no replies, no telemetry, no position broadcasts — during a 95-second receive-only window, immediately after transmitting on the primary channel.
  • Its node database lists the intended destination with lastHeard decoding to 2025-11-01, nine months ago. The hopsAway: 0 entries I relied on are stale records from when both radios were presumably side by side, not current reachability.

So the two nodes are not in contact, and nothing was delivered. The only claim those probes actually support is that sendText() accepted a 233-character payload without raising — a check against DATA_PAYLOAD_LEN at the library boundary, not an on-air result. In particular my statement that "PKC does not eat into the payload the way I assumed" is unsupported; I have no evidence either way, so the conservative chunk size in this PR should stand on its original reasoning rather than on that comment.

Withdrawing the suggestion to raise the default to 208 chars until someone can produce a real two-node measurement.

Nothing in the PR diff itself changed — it was never sized from that data, and the 471 tests are unaffected. The "not verified over the air" caveat in the description remains accurate, and is now simply complete rather than partial.

@pagcoinbr

Copy link
Copy Markdown
Author

Retracting my previous comment — the measurement in it is wrong. Apologies for the noise.

I read sendText(..., wantAck=True, onResponse=cb) firing with error_reason: NONE as delivery confirmation. It is not. Inspecting the full response packet shows it originates from the local node:

from      : 0xb03d645c   <- the sending node itself, not the destination
hopStart  : None   hopLimit: None
rxSnr     : None   rxRssi : None
portnum   : ROUTING_APP

All hop and signal fields are unpopulated, which is what a locally generated packet looks like. Nothing crossed the air.

Two further checks confirm it. The receiving node's lastHeard for the destination decodes to 2025-11-01, nine months ago — the hopsAway: 0 entries I relied on are stale database records from when both radios sat on the same desk. And a broadcast on the primary channel followed by a 95-second listen returned zero packets from any node: no reply, no telemetry, no position beacons. That node is not in contact with any mesh.

So the three probes prove only that sendText() accepted a 233-character payload without raising — a DATA_PAYLOAD_LEN check at the library boundary. They say nothing about PKC overhead or on-air behaviour.

Withdrawing the suggestion to raise the default to 208 chars. It rested entirely on that bogus result. CHUNK_SIZE_B64 = 168 should stay where it is until someone runs a genuine two-node test.

The diff itself is unaffected — the chunk size was chosen from Constants.DATA_PAYLOAD_LEN and the v1.0 message envelope, before any of this, and no code changed on the basis of the retracted numbers. The "not verified over the air" caveat in the PR description simply stands in full rather than in part.

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