PortalBTCLib/index.ts
2025-07-09 16:47:19 +02:00

531 lines
15 KiB
TypeScript

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<Proof[]>
*/
async redeemCashuQuotes() {
return this.cashuSDK.redeemCashuQuotes();
}
/**
* Generates a Lightning invoice
*
* @param amountSat - The amount to generate the invoice for
* @returns Promise<string> - The Lightning invoice
*/
async generateBolt11Invoice(amountSat: number): Promise<string> {
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<void>
*/
async redeemToken(token: string): Promise<void> {
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<void>
*/
async payBolt11InvoiceUsingCashu(invoice: string): Promise<void> {
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<InputType> - The parsed payment
*/
async parsePayment(destination: string): Promise<InputType> {
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<void> - 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<Payment[]> - 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<string, (() => 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<string> - 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;
}
}