@bsv/message-box-client
Version:
A client for P2P messaging and payments
1,021 lines • 112 kB
JavaScript
"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