import initBreez, { type BindingLiquidSdk, connect as breezConnect, defaultConfig as defaultBreezConfig, type InputType, type LogEntry, type Payment, 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, cashuMeltThreshold, ); } private async init() { await initBreez(); await this.cashuSDK.init(); await this.maybeMelt(); 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(); }, }); setLogger({ log: (e: LogEntry) => { if (this.loggingEnabled) { console.log(`[Breez] [${e.level}] ${e.line}`); } }, }); await this.redeemCashuQuotes(); } private async refreshBreezBalances() { 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); } /** * 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(); } /** * 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: { type: "bitcoin", receiverAmountSat: amount }, }); 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); 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); 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, }; 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); } }