diff --git a/btcmesh_cli.py b/btcmesh_cli.py index 1b6cb47..c3011f1 100644 --- a/btcmesh_cli.py +++ b/btcmesh_cli.py @@ -1,8 +1,15 @@ #!/usr/bin/env python3 +import base64 import uuid import os from core.config_loader import get_meshtastic_serial_port from core.logger_setup import setup_logger +from core.reassembler import ( + CHUNK_PREFIX, + CHUNK_PREFIX_B64, + ENCODING_B64, + ENCODING_HEX, +) import time from pubsub import pub import queue @@ -17,7 +24,16 @@ def is_valid_hex(s): return False -CHUNK_SIZE = 170 # hex chars (100 bytes) +CHUNK_SIZE = 170 # hex chars (85 tx bytes) + +# base64 spends 1.33 chars per transaction byte instead of hex's 2, so the same +# 187-character message carries 126 tx bytes instead of 85. Kept a multiple of 4 +# so every chunk is whole base64 quanta. Override to calibrate for a radio or +# region that leaves less room in the 233-byte Meshtastic payload. +CHUNK_SIZE_B64 = int(os.environ.get("BTCMESH_CHUNK_SIZE_B64", "168")) +assert ( + CHUNK_SIZE_B64 > 0 and CHUNK_SIZE_B64 % 4 == 0 +), "BTCMESH_CHUNK_SIZE_B64 must be a positive multiple of 4" def chunk_transaction(tx_hex, chunk_size): @@ -25,6 +41,13 @@ def chunk_transaction(tx_hex, chunk_size): return [tx_hex[i : i + chunk_size] for i in range(0, len(tx_hex), chunk_size)] +def encode_payload(tx_hex, encoding): + """Encode a raw transaction hex string for transport over the mesh.""" + if encoding == ENCODING_B64: + return base64.b64encode(bytes.fromhex(tx_hex)).decode("ascii") + return tx_hex + + def generate_session_id(): """Generate a unique 5-character hex session ID.""" return uuid.uuid4().hex[:5] @@ -207,6 +230,16 @@ def cli_main( action="store_true", help="Only parse and print arguments, do not send", ) + parser.add_argument( + "--encoding", + choices=[ENCODING_HEX, ENCODING_B64], + default=ENCODING_HEX, + help=( + "Payload encoding. 'base64' (protocol v1.1) carries ~48%% more " + "transaction per message and needs fewer ACK round trips, but " + "the relay must understand BTC_TX64. Default: hex (v1.0)." + ), + ) args = parser.parse_args() tx_hex = args.tx if len(tx_hex) % 2 != 0 or not re.fullmatch(r"[0-9a-fA-F]+", tx_hex): @@ -216,15 +249,21 @@ def cli_main( ) raise ValueError("Invalid raw transaction hex") logger = injected_logger if injected_logger is not None else cli_logger + # Callers that build their own args object (the GUI, tests) predate the + # flag, so default to the v1.0 encoding rather than requiring the attribute. + encoding = getattr(args, "encoding", None) or ENCODING_HEX + chunk_prefix = CHUNK_PREFIX_B64 if encoding == ENCODING_B64 else CHUNK_PREFIX + chunk_size = CHUNK_SIZE_B64 if encoding == ENCODING_B64 else CHUNK_SIZE + wire_payload = encode_payload(tx_hex, encoding) if args.dry_run: print(f"Arguments parsed successfully:") print(f" Destination: {args.destination}") print(f" Raw TX Hex: {args.tx}") session_id = getattr(args, "session_id", None) or generate_session_id() - chunks = chunk_transaction(tx_hex, CHUNK_SIZE) + chunks = chunk_transaction(wire_payload, chunk_size) total_chunks = len(chunks) for i, payload in enumerate(chunks, 1): - print(f"BTC_TX|{session_id}|{i}/{total_chunks}|{payload}") + print(f"{chunk_prefix}{session_id}|{i}/{total_chunks}|{payload}") return 0 iface = ( injected_iface @@ -247,7 +286,7 @@ def cli_main( ) # Set global session ID _expected_sender_node_id = args.destination # Set global expected sender - chunks = chunk_transaction(tx_hex, CHUNK_SIZE) + chunks = chunk_transaction(wire_payload, chunk_size) total_chunks = len(chunks) # Determine if we are using injected receiver or setting up our own @@ -288,7 +327,7 @@ def cli_main( current_retries = 0 while current_retries < MAX_RETRIES: - msg_to_send = f"BTC_TX|{_current_session_id}|{chunk_num}/{total_chunks}|{payload}" + msg_to_send = f"{chunk_prefix}{_current_session_id}|{chunk_num}/{total_chunks}|{payload}" try: iface.sendText(text=msg_to_send, destinationId=args.destination) print( @@ -607,6 +646,13 @@ def cli_main( logger.info( f"SystemExit caught in cli_main for session {_current_session_id} with code {e.code}" ) + if e.code and encoding == ENCODING_B64: + # A relay running protocol v1.0 ignores BTC_TX64 outright, so + # the symptom is silence rather than a NACK. + print( + "No ACK from the relay. If it runs protocol v1.0 (hex only), " + "retry with --encoding hex." + ) return e.code except Exception as e: logger.error( diff --git a/btcmesh_server.py b/btcmesh_server.py index 07e181a..1070527 100644 --- a/btcmesh_server.py +++ b/btcmesh_server.py @@ -22,6 +22,8 @@ ReassemblyError, CHUNK_PREFIX, CHUNK_PARTS_DELIMITER, + MismatchedEncodingError, + match_chunk_prefix, ) from core.rpc_client import BitcoinRPCClient from core.transaction_history import TransactionHistory @@ -82,8 +84,9 @@ def _format_node_id(node_id_val: Any) -> Optional[str]: def _extract_session_id_from_raw_chunk(message_text: str) -> Optional[str]: """Rudimentary attempt to extract tx_session_id for NACKs if full parsing fails.""" try: - if message_text.startswith(CHUNK_PREFIX): - parts = message_text[len(CHUNK_PREFIX) :].split(CHUNK_PARTS_DELIMITER) + matched = match_chunk_prefix(message_text) + if matched is not None: + parts = matched[0].split(CHUNK_PARTS_DELIMITER) if len(parts) > 0 and parts[0]: return parts[0] except Exception: # pylint: disable=broad-except @@ -223,13 +226,12 @@ def on_receive_text_message( f"Direct text from {sender_node_id_for_reply}: '{message_text}'" ) - if message_text.startswith(CHUNK_PREFIX): + matched_chunk = match_chunk_prefix(message_text) + if matched_chunk is not None: # Log every received chunk for diagnostics try: # Always parse session_id and chunk_info for NACK, even if add_chunk fails - parts = message_text[len(CHUNK_PREFIX) :].split( - CHUNK_PARTS_DELIMITER - ) + parts = matched_chunk[0].split(CHUNK_PARTS_DELIMITER) session_id = parts[0] if len(parts) > 0 else "UNKNOWN" chunk_info = parts[1] if len(parts) > 1 else "UNKNOWN" except Exception: @@ -351,7 +353,11 @@ def on_receive_text_message( error=str(error), raw_tx=reassembled_hex ) - except (InvalidChunkFormatError, MismatchedTotalChunksError) as e: + except ( + InvalidChunkFormatError, + MismatchedTotalChunksError, + MismatchedEncodingError, + ) as e: tx_session_id_for_nack = ( session_id or _extract_session_id_from_raw_chunk(message_text) diff --git a/core/reassembler.py b/core/reassembler.py index 391cd35..3266872 100644 --- a/core/reassembler.py +++ b/core/reassembler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import time from typing import Dict, Optional, Tuple, List, Any @@ -7,13 +8,39 @@ # Constants for chunk parsing CHUNK_PREFIX = "BTC_TX|" +CHUNK_PREFIX_B64 = "BTC_TX64|" CHUNK_PARTS_DELIMITER = "|" CHUNK_INDEX_TOTAL_DELIMITER = "/" +# Payload encodings. Hex is protocol v1.0; base64 (v1.1) carries ~48% more +# transaction per message because it spends 1.33 chars per byte instead of 2. +ENCODING_HEX = "hex" +ENCODING_B64 = "base64" + # Default timeout for reassembly sessions in seconds DEFAULT_REASSEMBLY_TIMEOUT_SECONDS = 5 * 60 # 5 minutes +def match_chunk_prefix(message_text: str) -> Optional[Tuple[str, str]]: + """ + Identify a transaction chunk message and its payload encoding. + + Returns (remainder_after_prefix, encoding) or None if the message is not a + transaction chunk. + + Note that "BTC_TX64|..." deliberately does not match CHUNK_PREFIX + ("BTC_TX|", with the pipe), so a v1.0 relay ignores base64 chunks outright + instead of misparsing them as hex. + """ + for prefix, encoding in ( + (CHUNK_PREFIX_B64, ENCODING_B64), + (CHUNK_PREFIX, ENCODING_HEX), + ): + if message_text.startswith(prefix): + return message_text[len(prefix) :], encoding + return None + + class ReassemblyError(Exception): """Custom exception for reassembly errors.""" @@ -32,6 +59,12 @@ class DuplicateChunkError(ReassemblyError): pass +class MismatchedEncodingError(ReassemblyError): + """Raised when the payload encoding changes mid-session.""" + + pass + + class InvalidChunkFormatError(ReassemblyError): """Raised when a chunk format is invalid.""" @@ -70,25 +103,32 @@ def __init__(self, timeout_seconds: int = DEFAULT_REASSEMBLY_TIMEOUT_SECONDS): f"TransactionReassembler initialized with timeout: {timeout_seconds}s" ) - def _parse_chunk(self, message_text: str) -> Tuple[str, int, int, str]: + def _parse_chunk(self, message_text: str) -> Tuple[str, int, int, str, str]: """ Parses a raw message string to extract transaction chunk components. - Format: "BTC_TX||/|" + Formats: + "BTC_TX||/|" + "BTC_TX64||/|" Args: message_text: The raw text message received. Returns: - A tuple containing (tx_session_id, chunk_num, total_chunks, hex_payload_part). + A tuple containing + (tx_session_id, chunk_num, total_chunks, payload_part, encoding). Raises: InvalidChunkFormatError: If the message_text does not conform to the expected format. """ - if not message_text.startswith(CHUNK_PREFIX): - raise InvalidChunkFormatError(f"Message does not start with {CHUNK_PREFIX}") + matched = match_chunk_prefix(message_text) + if matched is None: + raise InvalidChunkFormatError( + f"Message does not start with {CHUNK_PREFIX} or {CHUNK_PREFIX_B64}" + ) + remainder, encoding = matched - parts = message_text[len(CHUNK_PREFIX) :].split(CHUNK_PARTS_DELIMITER) + parts = remainder.split(CHUNK_PARTS_DELIMITER) if len(parts) != 3: raise InvalidChunkFormatError( f"Message does not have 3 parts after prefix: {parts}" @@ -125,7 +165,7 @@ def _parse_chunk(self, message_text: str) -> Tuple[str, int, int, str]: f"Invalid chunk numbering: {chunk_num}/{total_chunks}" ) - return tx_session_id, chunk_num, total_chunks, hex_payload_part + return tx_session_id, chunk_num, total_chunks, hex_payload_part, encoding def add_chunk(self, sender_id: Any, message_text: str) -> Optional[str]: """ @@ -159,7 +199,7 @@ def add_chunk(self, sender_id: Any, message_text: str) -> Optional[str]: session_key = sender_id try: - tx_session_id, chunk_num, total_chunks, hex_payload_part = ( + tx_session_id, chunk_num, total_chunks, hex_payload_part, encoding = ( self._parse_chunk(message_text) ) except InvalidChunkFormatError as e: @@ -184,12 +224,24 @@ def add_chunk(self, sender_id: Any, message_text: str) -> Optional[str]: sender_sessions[tx_session_id] = { "chunks": {}, "total_chunks": total_chunks, + "encoding": encoding, "last_update_time": current_time, "sender_id_str": str(sender_id), # Store original sender_id for replies } session_data = sender_sessions[tx_session_id] + # Check for consistency in payload encoding. Concatenating hex and + # base64 fragments would silently produce a corrupt transaction. + if session_data.get("encoding", ENCODING_HEX) != encoding: + error_msg = ( + f"{log_ctx} Mismatched encoding. Expected " + f"{session_data.get('encoding')}, got {encoding}. Discarding session." + ) + server_logger.error(error_msg) + del sender_sessions[tx_session_id] + raise MismatchedEncodingError(error_msg) + # Check for consistency in total_chunks if session_data["total_chunks"] != total_chunks: error_msg = ( @@ -237,6 +289,22 @@ def add_chunk(self, sender_id: Any, message_text: str) -> Optional[str]: reassembled_hex += session_data["chunks"][i] + # base64 sessions carry the transaction more densely on air; decode + # here so callers always receive hex and never learn about encoding. + if session_data.get("encoding") == ENCODING_B64: + try: + reassembled_hex = base64.b64decode( + reassembled_hex, validate=True + ).hex() + except ValueError as e: # binascii.Error subclasses ValueError + error_msg = ( + f"{log_ctx} Reassembly failed: payload is not valid " + f"base64 ({e})." + ) + server_logger.error(error_msg) + del sender_sessions[tx_session_id] + raise InvalidChunkFormatError(error_msg) + server_logger.info(f"{log_ctx} Reassembly successful.") # Clean up completed session del sender_sessions[tx_session_id] diff --git a/project/protocol_spec.md b/project/protocol_spec.md index 2b60195..94c9b3c 100644 --- a/project/protocol_spec.md +++ b/project/protocol_spec.md @@ -1,22 +1,43 @@ # BTCMesh Protocol Specification -Version 1.0 — February 2026 +Version 1.1 — August 2026 ## Overview BTCMesh uses a stop-and-wait ARQ protocol to reliably transmit Bitcoin raw transactions over LoRa Meshtastic direct messages. Transactions are chunked into small pieces that fit within LoRa payload limits, sent one at a time with acknowledgment, and reassembled by the server for broadcast to the Bitcoin network. +Chunks carry the transaction in one of two encodings, selected by the message prefix: + +| Encoding | Prefix | Chars per tx byte | Tx bytes per message | Since | +|----------|--------|-------------------|----------------------|-------| +| hex | `BTC_TX` | 2.00 | 85 | 1.0 | +| base64 | `BTC_TX64` | 1.33 | 126 | 1.1 | + +Both fit the same 187-character message. Because a Meshtastic text message is a fixed-size box, base64 carries ~48% more transaction per packet, and since stop-and-wait spends a full round trip per chunk, fewer chunks means proportionally less time on air. + +| Transaction | hex chunks | base64 chunks | +|-------------|-----------|---------------| +| 191 B (1-in 1-out P2WPKH) | 3 | 2 | +| 222 B (1-in 2-out P2WPKH) | 3 | 2 | +| 372 B (2-in 2-out P2WPKH) | 5 | 3 | +| 600 B (multi-input) | 8 | 5 | + +### Compatibility + +`BTC_TX64|` does not match `BTC_TX|` (the prefix includes the pipe), so a v1.0 relay ignores base64 chunks rather than misparsing them. Servers accept both encodings; clients default to hex so that relays can be upgraded before senders. A session must not mix encodings — see `BTC_NACK` below. + ## Protocol Flow BTCMesh Protocol Flow ## Message Types -### 1. BTC_TX (Client → Server) +### 1. BTC_TX / BTC_TX64 (Client → Server) -Transaction chunk containing a fragment of the raw transaction hex. +Transaction chunk containing a fragment of the encoded raw transaction. **Format:** `BTC_TX||/|` +**Format:** `BTC_TX64||/|` | Field | Type | Description | |-------|------|-------------| @@ -24,8 +45,13 @@ Transaction chunk containing a fragment of the raw transaction hex. | `chunk_number` | integer | 1-indexed chunk number | | `total_chunks` | integer | Total number of chunks in session | | `hex_payload` | string | Fragment of raw transaction hex | +| `base64_payload` | string | Fragment of the base64 encoding of the raw transaction | -**Example:** `BTC_TX|a1b2c|1/3|02000000000108bf2c7d...` +**Examples:** +- `BTC_TX|a1b2c|1/3|02000000000108bf2c7d...` +- `BTC_TX64|a1b2c|1/2|AgAAAAABCL8sfaXvry...` + +The base64 payload is produced by encoding the **whole** transaction once and then splitting the resulting string; it is not a per-chunk encoding. The server concatenates the fragments in order and decodes once. Chunk size is a multiple of 4, so every chunk except possibly the last is also independently decodable. ### 2. BTC_CHUNK_ACK (Server → Client) @@ -75,13 +101,16 @@ Reports an error during reassembly, validation, or broadcast. **Example:** `BTC_NACK|a1b2c|ERROR|Insufficient fee` +A session whose chunks arrive under more than one prefix (`BTC_TX` and `BTC_TX64`) is discarded and NACKed rather than reassembled: concatenating hex and base64 fragments would otherwise yield a silently corrupt transaction. Base64 that fails to decode after reassembly is reported the same way. + Total NACK message length is capped at 200 characters to fit LoRa payload constraints. ## Constants | Constant | Value | Description | |----------|-------|-------------| -| Chunk size | 170 hex chars (85 bytes) | Maximum hex payload per chunk | +| Chunk size (hex) | 170 hex chars (85 bytes) | Maximum hex payload per chunk | +| Chunk size (base64) | 168 base64 chars (126 bytes) | Maximum base64 payload per chunk; multiple of 4. Override with `BTCMESH_CHUNK_SIZE_B64` | | Session ID length | 5 hex chars | Random UUID-derived identifier | | ACK timeout | 30 seconds | Client waits this long for server ACK | | Retry timeout | 10 seconds | Client waits before retrying | @@ -131,17 +160,29 @@ The server condenses RPC error messages for LoRa size constraints. Common mappin | fee is too high | Fee too high | | absurdly-high-fee | Absurd fee | -For reassembly errors (InvalidChunkFormat, MismatchedTotalChunks), the error type and detail are included, truncated to fit the 200-character NACK limit. +For reassembly errors (InvalidChunkFormat, MismatchedTotalChunks, MismatchedEncoding), the error type and detail are included, truncated to fit the 200-character NACK limit. ## Chunk Sizing -Each Meshtastic text message has a payload limit. The chunk format includes overhead: +Each Meshtastic text message has a payload limit of 233 bytes (`Constants.DATA_PAYLOAD_LEN`). The chunk format includes overhead: ``` BTC_TX|<5 chars>|/| +BTC_TX64|<5 chars>|/| ``` -With a 5-char session ID and typical chunk numbering (e.g. `12/15`), overhead is ~20 characters. The 170 hex-char payload keeps total message size well within Meshtastic payload limits. +With a 5-char session ID, header overhead is 17 characters for `BTC_TX` and 19 for `BTC_TX64` at single-digit chunk counts, growing by 2 characters per extra digit in the counter. + +Both encodings are sized to the same 187-character message: + +| | payload chars | header | total | tx bytes carried | +|---|---|---|---|---| +| hex | 170 | 17 | 187 | 85 | +| base64 | 168 | 19 | 187 | 126 | + +Transactions needing more than 9 chunks widen the counter; at 100+ chunks (≈12.6 KB of transaction) a base64 message reaches 191 characters, still 42 bytes below the radio limit. + +The base64 chunk size is deliberately conservative — it matches the message length v1.0 already ships in production rather than pushing to the 233-byte cap, since PKC-encrypted direct messages consume part of the payload for the authentication tag. Set `BTCMESH_CHUNK_SIZE_B64` (a positive multiple of 4) to calibrate for a specific radio, region, or channel configuration. ## Multiple Concurrent Sessions diff --git a/tests/test_base64_chunking.py b/tests/test_base64_chunking.py new file mode 100644 index 0000000..bb4d511 --- /dev/null +++ b/tests/test_base64_chunking.py @@ -0,0 +1,275 @@ +"""Tests for base64 chunk encoding (protocol v1.1, BTC_TX64). + +Hex spends 2 characters per transaction byte; base64 spends 1.33. Since a +Meshtastic text message is a fixed-size box, base64 carries ~48% more +transaction per packet, and under stop-and-wait ARQ every packet removed is a +full round trip removed. + +These tests cover both directions (client chunking, server reassembly) and +assert that v1.0 hex behaviour is untouched. +""" + +import base64 +import unittest +from unittest.mock import patch + +from btcmesh_cli import ( + CHUNK_SIZE, + CHUNK_SIZE_B64, + chunk_transaction, + cli_main, + encode_payload, +) +from core.reassembler import ( + CHUNK_PREFIX, + CHUNK_PREFIX_B64, + ENCODING_B64, + ENCODING_HEX, + InvalidChunkFormatError, + MismatchedEncodingError, + TransactionReassembler, + match_chunk_prefix, +) + +# Meshtastic Constants.DATA_PAYLOAD_LEN +MESHTASTIC_PAYLOAD_LIMIT = 233 +# Longest message v1.0 already ships in production: "BTC_TX|xxxxx|1/3|" + 170 +V1_ENVELOPE = 187 + + +def tx_of_size(n_bytes, fill="ab"): + """Synthetic raw transaction hex of exactly n_bytes.""" + return (fill * n_bytes)[: n_bytes * 2] + + +def build_messages(tx_hex, session_id="a1b2c", encoding=ENCODING_B64): + """Client side: encode, chunk, and frame as wire messages.""" + prefix = CHUNK_PREFIX_B64 if encoding == ENCODING_B64 else CHUNK_PREFIX + size = CHUNK_SIZE_B64 if encoding == ENCODING_B64 else CHUNK_SIZE + chunks = chunk_transaction(encode_payload(tx_hex, encoding), size) + total = len(chunks) + return [ + f"{prefix}{session_id}|{i}/{total}|{payload}" + for i, payload in enumerate(chunks, 1) + ] + + +class TestPrefixMatching(unittest.TestCase): + def test_hex_prefix(self): + self.assertEqual( + match_chunk_prefix("BTC_TX|a1b2c|1/2|deadbeef"), + ("a1b2c|1/2|deadbeef", ENCODING_HEX), + ) + + def test_base64_prefix(self): + self.assertEqual( + match_chunk_prefix("BTC_TX64|a1b2c|1/2|3q2+7w=="), + ("a1b2c|1/2|3q2+7w==", ENCODING_B64), + ) + + def test_base64_prefix_is_not_confused_for_hex(self): + # "BTC_TX64|..." must NOT satisfy startswith("BTC_TX|"), otherwise a + # v1.0 relay would misparse base64 as hex instead of ignoring it. + self.assertFalse("BTC_TX64|a1b2c|1/1|3q2+7w==".startswith(CHUNK_PREFIX)) + + def test_non_chunk_returns_none(self): + self.assertIsNone(match_chunk_prefix("hello there")) + self.assertIsNone(match_chunk_prefix("BTC_ACK|a1b2c|SUCCESS|TXID:ab")) + + +class TestEncodePayload(unittest.TestCase): + def test_hex_is_passthrough(self): + tx_hex = tx_of_size(64) + self.assertEqual(encode_payload(tx_hex, ENCODING_HEX), tx_hex) + + def test_base64_roundtrip(self): + tx_hex = tx_of_size(191) + encoded = encode_payload(tx_hex, ENCODING_B64) + self.assertEqual(base64.b64decode(encoded).hex(), tx_hex) + + def test_base64_is_denser_than_hex(self): + tx_hex = tx_of_size(300) + self.assertLess(len(encode_payload(tx_hex, ENCODING_B64)), len(tx_hex)) + + def test_chunk_size_is_whole_base64_quanta(self): + # Each chunk must be a multiple of 4 chars so it decodes independently. + self.assertEqual(CHUNK_SIZE_B64 % 4, 0) + + +class TestBase64Reassembly(unittest.TestCase): + def setUp(self): + self.reassembler = TransactionReassembler(timeout_seconds=60) + + def test_roundtrip_returns_original_hex(self): + tx_hex = tx_of_size(372) + messages = build_messages(tx_hex) + result = None + for msg in messages: + result = self.reassembler.add_chunk("!sender01", msg) + self.assertEqual(result, tx_hex) + + def test_intermediate_chunks_return_none(self): + messages = build_messages(tx_of_size(372)) + self.assertGreater(len(messages), 1) + for msg in messages[:-1]: + self.assertIsNone(self.reassembler.add_chunk("!sender01", msg)) + + def test_single_chunk_transaction(self): + tx_hex = tx_of_size(100) + messages = build_messages(tx_hex) + self.assertEqual(len(messages), 1) + self.assertEqual(self.reassembler.add_chunk("!sender01", messages[0]), tx_hex) + + def test_hex_sessions_still_work(self): + """v1.0 regression: a hex client must be unaffected.""" + tx_hex = tx_of_size(372) + result = None + for msg in build_messages(tx_hex, encoding=ENCODING_HEX): + result = self.reassembler.add_chunk("!oldclient", msg) + self.assertEqual(result, tx_hex) + + def test_hex_and_base64_senders_concurrently(self): + hex_tx, b64_tx = tx_of_size(200), tx_of_size(260) + hex_msgs = build_messages(hex_tx, "hexse", ENCODING_HEX) + b64_msgs = build_messages(b64_tx, "b64se", ENCODING_B64) + hex_result = b64_result = None + # Interleave the two sessions from different senders. + for h, b in zip(hex_msgs, b64_msgs): + hex_result = self.reassembler.add_chunk("!hexnode", h) or hex_result + b64_result = self.reassembler.add_chunk("!b64node", b) or b64_result + for h in hex_msgs[len(b64_msgs):]: + hex_result = self.reassembler.add_chunk("!hexnode", h) or hex_result + self.assertEqual(hex_result, hex_tx) + self.assertEqual(b64_result, b64_tx) + + def test_mixed_encoding_in_one_session_is_rejected(self): + tx_hex = tx_of_size(372) + b64_msgs = build_messages(tx_hex, "mixed", ENCODING_B64) + hex_msgs = build_messages(tx_hex, "mixed", ENCODING_HEX) + self.reassembler.add_chunk("!sender01", b64_msgs[0]) + with self.assertRaises(MismatchedEncodingError): + self.reassembler.add_chunk("!sender01", hex_msgs[1]) + + def test_corrupt_base64_raises_invalid_chunk_format(self): + tx_hex = tx_of_size(100) + msg = build_messages(tx_hex)[0] + head, _, payload = msg.rpartition("|") + corrupt = f"{head}|{'!' * len(payload)}" + with self.assertRaises(InvalidChunkFormatError): + self.reassembler.add_chunk("!sender01", corrupt) + + def test_truncated_base64_raises_invalid_chunk_format(self): + tx_hex = tx_of_size(100) + msg = build_messages(tx_hex)[0] + head, _, payload = msg.rpartition("|") + with self.assertRaises(InvalidChunkFormatError): + self.reassembler.add_chunk("!sender01", f"{head}|{payload[:-3]}") + + +class TestChunkEconomics(unittest.TestCase): + """The whole point of the change: fewer packets, therefore fewer round trips.""" + + CASES = [ + # (tx bytes, description, expected hex chunks, expected base64 chunks) + (191, "1-in 1-out P2WPKH", 3, 2), + (222, "1-in 2-out P2WPKH", 3, 2), + (372, "2-in 2-out P2WPKH", 5, 3), + (600, "larger multi-input", 8, 5), + ] + + def test_chunk_counts(self): + for size, desc, want_hex, want_b64 in self.CASES: + tx_hex = tx_of_size(size) + with self.subTest(tx=desc): + self.assertEqual( + len(build_messages(tx_hex, encoding=ENCODING_HEX)), want_hex + ) + self.assertEqual( + len(build_messages(tx_hex, encoding=ENCODING_B64)), want_b64 + ) + + def test_base64_never_needs_more_chunks_than_hex(self): + for size in range(1, 1200, 7): + tx_hex = tx_of_size(size) + with self.subTest(tx_bytes=size): + self.assertLessEqual( + len(build_messages(tx_hex, encoding=ENCODING_B64)), + len(build_messages(tx_hex, encoding=ENCODING_HEX)), + ) + + +class TestMessageFitsOnAir(unittest.TestCase): + def test_common_transactions_stay_within_v1_envelope(self): + # Up to 9 chunks the chunk counter is 1 digit, so messages are exactly + # as long as the ones v1.0 already ships. + for size in (191, 222, 372, 600, 1100): + for msg in build_messages(tx_of_size(size)): + with self.subTest(tx_bytes=size): + self.assertLessEqual(len(msg), V1_ENVELOPE) + + def test_large_transactions_stay_well_under_the_radio_limit(self): + # 3-digit chunk counters widen the header by 4 chars. + for msg in build_messages(tx_of_size(20000)): + self.assertLessEqual(len(msg), MESHTASTIC_PAYLOAD_LIMIT) + + def test_hex_messages_unchanged(self): + for msg in build_messages(tx_of_size(600), encoding=ENCODING_HEX): + self.assertLessEqual(len(msg), V1_ENVELOPE) + + +class TestCliEncodingFlag(unittest.TestCase): + DEST = "!abcdef12" + + def _dry_run(self, tx_hex, **extra): + attrs = {"destination": self.DEST, "tx": tx_hex, "dry_run": True} + attrs.update(extra) + Args = type("Args", (), attrs) + with patch("builtins.print") as mock_print: + code = cli_main(args=Args) + self.assertEqual(code, 0) + return [str(c.args[0]) for c in mock_print.call_args_list] + + def test_default_is_hex_so_v1_relays_keep_working(self): + """Receivers upgrade before senders: the client default stays v1.0.""" + tx_hex = tx_of_size(200) + lines = self._dry_run(tx_hex) + chunk_lines = [ln for ln in lines if ln.startswith(CHUNK_PREFIX)] + self.assertTrue(chunk_lines) + self.assertFalse([ln for ln in lines if ln.startswith(CHUNK_PREFIX_B64)]) + self.assertIn(tx_hex[:CHUNK_SIZE], chunk_lines[0]) + + def test_base64_opt_in_emits_btc_tx64(self): + tx_hex = tx_of_size(372) + lines = self._dry_run(tx_hex, encoding=ENCODING_B64) + chunk_lines = [ln for ln in lines if ln.startswith(CHUNK_PREFIX_B64)] + self.assertEqual(len(chunk_lines), 3) + for i, line in enumerate(chunk_lines, 1): + self.assertIn(f"|{i}/3|", line) + + def test_opt_in_output_reassembles_back_to_the_original(self): + """End-to-end without a radio: CLI output straight into the server.""" + tx_hex = tx_of_size(600) + lines = self._dry_run(tx_hex, encoding=ENCODING_B64) + reassembler = TransactionReassembler(timeout_seconds=60) + result = None + for line in [ln for ln in lines if ln.startswith(CHUNK_PREFIX_B64)]: + result = reassembler.add_chunk("!sender01", line) + self.assertEqual(result, tx_hex) + + def test_explicit_hex_matches_default(self): + tx_hex = tx_of_size(372) + with_flag = [ + ln + for ln in self._dry_run(tx_hex, encoding=ENCODING_HEX, session_id="fixed") + if ln.startswith(CHUNK_PREFIX) + ] + without = [ + ln + for ln in self._dry_run(tx_hex, session_id="fixed") + if ln.startswith(CHUNK_PREFIX) + ] + self.assertEqual(with_flag, without) + + +if __name__ == "__main__": + unittest.main()