2023-03-28 14:34:01 +00:00
|
|
|
import { HexKey, RawEvent } from "@snort/nostr";
|
2023-02-25 21:18:36 +00:00
|
|
|
import { EmailRegex } from "Const";
|
|
|
|
import { bech32ToText, unwrap } from "Util";
|
|
|
|
|
|
|
|
const PayServiceTag = "payRequest";
|
|
|
|
|
2023-02-27 19:15:39 +00:00
|
|
|
export enum LNURLErrorCode {
|
|
|
|
ServiceUnavailable = 1,
|
|
|
|
InvalidLNURL = 2,
|
|
|
|
}
|
|
|
|
|
|
|
|
export class LNURLError extends Error {
|
|
|
|
code: LNURLErrorCode;
|
|
|
|
|
|
|
|
constructor(code: LNURLErrorCode, msg: string) {
|
|
|
|
super(msg);
|
|
|
|
this.code = code;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-25 21:18:36 +00:00
|
|
|
export class LNURL {
|
|
|
|
#url: URL;
|
|
|
|
#service?: LNURLService;
|
|
|
|
|
2023-02-27 19:15:39 +00:00
|
|
|
/**
|
|
|
|
* Setup LNURL service
|
|
|
|
* @param lnurl bech32 lnurl / lightning address / https url
|
|
|
|
*/
|
2023-02-25 21:18:36 +00:00
|
|
|
constructor(lnurl: string) {
|
2023-02-27 18:24:37 +00:00
|
|
|
lnurl = lnurl.toLowerCase().trim();
|
|
|
|
if (lnurl.startsWith("lnurl")) {
|
2023-02-25 21:18:36 +00:00
|
|
|
const decoded = bech32ToText(lnurl);
|
|
|
|
if (!decoded.startsWith("http")) {
|
2023-02-27 19:15:39 +00:00
|
|
|
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Not a url");
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
this.#url = new URL(decoded);
|
|
|
|
} else if (lnurl.match(EmailRegex)) {
|
2023-02-27 18:24:37 +00:00
|
|
|
const [handle, domain] = lnurl.split("@");
|
2023-02-25 21:18:36 +00:00
|
|
|
this.#url = new URL(`https://${domain}/.well-known/lnurlp/${handle}`);
|
2023-02-27 19:15:39 +00:00
|
|
|
} else if (lnurl.startsWith("https:")) {
|
2023-02-25 21:18:36 +00:00
|
|
|
this.#url = new URL(lnurl);
|
2023-02-27 19:15:39 +00:00
|
|
|
} else if (lnurl.startsWith("lnurlp:")) {
|
|
|
|
const tmp = new URL(lnurl);
|
|
|
|
tmp.protocol = "https:";
|
|
|
|
this.#url = tmp;
|
2023-02-25 21:18:36 +00:00
|
|
|
} else {
|
2023-02-27 19:15:39 +00:00
|
|
|
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Could not determine service url");
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-05 10:58:26 +00:00
|
|
|
/**
|
|
|
|
* URL of this payService
|
|
|
|
*/
|
|
|
|
get url() {
|
|
|
|
return this.#url;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return the optimal formatted LNURL
|
|
|
|
*/
|
|
|
|
get lnurl() {
|
|
|
|
if (this.isLNAddress) {
|
|
|
|
return this.getLNAddress();
|
|
|
|
}
|
|
|
|
return this.#url.toString();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Human readable name for this service
|
|
|
|
*/
|
|
|
|
get name() {
|
|
|
|
// LN Address formatted URL
|
|
|
|
if (this.isLNAddress) {
|
|
|
|
return this.getLNAddress();
|
|
|
|
}
|
|
|
|
// Generic LUD-06 url
|
|
|
|
return this.#url.hostname;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Is this LNURL a LUD-16 Lightning Address
|
|
|
|
*/
|
|
|
|
get isLNAddress() {
|
|
|
|
return this.#url.pathname.startsWith("/.well-known/lnurlp/");
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the LN Address for this LNURL
|
|
|
|
*/
|
|
|
|
getLNAddress() {
|
|
|
|
const pathParts = this.#url.pathname.split("/");
|
|
|
|
const username = pathParts[pathParts.length - 1];
|
|
|
|
return `${username}@${this.#url.hostname}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a NIP-57 zap tag from this LNURL
|
|
|
|
*/
|
|
|
|
getZapTag() {
|
|
|
|
if (this.isLNAddress) {
|
|
|
|
return ["zap", this.getLNAddress(), "lud16"];
|
|
|
|
} else {
|
|
|
|
return ["zap", this.#url.toString(), "lud06"];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-25 21:18:36 +00:00
|
|
|
async load() {
|
|
|
|
const rsp = await fetch(this.#url);
|
|
|
|
if (rsp.ok) {
|
|
|
|
this.#service = await rsp.json();
|
|
|
|
this.#validateService();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Fetch an invoice from the LNURL service
|
|
|
|
* @param amount Amount in sats
|
|
|
|
* @param comment
|
|
|
|
* @param zap
|
|
|
|
* @returns
|
|
|
|
*/
|
2023-03-28 14:34:01 +00:00
|
|
|
async getInvoice(amount: number, comment?: string, zap?: RawEvent) {
|
2023-02-25 21:18:36 +00:00
|
|
|
const callback = new URL(unwrap(this.#service?.callback));
|
|
|
|
const query = new Map<string, string>();
|
|
|
|
|
|
|
|
if (callback.search.length > 0) {
|
|
|
|
callback.search
|
|
|
|
.slice(1)
|
|
|
|
.split("&")
|
|
|
|
.forEach(a => {
|
|
|
|
const pSplit = a.split("=");
|
|
|
|
query.set(pSplit[0], pSplit[1]);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
query.set("amount", Math.floor(amount * 1000).toString());
|
|
|
|
if (comment && this.#service?.commentAllowed) {
|
|
|
|
query.set("comment", comment);
|
|
|
|
}
|
|
|
|
if (this.#service?.nostrPubkey && zap) {
|
2023-03-28 14:34:01 +00:00
|
|
|
query.set("nostr", JSON.stringify(zap));
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`;
|
|
|
|
const queryJoined = [...query.entries()].map(v => `${v[0]}=${encodeURIComponent(v[1])}`).join("&");
|
|
|
|
try {
|
|
|
|
const rsp = await fetch(`${baseUrl}?${queryJoined}`);
|
|
|
|
if (rsp.ok) {
|
|
|
|
const data: LNURLInvoice = await rsp.json();
|
|
|
|
console.debug("[LNURL]: ", data);
|
|
|
|
if (data.status === "ERROR") {
|
|
|
|
throw new Error(data.reason);
|
|
|
|
} else {
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
} else {
|
2023-02-27 19:15:39 +00:00
|
|
|
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, `Failed to fetch invoice (${rsp.statusText})`);
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
} catch (e) {
|
2023-02-27 19:15:39 +00:00
|
|
|
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, "Failed to load callback");
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Are zaps (NIP-57) supported
|
|
|
|
*/
|
|
|
|
get canZap() {
|
|
|
|
return this.#service?.nostrPubkey ? true : false;
|
|
|
|
}
|
|
|
|
|
2023-03-05 17:54:55 +00:00
|
|
|
/**
|
|
|
|
* Return pubkey of zap service
|
|
|
|
*/
|
|
|
|
get zapperPubkey() {
|
|
|
|
return this.#service?.nostrPubkey;
|
|
|
|
}
|
|
|
|
|
2023-02-25 21:18:36 +00:00
|
|
|
/**
|
|
|
|
* Get the max allowed comment length
|
|
|
|
*/
|
|
|
|
get maxCommentLength() {
|
|
|
|
return this.#service?.commentAllowed ?? 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Min sendable in milli-sats
|
|
|
|
*/
|
|
|
|
get min() {
|
|
|
|
return this.#service?.minSendable ?? 1_000; // 1 sat
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Max sendable in milli-sats
|
|
|
|
*/
|
|
|
|
get max() {
|
|
|
|
return this.#service?.maxSendable ?? 100e9; // 1 BTC in milli-sats
|
|
|
|
}
|
|
|
|
|
|
|
|
#validateService() {
|
|
|
|
if (this.#service?.tag !== PayServiceTag) {
|
2023-02-27 19:15:39 +00:00
|
|
|
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "Only LNURLp is supported");
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
if (!this.#service?.callback) {
|
2023-02-27 19:15:39 +00:00
|
|
|
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url");
|
2023-02-25 21:18:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface LNURLService {
|
|
|
|
tag: string;
|
|
|
|
nostrPubkey?: HexKey;
|
|
|
|
minSendable?: number;
|
|
|
|
maxSendable?: number;
|
|
|
|
metadata: string;
|
|
|
|
callback: string;
|
|
|
|
commentAllowed?: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface LNURLStatus {
|
|
|
|
status: "SUCCESS" | "ERROR";
|
|
|
|
reason?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface LNURLInvoice extends LNURLStatus {
|
|
|
|
pr?: string;
|
|
|
|
successAction?: LNURLSuccessAction;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface LNURLSuccessAction {
|
|
|
|
description?: string;
|
|
|
|
url?: string;
|
|
|
|
}
|