@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
463 lines (408 loc) • 17.1 kB
text/typescript
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)
};
}
}
}