@bsv/message-box-client
Version:
A client for P2P messaging and payments
1,045 lines • 89.9 kB
JavaScript
/**
* @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, P2PKH, PublicKey, ProtoWallet, Random } from '@bsv/sdk';
import { AuthSocketClient } from '@bsv/authsocket-client';
import * as Logger from './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' })
*/
export class MessageBoxClient {
host;
authFetch;
walletClient;
socket;
myIdentityKey;
joinedRooms = new Set();
lookupResolver;
networkPreset;
initialized = false;
originator;
/**
* @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 = {}) {
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 = this.host) {
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.
*/
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 ?? 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((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 ?? 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, overrideHost) {
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 }) {
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) => {
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: Utils.toArray(parsedBody.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, }, 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 ?? 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 ?? 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: 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) => {
if (handled)
return;
handled = true;
const socketAny = this.socket;
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 = {
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;
if (typeof 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) {
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 = 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;
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) {
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 = [
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) {
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 }) {
if (typeof acceptPayments !== 'boolean') {
accep