UNPKG

@bsv/message-box-client

Version:

A client for P2P messaging and payments

404 lines (356 loc) • 15.3 kB
/** * 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 { PeerMessage } from './types.js' import { WalletInterface, P2PKH, PublicKey, createNonce, AtomicBEEF, AuthFetch, Base64String, OriginatorDomainNameStringUnder250Bytes } from '@bsv/sdk' import * as Logger from './Utils/logger.js' function safeParse<T>(input: any): T { 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 = {} as T return fallback } } export const STANDARD_PAYMENT_MESSAGEBOX = 'payment_inbox' const STANDARD_PAYMENT_OUTPUT_INDEX = 0 /** * Configuration options for initializing PeerPayClient. */ export interface PeerPayClientConfig { messageBoxHost?: string messageBox?: string walletClient: WalletInterface enableLogging?: boolean // Added optional logging flag, originator?: OriginatorDomainNameStringUnder250Bytes } /** * Represents the parameters required to initiate a payment. */ export interface PaymentParams { recipient: string amount: number } /** * Represents a structured payment token. */ export interface PaymentToken { customInstructions: { derivationPrefix: Base64String derivationSuffix: Base64String } transaction: AtomicBEEF amount: number outputIndex?: number } /** * Represents an incoming payment received via MessageBox. */ export interface IncomingPayment { messageId: string sender: string token: PaymentToken outputIndex?: number } /** * PeerPayClient enables peer-to-peer Bitcoin payments using MessageBox. */ export class PeerPayClient extends MessageBoxClient { private readonly peerPayWalletClient: WalletInterface private _authFetchInstance?: AuthFetch private readonly messageBox: string constructor(config: PeerPayClientConfig) { const { messageBoxHost = 'https://messagebox.babbage.systems', walletClient, enableLogging = false, originator } = config // 🔹 Pass enableLogging to MessageBoxClient super({ host: messageBoxHost, walletClient, enableLogging, originator }) this.messageBox = config.messageBox ?? STANDARD_PAYMENT_MESSAGEBOX this.peerPayWalletClient = walletClient this.originator = originator } private get authFetchInstance(): AuthFetch { 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: PaymentParams): Promise<PaymentToken> { 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, 'self', this.originator) const derivationSuffix = await createNonce(this.peerPayWalletClient, 'self', this.originator) 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', labels: ['peerpay'], 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: PaymentParams, hostOverride?: string): Promise<any> { 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: this.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: PaymentParams, overrideHost?: string): Promise<void> { const paymentToken = await this.createPaymentToken(payment) try { // Attempt WebSocket first await this.sendLiveMessage({ recipient: payment.recipient, messageBox: this.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: this.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 }: { onPayment: (payment: IncomingPayment) => void overrideHost?: string }): Promise<void> { await this.listenForLiveMessages({ messageBox: this.messageBox, overrideHost, // Convert PeerMessage → IncomingPayment before calling onPayment onMessage: (message: PeerMessage) => { Logger.log('[MB CLIENT] Received Live Payment:', message) const incomingPayment: IncomingPayment = { messageId: message.messageId, sender: message.sender, token: safeParse<PaymentToken>(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: IncomingPayment): Promise<any> { 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: payment.token.outputIndex ?? STANDARD_PAYMENT_OUTPUT_INDEX, protocol: 'wallet payment' }], labels: ['peerpay'], 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: IncomingPayment): Promise<void> { 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: any) { if ( error != null && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string' && (error as { message: string }).message.includes('401') ) { Logger.warn(`[PP CLIENT] Authentication issue while acknowledging: ${(error as { message: string }).message}`) } else { Logger.error(`[PP CLIENT] Error acknowledging message: ${(error as { message: string }).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: any) { Logger.error(`[PP CLIENT] Error acknowledging message after refund: ${(error as { message: string }).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?: string): Promise<IncomingPayment[]> { const messages = await this.listMessages({ messageBox: this.messageBox, host: overrideHost }) return messages.map((msg: any) => { const parsedToken = safeParse<PaymentToken>(msg.body) return { messageId: msg.messageId, sender: msg.sender, token: parsedToken } }) } }