UNPKG

@bsv/message-box-client

Version:

A client for P2P messaging and payments

1,021 lines 112 kB
"use strict"; /** * @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 */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.MessageBoxClient = void 0; const sdk_1 = require("@bsv/sdk"); const authsocket_client_1 = require("@bsv/authsocket-client"); const Logger = __importStar(require("./Utils/logger.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' }) */ class MessageBoxClient { /** * @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 = {}) { var _a; this.joinedRooms = new Set(); this.initialized = false; const { host, walletClient, enableLogging = false, networkPreset = 'mainnet', originator = undefined } = options; const defaultHost = this.networkPreset === 'testnet' ? DEFAULT_TESTNET_HOST : DEFAULT_MAINNET_HOST; this.host = (_a = host === null || host === void 0 ? void 0 : host.trim()) !== null && _a !== void 0 ? _a : defaultHost; this.originator = originator; this.walletClient = walletClient !== null && walletClient !== void 0 ? walletClient : new sdk_1.WalletClient('auto', originator); this.authFetch = new sdk_1.AuthFetch(this.walletClient, undefined, undefined, originator); this.networkPreset = networkPreset; this.lookupResolver = new sdk_1.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 = this.host) { var _a; const normalizedHost = targetHost === null || targetHost === void 0 ? void 0 : 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 || ((_a = firstAdvertisement === null || firstAdvertisement === void 0 ? void 0 : firstAdvertisement.host) === null || _a === void 0 ? void 0 : _a.trim()) === '' || (firstAdvertisement === null || firstAdvertisement === void 0 ? void 0 : 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. */ async assertInitialized() { 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. */ getJoinedRooms() { 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. */ async getIdentityKey() { 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. */ get testSocket() { 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) { 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 !== null && overrideHost !== void 0 ? overrideHost : this.host; if (typeof targetHost !== 'string' || targetHost.trim() === '') { throw new Error('Cannot initialize WebSocket: No valid host provided'); } this.socket = (0, authsocket_client_1.AuthSocketClient)(targetHost, { wallet: this.walletClient, originator: this.originator }); let identitySent = false; let authenticated = false; this.socket.on('connect', () => { var _a; 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 { (_a = this.socket) === null || _a === void 0 ? void 0 : _a.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((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) { 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, host) { const hosts = []; try { const query = { identityKey: identityKey !== null && identityKey !== void 0 ? 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 = sdk_1.Transaction.fromBEEF(output.beef); const script = tx.outputs[output.outputIndex].lockingScript; const token = sdk_1.PushDrop.decode(script); const [, hostBuf] = token.fields; if (hostBuf == null || hostBuf.length === 0) { throw new Error('Empty host field'); } hosts.push({ host: sdk_1.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, overrideHost) { var _a, _b; 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 = `${(_a = this.myIdentityKey) !== null && _a !== void 0 ? _a : ''}-${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 ((_b = this.socket) === null || _b === void 0 ? void 0 : _b.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 }) { var _a; 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}`); (_a = this.socket) === null || _a === void 0 ? void 0 : _a.on(`sendMessage-${roomId}`, (message) => { void (async () => { Logger.log(`[MB CLIENT] Received message in room ${roomId}:`, message); try { let parsedBody = 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.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: sdk_1.Utils.toArray(parsedBody.encryptedMessage, 'base64') }, this.originator); message.body = sdk_1.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 }, overrideHost) { 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 !== null && overrideHost !== void 0 ? overrideHost : await this.resolveHostForRecipient(recipient); return await this.sendMessage({ recipient, messageBox, body }, targetHost); } let finalMessageId; 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 !== null && messageId !== void 0 ? 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; 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: sdk_1.Utils.toArray(typeof body === 'string' ? body : JSON.stringify(body), 'utf8') }, this.originator); outgoingBody = JSON.stringify({ encryptedMessage: sdk_1.Utils.toBase64(encryptedMessage.ciphertext) }); } return await new Promise((resolve, reject) => { var _a, _b; const ackEvent = `sendMessageAck-${roomId}`; let handled = false; const ackHandler = (response) => { if (handled) return; handled = true; const socketAny = this.socket; if (typeof (socketAny === null || socketAny === void 0 ? void 0 : 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 = { 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 (_a = this.socket) === null || _a === void 0 ? void 0 : _a.on(ackEvent, ackHandler); // Emit message to room (_b = this.socket) === null || _b === void 0 ? void 0 : _b.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; if (typeof (socketAny === null || socketAny === void 0 ? void 0 : socketAny.off) === 'function') { socketAny.off(ackEvent, ackHandler); } Logger.warn('[CLIENT] WebSocket acknowledgment timed out, falling back to HTTP'); const fallbackMessage = { 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) { 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() { 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, overrideHost) { var _a, _b; 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; 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; 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 = (_a = message.messageId) !== null && _a !== void 0 ? _a : 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; 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: sdk_1.Utils.toArray(typeof message.body === 'string' ? message.body : JSON.stringify(message.body), 'utf8') }, this.originator); finalBody = JSON.stringify({ encryptedMessage: sdk_1.Utils.toBase64(encryptedMessage.ciphertext) }); } const requestBody = { message: { ...message, messageId, body: finalBody }, ...(paymentData != null && { payment: paymentData }) }; try { const finalHost = overrideHost !== null && overrideHost !== void 0 ? 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((_b = parsedResponse.description) !== null && _b !== void 0 ? _b : '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, overrideHost) { var _a, _b, _c; 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); const quotesByRecipient = Array.isArray(quoteResponse === null || quoteResponse === void 0 ? void 0 : quoteResponse.quotesByRecipient) ? quoteResponse.quotesByRecipient : []; const blocked = ((_a = quoteResponse === null || quoteResponse === void 0 ? void 0 : quoteResponse.blockedRecipients) !== null && _a !== void 0 ? _a : []); const totals = quoteResponse === null || quoteResponse === void 0 ? void 0 : 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(); 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 !== null && overrideHost !== void 0 ? overrideHost : await this.resolveHostForRecipient(allowedRecipients[0])).replace(/\/+$/, ''); const singleDeliveryKey = (_b = deliveryAgentIdentityKeyByHost[finalHost]) !== null && _b !== void 0 ? _b : 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 = []; 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; 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(() => ({})); if (!response.ok || parsed.status !== 'success') { const msg = !response.ok ? `HTTP ${response.status} - ${response.statusText}` : ((_c = parsed.description) !== null && _c !== void 0 ? _c : 'Unknown server error'); throw new Error(msg); } // server returns { results: [{ recipient, messageId }] } const sent = Array.isArray(parsed.results) ? parsed.results : []; const failed = []; // handled server-side now const status = sent.length === allowedRecipient