← Blog

April 2, 2026 · protocol · 8 min read

Dual-stack rollout: how we kept the old world alive

When you rebrand a mesh messenger and leave the wire format alone, the two apps interoperate. Users of the old app and users of the new app find each other on the same Bluetooth service, decode the same packets, and share the same geohash rooms. That is a feature, not an accident.

When you rebrand a mesh messenger and change the identifiers, the new app is isolated. A fresh BLE service UUID, a fresh Noise prologue, a fresh set of Nostr event kinds, and nobody on the outside can hear you. That is also a feature, not an accident.

We wanted both. A clean Hoppr-to-Hoppr channel, for when Hoppr-native traffic is worth recognising on its own, and participation in the existing open geohash ecosystem, for when our users just want to reach people. Dual-stack is how we got there.

What ships

Two parallel protocol variants, on the same radio, through the same relays. Every layer is dual-stack.

Surface              Hoppr-native                               Public interop
-------              ------------                               --------------
BLE service UUID     F71E237E-B01A-414D-8082-5FE30926FA1F       08E18F72-B0C1-4841-B1F7-3F80E68261D3
BLE characteristic   F72B86C8-B80A-466A-9B95-9097B042D989       DDF450D3-BFD4-44C9-898A-69E469DBD395
Noise prologue       "hoppr-mesh/1"                             empty
Nostr ephemeral      kind 20100                                 kind 20000
Nostr presence       kind 20101                                 kind 20001
Geohash tag key      "hg"                                       "g"
Packet version byte  0x02                                       0x01

The peripheral advertises both BLE services at once. The central scans for both. The Nostr publisher emits each outbound ephemeral or presence event twice, once on the native kind set with the hg tag, once on the public kind set with the g tag. Incoming packets are demultiplexed on byte zero: 0x01 routes to the legacy decoder, 0x02 routes to the native decoder. Handshakes over a native-UUID connection use the hoppr-mesh/1 prologue, handshakes over the public-compat UUID use the legacy prologue, so the Noise sessions are cryptographically partitioned. Same radio, same relays, two lanes.

How the code stays sane

One enum: HopprProtocolVariant { HOPPR, BITCHAT }. Every layer that needs to know about the variant takes it as an optional parameter. The default is the public-interop value, because that is what the existing call sites in the upstream codebase already speak. New Hoppr-native code paths pass .HOPPR explicitly. No fork. No long-running divergent branches. No three hundred conflict markers to rebase through.

This one decision is the reason the rollout landed in a single sprint. If we had pushed Hoppr-native as the new default and made the legacy variant the opt-in path, every file that previously built a packet would have had to be touched. Instead, we kept the existing flow as the baseline and added the new flow beside it. Files that have not been rewritten to care about the variant still compile and still behave exactly as they always did.

The user-facing flag

Settings has a toggle labelled "Include public mesh". Internally it is a single UserDefaults key, publicInteropEnabled, defaulting to true.

When it is on, Hoppr publishes every outbound Nostr message twice, once Hoppr-native and once public-compat, and subscribes to both inbound variant sets. On BLE, it advertises both service UUIDs and scans for both. A peer on the existing open geohash ecosystem sees us. A Hoppr peer sees us twice, but MessageRouter dedupes by packet ID and only the first arrival is dispatched.

When it is off, Hoppr stays in its own lane. It publishes only on the native kind set, advertises only the native BLE service, and drops public-variant events before dispatch. The user is now invisible to anyone not running Hoppr, which is almost everyone.

Why this is not a fork

A fork picks a side. Dual-stack does not.

On day one, the Hoppr-native channel is empty of users except us. The native BLE service has nothing to scan. The native Nostr kinds have no subscribers. On day one, the public channel is crowded. Our users want to reach people, not prove a point. That is why the flag starts on by default.

A year from now, the balance may flip. Or it may not. Dual-stack does not require us to predict that. The protocol does not care which lane has more traffic.

What it buys us long-term

The moment the Hoppr-native side has enough users to feel like a real conversation space, nothing needs to change on the wire. The toggle becomes a personal preference. Users who want a cleaner room flip it off. Users who want maximum reach leave it on. The protocol does not have to flag-day anything. There is no cutover, no coordinated upgrade, no broken clients on the morning of the change.

If we ever ship a Hoppr-only feature, something that leans on a native-only payload type, it lives on the 0x02 packet version and kind 20100 and simply does not exist on the public side. No leakage. No accidentally-broadcasting-it. The partition is structural.

Honest caveats

Dual-stack is a scoping decision, not a hiding decision. A determined observer with a BLE scanner or Wireshark still identifies the outer framing as protocol-compatible with the public mesh. The service UUIDs are in plaintext on the air. Anyone who cares to look can see that a given device is advertising a mesh-messenger service. Dual-stack does not try to change that.

What it does change is the conversation space at the application layer. That is the layer users actually interact with. A Hoppr user who turns off the public interop flag is not listening to the public rooms and is not speaking to them. Their messages are not in the public feed. Their replies are not routed there. The lane is clean at the protocol layer, not the radio layer.

What did not work first time

We initially tried to re-key per-peer sessions when a peer switched variants mid-connection. The idea was that a single device talking to another single device should need only one Noise session no matter which stack the packets came in on. In practice it caused more bugs than it solved: session state races, key-drift on reconnect, two messages arriving in the same second on different variants and the second one getting decrypted with the wrong key.

The final design keys sessions by (peerID, variant) and accepts that a single device running both variants has two separate sessions with its counterpart. Twice the state, but each session is cryptographically independent and there is no cross-variant decision to get wrong. Simpler. Less branching. Less to audit.

Closing

Dual-stack is not elegant. It is more code. It doubles our outbound Nostr traffic when the flag is on, and it doubles our BLE advertisement payload. We will spend the next few months watching relay rate-limits and tuning back-off behaviour.

But it is honest about what we are doing: adding a new lane without closing the old road. Most protocol migrations want to be binary. They pick a cutover date, they push an update, and on the morning of the change half the network stops talking to the other half. Ours is not a cutover. Ours is a merge lane. Drivers pick the lane they want, the old lane stays open, and eventually, without anyone being told to change, the weight shifts.

Published by the Hoppr team. Mirrored as a NIP-23 long-form event (kind 30023) on the Hoppr publication key. Questions: hello@hoppr.chat.