533 lines
15 KiB
TypeScript
533 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"));
|
|
try {
|
|
await this.redeemCashuQuotes();
|
|
await this.maybeMelt();
|
|
} catch {}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|