UNPKG

@bsv/message-box-client

Version:

A client for P2P messaging and payments

1,311 lines (1,174 loc) 99.3 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, MessageBoxMultiQuote, MessageBoxQuote, ListPermissionsParams, GetQuoteParams, SendListParams, SendListResult, } 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) as MessageBoxQuote 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}`) } } /** * Multi-recipient sender. Uses the multi-quote route to: * - identify blocked recipients * - compute per-recipient payment * Then sends to the allowed recipients with payment attached. */ async sendMesagetoRecepients( params: SendListParams, overrideHost?: string ): Promise<SendListResult> { await this.assertInitialized() const { recipients, messageBox, body, skipEncryption } = params if (!Array.isArray(recipients) || recipients.length === 0) { throw new Error('You must provide at least one recipient!') } if (!messageBox || messageBox.trim() === '') { throw new Error('You must provide a messageBox to send this message into!') } if (body == null || (typeof body === 'string' && body.trim().length === 0)) { throw new Error('Every message must have a body!') } // 1) Multi-quote for all recipients const quoteResponse = await this.getMessageBoxQuote({ recipient: recipients, messageBox }, overrideHost) as MessageBoxMultiQuote const quotesByRecipient = Array.isArray(quoteResponse?.quotesByRecipient) ? quoteResponse.quotesByRecipient : [] const blocked = (quoteResponse?.blockedRecipients ?? []) as string[] const totals = quoteResponse?.totals // 2) Filter allowed recipients const allowedRecipients = recipients.filter(r => !blocked.includes(r)) if (allowedRecipients.length === 0) { return { status: 'error', description: `All ${recipients.length} recipients are blocked.`, sent: [], blocked, failed: recipients.map(r => ({ recipient: r, error: 'blocked' })), totals } } // 3) Map recipient -> fees const perRecipientQuotes = new Map<string, { recipientFee: number, deliveryFee: number }>() for (const q of quotesByRecipient) { perRecipientQuotes.set(q.recipient, { recipientFee: q.recipientFee, deliveryFee: q.deliveryFee }) } // 4) One delivery agent only (batch goes to one server) const { deliveryAgentIdentityKeyByHost } = quoteResponse if (!deliveryAgentIdentityKeyByHost || Object.keys(deliveryAgentIdentityKeyByHost).length === 0) { throw new Error('Missing delivery agent identity keys in quote response.') } if (Object.keys(deliveryAgentIdentityKeyByHost).length > 1 && !overrideHost) { // To keep the single-POST invariant, we require all recipients to share a host throw new Error('Recipients resolve to multiple hosts. Use overrideHost to force a single server or split by host.') } // pick the host to POST to const finalHost = (overrideHost ?? await this.resolveHostForRecipient(allowedRecipients[0])).replace(/\/+$/,'') const singleDeliveryKey = deliveryAgentIdentityKeyByHost[finalHost] ?? Object.values(deliveryAgentIdentityKeyByHost)[0] if (!singleDeliveryKey) { throw new Error('Could not determine server delivery agent identity key.') } // 5) Identity key (sender) if (!this.myIdentityKey) { const keyResult = await this.walletClient.getPublicKey({ identityKey: true }, this.originator) this.myIdentityKey = keyResult.publicKey } // 6) Build per-recipient messageIds (HMAC), same order as allowedRecipients const messageIds: string[] = [] for (const r of allowedRecipients) { const hmac = await this.walletClient.createHmac({ data: Array.from(new TextEncoder().encode(JSON.stringify(body))), protocolID: [1, 'messagebox'], keyID: '1', counterparty: r }, this.originator) const mid = Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('') messageIds.push(mid) } // 7) Body: for batch route the server expects a single shared body // NOTE: If you need per-recipient encryption, we must change the server payload shape. let finalBody: string if (skipEncryption === true) { finalBody = typeof body === 'string' ? body : JSON.stringify(body) } else { // safest for now: send plaintext; the recipients can decrypt payload fields client-side if needed finalBody = typeof body === 'string' ? body : JSON.stringify(body) } // 8) ONE batch payment with server output at index 0 const paymentData = await this.createMessagePaymentBatch( allowedRecipients, perRecipientQuotes, singleDeliveryKey ) // 9) Single POST to /sendMessage with recipients[] + messageId[] const requestBody = { message: { recipients: allowedRecipients, messageBox, messageId: messageIds, // aligned by index with recipients body: finalBody }, payment: paymentData } Logger.log('[MB CLIENT] Sending HTTP request to:', `${finalHost}/sendMessage`) Logger.log('[MB CLIENT] Request Body (batch):', JSON.stringify({ ...requestBody, payment: { ...paymentData, tx: '<omitted>' } }, null, 2)) try { const response = await this.authFetch.fetch(`${finalHost}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }) const parsed = await response.json().catch(() => ({} as any)) if (!response.ok || parsed.status !== 'success') { const msg = !response.ok ? `HTTP ${response.status} - ${response.statusText}` : (parsed.description ?? 'Unknown server error') throw new Error(msg) } // server returns { results: [{ recipient, messageId }] } const sent = Array.isArray(parsed.results) ? parsed.results : [] const failed: Array<{ recipient: string, error: string }> = [] // handled server-side now const status: SendListResult['status'] = sent.length === allowedRecipients.length ? 'success' : sent.length > 0 ? 'partial' : 'error' const description = status === 'success' ? `Sent to ${sent.length} recipients.` : status === 'partial' ? `Sent to ${sent.length} recipients; ${allowedRecipients.length - sent.length} failed; ${blocked.length} blocked.` : `Failed to send to ${allowedRecipients.length} allowed recipients. ${blocked.length} blocked.` return { status, description, sent, blocked, failed, totals } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error' return { status: 'error', description: `Batch send failed: ${msg}`, sent: [], blocked, failed: allowedRecipients.map(r => ({ recipient: r, error: msg })), totals } } } /** * @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 finalUnlockScr