@bringinxyz/lnurl-pay
Version:
Enhanced LNURL Pay with Bringin POS mode support for Lightning micro-payments (20+ sats vs 22k+ sats)
427 lines (382 loc) • 13.2 kB
JavaScript
const lnurlPay = require("lnurl-pay");
const crypto = require("crypto");
/**
* HTTP client factory with automatic fallback
* Uses built-in fetch for Node 18+ or axios as fallback
*/
const httpClient = (() => {
try {
// Try built-in fetch (Node 18+)
if (typeof globalThis.fetch !== "undefined") {
return {
async get(url, options = {}) {
const timeout = options.timeout || 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": "bringin-lnurl-pay/1.0.0",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
const data = await response.json();
return { data };
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
},
};
}
} catch (e) {
// Fallback to axios
}
try {
const axios = require("axios");
return {
async get(url, options = {}) {
const timeout = options.timeout || 30000;
const response = await axios.get(url, {
timeout,
headers: {
Accept: "application/json",
"User-Agent": "bringin-lnurl-pay/1.0.0",
},
});
return response;
},
};
} catch (e) {
throw new Error("Please install axios: npm install axios");
}
})();
/**
* Input validation utilities
*/
const validators = {
/**
* Validate Lightning address format
* @param {string} address - Lightning address to validate
* @returns {boolean} True if valid
*/
isValidLightningAddress(address) {
if (typeof address !== "string") return false;
const parts = address.split("@");
if (parts.length !== 2) return false;
const [username, domain] = parts;
return username.length > 0 && domain.length > 0 && domain.includes(".");
},
/**
* Validate amount in satoshis
* @param {number} amount - Amount to validate
* @returns {boolean} True if valid
*/
isValidAmount(amount) {
return typeof amount === "number" && amount > 0 && Number.isInteger(amount);
},
/**
* Validate comment length
* @param {string} comment - Comment to validate
* @param {number} maxLength - Maximum allowed length
* @returns {boolean} True if valid
*/
isValidComment(comment, maxLength) {
if (!comment) return true;
return typeof comment === "string" && comment.length <= maxLength;
},
};
/**
* Enhanced LNURL Pay with POS mode support
* Drop-in replacement for lnurl-pay library that adds Bringin POS functionality
*/
class EnhancedLnurlPay {
/**
* Request invoice with POS mode support - same API as lnurl-pay
* @param {Object} options - Request options
* @param {string} options.lnUrlOrAddress - Lightning address or LNURL
* @param {number} options.tokens - Amount in satoshis
* @param {string} [options.comment] - Optional comment
* @param {boolean} [options.posMode=false] - Enable POS mode for lower minimums
* @param {boolean} [options.onionAllowed=false] - Allow onion URLs
* @param {Function} [options.fetchGet] - Custom fetch function
* @param {number} [options.timeout=30000] - Request timeout in milliseconds
* @returns {Promise<Object>} Invoice response in lnurl-pay format
* @throws {Error} When validation fails or request errors occur
*/
static async requestInvoice(options) {
const {
lnUrlOrAddress,
tokens,
comment,
posMode = false,
onionAllowed = false,
fetchGet,
timeout = 30000,
...otherOptions
} = options;
// Input validation
if (!lnUrlOrAddress) {
throw new Error("lnUrlOrAddress is required");
}
if (!validators.isValidAmount(tokens)) {
throw new Error("tokens must be a positive integer");
}
// For non-POS requests or non-Lightning addresses, use original lnurl-pay
if (!posMode || !lnUrlOrAddress.includes("@")) {
return await lnurlPay.requestInvoice(options);
}
try {
// Get POS service parameters
const params = await this.requestPayServiceParams({
lnUrlOrAddress,
posMode: true,
timeout,
});
// Validate amount against service limits
if (tokens < params.min) {
throw new Error(`Amount too small. Minimum: ${params.min} sats`);
}
if (tokens > params.max) {
throw new Error(`Amount too large. Maximum: ${params.max} sats`);
}
// Validate comment
if (!validators.isValidComment(comment, params.commentAllowed)) {
throw new Error(
`Comment too long. Maximum: ${params.commentAllowed} characters`
);
}
// Request invoice
const amountMillisats = tokens * 1000;
let invoiceUrl = `${params.callback}?amount=${amountMillisats}`;
if (comment) {
invoiceUrl += `&comment=${encodeURIComponent(comment)}`;
}
const response = await httpClient.get(invoiceUrl, { timeout });
if (response.data.status !== "OK") {
throw new Error(
`Invoice request failed: ${response.data.reason || "Unknown error"}`
);
}
// Return in lnurl-pay compatible format
return {
invoice: response.data.pr,
params: params,
successAction: response.data.successAction || null,
rawData: response.data,
// Additional fields for compatibility
hasValidAmount: true,
hasValidDescriptionHash: true,
validatePreimage: () => true, // Placeholder function
};
} catch (error) {
if (
error.message.includes("Amount too small") ||
error.message.includes("Amount too large") ||
error.message.includes("Comment too long")
) {
throw error; // Re-throw validation errors as-is
}
throw new Error(`POS invoice request failed: ${error.message}`);
}
}
/**
* Request pay service params with POS support
* @param {Object} options - Request options
* @param {string} options.lnUrlOrAddress - Lightning address or LNURL
* @param {boolean} [options.posMode=false] - Enable POS mode
* @param {boolean} [options.onionAllowed=false] - Allow onion URLs
* @param {Function} [options.fetchGet] - Custom fetch function
* @param {number} [options.timeout=30000] - Request timeout in milliseconds
* @returns {Promise<Object>} Service parameters in lnurl-pay format
* @throws {Error} When validation fails or request errors occur
*/
static async requestPayServiceParams(options) {
const {
lnUrlOrAddress,
posMode = false,
onionAllowed = false,
fetchGet,
timeout = 30000,
} = options;
// Input validation
if (!lnUrlOrAddress) {
throw new Error("lnUrlOrAddress is required");
}
// For non-POS requests or non-Lightning addresses, use original lnurl-pay
if (!posMode || !lnUrlOrAddress.includes("@")) {
return await lnurlPay.requestPayServiceParams(options);
}
try {
const [username, domain] = lnUrlOrAddress.split("@");
if (!username || !domain) {
throw new Error("Invalid Lightning address format");
}
const url = `https://${domain}/.well-known/lnurlp/${username}?pos=true`;
const response = await httpClient.get(url, { timeout });
const data = response.data;
if (data.status !== "OK") {
throw new Error(`Service error: ${data.reason || "Invalid response"}`);
}
// Parse metadata
const metadata =
typeof data.metadata === "string"
? JSON.parse(data.metadata)
: data.metadata;
const description = this.parseDescription(metadata);
// Return in lnurl-pay format
return {
callback: data.callback,
fixed: false,
min: parseInt(data.minSendable) / 1000, // Convert millisats to sats
max: parseInt(data.maxSendable) / 1000, // Convert millisats to sats
domain: domain,
metadata: metadata,
metadataHash: this.calculateMetadataHash(data.metadata),
identifier: lnUrlOrAddress,
description: description,
image: this.extractImage(metadata),
commentAllowed: data.commentAllowed || 0,
rawData: data,
};
} catch (error) {
if (error.message.includes("Invalid Lightning address")) {
throw error; // Re-throw validation errors as-is
}
throw new Error(`Failed to get POS service params: ${error.message}`);
}
}
/**
* Request invoice with service params (2nd step) - with POS support
* @param {Object} options - Request options
* @param {Object} options.params - Service parameters from requestPayServiceParams
* @param {number} options.tokens - Amount in satoshis
* @param {string} [options.comment] - Optional comment
* @param {number} [options.timeout=30000] - Request timeout in milliseconds
* @returns {Promise<Object>} Invoice response
* @throws {Error} When validation fails or request errors occur
*/
static async requestInvoiceWithServiceParams(options) {
const { params, tokens, comment, timeout = 30000 } = options;
// Input validation
if (!params) {
throw new Error("params is required");
}
if (!validators.isValidAmount(tokens)) {
throw new Error("tokens must be a positive integer");
}
// If this is a POS params object (has our POS marker), handle specially
if (
params.rawData &&
params.rawData.status === "OK" &&
params.callback.includes("bringin.xyz")
) {
// Validate amount
if (tokens < params.min) {
throw new Error(`Amount too small. Minimum: ${params.min} sats`);
}
if (tokens > params.max) {
throw new Error(`Amount too large. Maximum: ${params.max} sats`);
}
// Validate comment
if (!validators.isValidComment(comment, params.commentAllowed)) {
throw new Error(
`Comment too long. Maximum: ${params.commentAllowed} characters`
);
}
// Request invoice
const amountMillisats = tokens * 1000;
let invoiceUrl = `${params.callback}?amount=${amountMillisats}`;
if (comment) {
invoiceUrl += `&comment=${encodeURIComponent(comment)}`;
}
const response = await httpClient.get(invoiceUrl, { timeout });
return {
invoice: response.data.pr,
successAction: response.data.successAction || null,
rawData: response.data,
};
}
// Use original lnurl-pay for standard params
return await lnurlPay.requestInvoiceWithServiceParams(options);
}
/**
* Parse description from metadata
* @param {Array} metadata - Metadata array
* @returns {string} Description text
*/
static parseDescription(metadata) {
try {
if (!Array.isArray(metadata)) {
return "Payment";
}
const textPlain = metadata.find(
(item) => Array.isArray(item) && item[0] === "text/plain"
);
return textPlain ? textPlain[1] : "Payment";
} catch (error) {
return "Payment";
}
}
/**
* Extract image from metadata
* @param {Array} metadata - Metadata array
* @returns {string} Image data or empty string
*/
static extractImage(metadata) {
try {
if (!Array.isArray(metadata)) {
return "";
}
const image = metadata.find(
(item) =>
Array.isArray(item) &&
(item[0] === "image/png;base64" || item[0] === "image/jpeg;base64")
);
return image ? image[1] : "";
} catch (error) {
return "";
}
}
/**
* Calculate metadata hash
* @param {string|Array} metadata - Metadata to hash
* @returns {string} Hash string
*/
static calculateMetadataHash(metadata) {
try {
const metadataStr =
typeof metadata === "string" ? metadata : JSON.stringify(metadata);
return crypto
.createHash("sha256")
.update(metadataStr)
.digest("hex")
.substring(0, 32);
} catch (error) {
return crypto
.createHash("sha256")
.update("")
.digest("hex")
.substring(0, 32);
}
}
}
// Export both the class and individual functions for compatibility
module.exports = EnhancedLnurlPay;
// Also export individual functions to match lnurl-pay API
module.exports.requestInvoice =
EnhancedLnurlPay.requestInvoice.bind(EnhancedLnurlPay);
module.exports.requestPayServiceParams =
EnhancedLnurlPay.requestPayServiceParams.bind(EnhancedLnurlPay);
module.exports.requestInvoiceWithServiceParams =
EnhancedLnurlPay.requestInvoiceWithServiceParams.bind(EnhancedLnurlPay);