UNPKG

@bsv/message-box-client

Version:

A client for P2P messaging and payments

1,303 lines (1,168 loc) 84.2 kB
/** * @file MessageBoxClient.ts * @description * Provides the `MessageBoxClient` class — a secure client library for sending and receiving messages * via a Message Box Server over HTTP and WebSocket. Messages are authenticated, optionally encrypted, * and routed using identity-based addressing based on BRC-2/BRC-42/BRC-43 protocols. * * Core Features: * - Authenticated message transport using identity keys * - Deterministic message ID generation via HMAC (BRC-2) * - AES-256-GCM encryption using ECDH shared secrets derived via BRC-42/BRC-43 * - Support for sending messages to self (`counterparty: 'self'`) * - Live message streaming using WebSocket rooms * - Optional plaintext messaging with `skipEncryption` * - Overlay host discovery and advertisement broadcasting via SHIP * - MessageBox-based organization and acknowledgment system * * See BRC-2 for details on the encryption scheme: https://github.com/bitcoin-sv/BRCs/blob/master/wallet/0002.md * * @module MessageBoxClient * @author Project Babbage * @license Open BSV License */ import { WalletClient, AuthFetch, LookupResolver, TopicBroadcaster, Utils, Transaction, PushDrop, PubKeyHex, P2PKH, PublicKey, CreateActionOutput, WalletInterface, ProtoWallet, InternalizeOutput, Random, OriginatorDomainNameStringUnder250Bytes } from '@bsv/sdk' import { AuthSocketClient } from '@bsv/authsocket-client' import * as Logger from './Utils/logger.js' import { AcknowledgeMessageParams, AdvertisementToken, EncryptedMessage, ListMessagesParams, MessageBoxClientOptions, Payment, PeerMessage, SendMessageParams, SendMessageResponse, DeviceRegistrationParams, DeviceRegistrationResponse, RegisteredDevice, ListDevicesResponse } from './types.js' import { SetMessageBoxPermissionParams, GetMessageBoxPermissionParams, MessageBoxPermission, MessageBoxQuote, ListPermissionsParams, GetQuoteParams } from './types/permissions.js' const DEFAULT_MAINNET_HOST = 'https://messagebox.babbage.systems' const DEFAULT_TESTNET_HOST = 'https://staging-messagebox.babbage.systems' /** * @class MessageBoxClient * @description * A secure client for sending and receiving authenticated, encrypted messages * through a MessageBox server over HTTP and WebSocket. * * Core Features: * - Identity-authenticated message transport (BRC-2) * - AES-256-GCM end-to-end encryption with BRC-42/BRC-43 key derivation * - HMAC-based message ID generation for deduplication * - Live WebSocket messaging with room-based subscription management * - Overlay network discovery and host advertisement broadcasting (SHIP protocol) * - Fallback to HTTP messaging when WebSocket is unavailable * * **Important:** * The MessageBoxClient automatically calls `await init()` if needed. * Manual initialization is optional but still supported. * * You may call `await init()` manually for explicit control, but you can also use methods * like `sendMessage()` or `listenForLiveMessages()` directly — the client will initialize itself * automatically if not yet ready. * * @example * const client = new MessageBoxClient({ walletClient, enableLogging: true }) * await client.init() // <- Required before using the client * await client.sendMessage({ recipient, messageBox: 'payment_inbox', body: 'Hello world' }) */ export class MessageBoxClient { private host: string public readonly authFetch: AuthFetch private readonly walletClient: WalletInterface private socket?: ReturnType<typeof AuthSocketClient> private myIdentityKey?: string private readonly joinedRooms: Set<string> = new Set() private readonly lookupResolver: LookupResolver private readonly networkPreset: 'local' | 'mainnet' | 'testnet' private initialized = false protected originator?: OriginatorDomainNameStringUnder250Bytes /** * @constructor * @param {Object} options - Initialization options for the MessageBoxClient. * @param {string} [options.host] - The base URL of the MessageBox server. If omitted, defaults to mainnet/testnet hosts. * @param {WalletInterface} options.walletClient - Wallet instance used for authentication, signing, and encryption. * @param {boolean} [options.enableLogging=false] - Whether to enable detailed debug logging to the console. * @param {'local' | 'mainnet' | 'testnet'} [options.networkPreset='mainnet'] - Overlay network preset used for routing and advertisement lookup. * * @description * Constructs a new MessageBoxClient. * * **Note:** * Passing a `host` during construction sets the default server. * If you do not manually call `await init()`, the client will automatically initialize itself on first use. * * @example * const client = new MessageBoxClient({ * host: 'https://messagebox.example', * walletClient, * enableLogging: true, * networkPreset: 'testnet' * }) * await client.init() */ constructor (options: MessageBoxClientOptions = {}) { const { host, walletClient, enableLogging = false, networkPreset = 'mainnet', originator = undefined } = options const defaultHost = this.networkPreset === 'testnet' ? DEFAULT_TESTNET_HOST : DEFAULT_MAINNET_HOST this.host = host?.trim() ?? defaultHost this.originator = originator this.walletClient = walletClient ?? new WalletClient('auto', originator) this.authFetch = new AuthFetch(this.walletClient, undefined, undefined, originator) this.networkPreset = networkPreset this.lookupResolver = new LookupResolver({ networkPreset }) if (enableLogging) { Logger.enable() } } /** * @method init * @async * @param {string} [targetHost] - Optional host to set or override the default host. * @param {string} [originator] - Optional originator to use with walletClient. * @returns {Promise<void>} * * @description * Initializes the MessageBoxClient by setting or anointing a MessageBox host. * * - If the client was constructed with a host, it uses that unless a different targetHost is provided. * - If no prior advertisement exists for the identity key and host, it automatically broadcasts a new advertisement. * - After calling init(), the client becomes ready to send, receive, and acknowledge messages. * * This method can be called manually for explicit control, * but will be automatically invoked if omitted. * @throws {Error} If no valid host is provided, or anointing fails. * * @example * const client = new MessageBoxClient({ host: 'https://mybox.example', walletClient }) * await client.init() * await client.sendMessage({ recipient, messageBox: 'inbox', body: 'Hello' }) */ async init (targetHost: string = this.host): Promise<void> { const normalizedHost = targetHost?.trim() if (normalizedHost === '') { throw new Error('Cannot anoint host: No valid host provided') } // Check if this is an override host if (normalizedHost !== this.host) { this.initialized = false this.host = normalizedHost } if (this.initialized) return // 1. Get our identity key const identityKey = await this.getIdentityKey() // 2. Check for any matching advertisements for the given host const [firstAdvertisement] = await this.queryAdvertisements(identityKey, normalizedHost) // 3. If none our found, anoint this host if (firstAdvertisement == null || firstAdvertisement?.host?.trim() === '' || firstAdvertisement?.host !== normalizedHost) { Logger.log('[MB CLIENT] Anointing host:', normalizedHost) try { const { txid } = await this.anointHost(normalizedHost) if (txid == null || txid.trim() === '') { throw new Error('Failed to anoint host: No transaction ID returned') } } catch (error) { Logger.log('[MB CLIENT] Failed to anoint host, continuing with default functionality:', error) // Continue with default host - client can still function for basic operations } } this.initialized = true } /** * @method assertInitialized * @private * @description * Ensures that the MessageBoxClient has completed initialization before performing sensitive operations * like sending, receiving, or acknowledging messages. * * If the client is not yet initialized, it will automatically call `await init()` to complete setup. * * Used automatically by all public methods that require initialization. */ private async assertInitialized (): Promise<void> { if (!this.initialized || this.host == null || this.host.trim() === '') { await this.init() } } /** * @method getJoinedRooms * @returns {Set<string>} A set of currently joined WebSocket room IDs * @description * Returns a live list of WebSocket rooms the client is subscribed to. * Useful for inspecting state or ensuring no duplicates are joined. */ public getJoinedRooms (): Set<string> { return this.joinedRooms } /** * @method getIdentityKey * @param {string} [originator] - Optional originator to use for identity key lookup * @returns {Promise<string>} The identity public key of the user * @description * Returns the client's identity key, used for signing, encryption, and addressing. * If not already loaded, it will fetch and cache it. */ public async getIdentityKey (): Promise<string> { if (this.myIdentityKey != null && this.myIdentityKey.trim() !== '') { return this.myIdentityKey } Logger.log('[MB CLIENT] Fetching identity key...') try { const keyResult = await this.walletClient.getPublicKey({ identityKey: true }, this.originator) this.myIdentityKey = keyResult.publicKey Logger.log(`[MB CLIENT] Identity key fetched: ${this.myIdentityKey}`) return this.myIdentityKey } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to fetch identity key:', error) throw new Error('Identity key retrieval failed') } } /** * @property testSocket * @readonly * @returns {AuthSocketClient | undefined} The internal WebSocket client (or undefined if not connected). * @description * Exposes the underlying Authenticated WebSocket client used for live messaging. * This is primarily intended for debugging, test frameworks, or direct inspection. * * Note: Do not interact with the socket directly unless necessary. * Use the provided `sendLiveMessage`, `listenForLiveMessages`, and related methods. */ public get testSocket (): ReturnType<typeof AuthSocketClient> | undefined { return this.socket } /** * @method initializeConnection * @param {string} [originator] - Optional originator to use for authentication. * @async * @returns {Promise<void>} * @description * Establishes an authenticated WebSocket connection to the configured MessageBox server. * Enables live message streaming via room-based channels tied to identity keys. * * This method: * 1. Retrieves the user’s identity key if not already set * 2. Initializes a secure AuthSocketClient WebSocket connection * 3. Authenticates the connection using the identity key * 4. Waits up to 5 seconds for authentication confirmation * * If authentication fails or times out, the connection is rejected. * * @throws {Error} If the identity key is unavailable or authentication fails * * @example * const mb = new MessageBoxClient({ walletClient }) * await mb.initializeConnection() * // WebSocket is now ready for use */ async initializeConnection (overrideHost?: string): Promise<void> { Logger.log('[MB CLIENT] initializeConnection() STARTED') if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { await this.getIdentityKey() } if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { Logger.error('[MB CLIENT ERROR] Identity key is still missing after retrieval!') throw new Error('Identity key is missing') } Logger.log('[MB CLIENT] Setting up WebSocket connection...') if (this.socket == null) { const targetHost = overrideHost ?? this.host if (typeof targetHost !== 'string' || targetHost.trim() === '') { throw new Error('Cannot initialize WebSocket: No valid host provided') } this.socket = AuthSocketClient(targetHost, { wallet: this.walletClient, originator: this.originator}, ) let identitySent = false let authenticated = false this.socket.on('connect', () => { Logger.log('[MB CLIENT] Connected to WebSocket.') if (!identitySent) { Logger.log('[MB CLIENT] Sending authentication data:', this.myIdentityKey) if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { Logger.error('[MB CLIENT ERROR] Cannot send authentication: Identity key is missing!') } else { this.socket?.emit('authenticated', { identityKey: this.myIdentityKey }) identitySent = true } } }) // Listen for authentication success from the server this.socket.on('authenticationSuccess', (data) => { Logger.log(`[MB CLIENT] WebSocket authentication successful: ${JSON.stringify(data)}`) authenticated = true }) // Handle authentication failures this.socket.on('authenticationFailed', (data) => { Logger.error(`[MB CLIENT ERROR] WebSocket authentication failed: ${JSON.stringify(data)}`) authenticated = false }) this.socket.on('disconnect', () => { Logger.log('[MB CLIENT] Disconnected from MessageBox server') this.socket = undefined identitySent = false authenticated = false }) this.socket.on('error', (error) => { Logger.error('[MB CLIENT ERROR] WebSocket error:', error) }) // Wait for authentication confirmation before proceeding await new Promise<void>((resolve, reject) => { setTimeout(() => { if (authenticated) { Logger.log('[MB CLIENT] WebSocket fully authenticated and ready!') resolve() } else { reject(new Error('[MB CLIENT ERROR] WebSocket authentication timed out!')) } }, 5000) // Timeout after 5 seconds }) } } /** * @method resolveHostForRecipient * @async * @param {string} identityKey - The public identity key of the intended recipient. * @param {string} [originator] - The originator to use for the WalletClient. * @returns {Promise<string>} - A fully qualified host URL for the recipient's MessageBox server. * * @description * Attempts to resolve the most recently anointed MessageBox host for the given identity key * using the BSV overlay network and the `ls_messagebox` LookupResolver. * * If no advertisements are found, or if resolution fails, the client will fall back * to its own configured `host`. This allows seamless operation in both overlay and non-overlay environments. * * This method guarantees a non-null return value and should be used directly when routing messages. * * @example * const host = await resolveHostForRecipient('028d...') // → returns either overlay host or this.host */ async resolveHostForRecipient (identityKey: string): Promise<string> { const advertisementTokens = await this.queryAdvertisements(identityKey, undefined) if (advertisementTokens.length === 0) { Logger.warn(`[MB CLIENT] No advertisements for ${identityKey}, using default host ${this.host}`) return this.host } // Return the first host found return advertisementTokens[0].host } /** * Core lookup: ask the LookupResolver (optionally filtered by host), * decode every PushDrop output, and collect all the host URLs you find. * * @param identityKey the recipient’s public key * @param host? if passed, only look for adverts anointed at that host * @returns 0-length array if nothing valid was found */ async queryAdvertisements ( identityKey?: string, host?: string, ): Promise<AdvertisementToken[]> { const hosts: AdvertisementToken[] = [] try { const query: Record<string, string> = { identityKey: identityKey ?? await this.getIdentityKey() } if (host != null && host.trim() !== '') query.host = host const result = await this.lookupResolver.query({ service: 'ls_messagebox', query }) if (result.type !== 'output-list') { throw new Error(`Unexpected result type: ${String(result.type)}`) } for (const output of result.outputs) { try { const tx = Transaction.fromBEEF(output.beef) const script = tx.outputs[output.outputIndex].lockingScript const token = PushDrop.decode(script) const [, hostBuf] = token.fields if (hostBuf == null || hostBuf.length === 0) { throw new Error('Empty host field') } hosts.push({ host: Utils.toUTF8(hostBuf), txid: tx.id('hex'), outputIndex: output.outputIndex, lockingScript: script, beef: output.beef }) } catch { // skip any malformed / non-PushDrop outputs } } } catch (err) { Logger.error('[MB CLIENT ERROR] _queryAdvertisements failed:', err) } return hosts } /** * @method joinRoom * @async * @param {string} messageBox - The name of the WebSocket room to join (e.g., "payment_inbox"). * @returns {Promise<void>} * * @description * Joins a WebSocket room that corresponds to the user’s identity key and the specified message box. * This is required to receive real-time messages via WebSocket for a specific type of communication. * * If the WebSocket connection is not already established, this method will first initialize the connection. * It also ensures the room is only joined once, and tracks all joined rooms in an internal set. * * Room ID format: `${identityKey}-${messageBox}` * * @example * await client.joinRoom('payment_inbox') * // Now listening for real-time messages in room '028d...-payment_inbox' */ async joinRoom (messageBox: string, overrideHost?: string): Promise<void> { Logger.log(`[MB CLIENT] Attempting to join WebSocket room: ${messageBox}`) // Ensure WebSocket connection is established first if (this.socket == null) { Logger.log('[MB CLIENT] No WebSocket connection. Initializing...') await this.initializeConnection(overrideHost) } if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { throw new Error('[MB CLIENT ERROR] Identity key is not defined') } const roomId = `${this.myIdentityKey ?? ''}-${messageBox}` if (this.joinedRooms.has(roomId)) { Logger.log(`[MB CLIENT] Already joined WebSocket room: ${roomId}`) return } try { Logger.log(`[MB CLIENT] Joining WebSocket room: ${roomId}`) await this.socket?.emit('joinRoom', roomId) this.joinedRooms.add(roomId) Logger.log(`[MB CLIENT] Successfully joined room: ${roomId}`) } catch (error) { Logger.error(`[MB CLIENT ERROR] Failed to join WebSocket room: ${roomId}`, error) } } /** * @method listenForLiveMessages * @async * @param {Object} params - Configuration for the live message listener. * @param {function} params.onMessage - A callback function that will be triggered when a new message arrives. * @param {string} params.messageBox - The messageBox name (e.g., `payment_inbox`) to listen for. * @returns {Promise<void>} * * @description * Subscribes the client to live messages over WebSocket for a specific messageBox. * * This method: * - Ensures the WebSocket connection is initialized and authenticated. * - Joins the correct room formatted as `${identityKey}-${messageBox}`. * - Listens for messages broadcast to the room. * - Automatically attempts to parse and decrypt message bodies. * - Emits the final message (as a `PeerMessage`) to the supplied `onMessage` handler. * * If the incoming message is encrypted, the client decrypts it using AES-256-GCM via * ECDH shared secrets derived from identity keys as defined in [BRC-2](https://github.com/bitcoin-sv/BRCs/blob/master/wallet/0002.md). * Messages sent by the client to itself are decrypted using `counterparty = 'self'`. * * @example * await client.listenForLiveMessages({ * messageBox: 'payment_inbox', * onMessage: (msg) => console.log('Received live message:', msg) * }) */ async listenForLiveMessages ({ onMessage, messageBox, overrideHost }: { onMessage: (message: PeerMessage) => void messageBox: string overrideHost?: string }): Promise<void> { Logger.log(`[MB CLIENT] Setting up listener for WebSocket room: ${messageBox}`) // Ensure WebSocket connection is established first if (this.socket == null) { Logger.log('[MB CLIENT] No WebSocket connection. Initializing...') await this.initializeConnection(overrideHost) } // Join the room await this.joinRoom(messageBox, this.originator) // Ensure identity key is available before creating roomId if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { throw new Error('[MB CLIENT ERROR] Identity key is missing. Cannot construct room ID.') } const roomId = `${this.myIdentityKey}-${messageBox}` Logger.log(`[MB CLIENT] Listening for messages in room: ${roomId}`) this.socket?.on(`sendMessage-${roomId}`, (message: PeerMessage) => { void (async () => { Logger.log(`[MB CLIENT] Received message in room ${roomId}:`, message) try { let parsedBody: unknown = message.body if (typeof parsedBody === 'string') { try { parsedBody = JSON.parse(parsedBody) } catch { // Leave it as-is (plain text) } } if ( parsedBody != null && typeof parsedBody === 'object' && typeof (parsedBody as any).encryptedMessage === 'string' ) { Logger.log(`[MB CLIENT] Decrypting message from ${String(message.sender)}...`) const decrypted = await this.walletClient.decrypt({ protocolID: [1, 'messagebox'], keyID: '1', counterparty: message.sender, ciphertext: Utils.toArray((parsedBody as any).encryptedMessage, 'base64') }, this.originator) message.body = Utils.toUTF8(decrypted.plaintext) } else { Logger.log('[MB CLIENT] Message is not encrypted.') message.body = typeof parsedBody === 'string' ? parsedBody : (() => { try { return JSON.stringify(parsedBody) } catch { return '[Error: Unstringifiable message]' } })() } } catch (err) { Logger.error('[MB CLIENT ERROR] Failed to parse or decrypt live message:', err) message.body = '[Error: Failed to decrypt or parse message]' } onMessage(message) })() }) } /** * @method sendLiveMessage * @async * @param {SendMessageParams} param0 - The message parameters including recipient, box name, body, and options. * @returns {Promise<SendMessageResponse>} A success response with the generated messageId. * * @description * Sends a message in real time using WebSocket with authenticated delivery and overlay fallback. * * This method: * - Ensures the WebSocket connection is open and joins the correct room. * - Derives a unique message ID using an HMAC of the message body and counterparty identity key. * - Encrypts the message body using AES-256-GCM based on the ECDH shared secret between derived keys, per [BRC-2](https://github.com/bitcoin-sv/BRCs/blob/master/wallet/0002.md), * unless `skipEncryption` is explicitly set to `true`. * - Sends the message to a WebSocket room in the format `${recipient}-${messageBox}`. * - Waits for acknowledgment (`sendMessageAck-${roomId}`). * - If no acknowledgment is received within 10 seconds, falls back to `sendMessage()` over HTTP. * * This hybrid delivery strategy ensures reliability in both real-time and offline-capable environments. * * @throws {Error} If message validation fails, HMAC generation fails, or both WebSocket and HTTP fail to deliver. * * @example * await client.sendLiveMessage({ * recipient: '028d...', * messageBox: 'payment_inbox', * body: { amount: 1000 } * }) */ async sendLiveMessage ({ recipient, messageBox, body, messageId, skipEncryption, checkPermissions, }: SendMessageParams, overrideHost?: string): Promise<SendMessageResponse> { if (recipient == null || recipient.trim() === '') { throw new Error('[MB CLIENT ERROR] Recipient identity key is required') } if (messageBox == null || messageBox.trim() === '') { throw new Error('[MB CLIENT ERROR] MessageBox is required') } if (body == null || (typeof body === 'string' && body.trim() === '')) { throw new Error('[MB CLIENT ERROR] Message body cannot be empty') } // Ensure room is joined before sending await this.joinRoom(messageBox, this.originator) // Fallback to HTTP if WebSocket is not connected if (this.socket == null || !this.socket.connected) { Logger.warn('[MB CLIENT WARNING] WebSocket not connected, falling back to HTTP') const targetHost = overrideHost ?? await this.resolveHostForRecipient(recipient) return await this.sendMessage({ recipient, messageBox, body }, targetHost) } let finalMessageId: string try { const hmac = await this.walletClient.createHmac({ data: Array.from(new TextEncoder().encode(JSON.stringify(body))), protocolID: [1, 'messagebox'], keyID: '1', counterparty: recipient }, this.originator) finalMessageId = messageId ?? Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('') } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to generate HMAC:', error) throw new Error('Failed to generate message identifier.') } const roomId = `${recipient}-${messageBox}` Logger.log(`[MB CLIENT] Sending WebSocket message to room: ${roomId}`) let outgoingBody: string if (skipEncryption === true) { outgoingBody = typeof body === 'string' ? body : JSON.stringify(body) } else { const encryptedMessage = await this.walletClient.encrypt({ protocolID: [1, 'messagebox'], keyID: '1', counterparty: recipient, plaintext: Utils.toArray(typeof body === 'string' ? body : JSON.stringify(body), 'utf8') }, this.originator) outgoingBody = JSON.stringify({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) }) } return await new Promise((resolve, reject) => { const ackEvent = `sendMessageAck-${roomId}` let handled = false const ackHandler = (response?: SendMessageResponse): void => { if (handled) return handled = true const socketAny = this.socket as any if (typeof socketAny?.off === 'function') { socketAny.off(ackEvent, ackHandler) } Logger.log('[MB CLIENT] Received WebSocket acknowledgment:', response) if (response == null || response.status !== 'success') { Logger.warn('[MB CLIENT] WebSocket message failed or returned unexpected response. Falling back to HTTP.') const fallbackMessage: SendMessageParams = { recipient, messageBox, body, messageId: finalMessageId, skipEncryption, checkPermissions } this.resolveHostForRecipient(recipient) .then(async (host) => { return await this.sendMessage(fallbackMessage, host) }) .then(resolve) .catch(reject) } else { Logger.log('[MB CLIENT] Message sent successfully via WebSocket:', response) resolve(response) } } // Attach acknowledgment listener this.socket?.on(ackEvent, ackHandler) // Emit message to room this.socket?.emit('sendMessage', { roomId, message: { messageId: finalMessageId, recipient, body: outgoingBody } }) // Timeout: Fallback to HTTP if no acknowledgment received setTimeout(() => { if (!handled) { handled = true const socketAny = this.socket as any if (typeof socketAny?.off === 'function') { socketAny.off(ackEvent, ackHandler) } Logger.warn('[CLIENT] WebSocket acknowledgment timed out, falling back to HTTP') const fallbackMessage: SendMessageParams = { recipient, messageBox, body, messageId: finalMessageId, skipEncryption, checkPermissions } this.resolveHostForRecipient(recipient) .then(async (host) => { return await this.sendMessage(fallbackMessage, host) }) .then(resolve) .catch(reject) } }, 10000) }) } /** * @method leaveRoom * @async * @param {string} messageBox - The name of the WebSocket room to leave (e.g., `payment_inbox`). * @returns {Promise<void>} * * @description * Leaves a previously joined WebSocket room associated with the authenticated identity key. * This helps reduce unnecessary message traffic and memory usage. * * If the WebSocket is not connected or the identity key is missing, the method exits gracefully. * * @example * await client.leaveRoom('payment_inbox') */ async leaveRoom (messageBox: string): Promise<void> { await this.assertInitialized() if (this.socket == null) { Logger.warn('[MB CLIENT] Attempted to leave a room but WebSocket is not connected.') return } if (this.myIdentityKey == null || this.myIdentityKey.trim() === '') { throw new Error('[MB CLIENT ERROR] Identity key is not defined') } const roomId = `${this.myIdentityKey}-${messageBox}` Logger.log(`[MB CLIENT] Leaving WebSocket room: ${roomId}`) this.socket.emit('leaveRoom', roomId) // Ensure the room is removed from tracking this.joinedRooms.delete(roomId) } /** * @method disconnectWebSocket * @async * @returns {Promise<void>} Resolves when the WebSocket connection is successfully closed. * * @description * Gracefully disconnects the WebSocket connection to the MessageBox server. * This should be called when the client is shutting down, logging out, or no longer * needs real-time communication to conserve system resources. * * @example * await client.disconnectWebSocket() */ async disconnectWebSocket (): Promise<void> { await this.assertInitialized() if (this.socket != null) { Logger.log('[MB CLIENT] Closing WebSocket connection...') this.socket.disconnect() this.socket = undefined } else { Logger.log('[MB CLIENT] No active WebSocket connection to close.') } } /** * @method sendMessage * @async * @param {SendMessageParams} message - Contains recipient, messageBox name, message body, optional messageId, and skipEncryption flag. * @param {string} [overrideHost] - Optional host to override overlay resolution (useful for testing or private routing). * @returns {Promise<SendMessageResponse>} - Resolves with `{ status, messageId }` on success. * * @description * Sends a message over HTTP to a recipient's messageBox. This method: * * - Derives a deterministic `messageId` using an HMAC of the message body and recipient key. * - Encrypts the message body using AES-256-GCM, derived from a shared secret using BRC-2-compliant key derivation and ECDH, unless `skipEncryption` is set to true. * - Automatically resolves the host via overlay LookupResolver unless an override is provided. * - Authenticates the request using the current identity key with `AuthFetch`. * * This is the fallback mechanism for `sendLiveMessage` when WebSocket delivery fails. * It is also used for message types that do not require real-time delivery. * * @throws {Error} If validation, encryption, HMAC, or network request fails. * * @example * await client.sendMessage({ * recipient: '03abc...', * messageBox: 'notifications', * body: { type: 'ping' } * }) */ async sendMessage ( message: SendMessageParams, overrideHost?: string, ): Promise<SendMessageResponse> { await this.assertInitialized() if (message.recipient == null || message.recipient.trim() === '') { throw new Error('You must provide a message recipient!') } if (message.messageBox == null || message.messageBox.trim() === '') { throw new Error('You must provide a messageBox to send this message into!') } if (message.body == null || (typeof message.body === 'string' && message.body.trim().length === 0)) { throw new Error('Every message must have a body!') } // Optional permission checking for backwards compatibility let paymentData: Payment | undefined if (message.checkPermissions === true) { try { Logger.log('[MB CLIENT] Checking permissions and fees for message...') // Get quote to check if payment is required const quote = await this.getMessageBoxQuote({ recipient: message.recipient, messageBox: message.messageBox }, overrideHost) if (quote.recipientFee === -1) { throw new Error('You have been blocked from sending messages to this recipient.') } if (quote.recipientFee > 0 || quote.deliveryFee > 0) { const requiredPayment = quote.recipientFee + quote.deliveryFee if (requiredPayment > 0) { Logger.log(`[MB CLIENT] Creating payment of ${requiredPayment} sats for message...`) // Create payment using helper method paymentData = await this.createMessagePayment( message.recipient, quote, overrideHost ) Logger.log('[MB CLIENT] Payment data prepared:', paymentData) } } } catch (error) { throw new Error(`Permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } } let messageId: string try { const hmac = await this.walletClient.createHmac({ data: Array.from(new TextEncoder().encode(JSON.stringify(message.body))), protocolID: [1, 'messagebox'], keyID: '1', counterparty: message.recipient }, this.originator) messageId = message.messageId ?? Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('') } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to generate HMAC:', error) throw new Error('Failed to generate message identifier.') } let finalBody: string | EncryptedMessage if (message.skipEncryption === true) { finalBody = typeof message.body === 'string' ? message.body : JSON.stringify(message.body) } else { const encryptedMessage = await this.walletClient.encrypt({ protocolID: [1, 'messagebox'], keyID: '1', counterparty: message.recipient, plaintext: Utils.toArray(typeof message.body === 'string' ? message.body : JSON.stringify(message.body), 'utf8') }, this.originator) finalBody = JSON.stringify({ encryptedMessage: Utils.toBase64(encryptedMessage.ciphertext) }) } const requestBody = { message: { ...message, messageId, body: finalBody }, ...(paymentData != null && { payment: paymentData }) } try { const finalHost = overrideHost ?? await this.resolveHostForRecipient(message.recipient) Logger.log('[MB CLIENT] Sending HTTP request to:', `${finalHost}/sendMessage`) Logger.log('[MB CLIENT] Request Body:', JSON.stringify(requestBody, null, 2)) if (this.myIdentityKey == null || this.myIdentityKey === '') { try { const keyResult = await this.walletClient.getPublicKey({ identityKey: true }, this.originator) this.myIdentityKey = keyResult.publicKey Logger.log(`[MB CLIENT] Fetched identity key before sending request: ${this.myIdentityKey}`) } catch (error) { Logger.error('[MB CLIENT ERROR] Failed to fetch identity key:', error) throw new Error('Identity key retrieval failed') } } const response = await this.authFetch.fetch(`${finalHost}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) if (response.bodyUsed) { throw new Error('[MB CLIENT ERROR] Response body has already been used!') } const parsedResponse = await response.json() Logger.log('[MB CLIENT] Raw Response Body:', parsedResponse) if (!response.ok) { Logger.error(`[MB CLIENT ERROR] Failed to send message. HTTP ${response.status}: ${response.statusText}`) throw new Error(`Message sending failed: HTTP ${response.status} - ${response.statusText}`) } if (parsedResponse.status !== 'success') { Logger.error(`[MB CLIENT ERROR] Server returned an error: ${String(parsedResponse.description)}`) throw new Error(parsedResponse.description ?? 'Unknown error from server.') } Logger.log('[MB CLIENT] Message successfully sent.') return { ...parsedResponse, messageId } } catch (error) { Logger.error('[MB CLIENT ERROR] Network or timeout error:', error) const errorMessage = error instanceof Error ? error.message : 'Unknown error' throw new Error(`Failed to send message: ${errorMessage}`) } } /** * @method anointHost * @async * @param {string} host - The full URL of the server you want to designate as your MessageBox host (e.g., "https://mybox.com"). * @returns {Promise<{ txid: string }>} - The transaction ID of the advertisement broadcast to the overlay network. * * @description * Broadcasts a signed overlay advertisement using a PushDrop output under the `tm_messagebox` topic. * This advertisement announces that the specified `host` is now authorized to receive and route * messages for the sender’s identity key. * * The broadcasted message includes: * - The identity key * - The chosen host URL * * This is essential for enabling overlay-based message delivery via SHIP and LookupResolver. * The recipient’s host must advertise itself for message routing to succeed in a decentralized manner. * * @throws {Error} If the URL is invalid, the PushDrop creation fails, or the overlay broadcast does not succeed. * * @example * const { txid } = await client.anointHost('https://my-messagebox.io') */ async anointHost (host: string): Promise<{ txid: string }> { Logger.log('[MB CLIENT] Starting anointHost...') try { if (!host.startsWith('http')) { throw new Error('Invalid host URL') } const identityKey = await this.getIdentityKey() Logger.log('[MB CLIENT] Fields - Identity:', identityKey, 'Host:', host) const fields: number[][] = [ Utils.toArray(identityKey, 'hex'), Utils.toArray(host, 'utf8') ] const pushdrop = new PushDrop(this.walletClient, this.originator) Logger.log('Fields:', fields.map(a => Utils.toHex(a))) Logger.log('ProtocolID:', [1, 'messagebox advertisement']) Logger.log('KeyID:', '1') Logger.log('SignAs:', 'self') Logger.log('anyoneCanSpend:', false) Logger.log('forSelf:', true) const script = await pushdrop.lock( fields, [1, 'messagebox advertisement'], '1', 'anyone', true ) Logger.log('[MB CLIENT] PushDrop script:', script.toASM()) const { tx, txid } = await this.walletClient.createAction({ description: 'Anoint host for overlay routing', outputs: [{ basket: 'overlay advertisements', lockingScript: script.toHex(), satoshis: 1, outputDescription: 'Overlay advertisement output' }], options: { randomizeOutputs: false, acceptDelayedBroadcast: false } }, this.originator) Logger.log('[MB CLIENT] Transaction created:', txid) if (tx !== undefined) { const broadcaster = new TopicBroadcaster(['tm_messagebox'], { networkPreset: this.networkPreset }) const result = await broadcaster.broadcast(Transaction.fromAtomicBEEF(tx)) Logger.log('[MB CLIENT] Advertisement broadcast succeeded. TXID:', result.txid) if (typeof result.txid !== 'string') { throw new Error('Anoint failed: broadcast did not return a txid') } return { txid: result.txid } } throw new Error('Anoint failed: failed to create action!') } catch (err) { Logger.error('[MB CLIENT ERROR] anointHost threw:', err) throw err } } /** * @method revokeHostAdvertisement * @async * @param {AdvertisementToken} advertisementToken - The advertisement token containing the messagebox host to revoke. * @param {string} [originator] - Optional originator to use with walletClient. * @returns {Promise<{ txid: string }>} - The transaction ID of the revocation broadcast to the overlay network. * * @description * Broadcasts a signed revocation transaction indicating the advertisement token should be removed * and no longer tracked by lookup services. * * @example * const { txid } = await client.revokeHost('https://my-messagebox.io') */ async revokeHostAdvertisement (advertisementToken: AdvertisementToken): Promise<{ txid: string }> { Logger.log('[MB CLIENT] Starting revokeHost...') const outpoint = `${advertisementToken.txid}.${advertisementToken.outputIndex}` try { const { signableTransaction } = await this.walletClient.createAction({ description: 'Revoke MessageBox host advertisement', inputBEEF: advertisementToken.beef, inputs: [ { outpoint, unlockingScriptLength: 73, inputDescription: 'Revoking host advertisement token' } ] }, this.originator) if (signableTransaction === undefined) { throw new Error('Failed to create signable transaction.') } const partialTx = Transaction.fromBEEF(signableTransaction.tx) // Prepare the unlocker const pushdrop = new PushDrop(this.walletClient, this.originator) const unlocker = await pushdrop.unlock( [1, 'messagebox advertisement'], '1', 'anyone', 'all', false, advertisementToken.outputIndex, advertisementToken.lockingScript ) // Convert to Transaction, apply signature const finalUnlockScript = await unlocker.sign(partialTx, advertisementToken.outputIndex) // Complete signing with the final unlock script const { tx: signedTx } = await this.walletClient.signAction({ reference: signableTransaction.reference, spends: { [advertisementToken.outputIndex]: { unlockingScript: finalUnlockScript.toHex() } }, options: { acceptDelayedBroadcast: false } }, this.originator) if (signedTx === undefined) { throw new Error('Failed to finalize the transaction signature.') } const broadcaster = new TopicBroadcaster(['tm_messagebox'], { networkPreset: this.networkPreset }) const result = await broadcaster.broadcast(Transaction.fromAtomicBEEF(signedTx)) Logger.log('[MB CLIENT] Revocation broadcast succeeded. TXID:', result.txid) if (typeof result.txid !== 'string') { throw new Error('Revoke failed: broadcast did not return a txid') } return { txid: result.txid } } catch (err) { Logger.error('[MB CLIENT ERROR] revokeHost threw:', err) throw err } } /** * @method listMessages * @async * @param {ListMessagesParams} params - Contains the name of the messageBox to read from. * @returns {Promise<PeerMessage[]>} - Returns an array of decrypted `PeerMessage` objects. * * @description * Retrieves all messages from the specified `messageBox` assigned to the current identity key. * Unless a host override is provided, messages are fetched from the resolved overlay host (via LookupResolver) or the default host if no advertisement is found. * * Each message is: * - Parsed and, if encrypted, decrypted using AES-256-GCM via BRC-2-compliant ECDH key derivation and symmetric encryption. * - Automatically processed for payments: if the message includes recipient fee payments, they are internalized using `walletClient.internalizeAction()`. * - Returned as a normalized `PeerMessage` with readable string body content. * * Payment Processing: * - Detects messages that include payment data (from paid message delivery). * - Automatically internalizes recipient payment outputs, allowing you to receive payments without additional API calls. * - Only recipient payments are stored with messages - delivery fees are already processed by the server. * - Continues processing messages even if payment internalization fails. * * Decryption automatically derives a shared secret using the sender's identity key and the receiver's child private key. * If the sender is the same as the recipient, the `counterparty` is set to `'self'`. * * @throws {Error} If no messageBox is specified, the request fails, or the server returns an error. * * @example * const messages = await client.listMessages({ messageBox: 'inbox' }) * messages.forEach(msg => console.log(msg.sender, msg.body)) * // Payments included with messages are automatically received */ async listMessages ({ messageBox, host, acceptPayments }: ListMessagesParams): Promise<PeerMessage[]> { if (typeof acceptPayments !== 'boolean') { acceptPayments = true } if (messageBox.trim() === '') { throw new Error('MessageBox cannot be empty') } let hosts: string[] = host != null ? [host] : [] if (hosts.length === 0) { const advertisedHosts = await this.queryAdvertisements( await this.getIdentityKey(), undefined, ) hosts = Array.from(new Set([this.host, ...advertisedHosts.map(h => h.host)])) } // Query each host in parallel const fetchFromHost = async (host: string): Promise<PeerMessage[]> => { try { Logger.log(`[MB CLIENT] Listing messages from ${host}…`) const res = await this.authFetch.fetch(`${host}/listMessages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageBox }) }) if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`) const data = await res.json() if (data.status === 'error') throw new Error(data.description ?? 'Unknown server error') return data.messages as PeerMessage[] } catch (err) { Logger.log(`[MB CLIENT DEBUG] listMessages failed for ${host}:`, err) throw err // re-throw to be caught in the settled promise } } const settled = await Promise.allSettled(hosts.map(fetchFromHost)) // 3. Split successes / failures const messagesByHost: PeerMessage[][] = [] const errors: any[] = [] for (const r of settled) { if (r.status === 'fulfilled') { messagesByHost.push(r.value) } else { errors.push(r.reason) } } // 4. If *every* host failed – throw aggregated error if (messagesByHost.length === 0) { throw new Error('Failed to retrieve messages from any host') } // 5. Merge & de‑duplicate (first‑seen wins) const dedupMap = new Map<string, PeerMessage>() for (const messageList of messagesByHost) { for (const m of messageList) { if (!dedupMap.has(m.messageId)) dedupMap.set(m.messageId, m) } } // 6. Early‑out: no messages but at least one host succeeded → [] if (dedupMap.size === 0) return [] const messages: PeerMessage[] = Array.from(dedupMap.values()) for (const message of messages) { try { const parsedBody: unknown = typeof message.body === 'string' ? this.tryParse(message.body) : message.body let messageContent: any = parsedBody let paymentData: Payment | undefined if ( parsedBody != null && typeof parsedBody === 'object' && 'message' in parsedBody ) { // Handle wrapped message format (with payment data) const wrappedMessage = (parsedBody as any).message messageContent = typeof wrappedMessage === 'string' ? this.tryParse(wrappedMessage) : wrappedMessage paymentData = (parsedBody as any).payment } // Process payment if present - server now only stores recipient payments if (acceptPayments && paymentData?.tx != null && paymentData.outputs != null) { try { Logger.log( `[MB CLIENT] Processing recipient payment in