Skip to content

Commit

Permalink
REF: lnurl class to ts
Browse files Browse the repository at this point in the history
  • Loading branch information
Overtorment committed Sep 8, 2024
1 parent e111b42 commit a61f8fc
Showing 1 changed file with 107 additions and 54 deletions.
161 changes: 107 additions & 54 deletions class/lnurl.js → class/lnurl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,51 @@ import bolt11 from 'bolt11';
import createHash from 'create-hash';
import { createHmac } from 'crypto';
import CryptoJS from 'crypto-js';
// @ts-ignore
import secp256k1 from 'secp256k1';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api

const ONION_REGEX = /^(http:\/\/[^/:@]+\.onion(?::\d{1,5})?)(\/.*)?$/; // regex for onion URL

interface LnurlPayServicePayload {
callback: string;
fixed: boolean;
min: number;
max: number;
domain: string;
metadata: string;
description?: string;
image?: string;
amount: number;
commentAllowed?: number;
}

interface LnurlPayServiceBolt11Payload {
pr: string;
successAction?: any;
disposable?: boolean;
tag: string;
metadata: any;
minSendable: number;
maxSendable: number;
callback: string;
commentAllowed: number;
}

interface DecodedInvoice {
destination: string;
num_satoshis: string;
num_millisatoshis: string;
timestamp: string;
fallback_addr: string;
route_hints: any[];
payment_hash?: string;
description_hash?: string;
cltv_expiry?: string;
expiry?: string;
description?: string;
}

