UNPKG

@atomiqlabs/sdk-lib

Version:

Basic SDK functionality library for atomiq

463 lines (408 loc) 17.1 kB
import {RequestError} from "../errors/RequestError"; import {decode as bolt11Decode, PaymentRequestObject, TagsObject} from "@atomiqlabs/bolt11"; import {UserError} from "../errors/UserError"; import {httpGet, tryWithRetries} from "./Utils"; import {bech32} from "@scure/base"; import {cbc} from "@noble/ciphers/aes"; import {Buffer} from "buffer"; import {sha256} from "@noble/hashes/sha2"; export type LNURLWithdrawParams = { tag: "withdrawRequest"; k1: string; callback: string; domain: string; minWithdrawable: number; maxWithdrawable: number; defaultDescription: string; balanceCheck?: string; payLink?: string; } export type LNURLPayParams = { tag: "payRequest"; callback: string; domain: string; minSendable: number; maxSendable: number; metadata: string; decodedMetadata: string[][]; commentAllowed: number; } export type LNURLPayResult = { pr: string; successAction: LNURLPaySuccessAction | null; disposable: boolean | null; routes: []; } export type LNURLPaySuccessAction = { tag: string; description: string | null; url: string | null; message: string | null; ciphertext: string | null; iv: string | null; }; export type LNURLDecodedSuccessAction = { description: string, text?: string, url?: string }; export type LNURLWithdrawParamsWithUrl = LNURLWithdrawParams & {url: string}; export type LNURLPayParamsWithUrl = LNURLPayParams & {url: string}; export type LNURLPay = { type: "pay", min: bigint, max: bigint, commentMaxLength: number, shortDescription: string, longDescription?: string, icon?: string, params: LNURLPayParamsWithUrl } export function isLNURLPay(value: any): value is LNURLPay { return ( typeof value === "object" && value != null && value.type === "pay" && typeof(value.min) === "bigint" && typeof(value.max) === "bigint" && typeof value.commentMaxLength === "number" && typeof value.shortDescription === "string" && (value.longDescription === undefined || typeof value.longDescription === "string") && (value.icon === undefined || typeof value.icon === "string") && isLNURLPayParams(value.params) ); } export type LNURLWithdraw= { type: "withdraw", min: bigint, max: bigint, params: LNURLWithdrawParamsWithUrl } export function isLNURLWithdraw(value: any): value is LNURLWithdraw { return ( typeof value === "object" && value != null && value.type === "withdraw" && typeof(value.min) === "bigint" && typeof(value.max) === "bigint" && isLNURLWithdrawParams(value.params) ); } export type LNURLOk = { status: "OK" }; export type LNURLError = { status: "ERROR", reason?: string }; export function isLNURLError(obj: any): obj is LNURLError { return obj.status==="ERROR" && (obj.reason==null || typeof obj.reason==="string"); } export function isLNURLPayParams(obj: any): obj is LNURLPayParams { return obj.tag==="payRequest"; } export function isLNURLWithdrawParams(obj: any): obj is LNURLWithdrawParams { return obj.tag==="withdrawRequest"; } export function isLNURLPayResult(obj: LNURLPayResult, domain?: string): obj is LNURLPayResult { return typeof obj.pr==="string" && (obj.routes==null || Array.isArray(obj.routes)) && (obj.disposable===null || obj.disposable===undefined || typeof obj.disposable==="boolean") && (obj.successAction==null || isLNURLPaySuccessAction(obj.successAction, domain)); } export function isLNURLPaySuccessAction(obj: any, domain?: string): obj is LNURLPaySuccessAction { if(obj==null || typeof obj !== 'object' || typeof obj.tag !== 'string') return false; switch(obj.tag) { case "message": return obj.message!=null && obj.message.length<=144; case "url": return obj.description!=null && obj.description.length<=144 && obj.url!=null && (domain==null || new URL(obj.url).hostname===domain); case "aes": return obj.description!=null && obj.description.length<=144 && obj.ciphertext!=null && obj.ciphertext.length<=4096 && BASE64_REGEX.test(obj.ciphertext) && obj.iv!=null && obj.iv.length<=24 && BASE64_REGEX.test(obj.iv); default: //Unsupported action return false; } } export const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; export const MAIL_REGEX = /(?:[A-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[A-z0-9](?:[A-z0-9-]*[A-z0-9])?\.)+[A-z0-9](?:[A-z0-9-]*[A-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[A-z0-9-]*[A-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; export class LNURL { private static findBech32LNURL(str: string) { const arr = /,*?((lnurl)([0-9]{1,}[a-z0-9]+){1})/.exec(str.toLowerCase()); if(arr==null) return null; return arr[1]; } private static isBech32LNURL(str: string): boolean { return this.findBech32LNURL(str)!=null; } /** * Checks whether a provided string is bare (non bech32 encoded) lnurl * @param str * @private */ private static isBareLNURL(str: string): boolean { try { return str.startsWith("lnurlw://") || str.startsWith("lnurlp://"); } catch(e) {} return false; } /** * Checks if the provided string is a lightning network address (e.g. satoshi@nakamoto.com) * @param str * @private */ private static isLightningAddress(str: string): boolean { return MAIL_REGEX.test(str); } /** * Checks whether a given string is a LNURL or lightning address * @param str */ static isLNURL(str: string): boolean { return LNURL.isBech32LNURL(str) || LNURL.isLightningAddress(str) || LNURL.isBareLNURL(str); } /** * Extracts the URL that needs to be request from LNURL or lightning address * @param str * @private * @returns An URL to send the request to, or null if it cannot be parsed */ private static extractCallUrl(str: string): string | null { if(MAIL_REGEX.test(str)) { //lightning e-mail like address const arr = str.split("@"); const username = arr[0]; const domain = arr[1]; let scheme = "https"; if(domain.endsWith(".onion")) { scheme = "http"; } return scheme+"://"+domain+"/.well-known/lnurlp/"+username; } else if(LNURL.isBareLNURL(str)) { //non-bech32m encoded lnurl const data = str.substring("lnurlw://".length); const httpUrl = new URL("http://"+data); let scheme = "https"; if(httpUrl.hostname.endsWith(".onion")) { scheme = "http"; } return scheme+"://"+data; } else { const lnurl = LNURL.findBech32LNURL(str); if(lnurl!=null) { let { prefix: hrp, words: dataPart } = bech32.decode(lnurl as any, 2000); let requestByteArray = bech32.fromWords(dataPart); return Buffer.from(requestByteArray).toString(); } } return null; } /** * Sends a request to obtain data about a specific LNURL or lightning address * * @param str A lnurl or lightning address * @param shouldRetry Whether we should retry in case of network failure * @param timeout Request timeout in milliseconds * @param abortSignal */ static async getLNURL( str: string, shouldRetry: boolean = true, timeout?: number, abortSignal?: AbortSignal ) : Promise<LNURLPayParamsWithUrl | LNURLWithdrawParamsWithUrl | null> { if(shouldRetry==null) shouldRetry = true; const url: string = LNURL.extractCallUrl(str); if(url!=null) { const sendRequest = () => httpGet<LNURLPayParams | LNURLWithdrawParams | LNURLError>(url, timeout, abortSignal, true); let response = shouldRetry ? await tryWithRetries(sendRequest, null, RequestError, abortSignal) : await sendRequest(); if(isLNURLError(response)) return null; if(response.tag==="payRequest") try { response.decodedMetadata = JSON.parse(response.metadata) } catch (err) { response.decodedMetadata = [] } if(!isLNURLPayParams(response) && !isLNURLWithdrawParams(response)) return null; return { ...response, url: str }; } } /** * Sends a request to obtain data about a specific LNURL or lightning address * * @param str A lnurl or lightning address * @param shouldRetry Whether we should retry in case of network failure * @param timeout Request timeout in milliseconds * @param abortSignal */ static async getLNURLType(str: string, shouldRetry?: boolean, timeout?: number, abortSignal?: AbortSignal): Promise<LNURLPay | LNURLWithdraw | null> { let res: any = await LNURL.getLNURL(str, shouldRetry, timeout, abortSignal); if(res.tag==="payRequest") { const payRequest: LNURLPayParamsWithUrl = res; let shortDescription: string; let longDescription: string; let icon: string; payRequest.decodedMetadata.forEach(data => { switch(data[0]) { case "text/plain": shortDescription = data[1]; break; case "text/long-desc": longDescription = data[1]; break; case "image/png;base64": icon = "data:"+data[0]+","+data[1]; break; case "image/jpeg;base64": icon = "data:"+data[0]+","+data[1]; break; } }); return { type: "pay", min: BigInt(payRequest.minSendable) / 1000n, max: BigInt(payRequest.maxSendable) / 1000n, commentMaxLength: payRequest.commentAllowed || 0, shortDescription, longDescription, icon, params: payRequest } } if(res.tag==="withdrawRequest") { const payRequest: LNURLWithdrawParamsWithUrl = res; return { type: "withdraw", min: BigInt(payRequest.minWithdrawable) / 1000n, max: BigInt(payRequest.maxWithdrawable) / 1000n, params: payRequest } } return null; } /** * Uses a LNURL-pay request by obtaining a lightning network invoice from it * * @param payRequest LNURL params as returned from the getLNURL call * @param amount Amount of sats (BTC) to pay * @param comment Optional comment for the payment request * @param timeout Request timeout in milliseconds * @param abortSignal * @throws {RequestError} If the response is non-200, status: ERROR, or invalid format */ static async useLNURLPay( payRequest: LNURLPayParamsWithUrl, amount: bigint, comment?: string, timeout?: number, abortSignal?: AbortSignal ): Promise<{ invoice: string, parsedInvoice: PaymentRequestObject & { tagsObject: TagsObject; }, successAction?: LNURLPaySuccessAction }> { const params = ["amount="+(amount * 1000n).toString(10)]; if(comment!=null) { params.push("comment="+encodeURIComponent(comment)); } const queryParams = (payRequest.callback.includes("?") ? "&" : "?")+params.join("&"); const response = await tryWithRetries( () => httpGet<LNURLPayResult | LNURLError>(payRequest.callback+queryParams, timeout, abortSignal, true), null, RequestError, abortSignal ); if(isLNURLError(response)) throw new RequestError("LNURL callback error: "+response.reason, 200); if(!isLNURLPayResult(response)) throw new RequestError("Invalid LNURL response!", 200); const parsedPR = bolt11Decode(response.pr); const descHash = Buffer.from(sha256(payRequest.metadata)).toString("hex"); if(parsedPR.tagsObject.purpose_commit_hash!==descHash) throw new RequestError("Invalid invoice received (description hash)!", 200); const invoiceMSats = BigInt(parsedPR.millisatoshis); if(invoiceMSats !== (amount * 1000n)) throw new RequestError("Invalid invoice received (amount)!", 200); return { invoice: response.pr, parsedInvoice: parsedPR, successAction: response.successAction } } /** * Submits the bolt11 lightning invoice to the lnurl withdraw url * * @param withdrawRequest Withdraw request to use * @param withdrawRequest.k1 K1 parameter * @param withdrawRequest.callback A URL to call * @param lnpr bolt11 lightning network invoice to submit to the withdrawal endpoint * @throws {RequestError} If the response is non-200 or status: ERROR */ static async postInvoiceToLNURLWithdraw( withdrawRequest: {k1: string, callback: string}, lnpr: string ): Promise<void> { const params = [ "pr="+lnpr, "k1="+withdrawRequest.k1 ]; const queryParams = (withdrawRequest.callback.includes("?") ? "&" : "?")+params.join("&"); const response = await tryWithRetries( () => httpGet<LNURLOk | LNURLError>(withdrawRequest.callback+queryParams, null, null, true), null, RequestError ); if(isLNURLError(response)) throw new RequestError("LNURL callback error: " + response.reason, 200); } /** * Uses a LNURL-withdraw request by submitting a lightning network invoice to it * * @param withdrawRequest Withdrawal request as returned from getLNURL call * @param lnpr bolt11 lightning network invoice to submit to the withdrawal endpoint * @throws {UserError} In case the provided bolt11 lightning invoice has an amount that is out of bounds for * the specified LNURL-withdraw request */ static async useLNURLWithdraw( withdrawRequest: LNURLWithdrawParamsWithUrl, lnpr: string ): Promise<void> { const min = BigInt(withdrawRequest.minWithdrawable) / 1000n; const max = BigInt(withdrawRequest.maxWithdrawable) / 1000n; const parsedPR = bolt11Decode(lnpr); const amount = (BigInt(parsedPR.millisatoshis) + 999n) / 1000n; if(amount < min) throw new UserError("Invoice amount less than minimum LNURL-withdraw limit"); if(amount > max) throw new UserError("Invoice amount more than maximum LNURL-withdraw limit"); return await LNURL.postInvoiceToLNURLWithdraw(withdrawRequest, lnpr); } static decodeSuccessAction(successAction: LNURLPaySuccessAction, secret: string): LNURLDecodedSuccessAction | null { if(secret==null) return null; if(successAction==null) return null; if(successAction.tag==="message") { return { description: successAction.message }; } if(successAction.tag==="url") { return { description: successAction.description, url: successAction.url }; } if(successAction.tag==="aes") { const CBC = cbc(Buffer.from(secret, "hex"), Buffer.from(successAction.iv, "hex")); let plaintext = CBC.decrypt(Buffer.from(successAction.ciphertext, "base64")); // remove padding const size = plaintext.length; const pad = plaintext[size - 1]; return { description: successAction.description, text: Buffer.from(plaintext).toString("utf8", 0, size - pad) }; } } }