2026-02-26 11:19:47 +01:00
|
|
|
# Relay transport
|
|
|
|
|
|
|
|
|
|
The relay layer handles everything below the application protocol: WebSocket connectivity,
|
|
|
|
|
message encryption, reconnection, and message queuing. Application code (wallet, dapp) should
|
|
|
|
|
never touch NDK or Nostr directly.
|
|
|
|
|
|
|
|
|
|
## Stack
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
Application messages (hdwalletv1 JSON)
|
|
|
|
|
↓ JSON.stringify
|
|
|
|
|
NDK PrivateDirectMessage (kind 14, rumor)
|
|
|
|
|
↓ NIP-17 gift wrap (kind 1059, encrypted to recipient)
|
|
|
|
|
NDK → Nostr relay (WebSocket)
|
|
|
|
|
↓ stored as kind 1059 event, tagged with recipient pubkey
|
|
|
|
|
NDK ← Nostr relay subscription (filter: kind=1059, #p=<our_pubkey>)
|
|
|
|
|
↓ giftUnwrap → PrivateDirectMessage → JSON.parse
|
|
|
|
|
Application message handler
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## RelayClient
|
|
|
|
|
|
|
|
|
|
`RelayClient` is the low-level building block. One instance per peer connection.
|
|
|
|
|
|
|
|
|
|
### Responsibilities
|
|
|
|
|
|
|
|
|
|
- Owns an NDK instance configured with explicit relay URLs.
|
|
|
|
|
- Subscribes to `kind:1059` (GiftWrap) events tagged with its own pubkey.
|
|
|
|
|
- Decrypts and unwraps incoming events using NIP-17 `giftUnwrap`.
|
|
|
|
|
- Publishes outbound messages using `giftWrap` addressed to the paired peer.
|
|
|
|
|
- Filters incoming messages by peer pubkey (rejects messages from unknown senders).
|
|
|
|
|
- Discards messages older than `lastProcessedTimestamp` (replay protection).
|
|
|
|
|
- Queues outbound messages until at least one relay is ready.
|
|
|
|
|
|
|
|
|
|
### Construction
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
new RelayClient({
|
|
|
|
|
explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.cauldron.quest:443"]
|
|
|
|
|
signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity)
|
|
|
|
|
pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange)
|
|
|
|
|
logNetworkActivity?: boolean; // default true
|
|
|
|
|
})
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Key methods
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
connect(): Promise<void>
|
|
|
|
|
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
|
|
|
|
|
|
|
|
|
|
disconnect(): Promise<void>
|
|
|
|
|
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
|
|
|
|
|
|
|
|
|
|
relay(message: ProtocolMessage): Promise<void>
|
|
|
|
|
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
|
|
|
|
|
|
|
|
|
|
setPairedPublicKey(key: Uint8Array): void
|
|
|
|
|
// Called after key exchange. Enables outbound messages and incoming peer filtering.
|
|
|
|
|
|
|
|
|
|
isKeyExchangeComplete(): boolean
|
|
|
|
|
// true once pairedPublicKey is set and non-zero.
|
|
|
|
|
|
|
|
|
|
nextSequence(): number
|
|
|
|
|
// Returns a unique sequence number (starts at random offset, increments by 2).
|
|
|
|
|
|
|
|
|
|
getLastProcessedTimestamp() / setLastProcessedTimestamp(ts): void
|
|
|
|
|
// Persist/restore across reconnects to avoid re-delivering buffered messages.
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Message queue
|
|
|
|
|
|
|
|
|
|
Before any relay is confirmed as connected, outbound `relay()` calls are enqueued in a
|
|
|
|
|
`MessageQueue`. The client polls every 100 ms for up to 5 seconds for at least one relay with
|
|
|
|
|
status 1 (connected). After that timeout it assumes the relay is ready and flushes the queue
|
|
|
|
|
regardless (avoiding silent message loss on slow connections).
|
|
|
|
|
|
|
|
|
|
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
|
|
|
|
|
held rather than dropped.
|
|
|
|
|
|
|
|
|
|
### Replay protection
|
|
|
|
|
|
|
|
|
|
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
|
|
|
|
|
to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimestamp` is silently
|
|
|
|
|
dropped. This prevents the relay from re-delivering messages that were already handled before a
|
|
|
|
|
disconnect.
|
|
|
|
|
|
2026-04-08 09:46:00 +02:00
|
|
|
### Keepalive
|
|
|
|
|
|
|
|
|
|
`RelayClient` creates its `SimplePool` with `enablePing: true`. This enables nostr-tools'
|
|
|
|
|
built-in heartbeat: every 29 seconds the pool pings each connected relay and expects a
|
|
|
|
|
response within 20 seconds. In Node.js this uses native WebSocket ping/pong frames; in
|
|
|
|
|
browsers (where the WebSocket API doesn't expose ping) it falls back to sending a dummy
|
|
|
|
|
subscription request and waiting for EOSE.
|
|
|
|
|
|
|
|
|
|
If a relay fails to respond, nostr-tools closes the WebSocket, which fires the subscription
|
|
|
|
|
`onclose` callback, which emits `"disconnect"` on `RelayClient`, which triggers the
|
|
|
|
|
connection manager's existing reconnect loop. This detects "zombie" TCP connections where the
|
|
|
|
|
socket appears open but the relay is unreachable.
|
|
|
|
|
|
|
|
|
|
Additionally, if a `publishMessage()` call fails (all relays reject the event),
|
|
|
|
|
`RelayClient` emits `"disconnect"` alongside the thrown error. This ensures the reconnect
|
|
|
|
|
loop starts immediately rather than waiting for the next ping cycle.
|
|
|
|
|
|
2026-02-26 11:19:47 +01:00
|
|
|
### Sequence numbers
|
|
|
|
|
|
|
|
|
|
`nextSequence()` starts at a random offset in the safe integer range and increments by 2. This
|
|
|
|
|
means two instances are unlikely to collide (they start at different offsets), and the step of 2
|
|
|
|
|
leaves room for error responses at odd offsets if needed in the future.
|
|
|
|
|
|
|
|
|
|
## initiateRelay
|
|
|
|
|
|
|
|
|
|
`initiateRelay()` (in `relay-handler.ts`) wraps `RelayClient` with a reconnect loop:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
initiateRelay(
|
|
|
|
|
statusCallback: RelayStatusCallback,
|
|
|
|
|
privateKey: Uint8Array,
|
|
|
|
|
initialPairedKey: Uint8Array,
|
|
|
|
|
options: { explicitRelayUrls, reconnectInterval?, maxReconnectAttempts? }
|
|
|
|
|
): () => void // returns cleanup function
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
It calls `statusCallback` with a `RelayUpdatePayload` on every state change:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
interface RelayUpdatePayload {
|
|
|
|
|
client: RelayClient;
|
|
|
|
|
status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The reconnect loop is simple: on a disconnect event from the client it waits
|
|
|
|
|
`reconnectInterval` (default 5000 ms) and calls `client.connect()` again. No exponential backoff
|
|
|
|
|
currently — connections are expected to be stable (relay and mobile network) and fast to re-establish.
|
|
|
|
|
|
|
|
|
|
## initiateDappRelay
|
|
|
|
|
|
|
|
|
|
Higher-level helper that bundles credential generation, URI encoding, key exchange handling,
|
|
|
|
|
and reconnection into one call:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
initiateDappRelay(
|
|
|
|
|
statusCallback: RelayStatusCallback,
|
|
|
|
|
options?: {
|
|
|
|
|
explicitRelayUrls?: string[];
|
|
|
|
|
reconnectInterval?: number;
|
|
|
|
|
maxReconnectAttempts?: number;
|
|
|
|
|
existingCredentials?: { privateKey: string; secret: string };
|
|
|
|
|
}
|
|
|
|
|
): DappRelayResult
|
|
|
|
|
|
|
|
|
|
interface DappRelayResult {
|
|
|
|
|
client: RelayClient;
|
|
|
|
|
uri: string; // wiz:// URI to encode into QR
|
|
|
|
|
credentials: KeyExchangeCredentials;
|
|
|
|
|
events: EventEmitter<{ keyexchangecomplete: [walletPublicKey: Uint8Array] }>;
|
|
|
|
|
cleanup: () => void;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Internally it wraps the caller's `statusCallback` to intercept `wallet_ready` messages,
|
|
|
|
|
verify the secret, and call `client.setPairedPublicKey()` before the message is processed by
|
|
|
|
|
`DappConnectionManager`. On reconnect, if `walletPublicKeyNostr` is already known it immediately
|
|
|
|
|
restores the paired key so outbound messages can be sent before the next `wallet_ready` arrives.
|
|
|
|
|
|
|
|
|
|
## initiateWalletRelay
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
initiateWalletRelay(
|
|
|
|
|
statusCallback: RelayStatusCallback,
|
|
|
|
|
options: {
|
|
|
|
|
uri: string; // wiz:// URI from QR scan
|
|
|
|
|
walletPrivateKey: Uint8Array; // wallet's relay identity key (may be ephemeral)
|
|
|
|
|
}
|
|
|
|
|
): { cleanup: () => void }
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Decodes the URI, sets the dapp's public key as the initial paired key, and starts the relay.
|
|
|
|
|
On each connect/reconnect, the wallet sends `wallet_ready` which carries the key exchange fields
|
|
|
|
|
(`public_key`, `secret`) alongside the session data. This gives the dapp the wallet's pubkey
|
|
|
|
|
atomically with the application handshake data.
|
|
|
|
|
|
|
|
|
|
## Encryption: NIP-17 gift wrap
|
|
|
|
|
|
|
|
|
|
NIP-17 gift wrap works like a sealed envelope:
|
|
|
|
|
|
|
|
|
|
1. **Rumor** (kind 14, `PrivateDirectMessage`): the plaintext message, signed by the sender.
|
|
|
|
|
Content is the JSON payload. Tags include `["p", recipient_pubkey_hex]`.
|
|
|
|
|
2. **Seal** (kind 13): the rumor encrypted with a shared ECDH secret derived from sender's
|
|
|
|
|
private key and recipient's public key. No `p` tag — unlinkable to sender/recipient.
|
|
|
|
|
3. **Wrap** (kind 1059, `GiftWrap`): the seal encrypted to the recipient's public key using a
|
|
|
|
|
freshly generated throwaway key. Has a `["p", recipient_pubkey_hex]` tag so the relay can
|
|
|
|
|
route it to the right subscription.
|
|
|
|
|
|
|
|
|
|
The relay sees only kind 1059 with a recipient tag. It cannot read content or link messages to
|
|
|
|
|
senders. The throwaway outer key means even the relay cannot correlate multiple messages from
|
|
|
|
|
the same sender.
|
|
|
|
|
|
|
|
|
|
NDK handles all three layers in `giftWrap()` / `giftUnwrap()`.
|
|
|
|
|
|
|
|
|
|
## Default relay
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
wss://relay.cauldron.quest:443
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
This is a Cauldron-operated Nostr relay. Nothing in the protocol prevents using any other
|
|
|
|
|
standard Nostr relay. The relay is specified in the connection URI, so wallet and dapp always
|
|
|
|
|
use the same relay without out-of-band coordination.
|