@bsv/p2p
Version:
A client for P2P messaging and payments
342 lines (297 loc) • 12.6 kB
text/typescript
import { MessageBoxClient, PeerMessage } from './MessageBoxClient.js'
import { WalletClient, P2PKH, PublicKey, createNonce, AtomicBEEF, AuthFetch, Base64String } from '@bsv/sdk'
import { Logger } from './Utils/logger.js'
export const STANDARD_PAYMENT_MESSAGEBOX = 'payment_inbox'
const STANDARD_PAYMENT_OUTPUT_INDEX = 0
/**
* Configuration options for initializing PeerPayClient.
*/
export interface PeerPayClientConfig {
messageBoxHost?: string
walletClient: WalletClient
enableLogging?: boolean // 🔹 Added optional logging flag
}
/**
* 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
}
/**
* Represents an incoming payment received via MessageBox.
*/
export interface IncomingPayment {
messageId: string
sender: string
token: PaymentToken
}
/**
* PeerPayClient enables peer-to-peer Bitcoin payments using MessageBox.
*/
export class PeerPayClient extends MessageBoxClient {
private readonly peerPayWalletClient: WalletClient
private _authFetchInstance?: AuthFetch
constructor(config: PeerPayClientConfig) {
const { messageBoxHost = 'https://messagebox.babbage.systems', walletClient, enableLogging = false } = config
// 🔹 Pass enableLogging to MessageBoxClient
super({ host: messageBoxHost, walletClient, enableLogging })
this.peerPayWalletClient = walletClient
}
private get authFetchInstance(): AuthFetch {
if (!this._authFetchInstance) {
this._authFetchInstance = new AuthFetch(this.peerPayWalletClient)
}
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)
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
})
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
}
})
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.
* @returns {Promise<any>} Resolves with the payment result.
* @throws {Error} If the recipient is missing or the amount is invalid.
*/
async sendPayment(payment: PaymentParams): 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 sending
await this.sendMessage({
recipient: payment.recipient,
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
body: paymentToken
})
}
/**
* 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.
* @returns {Promise<void>} Resolves when the payment has been sent.
* @throws {Error} If payment token generation fails.
*/
async sendLivePayment(payment: PaymentParams): Promise<void> {
const paymentToken = await this.createPaymentToken(payment)
// Ensure the recipient is included before sending
await this.sendLiveMessage({
recipient: payment.recipient,
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
body: paymentToken
})
}
/**
* 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.
* @returns {Promise<void>} Resolves when the listener is successfully set up.
*/
async listenForLivePayments({
onPayment
}: { onPayment: (payment: IncomingPayment) => void }): Promise<void> {
await this.listenForLiveMessages({
messageBox: STANDARD_PAYMENT_MESSAGEBOX,
// 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: JSON.parse(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: STANDARD_PAYMENT_OUTPUT_INDEX,
protocol: 'wallet payment'
}],
description: 'PeerPay Payment'
})
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) {
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.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: any) {
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.
*
* @returns {Promise<IncomingPayment[]>} Resolves with an array of pending payments.
*/
async listIncomingPayments(): Promise<IncomingPayment[]> {
const messages = await this.listMessages({ messageBox: STANDARD_PAYMENT_MESSAGEBOX })
return messages.map((msg: any) => ({
messageId: msg.messageId,
sender: msg.sender,
token: JSON.parse(msg.body)
}))
}
}