Peer communication protocol
Reconciled against the code on 2026-09-09.
This document specifies how Tally clients exchange application messages with each other. It is written as a protocol-level spec so the same rules can be implemented on any transport, by any client, and against any compatible relay server.
The core ideas are:
- Every user is their own identity. A user is a public key. There is no central user directory; a client only needs the recipient's public key to talk to them.
- Every message is encrypted end-to-end between a pair of users. The relay server learns routing metadata (sender, recipient, message id) but never reads the payload.
- Every message is signed by its author. The Schnorr signature travels inside the encrypted payload and lets any third party verify authorship later — even when a message is forwarded during ledger sync — without trusting the forwarder.
- Messages are delivered over any available path. A direct peer-to-peer channel is preferred; a relay server is the fallback so clients can exchange messages even when they are never online at the same time.
- The same message id is used on every path. A client that receives the same message through multiple paths processes it exactly once.
1. Terminology
- User — a participant identified by a long-lived secp256k1 public key (x-only, 32 bytes, as per BIP-340 / Nostr).
- Client — a program acting on behalf of one user. A user MAY run multiple clients; this document treats each client as if it holds the user's private key.
- Device — one live connection of a client to a relay server. The relay assigns each connection an ephemeral
device_id. A user may have several devices (different browsers, phone + laptop, two tabs) online at once, all sharing the sameuser_idbut each with a distinctdevice_id. - Peer — another user a client wants to talk to. "Peer" is a role, not a network concept. When a user is online on multiple devices, each remote device is an independent peer at the transport layer even though they share a single
user_id. - Self-peer — another device of the same user. Self-peers share the sending user's private key and therefore already derive the same ECDH shared secret with every contact. Self-peers exist to keep the user's own devices in sync. See §13.
- Direct channel — any transport that carries bytes directly between two clients without a relay learning the contents (for example a WebRTC data channel, a LAN link, a Bluetooth link).
- Relay server — a server that accepts envelopes from one client and delivers them to another. It does not need to be trusted with message contents.
- Envelope — the ciphertext container exchanged on the wire. See §4.
- Inner message — the plaintext application message that is signed and then encrypted inside an envelope. See §3.
- Outbox — a client-local, persistent list of inner messages that still need to be delivered.
- Receipt — a small acknowledgement message sent by the recipient to confirm an inner message was received and processed.
- Ledger — a client-local, persistent, append-only log of signed peer messages. See the Ledger spec.
2. Identity and keys
Each user is identified by an x-only secp256k1 public key (BIP-340). The corresponding private key stays on the client and MUST NOT leave it. Encoded forms:
- Hex — 64 lowercase hexadecimal characters, used internally for crypto operations.
- npub (bech32 with HRP
npub) — the canonical user id used everywhere outside of raw crypto calls.
"User id" and "public key" are interchangeable in this spec; both refer to the npub encoding unless hex is explicitly stated.
Two users who want to communicate MUST already know each other's public key. How they obtain it (QR code, out-of-band exchange, friend request flow) is out of scope for this document.
3. Inner message
Every application-level message a client sends to another user is first built as a plaintext inner message object, then Schnorr-signed, then encrypted into an envelope (§4).
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Globally unique message id. MUST be generated by the sender and MUST be stable for retries. |
type | string | yes | Application-specific message type (e.g. friend_request, transaction_created, name_changed). |
from_user_id | string | yes | npub of the sender. |
to_user_id | string | yes | npub of the intended recipient. |
created_at | string | yes | ISO-8601 timestamp of when the sender created the message. |
payload | object | yes | Type-specific fields. Opaque to this spec. |
signature | string | yes | Hex-encoded Schnorr signature. See §3.3. |
Additional fields MAY be present. Implementations MUST preserve unknown fields when storing or forwarding a message.
3.1 Message ids
id is the unit of deduplication and retry. It MUST:
- Be globally unique with overwhelming probability (e.g. UUIDv4, ULID, a random 128-bit token, or a hash of the message contents).
- Be assigned exactly once by the sender, when the message is first created.
- Remain the same across every retry and across every delivery path. Re-queuing a message from the outbox MUST NOT change its id.
A recipient who has already processed a given id from a given from_user_id MUST NOT process it again. See §7.
3.2 Transport-only messages
Some messages exist only to manage a live direct channel and have no meaning off that channel (for example ping, pong, and receipts for messages that were just delivered on the same channel). These messages:
- MAY be sent as plaintext inside a live direct channel.
- MUST NOT be sent through the relay server as plaintext.
- If they need to traverse the relay, they MUST be wrapped in an envelope like any other inner message.
Transport-only messages are not persisted in the outbox.
3.3 Authorship proofs
Every durable application inner message carries an authorship proof: a signed Nostr event (kind 177700, proof type tally-nostr-event-v1) whose content commits to a canonical digest of the message, independent of the transport envelope. Before TIP-006 this was a bare BIP-340 signature in a top-level signature field; that form survives as tally-canonical-schnorr-v1 and is still accepted on receipt so older records keep verifying. Both are Schnorr over secp256k1 and cover the same digest — moving to a Nostr event is what lets the key live in an external signer (see TIP-006).
Why prove authorship in addition to encrypting? The envelope's AEAD proves that whoever holds the ECDH shared secret for (sender, recipient) produced the ciphertext. That is sufficient for live delivery between two parties. However, when a ledger entry from a third user is forwarded during sync recovery — for example, Bob sends Alice a transaction_created message that Carol needs to recover — the AES-GCM on the sync envelope only proves Bob produced the batch, not that the enclosed entry originated with Alice. A proof on each entry establishes the original from_user_id as its author independent of the transport.
Digest construction:
- Take the inner message object with
signatureremoved (or empty string). - Produce a canonical JSON string: keys sorted lexicographically, no extra whitespace.
- Compute
digest = SHA-256(canonical_json)(32 bytes, hex-encoded).
Signing:
signature_hex = schnorr_sign(digest_hex, sender_private_key_hex)
Uses BIP-340 Schnorr signatures on secp256k1.
Verification:
ok = schnorr_verify(signature_hex, digest_hex, sender_public_key_hex)
The public key is derived from from_user_id (decoded from npub to 32-byte hex if needed).
When signatures are produced and stored:
- The sender signs the message at queue time (when
queuePeerMessageis called). - The signature is reused on every retry — it MUST NOT change between retries.
- The signature is preserved in the ledger's
signaturefield so it can be verified offline after the fact. - Verifying a ledger entry requires reconstructing the same canonical inner-message shape from the stored entry, substituting
originated_atforcreated_at.
4. Envelope
An envelope is the object actually transmitted on the wire whenever an inner message has to cross any untrusted boundary (a direct channel carrying data to a second user, or a relay server). It looks like this:
{
"type": "peer_envelope",
"envelope_version": 2,
"id": "<inner.id>",
"from_user_id": "<inner.from_user_id>",
"to_user_id": "<inner.to_user_id>",
"ciphertext": "<base64 of iv || aead(signed_inner_json)>"
}
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Constant string "peer_envelope". |
envelope_version | number | yes | Integer version of this envelope format. Current version is 3; 2 is accepted on receipt only. |
id | string | yes | Copy of inner.id. Used for routing, dedup, and acknowledgements without decrypting. |
from_user_id | string | yes | Copy of inner.from_user_id. Needed by the recipient to pick the right key for decryption. |
to_user_id | string | yes | Copy of inner.to_user_id. Used by the relay server and by the recipient to verify addressing. |
ciphertext | string | yes | Base64 of the AEAD ciphertext. Contents defined in §5. |
The envelope's plaintext fields are claims. A recipient MUST verify those claims against the authenticated inner fields after decryption (see §5.3). A relay server MAY rely on these fields for routing but MUST treat them as untrusted.
Version history. Version 2 moved the signed inner message (including its proof) inside the ciphertext. Version 3 replaces the cipher suite with NIP-44 v2 (§5.3). Clients MUST send version 3 and MUST reject any version they do not recognise; they SHOULD still decrypt version 2 so envelopes queued on a relay before the upgrade can be drained. Version 1 is not accepted.
Implementations MAY add non-normative fields (e.g. queued_at hints from a relay). Unknown fields MUST be ignored by receivers.
5. Encryption
5.1 Shared secret (ECDH)
For each ordered pair of users (A, B), both sides derive the same shared secret from their own private key and the other user's public key:
shared = ECDH(A.private, B.public) = ECDH(B.private, A.public)
The shared secret is the x-coordinate of the resulting secp256k1 curve point. Because public keys are x-only (BIP-340), receivers MUST lift them to full curve points choosing the even-y point before the scalar multiplication.
The ECDH shared secret is symmetric — both sides derive the same value — so a single key covers both directions of the conversation.
5.2 Symmetric key derivation
A symmetric encryption key is derived from the shared secret:
key = SHA-256(shared_x_coordinate) // 32 bytes
The derived key MUST be treated as sensitive and SHOULD be cached in memory per peer to avoid repeated scalar multiplication.
5.3 AEAD encryption
Envelope version 3 encrypts the inner message with NIP-44 v2 (ChaCha20 + HMAC-SHA256, with NIP-44's own conversation-key derivation and padding). This is the single scheme for every key provider, because an external signer (NIP-07) exposes nip44.encrypt/decrypt and never the raw key — so the hand-rolled suite in §§5.1–5.2 could not be used with one. Those sections describe the version 2 suite, kept because clients still decrypt v2 envelopes on receipt.
Version 2 (legacy, decrypt-only): the signed inner message is encrypted with AES-256-GCM (96-bit IVs).
Encryption:
- Build the authorship proof (§3.3) and attach it to the inner message.
- Serialize the signed inner message to UTF-8 JSON.
- Generate a fresh 12-byte IV from a cryptographic random source. IVs MUST NOT be reused with the same key.
- Compute
aead_output = AES_256_GCM_Encrypt(key, iv, plaintext). The output includes the 16-byte authentication tag. - Set
ciphertext = base64(iv || aead_output).
Decryption:
base64_decodethe ciphertext and split the first 12 bytes asiv, the remainder asaead_output.- Compute
plaintext = AES_256_GCM_Decrypt(key, iv, aead_output). If decryption fails (auth tag mismatch), drop the envelope. - Parse
plaintextas JSON into an inner message object. - Verify that
inner.id,inner.from_user_id, andinner.to_user_idmatch the envelope's plaintext fields. If any mismatch, drop the envelope. - Verify that
inner.to_user_idis the receiving user's own public key. If not, drop the envelope. - Verify the authorship proof on
inneragainstinner.from_user_id(§3.3). If invalid, drop the envelope.
Steps 2–5 establish that the ECDH peer produced the ciphertext and that the outer envelope was not tampered with. Step 6 additionally proves that the original from_user_id authored the inner content — this is what makes forwarded ledger entries trustworthy during sync (§12).
5.4 Algorithm negotiation
envelope_version is the single negotiation point for the envelope format and cryptographic suite. A client MUST reject any envelope whose envelope_version it does not recognize. Future versions of this spec MAY introduce new values and new cipher suites.
6. Delivery paths
A message can travel from sender to recipient along any of these paths. A client MAY try several paths for the same message; the recipient's deduplication (§7) makes this safe.
6.1 Direct channel (preferred)
When the sender's client has an open direct channel to the recipient's client (e.g. a WebRTC data channel), it sends the envelope as a single JSON frame over that channel. The channel carries both encrypted envelopes and plaintext transport-only messages.
Direct channels are typically negotiated using a signaling relay (§6.3) but may come from any source that lets two clients exchange raw bytes.
6.2 Relay server outbox (fallback)
When the sender cannot deliver an envelope directly — because the recipient is offline, or because no direct channel is available — it hands the envelope to a relay server. A relay server MUST implement at minimum these operations:
queue_peer_envelope(envelope)— accept an envelope for later delivery.peer_envelope(envelope)— push a previously queued envelope to a recipient.
The relay server behaves as follows:
- On receiving
queue_peer_envelope:- Validate that the envelope has
id,from_user_id,to_user_id, andciphertext. - If the recipient is currently connected, immediately push
peer_envelopeto them. If the push succeeds, the server MAY drop the envelope without storing it. - Otherwise, store the envelope in a per-recipient queue keyed by
envelope.id(see §6.4 for limits).
- Validate that the envelope has
- On receiving a
registerfrom a client that announces its own user id:- Flush every envelope stored for that user id by pushing
peer_envelopemessages, one per envelope. - After flushing the entire queue, push a
queue_drainedsignal to the client. - After a successful push, remove the envelope from the queue.
- Flush every envelope stored for that user id by pushing
The relay server MUST NOT modify envelopes. Clients MUST assume the server might reorder, drop, or duplicate envelopes; dedup and retries live on the clients.
The relay server is an untrusted intermediary. It learns from, to, and id for every message routed through it, plus approximate timing. It does not learn the inner message contents.
6.3 Signaling for direct channels
When direct channels are WebRTC-based, the relay server also acts as a signaling relay: it announces peer presence, tells two clients to open a direct channel (peer_connect / peer_disconnect signals), and forwards WebRTC offer/answer/ICE payloads between them. Application messages — as opposed to signaling — always travel inside envelopes.
Signaling is device-scoped. Because a user MAY be online on several devices at the same time (same user_id, different WebSocket sessions), every peer-scoped signaling message carries both peer_user_id and peer_device_id:
| Message | Required fields |
|---|---|
peer_connect | peer_user_id, peer_device_id, initiator (bool) |
peer_disconnect | peer_user_id, peer_device_id |
webrtc_signal | peer_user_id, peer_device_id, signal (offer / answer / ICE) |
Clients MUST thread the peer_device_id they received on peer_connect through every subsequent webrtc_signal so the relay can route the ICE candidates to the exact device that sent the matching offer. The relay MAY fall back to user-level routing when the target user has a single active device, but clients MUST NOT rely on this.
Envelopes (peer_envelope) are not device-scoped: when a sender hands an envelope to the relay, the relay MUST deliver a copy to every online device of the recipient user. Each recipient device independently decrypts and dedups against its own processed_peer_message_ids (§7) so overlapping deliveries are safe.
6.4 Server queue bounds
Relay servers MUST bound their per-recipient queue to avoid unbounded growth. When the queue for a recipient is full:
- The server MAY evict the oldest stored envelope and accept the new one, OR
- The server MAY reject the new envelope.
Either policy is conformant as long as the limit is documented. Clients MUST be prepared for their envelope to be silently dropped and rely on retries (§8.2) rather than any server-side guarantee.
6.5 Multiple relay servers
A client MAY be connected to more than one relay server at the same time, and a given envelope MAY be handed to several of them. Recipients rely on message-id deduplication to make this safe. There is no coordination between relay servers.
7. Deduplication
Every client maintains a set of processed message ids. On every inbound inner message — regardless of which path delivered it — the client MUST:
- Validate the envelope (see §5.3).
- If the message
idis already in the processed set, drop the message and SHOULD still send a receipt (§8.1) so a stuck sender can progress. - Otherwise, apply the message to local state and add the
idto the processed set.
The processed set MUST persist across client restarts. It MAY be pruned by age or size; a pruning policy is conformant as long as no message id is dropped from the set before any sender could reasonably stop retrying it.
Dedup applies equally to:
- The same envelope arriving via direct channel and via relay.
- The same envelope arriving twice over different relay servers.
- The same envelope arriving twice through a multi-hop peer mesh.
- A sender retrying after a crash.
8. Acknowledgements and retries
8.1 Receipts
For every inner message a client accepts and processes, it SHOULD send a receipt back to the original sender. A receipt is itself an inner message with:
type = "received"from_user_id= the recipient of the original message (now the receipt sender)to_user_id= the sender of the original messagepayload.message_id= theidof the message being acknowledgedpayload.result= an application-defined status, at minimum distinguishing"processed"from"ignored"
Receipts are inner messages and MUST follow the same encryption rules. They MAY be delivered via any path:
- If the recipient has a live direct channel to the original sender, receipts MAY be sent as plaintext transport-only messages on that channel (see §3.2).
- Otherwise the receipt MUST be wrapped in an envelope like any other inner message, and MAY be handed to a relay server for offline delivery.
A receipt MUST NOT itself generate a receipt. On receiving an inner message with type = "received", a client processes it and stops — no acknowledgement is sent.
8.2 Outbox semantics
The sender keeps every unacknowledged inner message in its persistent outbox until a matching receipt arrives. While an id is in the outbox the sender MAY:
- Retry delivery through any available path, any number of times, with its own backoff policy.
- Hand the same envelope to the relay server again. Re-queuing is idempotent: the relay server keys its queue by
envelope.id, so a given envelope takes at most one slot.
Once a receipt for id arrives, the sender removes the message from its outbox and stops retrying.
If a client has previously handed an envelope to a relay server and then observes that the relay server has restarted (for example, by detecting a new WebSocket session), it MUST be prepared to re-queue the envelope. In practice this means the client SHOULD clear any in-memory "already queued on server" tracking whenever it (re-)registers with a relay server.
8.3 Sender-side dedup hint
A client MAY keep a per-session cache of envelope ids it has already handed to the relay server in the current session, to avoid spamming the relay with duplicates of the same envelope while no new events have changed its state. This cache is an optimisation only and MUST be reset on every (re)registration with the relay.
9. Data flows
This section walks through the four combinations of sender and recipient online state.
Legend:
A → Bmeans user A sends to user B.[…]denotes an encrypted envelope.-->is a direct channel,==>is a relay path.
9.1 Both online, direct channel available
A Relay B
| sign + wrap inner as [msg] |
|------------------------- [msg] -------------------------->|
| | decrypt, verify sig, apply
| | add id to processed set
|<------------------------ [receipt] ----------------------|
| or over the same direct channel as plaintext |
| remove id from outbox |
The relay server is not involved. This is the common fast path.
9.2 Both online, no direct channel (yet)
A Relay B
|--- queue_peer_envelope([msg]) --->| |
| |-- peer_envelope([msg]) -->|
| | decrypt, verify sig, apply
| |<-- queue_peer_envelope([receipt]) --|
|<-- peer_envelope([receipt]) ------| |
The relay forwards the envelope immediately instead of storing it. Same protocol on the receipt path.
9.3 Sender online, recipient offline
A Relay B (offline)
|--- queue_peer_envelope([msg]) --->| .
| | store under B .
| | .
. B comes online, registers
|<----- queue_drained ----|
|-- peer_envelope([msg]) -->|
| | decrypt, verify sig, apply
|<-- queue_peer_envelope([receipt]) --|
A | |
|<-- peer_envelope([receipt]) ------| |
| remove id from outbox |
The relay stores the envelope in B's queue until B registers, then flushes it and sends queue_drained. B sends the receipt back the same way.
9.4 Both offline at different times
A Relay B
|--- queue_peer_envelope([msg]) --->| .
| (A goes offline) | store under B .
| .
. B registers
|<----- queue_drained ----|
|-- peer_envelope([msg]) -->|
| | decrypt, verify sig, apply
|<-- queue_peer_envelope([receipt]) --|
| store under A |
| | (B goes offline)
A reconnects | .
|-- register --------------------->| .
|<-- peer_envelope([receipt]) ------| .
| remove id from outbox | .
Neither user is ever directly connected to the other. The relay holds both the original message and its receipt until each side comes back.
9.5 Racing paths
If A has both a direct channel to B and a relay route, A MAY send the envelope on both. B will decrypt whichever arrives first, apply the message, and drop the second as a duplicate (§7). B's receipt may likewise arrive at A via both paths; A processes whichever arrives first and treats the second as a no-op.
10. Security properties and non-goals
Properties provided by this protocol:
- Confidentiality against relay servers and network observers: envelopes are AEAD-encrypted under a key only the sender and recipient can derive.
- Integrity of the inner message: AEAD authentication makes any tampering observable.
- Sender authentication between two known users: successful decryption proves the envelope came from the claimed
from_user_id(given the recipient trusts the public key binding). - Non-repudiation / third-party verifiability: Schnorr signatures on inner messages let any party who knows
from_user_id's public key verify authorship, without trusting the message forwarder. This is what makes ledger sync secure — see §12 and the Ledger spec. - Replay resistance at the application layer via the dedup set (§7). The protocol itself does not prevent a relay from replaying an envelope, but replays are discarded by the recipient.
Properties not provided:
- Forward secrecy. The ECDH key is long-lived. Compromise of either side's private key exposes all past messages that were recorded on the wire.
- Metadata privacy from the relay. The relay learns the pair
(from_user_id, to_user_id)and theidfor every envelope it sees. - Ordering across paths. Messages from the same sender are not guaranteed to arrive in any particular order. Applications needing ordering MUST carry ordering information inside
payload. - Delivery guarantees. Clients retry, relays MAY drop, and direct channels come and go. "Delivered" is defined only by the receipt (§8.1).
- Denial of service protection on the relay. Rate limiting and quota enforcement are relay-specific policy.
11. Versioning
This document describes version 2 of the envelope format (added Schnorr signatures inside the ciphertext). Any incompatible change — new required fields, different crypto suite, different serialization — MUST increment envelope_version. Receivers MUST reject unknown versions rather than trying to guess.
Inner message type values are application-level and evolve independently; adding a new type is not a protocol change.
12. Sync protocol (ledger recovery)
The sync protocol allows a client whose local state has been wiped or partially lost to reconstruct its connection history from peers who hold signed ledger entries. It runs over an established direct channel after the relay's queued envelope delivery is complete.
12.1 Motivation
A client that resets its data (intentionally or due to corruption) loses knowledge of existing friendships and transactions. Because peers hold signed copies of every message they exchanged, they can replay that history to the recovering client. The Schnorr signature on each entry guarantees that the recovering client can trust the replayed content without trusting the forwarder.
12.2 Prerequisites
Before syncing, a client MUST wait for the queue_drained signal from the relay server. This ensures any envelopes the relay was holding are processed before the client requests a diff from peers — otherwise the client may ask for entries it is about to receive via the normal path.
12.3 Message types
Sync messages travel as regular encrypted inner messages (wrapped in envelopes, Schnorr-signed). Two new types are defined:
| Type | Direction | Purpose |
|---|---|---|
sync_hello | initiator → peer | Advertise the set of entry ids the initiator already knows. |
sync_data | peer → initiator | Send the entries the initiator is missing. |
Sync messages are not application messages in the domain sense — they carry no business event — and do not appear in processed_peer_message_ids. They also do not generate receipts.
12.4 Flow
A (recovering) B (has history)
| |
| (queue_drained received from relay) |
| |
|--- sync_hello { known_ids: [...] } ----->|
| | filter own ledger for A<->B entries
| | subtract known_ids
|<--- sync_data { entries: [...] } --------|
| |
| for each entry (chronological): |
| verify authorship proof |
| apply to local state |
| persist updated state |
| update peerIds snapshot |
12.5 sync_hello payload
{
"known_ids": ["<entry id>", ...]
}
known_ids is the list of ledger entry ids the initiator already holds for the conversation with this peer (entries where from_user_id or to_user_id matches the peer).
12.6 sync_data payload
{
"entries": [
{
"id": "...",
"type": "friend_accept",
"from_user_id": "npub1...",
"to_user_id": "npub1...",
"originated_at": "ISO-8601",
"timestamp": "ISO-8601",
"signature": "<hex>",
"payload": { ... }
}
]
}
The sender filters its ledger for entries involving the two parties, removes any ids listed in sync_hello.known_ids, and sends the remainder.
12.7 Entry classification on receipt
When a client receives sync_data, it classifies each entry:
- inbound-like (
to_user_id === my_id): an entry addressed to me, potentially from the peer. Processed through the full application message handler (applyInboundPeerMessage) after signature verification. Applied in chronological order so earlier messages (e.g.friend_accept) are in place before later ones (e.g.transaction_created) that depend on them. - outbound-only (
from_user_id === my_id): an entry I originally authored that the peer is returning. Added to the local ledger so the id appears in futuresync_helloknown_ids, but not re-applied to local state (the effect was already applied when the message was first sent).
Entries that fail authorship verification are silently dropped. A compromised or malicious peer cannot inject forged history.
12.8 Remote peer allowance (friend mesh only)
When a client receives a peer_connect signal from the relay for a peer it does not currently recognise (the peer's id is not in its accepted friendships), it:
- Creates a WebRTC peer connection and completes the sync protocol normally.
- Starts a 15-second remote allowance timer.
- If sync succeeds and the peer is added to accepted friendships before the timer expires, the timer is cancelled and the peer connection remains open.
- If the timer expires before the peer is recognised, the connection is closed.
This lets a resetting client recover friendships from arriving peers without opening a permanent connection to unknown parties.
The allowance timer applies only to the friend mesh. Self-peers (§13) are always allowed and are never reaped by this mechanism.
13. Multi-device (same-user) sync
A single user MAY be online on multiple devices at the same time. Those devices MUST converge to the same local state so that an action taken on one device is visible on every other device — even if the triggering peer message was received on only one of them.
This is achieved by running a second self-mesh overlay alongside the friend mesh. The two overlays share almost all machinery; this section describes the differences.
13.1 Connection establishment
The relay server signals peer_connect whenever it has two or more connected clients with the same user_id (one peer_connect per remote device). The client routes the signal to one of two meshes based on the claimed peer_user_id:
| Condition | Mesh | Key used inside the mesh |
|---|---|---|
peer_user_id !== my_user_id | Friend mesh | peer_user_id |
peer_user_id === my_user_id | Self-mesh | peer_device_id |
Using peer_device_id as the self-mesh key ensures that each remote device gets its own peer entry even though they all share the same user id.
Re-pairing works like the friend mesh: when a self-peer that had connected drops unexpectedly, it sends connect_peer with its own user_id. The relay answers with a fresh peer_connect for every other device of that user (none if they are offline).
WebRTC negotiation, data channels, JSON framing, and envelope encoding are identical to the friend mesh. The polite/impolite role is still decided by lexicographic comparison of the local and remote key; on the self-mesh this compares device ids.
13.2 Encryption for self-envelopes
The envelope format (§4) and the ECDH key derivation (§5.1) are unchanged. Because the "peer" is another device of the same user, both ends hold the same private key, so:
shared = ECDH(my_priv, my_pub)
key = SHA-256(shared)
Both devices derive identical keys from the local key pair alone and can decrypt each other's envelopes with no additional key exchange. from_user_id and to_user_id inside the envelope are both set to the user's own npub.
13.3 Signatures on self-messages
Self-mesh messages MUST still carry valid Schnorr signatures (§3.3). Reuse of the same signing code keeps the inner-message construction identical to friend-mesh messages and lets any ledger entry delivered via self-sync pass the same verification on the receiving device (§12.7 of the Ledger spec).
13.4 Remote allowance
Self-peers are trusted on arrival: there is no 15-second allowance timer and no requirement that they appear in any "accepted" set. A self-peer connection persists for as long as the relay reports the other device online.
13.5 Live ledger broadcast
Whenever the local client appends a new ledger entry (outbound or inbound), it MUST also broadcast that entry to every currently-connected self-peer so all of the user's devices converge on the same ledger. The broadcast piggybacks on the existing sync protocol:
- Transport: a
sync_datainner message (§12.6) sent over each self-peer data channel. - Scope: the broadcast carries every new ledger entry — entries from all conversations, not just a bilateral slice, because self-peers are entitled to see the full ledger.
- Loop prevention: the sender keeps a local set of already-broadcast ledger entry ids. A receiving self-peer seeds this set with the inbound ids before applying them so the resulting ledger-change tick does not echo them back.
13.6 Initial self-sync (on connection)
Immediately after a self-peer data channel opens, both devices exchange a sync_hello (§12.5) carrying the ids they already hold, and respond with sync_data carrying the missing entries. Unlike friend-mesh sync, the entry filter is not bilateral: a self-peer returns all ledger entries whose id is not in the initiator's known_ids.
On receipt, entries are classified using the same rule as friend sync (§12.7): inbound-like entries run through the application message handler; outbound-only entries are inserted into the ledger without re-application. Chronological replay is still required so dependent entries apply in order.
13.7 Receipts
Inner messages delivered via self-sync (both the initial catch-up and live broadcast) do not generate receipts. After convergence both devices share identical processed_peer_message_ids, so acknowledgement is redundant and a receipt from device A to device A' would itself need to be broadcast, causing an unbounded fan-out.
13.8 Relay fallback
There is no TURN server, so on some networks (carrier-grade NAT, routers without hairpinning or with mDNS blocked) two devices of the same user never get a WebRTC channel. Self-sync then runs through the relay instead:
- A client tracks which own devices a relay reports online (
peer_connect/peer_disconnectfor its ownuser_id). When an own device newly appears, the client immediately sends async_helloas a self-addressed envelope (§13.2) viaqueue_peer_envelope— without waiting to see whether WebRTC connects. Relays repeatpeer_connecton every candidate update, so only the first one per presence triggers this. - The relay delivers a self-addressed envelope to the sender's other online devices only. It never stores or pushes them: a device that comes online later triggers a fresh
sync_hello(§13.6). - Replies (
sync_data) and live broadcasts (§13.5) take the relay path too whenever some own device is online on a relay but has no open data channel. The relay path is a broadcast to all other own devices; duplicates with the direct channel are absorbed by ledger-id dedup. - Each relay delivers its own copy, so receivers drop sync messages whose id they've already handled. Only
sync_hello/sync_dataare accepted on this path.
The relay learns the timing and size of self-sync traffic, never its contents.
13.9 Security considerations
- Self-sync inherits the same authorship and AEAD guarantees as friend sync: a hostile relay or network observer cannot forge or read self-mesh traffic.
- A device that is not actually the same user cannot masquerade as a self-peer because the relay allocates
device_idonly for connections that authenticated with the matchinguser_idinregister(see §6.2 and the signaling server implementation). - Signature verification at the receiver still runs on every entry, so even if the self-mesh were somehow spoofed, entries with invalid signatures for the claimed
from_user_idwould be rejected.