import initBreez, { type BindingLiquidSdk, connect as breezConnect, defaultConfig as defaultBreezConfig, type InputType, type LogEntry, type Payment, type PaymentMethod, type PreparePayOnchainRequest, type PrepareReceiveRequest, type PrepareSendRequest, setLogger, } from "@breeztech/breez-sdk-liquid/web"; import { privateKeyFromSeedWords as nostrPrivateKeyFromSeedWords } from "nostr-tools/nip06"; import { getPublicKey, nip19 } from "nostr-tools"; import { type CashuStore, type CashuTxn, PaymentStatus } from "./paymentStatus"; export { type CashuStore, PaymentStatus } from "./paymentStatus"; import PortalBtcWalletCashu from "./cashu"; type CombinedPayment = Payment | CashuTxn; export type { CombinedPayment as Payment }; export default class PortalBtcWallet { static MINT_URL = "https://mint.minibits.cash/Bitcoin"; private breezSDK: BindingLiquidSdk | null = null; private cashuSDK: PortalBtcWalletCashu; private balances = { balance: 0, pendingReceive: 0, pendingSend: 0, }; get cashuBalance() { return this.cashuSDK.balance; } get balance() { return this.balances.balance + this.cashuBalance; } get pendingBalance() { return this.balances.pendingReceive - this.balances.pendingSend; } get npub() { const nostrPrivateKey = nostrPrivateKeyFromSeedWords(this.mnemonic); const nostrPubkey = getPublicKey(nostrPrivateKey); return nip19.npubEncode(nostrPubkey); } get lightningAddress() { return `${this.npub}@${this.baseDomain}`; } static async create( mnemonic: string, cashuStore: CashuStore, loggingEnabled = false, network: "mainnet" | "testnet" = "testnet", breezApiKey?: string, cashuMeltThreshold?: number, baseDomain?: string, ) { const wallet = new PortalBtcWallet( mnemonic, cashuStore, loggingEnabled, network, breezApiKey, cashuMeltThreshold, baseDomain, ); await wallet.init(); return wallet; } private constructor( private mnemonic: string, cashuStore: CashuStore, private loggingEnabled = false, private network: "mainnet" | "testnet" = "testnet", private breezApiKey = "MIIBajCCARygAwIBAgIHPgwAQY4DlTAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwNTA1MTY1OTM5WhcNMzUwNTAzMTY1OTM5WjAnMQwwCgYDVQQKEwNBcngxFzAVBgNVBAMTDkRhbm55IE1vcmFiaXRvMCowBQYDK2VwAyEA0IP1y98gPByiIMoph1P0G6cctLb864rNXw1LRLOpXXejfjB8MA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTaOaPuXmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTeqtaSVvON53SSFvxMtiCyayiYazAcBgNVHREEFTATgRFkYW5ueUBhcngtY2NuLmNvbTAFBgMrZXADQQAwJoh9BG8rEH1sOl+BpS12oNSwzgQga8ZcIAZ8Bjmd6QT4GSST0nLj06fs49pCkiULOl9ZoRIeIMc3M1XqV5UA", private cashuMeltThreshold = 2000, private baseDomain = "portalbtc.live", ) { this.cashuSDK = new PortalBtcWalletCashu( mnemonic, cashuStore, ); } private async init() { await initBreez(); this.breezSDK = await breezConnect({ mnemonic: this.mnemonic, config: defaultBreezConfig(this.network, this.breezApiKey), }); this.breezSDK.addEventListener({ onEvent: (e) => { if (this.loggingEnabled) { console.log(e); } this.refreshBreezBalances(); this.emitEvent("balanceUpdated"); }, }); setLogger({ log: (e: LogEntry) => { if (this.loggingEnabled) { console.log(`[Breez] [${e.level}] ${e.line}`); } }, }); await this.cashuSDK.init(() => this.emitEvent("balanceUpdated")); await this.redeemCashuQuotes(); await this.maybeMelt(); } private async refreshBreezBalances() { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); const info = await this.breezSDK.getInfo(); this.balances.balance = info?.walletInfo.balanceSat ?? 0; this.balances.pendingReceive = info?.walletInfo.pendingReceiveSat ?? 0; this.balances.pendingSend = info?.walletInfo.pendingSendSat ?? 0; } private async maybeMelt() { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); if (this.cashuBalance < this.cashuMeltThreshold) return; const cashuAmountToSwapToLiquid = this.cashuBalance - (this.cashuBalance % this.cashuMeltThreshold); const invoice = await this.generateBolt11Invoice(cashuAmountToSwapToLiquid); await this.cashuSDK.meltProofsToPayInvoice(invoice); this.emitEvent("balanceUpdated"); } /** * Redeems unredeemed Cashu quotes * * @returns Promise */ async redeemCashuQuotes() { return this.cashuSDK.redeemCashuQuotes(); } /** * Generates a Lightning invoice * * @param amountSat - The amount to generate the invoice for * @returns Promise - The Lightning invoice */ async generateBolt11Invoice(amountSat: number): Promise { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); const prepare = await this.breezSDK?.prepareReceivePayment({ paymentMethod: "bolt11Invoice", amount: { type: "bitcoin", payerAmountSat: amountSat }, amountSat: amountSat, } as PrepareReceiveRequest); const { destination } = await this.breezSDK.receivePayment({ prepareResponse: prepare, }); return destination as string; } /** * Redeems a Cashu token * * @param token - The Cashu token to redeem * @returns Promise */ async redeemToken(token: string): Promise { await this.cashuSDK.redeemToken(token); await this.maybeMelt(); this.emitEvent("balanceUpdated"); } /** * Pays a Lightning invoice using Cashu * * @param invoice - The Lightning invoice to pay * @returns Promise */ async payBolt11InvoiceUsingCashu(invoice: string): Promise { if (!invoice.trim()) throw new Error("Invoice is empty"); await this.cashuSDK.meltProofsToPayInvoice(invoice); } private async *payBolt11Invoice( parsedPayment: InputType, destination: string, amount: number, ) { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); if (parsedPayment.type !== "bolt11") { throw new Error("Invalid payment type"); } try { yield { status: PaymentStatus.AttemptingCashuPayment, error: null, }; await this.payBolt11InvoiceUsingCashu(destination); yield { status: PaymentStatus.PaymentSent, error: null, paymentHash: parsedPayment.invoice.paymentHash, }; return; } catch (cashuErr) { yield { status: PaymentStatus.CashuPaymentFailed, error: cashuErr instanceof Error ? cashuErr.message : "Unknown error", }; } yield { status: PaymentStatus.AttemptingLightningPayment, error: null, }; const prep = await this.breezSDK.prepareSendPayment({ destination, amount: amount > 0 ? { type: "bitcoin", receiverAmountSat: amount } : undefined, }); const res = await this.breezSDK.sendPayment({ prepareResponse: prep }); const paymentDetails = res.payment.details; if (paymentDetails.type === "lightning") { yield { status: PaymentStatus.PaymentSent, error: null, preimage: paymentDetails.preimage, paymentHash: paymentDetails.paymentHash, }; } else { yield { status: PaymentStatus.PaymentSent, error: null, }; } } private async *payChainAddress( parsedPayment: InputType, amount: number, ) { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); if ( parsedPayment.type !== "bitcoinAddress" && parsedPayment.type !== "liquidAddress" ) { throw new Error("Invalid payment type"); } if (amount <= 0) { yield { status: PaymentStatus.AmountRequired, error: "Amount required for bitcoin address", }; return; } const onChainRequest: PreparePayOnchainRequest = { amount: { type: "bitcoin", receiverAmountSat: amount }, }; yield { status: PaymentStatus.PreparingOnchainPayment, error: null, }; const onChainPreparedResponse = await this.breezSDK.preparePayOnchain( onChainRequest, ); yield { status: PaymentStatus.BroadcastingOnchainPayment, error: null, }; await this.breezSDK.payOnchain({ address: parsedPayment.address.address, prepareResponse: onChainPreparedResponse, }); yield { status: PaymentStatus.PaymentSent, error: null, }; } /** * Parses a payment destination * * @param destination - The payment destination * @returns Promise - The parsed payment */ async parsePayment(destination: string): Promise { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); const parsedPayment = await this.breezSDK.parse(destination); if (!parsedPayment) throw new Error("Failed to parse payment"); return parsedPayment; } /** * Pays a payment destination * * @param destination - The payment destination * @param amount - The amount to pay * @returns Promise - The payment status */ async *pay( destination: string, amount = 0, ) { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); yield { status: PaymentStatus.ParsingDestination, error: null, }; const parsedPayment = await this.parsePayment(destination); try { if (parsedPayment.type === "bolt11") { yield* this.payBolt11Invoice(parsedPayment, destination, amount); this.emitEvent("balanceUpdated"); return; } const paymentTypesThatRequireAmount = [ "bitcoinAddress", "liquidAddress", "bolt12Offer", "nodeId", ]; if (paymentTypesThatRequireAmount.includes(parsedPayment.type)) { if (amount <= 0) { yield { status: PaymentStatus.AmountRequired, error: "Amount required for payment type", }; return; } } if ( parsedPayment.type === "bitcoinAddress" || parsedPayment.type === "liquidAddress" ) { yield* this.payChainAddress(parsedPayment, amount); this.emitEvent("balanceUpdated"); return; } const paymentRequest: PrepareSendRequest = { destination }; if (amount > 0) { paymentRequest.amount = { type: "bitcoin", receiverAmountSat: amount, }; } yield { status: PaymentStatus.PreparingOnchainPayment, error: null, }; const prep = await this.breezSDK.prepareSendPayment( paymentRequest, ); yield { status: PaymentStatus.BroadcastingOnchainPayment, error: null, }; await this.breezSDK.sendPayment({ prepareResponse: prep }); yield { status: PaymentStatus.PaymentSent, error: null, }; this.emitEvent("balanceUpdated"); return; } catch (e: unknown) { if (e instanceof Error) { const raw = e.message; const protoMatch = raw.match(/Generic error:.*?\(\"(.+?)\"\)/); yield { status: PaymentStatus.PaymentFailed, error: protoMatch ? protoMatch[1] : raw, }; } else { yield { status: PaymentStatus.PaymentFailed, error: "Send failed", }; } } } /** * Lists payments * * @param limit - The number of payments to list * @param offset - The offset to start listing from * @returns Promise - The payments */ async listPayments(limit: number, offset: number) { const allPayments: CombinedPayment[] = []; const breezPayments = await this.breezSDK?.listPayments({}); if (breezPayments) { allPayments.push(...breezPayments); } allPayments.push(...this.cashuSDK.txns); const sortedPayments = allPayments.sort((a, b) => b.timestamp - a.timestamp ); return sortedPayments.slice(offset, offset + limit); } private eventListeners: Record void)[]> = {}; /** * Adds an event listener * * @param event - The event to listen for * @param listener - The listener function */ addEventListener(event: "balanceUpdated", listener: () => void) { if (!this.eventListeners[event]) { this.eventListeners[event] = []; } this.eventListeners[event].push(listener); } private emitEvent(event: "balanceUpdated") { if (this.loggingEnabled) { console.log(`[${event}]`); } for (const listener of this.eventListeners[event] ?? []) { listener(); } } /** * Refunds a payment * * @param payment - The payment to refund */ async refundPayment(payment: Payment) { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); const prepareAddr = await this.breezSDK.prepareReceivePayment({ paymentMethod: "bitcoinAddress", }); const receiveRes = await this.breezSDK.receivePayment({ prepareResponse: prepareAddr, }); const refundAddress = receiveRes.destination as string; try { const refundables = await this.breezSDK.listRefundables(); let swapAddress: string | undefined; if (refundables.length === 1) { swapAddress = refundables[0]?.swapAddress; } else { swapAddress = refundables.find( (r) => r.amountSat === payment.amountSat && Math.abs(r.timestamp - payment.timestamp) < 300, )?.swapAddress; } if (!swapAddress) { throw new Error("Could not identify refundable swap for this payment."); } const fees = await this.breezSDK.recommendedFees(); const feeRateSatPerVbyte = fees.economyFee; const refundRequest = { swapAddress, refundAddress, feeRateSatPerVbyte, } as const; try { await this.breezSDK.prepareRefund(refundRequest); } catch (err) { console.warn( "prepareRefund failed (may be expected for some swaps)", err, ); return; } await this.breezSDK.refund(refundRequest); } catch (err) { console.error("Refund failed", err); throw new Error( `Refund failed: ${err instanceof Error ? err.message : err}`, ); } } /** * Generates a new address for a payment method * * @param paymentMethod - The payment method to generate an address for * @returns Promise - The new address */ async generateAddress(paymentMethod: PaymentMethod) { if (!this.breezSDK) throw new Error("Breez SDK not initialized"); const prep = await this.breezSDK.prepareReceivePayment({ paymentMethod }); const res = await this.breezSDK.receivePayment({ prepareResponse: prep }); const rawDestination = res.destination; if (rawDestination === undefined) { throw new Error("Failed to generate address: destination is undefined."); } let destination = rawDestination; const colonIndex = destination.indexOf(":"); if (colonIndex !== -1) { destination = destination.substring(colonIndex + 1); } const questionMarkIndex = destination.indexOf("?"); if (questionMarkIndex !== -1) { destination = destination.substring(0, questionMarkIndex); } return destination; } }