@nostr-dev-kit/ndk
Version:
NDK - Nostr Development Kit
601 lines (515 loc) • 17.8 kB
text/typescript
import createDebug from "debug";
import type { NDKTag } from "../events";
import type { NDKEvent } from "../events";
import type { NDK } from "../ndk";
import type { NDKSigner } from "../signers";
import type { Hexpubkey } from "../user";
import { NDKUser } from "../user";
import { EventEmitter } from "tseep";
import { NDKNutzap } from "../events/kinds/nutzap";
import { NDKRelaySet } from "../relay/sets";
import { getRelayListForUsers } from "../utils/get-users-relay-list";
import {
type LnPaymentInfo,
type NDKLnUrlData,
type NDKPaymentConfirmationLN,
type NDKZapConfirmationLN,
getNip57ZapSpecFromLud,
} from "./ln";
import { generateZapRequest } from "./nip57";
import type {
CashuPaymentInfo,
NDKPaymentConfirmationCashu,
NDKZapConfirmationCashu,
} from "./nip61";
const d = createDebug("ndk:zapper");
export type NDKZapDetails<T> = T & {
/**
* Target of the zap
*/
target: NDKEvent | NDKUser;
/**
* Comment for the zap
*/
comment?: string;
/**
* Tags to add to the zap
*/
tags?: NDKTag[];
/**
* Pubkey of the user to zap to
*/
recipientPubkey: string;
/**
* Amount of the payment
*/
amount: number;
/**
* Unit of the payment (e.g. msat)
*/
unit: string;
/**
* Description of the payment for the sender's record
*/
paymentDescription?: string;
/**
* If this payment is for a nip57 zap, this will contain the zap request.
*/
nip57ZapRequest?: NDKEvent;
/**
* When set to true, when a pubkey is not zappable, we will
* automatically fallback to using NIP-61.
*
* Every pubkey must be able to receive money.
*
* @default false
*/
nutzapAsFallback?: boolean;
};
export type NDKZapConfirmation = NDKZapConfirmationLN | NDKZapConfirmationCashu;
export type NDKPaymentConfirmation = NDKPaymentConfirmationLN | NDKNutzap;
export type NDKZapSplit = {
pubkey: string;
amount: number;
};
export type NDKZapMethod = "nip57" | "nip61";
export type NDKLnLudData = { lud06?: string; lud16?: string };
export type NDKZapMethodInfo = NDKLnLudData | CashuPaymentInfo;
export type LnPayCb = (
payment: NDKZapDetails<LnPaymentInfo>
) => Promise<NDKPaymentConfirmationLN | undefined>;
export type CashuPayCb = (
payment: NDKZapDetails<CashuPaymentInfo>,
onLnInvoice?: (pr: string) => void
) => Promise<NDKPaymentConfirmationCashu | undefined>;
export type OnCompleteCb = (
results: Map<NDKZapSplit, NDKPaymentConfirmation | Error | undefined>
) => void;
interface NDKZapperOptions {
/**
* Comment to include in the zap event
*/
comment?: string;
/**
* Extra tags to add to the zap event
*/
tags?: NDKTag[];
signer?: NDKSigner;
lnPay?: LnPayCb;
cashuPay?: CashuPayCb;
onComplete?: OnCompleteCb;
nutzapAsFallback?: boolean;
ndk?: NDK;
}
/**
*
*/
class NDKZapper extends EventEmitter<{
/**
* An LN invoice has been fetched.
* @param param0
* @returns
*/
ln_invoice: ({
amount,
recipientPubkey,
unit,
nip57ZapRequest,
pr,
type,
}: {
amount: number;
recipientPubkey: string;
unit: string;
nip57ZapRequest?: NDKEvent;
pr: string;
type: NDKZapMethod;
}) => void;
ln_payment: ({
preimage,
amount,
recipientPubkey,
unit,
nip57ZapRequest,
pr,
}: {
preimage: string;
amount: number;
recipientPubkey: string;
pr: string;
unit: string;
nip57ZapRequest?: NDKEvent;
type: NDKZapMethod;
}) => void;
/**
* Emitted when a zap split has been completed
*/
"split:complete": (
split: NDKZapSplit,
info: NDKPaymentConfirmation | Error | undefined
) => void;
complete: (results: Map<NDKZapSplit, NDKPaymentConfirmation | Error | undefined>) => void;
notice: (message: string) => void;
}> {
public target: NDKEvent | NDKUser;
public ndk: NDK;
public comment?: string;
public amount: number;
public unit: string;
public tags?: NDKTag[];
public signer?: NDKSigner;
public zapMethod?: NDKZapMethod;
public nutzapAsFallback?: boolean;
public lnPay?: LnPayCb;
/**
* Called when a cashu payment is to be made.
* This function should swap/mint proofs for the required amount, in the required unit,
* in any of the provided mints and return the proofs and mint used.
*/
public cashuPay?: CashuPayCb;
public onComplete?: OnCompleteCb;
public maxRelays = 3;
/**
*
* @param target The target of the zap
* @param amount The amount to send indicated in the unit
* @param unit The unit of the amount
* @param opts Options for the zap
*/
constructor(
target: NDKEvent | NDKUser,
amount: number,
unit = "msat",
opts: NDKZapperOptions = {}
) {
super();
this.target = target;
this.ndk = opts.ndk || target.ndk!;
if (!this.ndk) {
throw new Error("No NDK instance provided");
}
this.amount = amount;
this.comment = opts.comment;
this.unit = unit;
this.tags = opts.tags;
this.signer = opts.signer;
this.nutzapAsFallback = opts.nutzapAsFallback ?? false;
this.lnPay = opts.lnPay || this.ndk.walletConfig?.lnPay;
this.cashuPay = opts.cashuPay || this.ndk.walletConfig?.cashuPay;
this.onComplete = opts.onComplete || this.ndk.walletConfig?.onPaymentComplete;
}
/**
* Initiate zapping process
*
* This function will calculate the splits for this zap and initiate each zap split.
*/
async zap(methods?: NDKZapMethod[]) {
// get all splits
const splits = this.getZapSplits();
const results = new Map<NDKZapSplit, NDKPaymentConfirmation | Error | undefined>();
await Promise.all(
splits.map(async (split) => {
let result: NDKPaymentConfirmation | Error | undefined;
try {
result = await this.zapSplit(split, methods);
} catch (e: any) {
result = new Error(e.message);
}
this.emit("split:complete", split, result);
results.set(split, result);
})
);
this.emit("complete", results);
if (this.onComplete) this.onComplete(results);
return results;
}
private async zapNip57(
split: NDKZapSplit,
data: NDKLnLudData
): Promise<NDKPaymentConfirmation | undefined> {
if (!this.lnPay) throw new Error("No lnPay function available");
const zapSpec = await getNip57ZapSpecFromLud(data, this.ndk);
if (!zapSpec) throw new Error("No zap spec available for recipient");
const relays = await this.relays(split.pubkey);
const zapRequest = await generateZapRequest(
this.target,
this.ndk,
zapSpec,
split.pubkey,
split.amount,
relays,
this.comment,
this.tags,
this.signer
);
if (!zapRequest) {
d("Unable to generate zap request");
throw new Error("Unable to generate zap request");
}
const pr = await this.getLnInvoice(zapRequest, split.amount, zapSpec);
if (!pr) {
d("Unable to get payment request");
throw new Error("Unable to get payment request");
}
this.emit("ln_invoice", {
amount: split.amount,
recipientPubkey: split.pubkey,
unit: this.unit,
nip57ZapRequest: zapRequest,
pr,
type: "nip57",
});
const res = await this.lnPay({
target: this.target,
recipientPubkey: split.pubkey,
paymentDescription: "NIP-57 Zap",
pr,
amount: split.amount,
unit: this.unit,
nip57ZapRequest: zapRequest,
});
if (res?.preimage) {
this.emit("ln_payment", {
preimage: res.preimage,
amount: split.amount,
recipientPubkey: split.pubkey,
pr,
unit: this.unit,
nip57ZapRequest: zapRequest,
type: "nip57",
});
}
return res;
}
/**
* Fetches information about a NIP-61 zap and asks the caller to create cashu proofs for the zap.
*
* (note that the cashuPay function can use any method to create the proofs, including using lightning
* to mint proofs in the specified mint, the responsibility of minting the proofs is delegated to the caller (e.g. ndk-wallet))
*/
async zapNip61(
split: NDKZapSplit,
data?: CashuPaymentInfo
): Promise<NDKNutzap | Error | undefined> {
if (!this.cashuPay) throw new Error("No cashuPay function available");
let ret: NDKPaymentConfirmationCashu | undefined;
ret = await this.cashuPay(
{
target: this.target,
recipientPubkey: split.pubkey,
paymentDescription: "NIP-61 Zap",
amount: split.amount,
unit: this.unit,
...(data ?? {}),
},
(pr: string) => {
this.emit("ln_invoice", {
pr,
amount: split.amount,
recipientPubkey: split.pubkey,
unit: this.unit,
type: "nip61",
});
}
);
d("NIP-61 Zap result: %o", ret);
if (ret instanceof Error) {
// we assign the error instead of throwing it so that we can try the next zap method
// but we want to keep the error around in case there is no successful zap
return ret;
}
if (ret) {
const { proofs, mint } = ret as NDKZapConfirmationCashu;
if (!proofs || !mint)
throw new Error(`Invalid zap confirmation: missing proofs or mint: ${ret}`);
const relays = await this.relays(split.pubkey);
const relaySet = NDKRelaySet.fromRelayUrls(relays, this.ndk);
// we have a confirmation, generate the nutzap
const nutzap = new NDKNutzap(this.ndk);
nutzap.tags = [...nutzap.tags, ...(this.tags || [])];
nutzap.proofs = proofs;
nutzap.mint = mint;
nutzap.target = this.target;
nutzap.comment = this.comment;
nutzap.unit = "sat";
nutzap.recipientPubkey = split.pubkey;
await nutzap.sign(this.signer);
nutzap.publish(relaySet);
return nutzap;
}
}
/**
* Get the zap methods available for the recipient and initiates the zap
* in the desired method.
* @param split
* @param methods - The methods to try, if not provided, all methods will be tried.
* @returns
*/
async zapSplit(
split: NDKZapSplit,
methods?: NDKZapMethod[]
): Promise<NDKPaymentConfirmation | undefined> {
const recipient = this.ndk.getUser({ pubkey: split.pubkey });
const zapMethods = await recipient.getZapInfo(2500);
let retVal: NDKPaymentConfirmation | Error | undefined;
const canFallbackToNip61 = this.nutzapAsFallback && this.cashuPay;
if (zapMethods.size === 0 && !canFallbackToNip61)
throw new Error(
"No zap method available for recipient and NIP-61 fallback is disabled"
);
const nip61Fallback = async () => {
if (!this.nutzapAsFallback) return;
const relayLists = await getRelayListForUsers([split.pubkey], this.ndk);
let relayUrls = relayLists.get(split.pubkey)?.readRelayUrls;
relayUrls = this.ndk.pool.connectedRelays().map((r) => r.url);
return await this.zapNip61(split, {
// use the user's relay list
relays: relayUrls,
// lock to the user's actual pubkey
p2pk: split.pubkey,
// allow intramint fallback
allowIntramintFallback: !!canFallbackToNip61,
});
};
const canUseNip61 = !methods || methods.includes("nip61");
const canUseNip57 = !methods || methods.includes("nip57");
const nip61Method = zapMethods.get("nip61") as CashuPaymentInfo;
if (nip61Method && canUseNip61) {
try {
retVal = await this.zapNip61(split, nip61Method);
if (retVal instanceof NDKNutzap) return retVal;
} catch (e: any) {
this.emit("notice", `NIP-61 attempt failed: ${e.message}`);
}
}
const nip57Method = zapMethods.get("nip57") as NDKLnLudData;
if (nip57Method && canUseNip57) {
try {
retVal = await this.zapNip57(split, nip57Method);
if (!(retVal instanceof Error)) return retVal;
} catch (e: any) {
this.emit("notice", `NIP-57 attempt failed: ${e.message}`);
}
}
if (canFallbackToNip61) {
retVal = await nip61Fallback();
if (retVal instanceof Error) throw retVal;
return retVal;
}
this.emit("notice", "Zap methods exhausted and there was no fallback to NIP-61");
if (retVal instanceof Error) throw retVal;
return retVal;
}
/**
* Gets a bolt11 for a nip57 zap
* @param event
* @param amount
* @param zapEndpoint
* @returns
*/
public async getLnInvoice(
zapRequest: NDKEvent,
amount: number,
data: NDKLnUrlData
): Promise<string | null> {
const zapEndpoint = data.callback;
const eventPayload = JSON.stringify(zapRequest.rawEvent());
d(
`Fetching invoice from ${zapEndpoint}?${new URLSearchParams({
amount: amount.toString(),
nostr: eventPayload,
})}`
);
const url = new URL(zapEndpoint);
url.searchParams.append("amount", amount.toString());
url.searchParams.append("nostr", eventPayload);
d(`Fetching invoice from ${url.toString()}`);
const response = await fetch(url.toString());
d(`Got response from zap endpoint: ${zapEndpoint}`, { status: response.status });
if (response.status !== 200) {
d(`Received non-200 status from zap endpoint: ${zapEndpoint}`, {
status: response.status,
amount: amount,
nostr: eventPayload,
});
const text = await response.text();
throw new Error(`Unable to fetch zap endpoint ${zapEndpoint}: ${text}`);
}
const body = await response.json();
return body.pr;
}
public getZapSplits(): NDKZapSplit[] {
if (this.target instanceof NDKUser) {
return [
{
pubkey: this.target.pubkey,
amount: this.amount,
},
];
}
const zapTags = this.target.getMatchingTags("zap");
if (zapTags.length === 0) {
return [
{
pubkey: this.target.pubkey,
amount: this.amount,
},
];
}
const splits: NDKZapSplit[] = [];
const total = zapTags.reduce((acc, tag) => acc + Number.parseInt(tag[2]), 0);
for (const tag of zapTags) {
const pubkey = tag[1];
const amount = Math.floor((Number.parseInt(tag[2]) / total) * this.amount);
splits.push({ pubkey, amount });
}
return splits;
}
/**
* Gets the zap method that should be used to zap a pubbkey
* @param ndk
* @param pubkey
* @returns
*/
async getZapMethods(
ndk: NDK,
recipient: Hexpubkey,
timeout = 2500
): Promise<Map<NDKZapMethod, NDKZapMethodInfo>> {
const user = ndk.getUser({ pubkey: recipient });
return await user.getZapInfo(timeout);
}
/**
* @returns the relays to use for the zap request
*/
public async relays(pubkey: Hexpubkey): Promise<string[]> {
let r: string[] = [];
if (this.ndk?.activeUser) {
const relayLists = await getRelayListForUsers(
[this.ndk.activeUser.pubkey, pubkey],
this.ndk
);
const relayScores = new Map<string, number>();
// go through the relay lists and try to get relays that are shared between the two users
for (const relayList of relayLists.values()) {
for (const url of relayList.readRelayUrls) {
const score = relayScores.get(url) || 0;
relayScores.set(url, score + 1);
}
}
// get the relays that are shared between the two users
r = Array.from(relayScores.entries())
.sort((a, b) => b[1] - a[1])
.map(([url]) => url)
.slice(0, this.maxRelays);
}
if (this.ndk?.pool?.permanentAndConnectedRelays().length) {
r = this.ndk.pool.permanentAndConnectedRelays().map((relay) => relay.url);
}
if (!r.length) {
r = [];
}
return r;
}
}
export { NDKZapper };