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

124 lines
3.2 KiB
TypeScript

import {
CashuMint,
CashuWallet,
MintQuoteState,
type Proof,
} from "@cashu/cashu-ts";
import { privateKeyFromSeedWords as nostrPrivateKeyFromSeedWords } from "nostr-tools/nip06";
import { finalizeEvent, nip98 } from "nostr-tools";
import type { BodyInit } from "bun";
interface NpubCashQuote {
createdAt: number;
paidAt?: number;
expiresAt: number;
mintUrl: string;
quoteId: string;
request: string;
amount: number;
state: "PAID" | "ISSUED" | "INFLIGHT";
locked: boolean;
}
interface NpubCashPaginationMetadata {
total: number;
limit: number;
}
export default class NpubCash {
constructor(private mnemonic: string) {}
private async fetchWithNip98<T>(
{ url, method, body }: {
url: string;
method: string;
body?: BodyInit | null;
},
): Promise<T> {
const nostrPrivateKey = nostrPrivateKeyFromSeedWords(this.mnemonic);
const urlWithoutQueryParams = url.split("?")[0] ?? "";
const npubCashNip98 = await nip98.getToken(
urlWithoutQueryParams,
method,
(event) => finalizeEvent(event, nostrPrivateKey),
);
return fetch(url, {
method,
headers: {
"Authorization": `Nostr ${npubCashNip98}`,
},
body,
}).then((data) => data.json() as Promise<T>);
}
private async getQuotes(since: number, limit: number, offset: number) {
return this.fetchWithNip98<
{
error: false;
data: { quotes: NpubCashQuote[] };
metadata: NpubCashPaginationMetadata;
} | {
error: true;
message: string;
}
>({
url:
`https://npubx.cash/api/v2/wallet/quotes?since=${since}&limit=${limit}&offset=${offset}`,
method: "GET",
});
}
private async getAllPaidQuotes(since: number) {
const quotes: NpubCashQuote[] = [];
let offset = 0;
while (true) {
const currentQuotes = await this.getQuotes(since, 50, offset);
if (currentQuotes.error === false) {
quotes.push(...currentQuotes.data.quotes);
if (quotes.length >= currentQuotes.metadata.total) {
break;
}
} else {
throw new Error(currentQuotes.message ?? "Unknown error");
}
offset += 50;
}
return quotes.sort((a, b) => a.createdAt - b.createdAt).filter(
(quote) => quote.state === "PAID",
);
}
async *tryRedeemUnredeemedCashuQuotes(
latestRedeemedCashuQuoteTimestamp: number,
) {
const proofs: Proof[] = [];
const quotes: NpubCashQuote[] = await this.getAllPaidQuotes(
latestRedeemedCashuQuoteTimestamp,
);
for (const quote of quotes) {
const mint = new CashuMint(quote.mintUrl);
const wallet = new CashuWallet(mint);
const req = await mint.checkMintQuote(quote.quoteId);
if (
req.state === MintQuoteState.PAID && typeof quote.paidAt === "number"
) {
const newProofs = await wallet.mintProofs(
quote.amount,
quote.quoteId,
);
proofs.push(...newProofs);
const amountReceived = newProofs.reduce(
(sum, p) => sum + p.amount,
0,
);
yield {
quoteId: quote.quoteId,
amountSat: amountReceived,
timestamp: quote.paidAt,
};
}
}
return proofs;
}
}