@bsv/message-box-client
Version:
A client for P2P messaging and payments
313 lines • 15 kB
JavaScript
/**
* PeerPayClient
*
* Extends `MessageBoxClient` to enable Bitcoin payments using the MetaNet identity system.
*
* This client handles payment token creation, message transmission over HTTP/WebSocket,
* payment reception (including acceptance and rejection logic), and listing of pending payments.
*
* It uses authenticated and encrypted message transmission to ensure secure payment flows
* between identified peers on the BSV network.
*/
import { MessageBoxClient } from './MessageBoxClient.js';
import { P2PKH, PublicKey, createNonce, AuthFetch } from '@bsv/sdk';
import * as Logger from './Utils/logger.js';
function safeParse(input) {
try {
return typeof input === 'string' ? JSON.parse(input) : input;
}
catch (e) {
Logger.error('[PP CLIENT] Failed to parse input in safeParse:', input);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fallback = {};
return fallback;
}
}
export const STANDARD_PAYMENT_MESSAGEBOX = 'payment_inbox';
const STANDARD_PAYMENT_OUTPUT_INDEX = 0;
/**
* PeerPayClient enables peer-to-peer Bitcoin payments using MessageBox.
*/
export class PeerPayClient extends MessageBoxClient {
peerPayWalletClient;
_authFetchInstance;
constructor(config) {
const { messageBoxHost = 'https://messagebox.babbage.systems', walletClient, enableLogging = false, originator } = config;
// 🔹 Pass enableLogging to MessageBoxClient
super({ host: messageBoxHost, walletClient, enableLogging, originator });
this.peerPayWalletClient = walletClient;
this.originator = originator;
}
get authFetchInstance() {
if (this._authFetchInstance === null || this._authFetchInstance === undefined) {
this._authFetchInstance = new AuthFetch(this.peerPayWalletClient, undefined, undefined, this.originator);
}
return this._authFetchInstance;
}
/**
* Generates a valid payment token for a recipient.
*
* This function derives a unique public key for the recipient, constructs a P2PKH locking script,
* and creates a payment action with the specified amount.
*
* @param {PaymentParams} payment - The payment details.
* @param {string} payment.recipient - The recipient's identity key.
* @param {number} payment.amount - The amount in satoshis to send.
* @returns {Promise<PaymentToken>} A valid payment token containing transaction details.
* @throws {Error} If the recipient's public key cannot be derived.
*/
async createPaymentToken(payment) {
if (payment.amount <= 0) {
throw new Error('Invalid payment details: recipient and valid amount are required');
}
;
// Generate derivation paths using correct nonce function
const derivationPrefix = await createNonce(this.peerPayWalletClient);
const derivationSuffix = await createNonce(this.peerPayWalletClient);
Logger.log(`[PP CLIENT] Derivation Prefix: ${derivationPrefix}`);
Logger.log(`[PP CLIENT] Derivation Suffix: ${derivationSuffix}`);
// Get recipient's derived public key
const { publicKey: derivedKeyResult } = await this.peerPayWalletClient.getPublicKey({
protocolID: [2, '3241645161d8'],
keyID: `${derivationPrefix} ${derivationSuffix}`,
counterparty: payment.recipient
}, this.originator);
Logger.log(`[PP CLIENT] Derived Public Key: ${derivedKeyResult}`);
if (derivedKeyResult == null || derivedKeyResult.trim() === '') {
throw new Error('Failed to derive recipient’s public key');
}
// Create locking script using recipient's public key
const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedKeyResult).toAddress()).toHex();
Logger.log(`[PP CLIENT] Locking Script: ${lockingScript}`);
// Create the payment action
const paymentAction = await this.peerPayWalletClient.createAction({
description: 'PeerPay payment',
outputs: [{
satoshis: payment.amount,
lockingScript,
customInstructions: JSON.stringify({
derivationPrefix,
derivationSuffix,
payee: payment.recipient
}),
outputDescription: 'Payment for PeerPay transaction'
}],
options: {
randomizeOutputs: false
}
}, this.originator);
if (paymentAction.tx === undefined) {
throw new Error('Transaction creation failed!');
}
Logger.log('[PP CLIENT] Payment Action:', paymentAction);
return {
customInstructions: {
derivationPrefix,
derivationSuffix
},
transaction: paymentAction.tx,
amount: payment.amount
};
}
/**
* Sends Bitcoin to a PeerPay recipient.
*
* This function validates the payment details and delegates the transaction
* to `sendLivePayment` for processing.
*
* @param {PaymentParams} payment - The payment details.
* @param {string} payment.recipient - The recipient's identity key.
* @param {number} payment.amount - The amount in satoshis to send.
* @param {string} [hostOverride] - Optional host override for the message box server.
* @returns {Promise<any>} Resolves with the payment result.
* @throws {Error} If the recipient is missing or the amount is invalid.
*/
async sendPayment(payment, hostOverride) {
if (payment.recipient == null || payment.recipient.trim() === '' || payment.amount <= 0) {
throw new Error('Invalid payment details: recipient and valid amount are required');
}
const paymentToken = await this.createPaymentToken(payment);
// Ensure the recipient is included before sendings
await this.sendMessage({
recipient: payment.recipient,
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
body: JSON.stringify(paymentToken)
}, hostOverride);
}
/**
* Sends Bitcoin to a PeerPay recipient over WebSockets.
*
* This function generates a payment token and transmits it over WebSockets
* using `sendLiveMessage`. The recipient's identity key is explicitly included
* to ensure proper message routing.
*
* @param {PaymentParams} payment - The payment details.
* @param {string} payment.recipient - The recipient's identity key.
* @param {number} payment.amount - The amount in satoshis to send.
* @param {string} [overrideHost] - Optional host override for WebSocket connection.
* @returns {Promise<void>} Resolves when the payment has been sent.
* @throws {Error} If payment token generation fails.
*/
async sendLivePayment(payment, overrideHost) {
const paymentToken = await this.createPaymentToken(payment);
try {
// Attempt WebSocket first
await this.sendLiveMessage({
recipient: payment.recipient,
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
body: JSON.stringify(paymentToken),
}, overrideHost);
}
catch (err) {
Logger.warn('[PP CLIENT] sendLiveMessage failed, falling back to HTTP:', err);
// Fallback to HTTP if WebSocket fails
await this.sendMessage({
recipient: payment.recipient,
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
body: JSON.stringify(paymentToken),
}, overrideHost);
}
}
/**
* Listens for incoming Bitcoin payments over WebSockets.
*
* This function listens for messages in the standard payment message box and
* converts incoming `PeerMessage` objects into `IncomingPayment` objects
* before invoking the `onPayment` callback.
*
* @param {Object} obj - The configuration object.
* @param {Function} obj.onPayment - Callback function triggered when a payment is received.
* @param {string} [obj.overrideHost] - Optional host override for WebSocket connection.
* @returns {Promise<void>} Resolves when the listener is successfully set up.
*/
async listenForLivePayments({ onPayment, overrideHost, }) {
await this.listenForLiveMessages({
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
overrideHost,
// Convert PeerMessage → IncomingPayment before calling onPayment
onMessage: (message) => {
Logger.log('[MB CLIENT] Received Live Payment:', message);
const incomingPayment = {
messageId: message.messageId,
sender: message.sender,
token: safeParse(message.body)
};
Logger.log('[PP CLIENT] Converted PeerMessage to IncomingPayment:', incomingPayment);
onPayment(incomingPayment);
}
});
}
/**
* Accepts an incoming Bitcoin payment and moves it into the default wallet basket.
*
* This function processes a received payment by submitting it for internalization
* using the wallet client's `internalizeAction` method. The payment details
* are extracted from the `IncomingPayment` object.
*
* @param {IncomingPayment} payment - The payment object containing transaction details.
* @returns {Promise<any>} Resolves with the payment result if successful.
* @throws {Error} If payment processing fails.
*/
async acceptPayment(payment) {
try {
Logger.log(`[PP CLIENT] Processing payment: ${JSON.stringify(payment, null, 2)}`);
const paymentResult = await this.peerPayWalletClient.internalizeAction({
tx: payment.token.transaction,
outputs: [{
paymentRemittance: {
derivationPrefix: payment.token.customInstructions.derivationPrefix,
derivationSuffix: payment.token.customInstructions.derivationSuffix,
senderIdentityKey: payment.sender
},
outputIndex: STANDARD_PAYMENT_OUTPUT_INDEX,
protocol: 'wallet payment'
}],
description: 'PeerPay Payment'
}, this.originator);
Logger.log(`[PP CLIENT] Payment internalized successfully: ${JSON.stringify(paymentResult, null, 2)}`);
Logger.log(`[PP CLIENT] Acknowledging payment with messageId: ${payment.messageId}`);
await this.acknowledgeMessage({ messageIds: [payment.messageId] });
return { payment, paymentResult };
}
catch (error) {
Logger.error(`[PP CLIENT] Error accepting payment: ${String(error)}`);
return 'Unable to receive payment!';
}
}
/**
* Rejects an incoming Bitcoin payment by refunding it to the sender, minus a fee.
*
* If the payment amount is too small (less than 1000 satoshis after deducting the fee),
* the payment is simply acknowledged and ignored. Otherwise, the function first accepts
* the payment, then sends a new transaction refunding the sender.
*
* @param {IncomingPayment} payment - The payment object containing transaction details.
* @returns {Promise<void>} Resolves when the payment is either acknowledged or refunded.
*/
async rejectPayment(payment) {
Logger.log(`[PP CLIENT] Rejecting payment: ${JSON.stringify(payment, null, 2)}`);
if (payment.token.amount - 1000 < 1000) {
Logger.log('[PP CLIENT] Payment amount too small after fee, just acknowledging.');
try {
Logger.log(`[PP CLIENT] Attempting to acknowledge message ${payment.messageId}...`);
if (this.authFetch === null || this.authFetch === undefined) {
Logger.warn('[PP CLIENT] Warning: authFetch is undefined! Ensure PeerPayClient is initialized correctly.');
}
Logger.log('[PP CLIENT] authFetch instance:', this.authFetch);
const response = await this.acknowledgeMessage({ messageIds: [payment.messageId] });
Logger.log(`[PP CLIENT] Acknowledgment response: ${response}`);
}
catch (error) {
if (error != null &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string' &&
error.message.includes('401')) {
Logger.warn(`[PP CLIENT] Authentication issue while acknowledging: ${error.message}`);
}
else {
Logger.error(`[PP CLIENT] Error acknowledging message: ${error.message}`);
throw error; // Only throw if it's another type of error
}
}
return;
}
Logger.log('[PP CLIENT] Accepting payment before refunding...');
await this.acceptPayment(payment);
Logger.log(`[PP CLIENT] Sending refund of ${payment.token.amount - 1000} to ${payment.sender}...`);
await this.sendPayment({
recipient: payment.sender,
amount: payment.token.amount - 1000 // Deduct fee
});
Logger.log('[PP CLIENT] Payment successfully rejected and refunded.');
try {
Logger.log(`[PP CLIENT] Acknowledging message ${payment.messageId} after refunding...`);
await this.acknowledgeMessage({ messageIds: [payment.messageId] });
Logger.log('[PP CLIENT] Acknowledgment after refund successful.');
}
catch (error) {
Logger.error(`[PP CLIENT] Error acknowledging message after refund: ${error.message}`);
}
}
/**
* Retrieves a list of incoming Bitcoin payments from the message box.
*
* This function queries the message box for new messages and transforms
* them into `IncomingPayment` objects by extracting relevant fields.
*
* @param {string} [overrideHost] - Optional host override to list payments from
* @returns {Promise<IncomingPayment[]>} Resolves with an array of pending payments.
*/
async listIncomingPayments(overrideHost) {
const messages = await this.listMessages({ messageBox: STANDARD_PAYMENT_MESSAGEBOX, host: overrideHost });
return messages.map((msg) => {
const parsedToken = safeParse(msg.body);
return {
messageId: msg.messageId,
sender: msg.sender,
token: parsedToken
};
});
}
}
//# sourceMappingURL=PeerPayClient.js.map