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