/**
* @see https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-pay.md
*/
Expand All @@ -16,23 +56,29 @@ export default class Lnurl {
static TAG_WITHDRAW_REQUEST = 'withdrawRequest'; // type of LNURL
static TAG_LOGIN_REQUEST = 'login'; // type of LNURL

constructor(url, AsyncStorage) {
private _lnurl: string;
private _lnurlPayServiceBolt11Payload: LnurlPayServiceBolt11Payload | false;
private _lnurlPayServicePayload: LnurlPayServicePayload | false;
private _AsyncStorage: any;
private _preimage: string | false;

constructor(url: string, AsyncStorage?: any) {
this._lnurl = url;
this._lnurlPayServiceBolt11Payload = false;
this._lnurlPayServicePayload = false;
this._AsyncStorage = AsyncStorage;
this._preimage = false;
}

static findlnurl(bodyOfText) {
static findlnurl(bodyOfText: string): string | null {
const res = /^(?:http.*[&?]lightning=|lightning:)?(lnurl1[02-9ac-hj-np-z]+)/.exec(bodyOfText.toLowerCase());
if (res) {
return res[1];
}
return null;
}

static getUrlFromLnurl(lnurlExample) {
static getUrlFromLnurl(lnurlExample: string): string | false {
const found = Lnurl.findlnurl(lnurlExample);
if (!found) {
if (Lnurl.isLightningAddress(lnurlExample)) {
Expand All @@ -49,22 +95,22 @@ export default class Lnurl {
return Buffer.from(bech32.fromWords(decoded.words)).toString();
}

static isLnurl(url) {
static isLnurl(url: string): boolean {
return Lnurl.findlnurl(url) !== null;
}

static isOnionUrl(url) {
static isOnionUrl(url: string): boolean {
return Lnurl.parseOnionUrl(url) !== null;
}

static parseOnionUrl(url) {
static parseOnionUrl(url: string): [string, string] | null {
const match = url.match(ONION_REGEX);
if (match === null) return null;
const [, baseURI, path] = match;
return [baseURI, path];
}

async fetchGet(url) {
async fetchGet(url: string): Promise<any> {
const resp = await fetch(url, { method: 'GET' });
if (resp.status >= 300) {
throw new Error('Bad response from server');
Expand All @@ -76,14 +122,14 @@ export default class Lnurl {
return reply;
}

decodeInvoice(invoice) {
decodeInvoice(invoice: string): DecodedInvoice {
const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice);

const decoded = {
destination: payeeNodeKey,
const decoded: DecodedInvoice = {
destination: payeeNodeKey ?? '',
num_satoshis: satoshis ? satoshis.toString() : '0',
num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0',
timestamp: timestamp.toString(),
timestamp: timestamp?.toString() ?? '',
fallback_addr: '',
route_hints: [],
};
Expand All @@ -92,10 +138,10 @@ export default class Lnurl {
const { tagName, data } = tags[i];
switch (tagName) {
case 'payment_hash':
decoded.payment_hash = data;
decoded.payment_hash = String(data);
break;
case 'purpose_commit_hash':
decoded.description_hash = data;
decoded.description_hash = String(data);
break;
case 'min_final_cltv_expiry':
decoded.cltv_expiry = data.toString();
Expand All @@ -104,21 +150,21 @@ export default class Lnurl {
decoded.expiry = data.toString();
break;
case 'description':
decoded.description = data;
decoded.description = String(data);
break;
}
}

if (!decoded.expiry) decoded.expiry = '3600'; // default

if (parseInt(decoded.num_satoshis, 10) === 0 && decoded.num_millisatoshis > 0) {
decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString();
if (parseInt(decoded.num_satoshis, 10) === 0 && parseInt(decoded.num_millisatoshis, 10) > 0) {
decoded.num_satoshis = (parseInt(decoded.num_millisatoshis, 10) / 1000).toString();
}

return decoded;
}

async requestBolt11FromLnurlPayService(amountSat, comment = '') {
async requestBolt11FromLnurlPayService(amountSat: number, comment: string = ''): Promise<LnurlPayServiceBolt11Payload> {
if (!this._lnurlPayServicePayload) throw new Error('this._lnurlPayServicePayload is not set');
if (!this._lnurlPayServicePayload.callback) throw new Error('this._lnurlPayServicePayload.callback is not set');
if (amountSat < this._lnurlPayServicePayload.min || amountSat > this._lnurlPayServicePayload.max)
Expand All @@ -132,15 +178,13 @@ export default class Lnurl {
);
const nonce = Math.floor(Math.random() * 2e16).toString(16);
const separator = this._lnurlPayServicePayload.callback.indexOf('?') === -1 ? '?' : '&';
if (this.getCommentAllowed() && comment && comment.length > this.getCommentAllowed()) {
comment = comment.substr(0, this.getCommentAllowed());
if (this.getCommentAllowed() && comment && comment.length > (this.getCommentAllowed() as number)) {
comment = comment.substr(0, this.getCommentAllowed() as number);
}
if (comment) comment = `&comment=${encodeURIComponent(comment)}`;
const urlToFetch =
this._lnurlPayServicePayload.callback + separator + 'amount=' + Math.floor(amountSat * 1000) + '&nonce=' + nonce + comment;
this._lnurlPayServiceBolt11Payload = await this.fetchGet(urlToFetch);
if (this._lnurlPayServiceBolt11Payload.status === 'ERROR')
throw new Error(this._lnurlPayServiceBolt11Payload.reason || 'requestBolt11FromLnurlPayService() error');
this._lnurlPayServiceBolt11Payload = (await this.fetchGet(urlToFetch)) as LnurlPayServiceBolt11Payload;

// check pr description_hash, amount etc:
const decoded = this.decodeInvoice(this._lnurlPayServiceBolt11Payload.pr);
Expand All @@ -155,11 +199,12 @@ export default class Lnurl {
return this._lnurlPayServiceBolt11Payload;
}

async callLnurlPayService() {
async callLnurlPayService(): Promise<LnurlPayServicePayload> {
if (!this._lnurl) throw new Error('this._lnurl is not set');
const url = Lnurl.getUrlFromLnurl(this._lnurl);
if (!url) throw new Error('Invalid LNURL');
// calling the url
const reply = await this.fetchGet(url);
const reply = (await this.fetchGet(url)) as LnurlPayServiceBolt11Payload;

if (reply.tag !== Lnurl.TAG_PAY_REQUEST) {
throw new Error('lnurl-pay expected, found tag ' + reply.tag);
Expand All @@ -168,8 +213,8 @@ export default class Lnurl {
const data = reply;

// parse metadata and extract things from it
let image;
let description;
let image: string | undefined;
let description: string | undefined;
const kvs = JSON.parse(data.metadata);
for (let i = 0; i < kvs.length; i++) {
const [k, v] = kvs[i];
Expand All @@ -185,14 +230,15 @@ export default class Lnurl {
}

// setting the payment screen with the parameters
const min = Math.ceil((data.minSendable || 0) / 1000);
const max = Math.floor(data.maxSendable / 1000);
const min = Math.ceil((data?.minSendable ?? 0) / 1000);
const max = Math.floor((data?.maxSendable ?? 0) / 1000);

this._lnurlPayServicePayload = {
callback: data.callback,
fixed: min === max,
min,
max,
// @ts-ignore idk
domain: data.callback.match(/^(https|http):\/\/([^/]+)\//)[2],
metadata: data.metadata,
description,
Expand All @@ -204,7 +250,7 @@ export default class Lnurl {
return this._lnurlPayServicePayload;
}

async loadSuccessfulPayment(paymentHash) {
async loadSuccessfulPayment(paymentHash: string): Promise<boolean> {
if (!paymentHash) throw new Error('No paymentHash provided');
let data;
try {
Expand All @@ -224,7 +270,7 @@ export default class Lnurl {
return true;
}

async storeSuccess(paymentHash, preimage) {
async storeSuccess(paymentHash: string, preimage: string | { data: Buffer }): Promise<void> {
if (typeof preimage === 'object') {
preimage = Buffer.from(preimage.data).toString('hex');
}
Expand All @@ -241,35 +287,39 @@ export default class Lnurl {
);
}

getSuccessAction() {
return this._lnurlPayServiceBolt11Payload.successAction;
getSuccessAction(): any | undefined {
return this._lnurlPayServiceBolt11Payload && 'successAction' in this._lnurlPayServiceBolt11Payload
? this._lnurlPayServiceBolt11Payload.successAction
: undefined;
}

getDomain() {
return this._lnurlPayServicePayload.domain;
getDomain(): string | undefined {
return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.domain : undefined;
}

getDescription() {
return this._lnurlPayServicePayload.description;
getDescription(): string | undefined {
return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.description : undefined;
}

getImage() {
return this._lnurlPayServicePayload.image;
getImage(): string | undefined {
return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.image : undefined;
}

getLnurl() {
getLnurl(): string {
return this._lnurl;
}

getDisposable() {
return this._lnurlPayServiceBolt11Payload.disposable;
getDisposable(): boolean | undefined {
return this._lnurlPayServiceBolt11Payload && 'disposable' in this._lnurlPayServiceBolt11Payload
? this._lnurlPayServiceBolt11Payload.disposable
: undefined;
}

getPreimage() {
getPreimage(): string | false {
return this._preimage;
}

static decipherAES(ciphertextBase64, preimageHex, ivBase64) {
static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string {
const iv = CryptoJS.enc.Base64.parse(ivBase64);
const key = CryptoJS.enc.Hex.parse(preimageHex);
return CryptoJS.AES.decrypt(Buffer.from(ciphertextBase64, 'base64').toString('hex'), key, {
Expand All @@ -279,27 +329,30 @@ export default class Lnurl {
}).toString(CryptoJS.enc.Utf8);
}

getCommentAllowed() {
return this?._lnurlPayServicePayload?.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed, 10) : false;
getCommentAllowed(): number | false {
if (!this._lnurlPayServicePayload) return false;
return this._lnurlPayServicePayload.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed.toString(), 10) : false;
}

getMin() {
return this?._lnurlPayServicePayload?.min ? parseInt(this._lnurlPayServicePayload.min, 10) : false;
getMin(): number | false {
if (!this._lnurlPayServicePayload) return false;
return this._lnurlPayServicePayload.min ? parseInt(this._lnurlPayServicePayload.min.toString(), 10) : false;
}

getMax() {
return this?._lnurlPayServicePayload?.max ? parseInt(this._lnurlPayServicePayload.max, 10) : false;
getMax(): number | false {
if (!this._lnurlPayServicePayload) return false;
return this._lnurlPayServicePayload.max ? parseInt(this._lnurlPayServicePayload.max.toString(), 10) : false;
}

getAmount() {
getAmount(): number | false {
return this.getMin();
}

authenticate(secret) {
authenticate(secret: string): Promise<void> {
return new Promise((resolve, reject) => {
if (!this._lnurl) throw new Error('this._lnurl is not set');

const url = parse(Lnurl.getUrlFromLnurl(this._lnurl), true);
const url = parse(Lnurl.getUrlFromLnurl(this._lnurl) || '', true);

const hmac = createHmac('sha256', secret);
hmac.on('readable', async () => {
Expand All @@ -308,7 +361,7 @@ export default class Lnurl {
if (!privateKey) return;
const privateKeyBuf = Buffer.from(privateKey, 'hex');
const publicKey = secp256k1.publicKeyCreate(privateKeyBuf);
const signatureObj = secp256k1.sign(Buffer.from(url.query.k1, 'hex'), privateKeyBuf);
const signatureObj = secp256k1.sign(Buffer.from(url.query.k1 as string, 'hex'), privateKeyBuf);
const derSignature = secp256k1.signatureExport(signatureObj.signature);

const reply = await this.fetchGet(`${url.href}&sig=${derSignature.toString('hex')}&key=${publicKey.toString('hex')}`);
Expand All @@ -326,7 +379,7 @@ export default class Lnurl {
});
}

static isLightningAddress(address) {
static isLightningAddress(address: string) {
// ensure only 1 `@` present:
if (address.split('@').length !== 2) return false;
const splitted = address.split('@');
Expand Down

0 comments on commit a61f8fc

Please sign in to comment.