From e058b85f4199d445855089b8cbde7633b66bbec5 Mon Sep 17 00:00:00 2001 From: Patrick Ulrich Date: Wed, 13 Nov 2024 20:04:35 -0500 Subject: [PATCH] End Money Glitch - Configuration Refactoring - Transition from using LNDHub to only LNbits - Domain Management Enhancements - Expanded Logging - Enhanced Error Handling --- Orangemart.cs | 1649 +++++++++++++++++++++++++------------------------ README.md | 128 +++- 2 files changed, 945 insertions(+), 832 deletions(-) diff --git a/Orangemart.cs b/Orangemart.cs index 006dc7a..b0d5f1d 100644 --- a/Orangemart.cs +++ b/Orangemart.cs @@ -1,123 +1,113 @@ using System; using System.Collections.Generic; -using System.Text; using System.IO; +using System.Linq; using Newtonsoft.Json; using Oxide.Core; -using Oxide.Core.Plugins; -using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; +using Oxide.Core.Libraries; namespace Oxide.Plugins { - [Info("Orangemart", "saulteafarmer", "0.2.0")] - [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments")] + [Info("Orangemart", "saulteafarmer", "0.3.0")] + [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments via LNbits")] public class Orangemart : CovalencePlugin { - // Configuration variables - private int CurrencyItemID; - private string BuyCurrencyCommandName; - private string SendCurrencyCommandName; - private string BuyVipCommandName; - private int VipPrice; - private string VipPermissionGroup; - private string CurrencyName; - private int SatsPerCurrencyUnit; - private int PricePerCurrencyUnit; - private string DiscordChannelName; // Added missing configuration variable - private ulong CurrencySkinID; - - // Transaction timing settings (moved to config) - private int CheckIntervalSeconds; - private int InvoiceTimeoutSeconds; - private int RetryDelaySeconds; - private int MaxRetries; - - // Blacklisted domains - private List BlacklistedDomains = new List(); - - // File names - private const string SellLogFile = "Orangemart/sell_log.json"; - private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json"; - - // LNDHub configuration - private LNDHubConfig config; - private string authToken; - - private List pendingInvoices = new List(); - private Dictionary retryCounts = new Dictionary(); - - // Added for handling 404-specific retries - private Dictionary retryCounts404 = new Dictionary(); - private const int Max404Retries = 5; - private const int RetryDelaySeconds404 = 5; - - private class LNDHubConfig + // Configuration sections and keys + private static class ConfigSections + { + public const string Commands = "Commands"; + public const string CurrencySettings = "CurrencySettings"; + public const string Discord = "Discord"; + public const string InvoiceSettings = "InvoiceSettings"; + public const string VIPSettings = "VIPSettings"; + } + + private static class ConfigKeys + { + // Commands + public const string BuyCurrencyCommandName = "BuyCurrencyCommandName"; + public const string SendCurrencyCommandName = "SendCurrencyCommandName"; + public const string BuyVipCommandName = "BuyVipCommandName"; + + // CurrencySettings + public const string CurrencyItemID = "CurrencyItemID"; + public const string CurrencyName = "CurrencyName"; + public const string CurrencySkinID = "CurrencySkinID"; + public const string PricePerCurrencyUnit = "PricePerCurrencyUnit"; + public const string SatsPerCurrencyUnit = "SatsPerCurrencyUnit"; + + // Discord + public const string DiscordChannelName = "DiscordChannelName"; + public const string DiscordWebhookUrl = "DiscordWebhookUrl"; + + // InvoiceSettings + public const string BlacklistedDomains = "BlacklistedDomains"; + public const string WhitelistedDomains = "WhitelistedDomains"; + public const string CheckIntervalSeconds = "CheckIntervalSeconds"; + public const string InvoiceTimeoutSeconds = "InvoiceTimeoutSeconds"; + public const string LNbitsApiKey = "LNbitsApiKey"; + public const string LNbitsBaseUrl = "LNbitsBaseUrl"; + public const string MaxRetries = "MaxRetries"; + + // VIPSettings + public const string VipPermissionGroup = "VipPermissionGroup"; + public const string VipPrice = "VipPrice"; + } + + // Configuration variables + private int currencyItemID; + private string buyCurrencyCommandName; + private string sendCurrencyCommandName; + private string buyVipCommandName; + private int vipPrice; + private string vipPermissionGroup; + private string currencyName; + private int satsPerCurrencyUnit; + private int pricePerCurrencyUnit; + private string discordChannelName; + private ulong currencySkinID; + private int checkIntervalSeconds; + private int invoiceTimeoutSeconds; + private int maxRetries; + private List blacklistedDomains = new List(); + private List whitelistedDomains = new List(); + private const string SellLogFile = "Orangemart/send_bitcoin.json"; + private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json"; + private LNbitsConfig config; + private List pendingInvoices = new List(); + private Dictionary retryCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // LNbits Configuration + private class LNbitsConfig { - public string Username { get; set; } - public string Password { get; set; } public string BaseUrl { get; set; } + public string ApiKey { get; set; } public string DiscordWebhookUrl { get; set; } - public static LNDHubConfig ParseLNDHubConnection(string connectionString) + public static LNbitsConfig ParseLNbitsConnection(string baseUrl, string apiKey, string discordWebhookUrl) { - try + var trimmedBaseUrl = baseUrl.TrimEnd('/'); + if (!Uri.IsWellFormedUriString(trimmedBaseUrl, UriKind.Absolute)) + throw new Exception("Invalid base URL in connection string."); + + return new LNbitsConfig { - var withoutScheme = connectionString.Replace("lndhub://", ""); - var parts = withoutScheme.Split('@'); - if (parts.Length != 2) - throw new Exception("Invalid connection string format."); - - var userInfoPart = parts[0]; - var baseUrlPart = parts[1]; - - var userInfo = userInfoPart.Split(':'); - if (userInfo.Length != 2) - throw new Exception("Invalid user info in connection string."); - - var username = userInfo[0]; - var password = userInfo[1]; - - var baseUrl = baseUrlPart.TrimEnd('/'); - if (!Uri.IsWellFormedUriString(baseUrl, UriKind.Absolute)) - throw new Exception("Invalid base URL in connection string."); - - return new LNDHubConfig - { - Username = username, - Password = password, - BaseUrl = baseUrl - }; - } - catch (Exception ex) - { - throw new Exception($"Error parsing LNDHub connection string: {ex.Message}"); - } + BaseUrl = trimmedBaseUrl, + ApiKey = apiKey, + DiscordWebhookUrl = discordWebhookUrl + }; } } - private class AuthResponse - { - [JsonProperty("access_token")] - public string AccessToken { get; set; } - - [JsonProperty("refresh_token")] - public string RefreshToken { get; set; } - } - + // Invoice and Payment Classes private class InvoiceResponse { [JsonProperty("payment_request")] public string PaymentRequest { get; set; } - [JsonProperty("r_hash")] - public RHashData RHash { get; set; } - - public class RHashData - { - [JsonProperty("data")] - public byte[] Data { get; set; } - } + [JsonProperty("payment_hash")] + public string PaymentHash { get; set; } } private class SellInvoiceLogEntry @@ -125,13 +115,11 @@ namespace Oxide.Plugins public string SteamID { get; set; } public string LightningAddress { get; set; } public bool Success { get; set; } - public int SatsAmount { get; set; } // Log the amount of sats sent - public int Fee { get; set; } // Log the fee - public int FeeMsat { get; set; } // Log the fee in millisatoshis - public string PaymentRequest { get; set; } // Log the BOLT11 payment request - public string PaymentHash { get; set; } // Log the payment hash - public bool CurrencyReturned { get; set; } // Indicates if currency was returned + public int SatsAmount { get; set; } + public string PaymentHash { get; set; } + public bool CurrencyReturned { get; set; } public DateTime Timestamp { get; set; } + public int RetryCount { get; set; } } private class BuyInvoiceLogEntry @@ -141,8 +129,9 @@ namespace Oxide.Plugins public bool IsPaid { get; set; } public DateTime Timestamp { get; set; } public int Amount { get; set; } - public bool CurrencyGiven { get; set; } // For currency purchases - public bool VipGranted { get; set; } // For VIP purchases + public bool CurrencyGiven { get; set; } + public bool VipGranted { get; set; } + public int RetryCount { get; set; } } private class PendingInvoice @@ -158,7 +147,17 @@ namespace Oxide.Plugins private enum PurchaseType { Currency, - Vip + Vip, + SendBitcoin + } + + private class PaymentStatusResponse + { + [JsonProperty("paid")] + public bool Paid { get; set; } + + [JsonProperty("preimage")] + public string Preimage { get; set; } } protected override void LoadConfig() @@ -166,54 +165,51 @@ namespace Oxide.Plugins base.LoadConfig(); try { - // Check if config is loaded properly - if (Config == null || !Config.Exists()) + bool configChanged = false; + + // Parse LNbits connection settings + config = LNbitsConfig.ParseLNbitsConnection( + GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.LNbitsBaseUrl, "https://your-lnbits-instance.com", ref configChanged), + GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.LNbitsApiKey, "your-lnbits-admin-api-key", ref configChanged), + GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordWebhookUrl, "https://discord.com/api/webhooks/your_webhook_url", ref configChanged) + ); + + // Parse Currency Settings + currencyItemID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyItemID, 1776460938, ref configChanged); + currencyName = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyName, "blood", ref configChanged); + satsPerCurrencyUnit = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.SatsPerCurrencyUnit, 1, ref configChanged); + pricePerCurrencyUnit = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.PricePerCurrencyUnit, 1, ref configChanged); + currencySkinID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencySkinID, 0UL, ref configChanged); + + // Parse Command Names + buyCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyCurrencyCommandName, "buyblood", ref configChanged); + sendCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.SendCurrencyCommandName, "sendblood", ref configChanged); + buyVipCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyVipCommandName, "buyvip", ref configChanged); + + // Parse VIP Settings + vipPrice = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPrice, 1000, ref configChanged); + vipPermissionGroup = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPermissionGroup, "vip", ref configChanged); + + // Parse Discord Settings + discordChannelName = GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordChannelName, "mart", ref configChanged); + + // Parse Invoice Settings + checkIntervalSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.CheckIntervalSeconds, 10, ref configChanged); + invoiceTimeoutSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.InvoiceTimeoutSeconds, 300, ref configChanged); + maxRetries = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.MaxRetries, 25, ref configChanged); + + blacklistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.BlacklistedDomains, new List { "example.com", "blacklisted.net" }, ref configChanged) + .Select(d => d.ToLower()).ToList(); + + whitelistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.WhitelistedDomains, new List(), ref configChanged) + .Select(d => d.ToLower()).ToList(); + + MigrateConfig(); + + if (configChanged) { - PrintError("Configuration file is missing or empty. Creating default configuration."); - LoadDefaultConfig(); SaveConfig(); } - - // Access LNDHubConnection from the InvoiceSettings section - string lndhubConnectionString = (Config["InvoiceSettings"] as Dictionary)?["LNDHubConnection"]?.ToString(); - string discordWebhookUrl = (Config["Discord"] as Dictionary)?["DiscordWebhookUrl"]?.ToString(); - - if (string.IsNullOrEmpty(lndhubConnectionString)) - { - PrintError("LNDHubConnection is not set in the configuration file."); - return; - } - - config = LNDHubConfig.ParseLNDHubConnection(lndhubConnectionString); - config.DiscordWebhookUrl = discordWebhookUrl; - - // Load other configuration settings... - var currencySettings = Config["CurrencySettings"] as Dictionary; - CurrencyItemID = Convert.ToInt32(currencySettings?["CurrencyItemID"] ?? 1776460938); - CurrencyName = currencySettings?["CurrencyName"]?.ToString() ?? "blood"; - SatsPerCurrencyUnit = Convert.ToInt32(currencySettings?["SatsPerCurrencyUnit"] ?? 1); - PricePerCurrencyUnit = Convert.ToInt32(currencySettings?["PricePerCurrencyUnit"] ?? 1); - CurrencySkinID = (ulong)Convert.ToInt64(currencySettings?["CurrencySkinID"] ?? 0); - - var commandsSettings = Config["Commands"] as Dictionary; - BuyCurrencyCommandName = commandsSettings?["BuyCurrencyCommandName"]?.ToString() ?? "buyblood"; - SendCurrencyCommandName = commandsSettings?["SendCurrencyCommandName"]?.ToString() ?? "sendblood"; - BuyVipCommandName = commandsSettings?["BuyVipCommandName"]?.ToString() ?? "buyvip"; - - var vipSettings = Config["VIPSettings"] as Dictionary; - VipPrice = Convert.ToInt32(vipSettings?["VipPrice"] ?? 1000); - VipPermissionGroup = vipSettings?["VipPermissionGroup"]?.ToString() ?? "vip"; - - // Load DiscordChannelName from Discord section - DiscordChannelName = (Config["Discord"] as Dictionary)?["DiscordChannelName"]?.ToString() ?? "mart"; - - // Load invoice settings - var invoiceSettings = Config["InvoiceSettings"] as Dictionary; - CheckIntervalSeconds = Convert.ToInt32(invoiceSettings?["CheckIntervalSeconds"] ?? 10); - InvoiceTimeoutSeconds = Convert.ToInt32(invoiceSettings?["InvoiceTimeoutSeconds"] ?? 300); - RetryDelaySeconds = Convert.ToInt32(invoiceSettings?["RetryDelaySeconds"] ?? 10); - MaxRetries = Convert.ToInt32(invoiceSettings?["MaxRetries"] ?? 25); - BlacklistedDomains = (invoiceSettings?["BlacklistedDomains"] as List)?.ConvertAll(d => d.ToString().ToLower()) ?? new List(); } catch (Exception ex) { @@ -221,46 +217,139 @@ namespace Oxide.Plugins } } + private void MigrateConfig() + { + bool configChanged = false; + + if (!(Config[ConfigSections.InvoiceSettings] is Dictionary invoiceSettings)) + { + invoiceSettings = new Dictionary(); + Config[ConfigSections.InvoiceSettings] = invoiceSettings; + configChanged = true; + } + + if (!invoiceSettings.ContainsKey(ConfigKeys.WhitelistedDomains)) + { + invoiceSettings[ConfigKeys.WhitelistedDomains] = new List(); + configChanged = true; + } + + if (configChanged) + { + SaveConfig(); + } + } + + private T GetConfigValue(string section, string key, T defaultValue, ref bool configChanged) + { + if (!(Config[section] is Dictionary data)) + { + data = new Dictionary(); + Config[section] = data; + configChanged = true; + } + + if (!data.TryGetValue(key, out var value)) + { + value = defaultValue; + data[key] = value; + configChanged = true; + } + + try + { + if (value is T tValue) + { + return tValue; + } + else if (typeof(T) == typeof(List)) + { + if (value is IEnumerable enumerable) + { + return (T)(object)enumerable.Select(item => item.ToString()).ToList(); + } + else if (value is string singleString) + { + return (T)(object)new List { singleString }; + } + else + { + PrintError($"Unexpected type for [{section}][{key}]. Using default value."); + data[key] = defaultValue; + configChanged = true; + return defaultValue; + } + } + else if (typeof(T) == typeof(ulong)) + { + if (value is long longVal) + { + return (T)(object)(ulong)longVal; + } + else if (value is ulong ulongVal) + { + return (T)(object)ulongVal; + } + else + { + return (T)Convert.ChangeType(value, typeof(T)); + } + } + else + { + return (T)Convert.ChangeType(value, typeof(T)); + } + } + catch (Exception ex) + { + PrintError($"Error converting config value for [{section}][{key}]: {ex.Message}. Using default value."); + data[key] = defaultValue; + configChanged = true; + return defaultValue; + } + } + protected override void LoadDefaultConfig() { - Config["Commands"] = new Dictionary + Config[ConfigSections.Commands] = new Dictionary { - ["BuyCurrencyCommandName"] = "buyblood", - ["BuyVipCommandName"] = "buyvip", - ["SendCurrencyCommandName"] = "sendblood" + [ConfigKeys.BuyCurrencyCommandName] = "buyblood", + [ConfigKeys.BuyVipCommandName] = "buyvip", + [ConfigKeys.SendCurrencyCommandName] = "sendblood" }; - Config["Discord"] = new Dictionary + Config[ConfigSections.CurrencySettings] = new Dictionary { - ["DiscordChannelName"] = "mart", - ["DiscordWebhookUrl"] = "https://discord.com/api/webhooks/your_webhook_url" + [ConfigKeys.CurrencyItemID] = 1776460938, + [ConfigKeys.CurrencyName] = "blood", + [ConfigKeys.CurrencySkinID] = 0UL, + [ConfigKeys.PricePerCurrencyUnit] = 1, + [ConfigKeys.SatsPerCurrencyUnit] = 1 }; - Config["InvoiceSettings"] = new Dictionary + Config[ConfigSections.Discord] = new Dictionary { - ["BlacklistedDomains"] = new List { "example.com", "blacklisted.net" }, - ["CheckIntervalSeconds"] = 10, - ["InvoiceTimeoutSeconds"] = 300, - ["LNDHubConnection"] = "lndhub://admin:password@sats.love/", - ["MaxRetries"] = 25, - ["RetryDelaySeconds"] = 10 + [ConfigKeys.DiscordChannelName] = "mart", + [ConfigKeys.DiscordWebhookUrl] = "https://discord.com/api/webhooks/your_webhook_url" }; - Config["VIPSettings"] = new Dictionary + Config[ConfigSections.InvoiceSettings] = new Dictionary { - ["VipPermissionGroup"] = "vip", - ["VipPrice"] = 1000 + [ConfigKeys.BlacklistedDomains] = new List { "example.com", "blacklisted.net" }, + [ConfigKeys.WhitelistedDomains] = new List(), + [ConfigKeys.CheckIntervalSeconds] = 10, + [ConfigKeys.InvoiceTimeoutSeconds] = 300, + [ConfigKeys.LNbitsApiKey] = "your-lnbits-admin-api-key", + [ConfigKeys.LNbitsBaseUrl] = "https://your-lnbits-instance.com", + [ConfigKeys.MaxRetries] = 25 }; - Config["CurrencySettings"] = new Dictionary + Config[ConfigSections.VIPSettings] = new Dictionary { - ["CurrencyItemID"] = 1776460938, - ["CurrencyName"] = "blood", - ["PricePerCurrencyUnit"] = 1, - ["SatsPerCurrencyUnit"] = 1, - ["CurrencySkinID"] = 0 + [ConfigKeys.VipPermissionGroup] = "vip", + [ConfigKeys.VipPrice] = 1000 }; - } + } private void Init() { @@ -278,20 +367,19 @@ namespace Oxide.Plugins return; } - // Register commands dynamically - AddCovalenceCommand(BuyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency"); - AddCovalenceCommand(SendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency"); - AddCovalenceCommand(BuyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip"); + // Register commands + AddCovalenceCommand(buyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency"); + AddCovalenceCommand(sendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency"); + AddCovalenceCommand(buyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip"); - timer.Every(CheckIntervalSeconds, CheckPendingInvoices); + // Start a timer to check pending invoices periodically + timer.Every(checkIntervalSeconds, CheckPendingInvoices); } private void Unload() { pendingInvoices.Clear(); retryCounts.Clear(); - retryCounts404.Clear(); // Clear 404-specific retries - authToken = null; } protected override void LoadDefaultMessages() @@ -302,10 +390,10 @@ namespace Oxide.Plugins ["NeedMoreCurrency"] = "You need more {0}. You currently have {1}.", ["FailedToReserveCurrency"] = "Failed to reserve currency. Please try again.", ["FailedToQueryLightningAddress"] = "Failed to query Lightning address for an invoice.", - ["FailedToAuthenticate"] = "Failed to authenticate with LNDHub.", + ["FailedToAuthenticate"] = "Failed to authenticate with LNbits.", ["InvoiceCreatedCheckDiscord"] = "Invoice created! Please check the #{0} channel on Discord to complete your payment.", ["FailedToCreateInvoice"] = "Failed to create an invoice. Please try again later.", - ["FailedToProcessPayment"] = "Failed to process payment.", + ["FailedToProcessPayment"] = "Failed to process payment. Please try again later.", ["CurrencySentSuccess"] = "You have successfully sent {0} {1}!", ["PurchaseSuccess"] = "You have successfully purchased {0} {1}!", ["PurchaseVipSuccess"] = "You have successfully purchased VIP status!", @@ -315,7 +403,10 @@ namespace Oxide.Plugins ["FailedToCreateCurrencyItem"] = "Failed to create {0} item for player {1}.", ["AddedToVipGroup"] = "Player {0} added to VIP group '{1}'.", ["InvoiceExpired"] = "Your invoice for {0} sats has expired. Please try again.", - ["BlacklistedDomain"] = "The domain '{0}' is currently blacklisted. Please use a different Lightning address." + ["BlacklistedDomain"] = "The domain '{0}' is currently blacklisted. Please use a different Lightning address.", + ["NotWhitelistedDomain"] = "The domain '{0}' is not whitelisted. Please use a Lightning address from the following domains: {1}.", + ["InvalidLightningAddress"] = "The Lightning Address provided is invalid or cannot be resolved.", + ["PaymentProcessing"] = "Your payment is being processed. You will receive a confirmation once it's complete." }, this); } @@ -324,276 +415,185 @@ namespace Oxide.Plugins return string.Format(lang.GetMessage(key, this, userId), args); } + private List GetAllInventoryItems(BasePlayer player) + { + List allItems = new List(); + + // Main Inventory + if (player.inventory.containerMain != null) + allItems.AddRange(player.inventory.containerMain.itemList); + + // Belt (Hotbar) + if (player.inventory.containerBelt != null) + allItems.AddRange(player.inventory.containerBelt.itemList); + + // Wear (Clothing) + if (player.inventory.containerWear != null) + allItems.AddRange(player.inventory.containerWear.itemList); + + return allItems; + } + private void CheckPendingInvoices() { - foreach (var invoice in pendingInvoices.ToArray()) + foreach (var invoice in pendingInvoices.ToList()) { - CheckInvoicePaid(invoice.RHash, (isPaid, isPending) => + string localPaymentHash = invoice.RHash.ToLower(); + CheckInvoicePaid(localPaymentHash, isPaid => { if (isPaid) { pendingInvoices.Remove(invoice); - if (invoice.Type == PurchaseType.Currency) + switch (invoice.Type) { - RewardPlayer(invoice.Player, invoice.Amount); - } - else if (invoice.Type == PurchaseType.Vip) - { - GrantVip(invoice.Player); + case PurchaseType.Currency: + RewardPlayer(invoice.Player, invoice.Amount); + break; + case PurchaseType.Vip: + GrantVip(invoice.Player); + break; + case PurchaseType.SendBitcoin: + invoice.Player.Reply(Lang("CurrencySentSuccess", invoice.Player.Id, invoice.Amount, currencyName)); + break; } - var logEntry = new BuyInvoiceLogEntry + if (invoice.Type == PurchaseType.SendBitcoin) { - SteamID = invoice.Player.Id, - InvoiceID = invoice.RHash, - IsPaid = true, - Timestamp = DateTime.UtcNow, - Amount = invoice.Amount, - CurrencyGiven = invoice.Type == PurchaseType.Currency, - VipGranted = invoice.Type == PurchaseType.Vip - }; - LogBuyInvoice(logEntry); - } - else if (!isPending) - { - if (!retryCounts.ContainsKey(invoice.RHash)) - { - retryCounts[invoice.RHash] = 0; - } - - retryCounts[invoice.RHash]++; - if (retryCounts[invoice.RHash] >= MaxRetries) - { - pendingInvoices.Remove(invoice); - retryCounts.Remove(invoice.RHash); - PrintWarning($"Invoice for player {invoice.Player.Id} expired (amount: {invoice.Amount} sats)."); - - // Notify the player about the expired invoice - invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); - - var logEntry = new BuyInvoiceLogEntry + var logEntry = new SellInvoiceLogEntry { - SteamID = invoice.Player.Id, - InvoiceID = invoice.RHash, - IsPaid = false, + SteamID = GetPlayerId(invoice.Player), + LightningAddress = ExtractLightningAddress(invoice.Memo), + Success = true, + SatsAmount = invoice.Amount, + PaymentHash = invoice.RHash, + CurrencyReturned = false, Timestamp = DateTime.UtcNow, - Amount = invoice.Amount, - CurrencyGiven = false, - VipGranted = false + RetryCount = retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 }; - LogBuyInvoice(logEntry); + LogSellTransaction(logEntry); + + Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); } else { - PrintWarning($"Retrying invoice {invoice.RHash}. Attempt {retryCounts[invoice.RHash]} of {MaxRetries}."); + var logEntry = CreateBuyInvoiceLogEntry( + player: invoice.Player, + invoiceID: invoice.RHash, + isPaid: true, + amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, + type: invoice.Type, + retryCount: retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 + ); + LogBuyInvoice(logEntry); + Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); + } + + retryCounts.Remove(invoice.RHash); + } + else + { + if (!retryCounts.ContainsKey(localPaymentHash)) + { + retryCounts[localPaymentHash] = 0; + Puts($"Initialized retry count for paymentHash: {localPaymentHash}"); + } + + retryCounts[localPaymentHash]++; + Puts($"Retry count for paymentHash {localPaymentHash}: {retryCounts[localPaymentHash]} of {maxRetries}"); + + if (retryCounts[localPaymentHash] >= maxRetries) + { + pendingInvoices.Remove(invoice); + int finalRetryCount = retryCounts[localPaymentHash]; + retryCounts.Remove(localPaymentHash); + PrintWarning($"Invoice for player {GetPlayerId(invoice.Player)} expired (amount: {invoice.Amount} sats)."); + + invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); + + if (invoice.Type == PurchaseType.SendBitcoin) + { + var basePlayer = invoice.Player.Object as BasePlayer; + if (basePlayer != null) + { + ReturnCurrency(basePlayer, invoice.Amount / satsPerCurrencyUnit); + Puts($"Refunded {invoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + else + { + PrintError($"Failed to find base player object for player {invoice.Player.Id} to refund currency."); + } + + var failedLogEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(invoice.Player), + LightningAddress = ExtractLightningAddress(invoice.Memo), + Success = false, + SatsAmount = invoice.Amount, + PaymentHash = invoice.RHash, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = finalRetryCount + }; + LogSellTransaction(failedLogEntry); + Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); + } + else + { + var failedLogEntry = CreateBuyInvoiceLogEntry( + player: invoice.Player, + invoiceID: localPaymentHash, + isPaid: false, + amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, + type: invoice.Type, + retryCount: finalRetryCount + ); + LogBuyInvoice(failedLogEntry); + Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); + } + } + else + { + PrintWarning($"Retrying invoice {localPaymentHash}. Attempt {retryCounts[localPaymentHash]} of {maxRetries}."); } } }); } } - private void CheckInvoicePaid(string rHash, Action callback) + private void CheckInvoicePaid(string paymentHash, Action callback) { - if (string.IsNullOrEmpty(authToken)) - { - GetAuthToken(token => - { - if (!string.IsNullOrEmpty(token)) - { - CheckInvoicePaid(rHash, callback); // Retry after getting token - } - else - { - callback(false, false); - } - }); - return; - } - - string baseUrlForCheckingInvoice = config.BaseUrl.Replace("/lndhub/ext", ""); // Remove any "/lndhub/ext" part - string url = $"{baseUrlForCheckingInvoice}/api/v1/payments/{rHash}"; - - PrintWarning($"Checking invoice at URL: {url}"); - PrintWarning($"rHash being checked: {rHash}"); + string normalizedPaymentHash = paymentHash.ToLower(); + string url = $"{config.BaseUrl}/api/v1/payments/{normalizedPaymentHash}"; var headers = new Dictionary { - { "X-Api-Key", authToken }, - { "Content-Type", "application/json" } + { "Content-Type", "application/json" }, + { "X-Api-Key", config.ApiKey } }; - webrequest.Enqueue(url, null, (code, response) => + MakeWebRequest(url, null, (code, response) => { - if (code == 404) - { - PrintError($"Error checking invoice status: HTTP {code} (Not Found)"); - PrintWarning($"Ensure the correct rHash: {rHash}"); - - // Initialize retry count for 404 if not present - if (!retryCounts404.ContainsKey(rHash)) - { - retryCounts404[rHash] = 1; - } - else - { - retryCounts404[rHash]++; - } - - // Check if retry count has exceeded the maximum allowed retries (5) - if (retryCounts404[rHash] <= Max404Retries) - { - PrintWarning($"Retrying invoice {rHash}. Attempt {retryCounts404[rHash]} of {Max404Retries}."); - - // Schedule a retry after 5 seconds - timer.Once(RetryDelaySeconds404, () => - { - CheckInvoicePaid(rHash, callback); - }); - } - else - { - PrintWarning($"Max retries reached for invoice {rHash}. Marking as failed."); - - // Remove the retry count as we've exceeded the max retries - retryCounts404.Remove(rHash); - - // Optionally, notify the player about the failed payment - var invoice = pendingInvoices.Find(inv => inv.RHash == rHash); - if (invoice != null) - { - pendingInvoices.Remove(invoice); - invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); - - var logEntry = new BuyInvoiceLogEntry - { - SteamID = invoice.Player.Id, - InvoiceID = invoice.RHash, - IsPaid = false, - Timestamp = DateTime.UtcNow, - Amount = invoice.Amount, - CurrencyGiven = invoice.Type == PurchaseType.Currency, - VipGranted = invoice.Type == PurchaseType.Vip - }; - LogBuyInvoice(logEntry); - } - - // Finally, mark the payment as failed - callback(false, false); - } - return; - } - if (code != 200 || string.IsNullOrEmpty(response)) { PrintError($"Error checking invoice status: HTTP {code}"); - callback(false, false); + callback(false); return; } try { - var jsonResponse = JsonConvert.DeserializeObject>(response); - - if (jsonResponse != null && jsonResponse.ContainsKey("paid")) - { - bool isPaid = Convert.ToBoolean(jsonResponse["paid"]); - bool isPending = jsonResponse.ContainsKey("status") && jsonResponse["status"].ToString().ToLower() == "pending"; - - if (isPaid) - { - // Remove retry counts (both general and 404-specific) on successful payment - if (retryCounts.ContainsKey(rHash)) - { - retryCounts.Remove(rHash); - } - - if (retryCounts404.ContainsKey(rHash)) - { - retryCounts404.Remove(rHash); - } - - callback(true, false); - } - else if (isPending) - { - callback(false, true); - } - else - { - // For other statuses, you might want to handle them differently - callback(false, false); - } - } - else - { - callback(false, false); - } + var paymentStatus = JsonConvert.DeserializeObject(response); + callback(paymentStatus != null && paymentStatus.Paid); } catch (Exception ex) { PrintError($"Failed to parse invoice status response: {ex.Message}"); - callback(false, false); + callback(false); } - }, this, RequestMethod.GET, headers); + }, RequestMethod.GET, headers); } - private void GetAuthToken(Action callback) - { - if (!string.IsNullOrEmpty(authToken)) - { - callback(authToken); - return; - } - - string url = $"{config.BaseUrl}/auth"; - Puts($"Attempting to authenticate with URL: {url}"); - - var requestBody = new - { - login = config.Username, - password = config.Password - }; - string jsonBody = JsonConvert.SerializeObject(requestBody); - - var headers = new Dictionary - { - { "Content-Type", "application/json" } - }; - - webrequest.Enqueue(url, jsonBody, (code, response) => - { - if (code != 200 || string.IsNullOrEmpty(response)) - { - PrintError($"Error getting auth token: HTTP {code}"); - PrintError($"Response: {response}"); - callback(null); - return; - } - - try - { - var authResponse = JsonConvert.DeserializeObject(response); - if (authResponse == null || string.IsNullOrEmpty(authResponse.AccessToken)) - { - PrintError("Invalid auth response."); - PrintError($"Response: {response}"); - callback(null); - return; - } - - authToken = authResponse.AccessToken; - callback(authToken); - } - catch (Exception ex) - { - PrintError($"Failed to parse auth response: {ex.Message}"); - PrintError($"Raw response: {response}"); - callback(null); - } - }, this, RequestMethod.POST, headers); - } - private void CmdSendCurrency(IPlayer player, string command, string[] args) { if (!player.HasPermission("orangemart.sendcurrency")) @@ -604,149 +604,190 @@ namespace Oxide.Plugins if (args.Length != 2 || !int.TryParse(args[0], out int amount) || amount <= 0) { - player.Reply(Lang("UsageSendCurrency", player.Id, SendCurrencyCommandName)); + player.Reply(Lang("UsageSendCurrency", player.Id, sendCurrencyCommandName)); return; } string lightningAddress = args[1]; - // Check if the Lightning address is from a blacklisted domain - if (IsLightningAddressBlacklisted(lightningAddress)) + if (!IsLightningAddressAllowed(lightningAddress)) { string domain = GetDomainFromLightningAddress(lightningAddress); - player.Reply(Lang("BlacklistedDomain", player.Id, domain)); + if (whitelistedDomains.Any()) + { + string whitelist = string.Join(", ", whitelistedDomains); + player.Reply(Lang("NotWhitelistedDomain", player.Id, domain, whitelist)); + } + else + { + player.Reply(Lang("BlacklistedDomain", player.Id, domain)); + } return; } var basePlayer = player.Object as BasePlayer; if (basePlayer == null) { - player.Reply(Lang("FailedToFindBasePlayer", player.Id, player.Name)); + player.Reply(Lang("FailedToFindBasePlayer", player.Id)); return; } - int currencyAmount = 0; - - // If CurrencySkinID is defined and greater than 0, only check for items with that skin ID - if (CurrencySkinID > 0) - { - var itemsWithSkin = basePlayer.inventory.FindItemsByItemID(CurrencyItemID); - foreach (var item in itemsWithSkin) - { - if (item.skin == CurrencySkinID) - { - currencyAmount += item.amount; - } - } - - // If no matching items were found, inform the player and stop - if (currencyAmount == 0) - { - player.Reply($"You do not have any {CurrencyName} with the required skin ID."); - return; - } - } - else - { - // Check for all items with the given CurrencyItemID regardless of skin - currencyAmount = basePlayer.inventory.GetAmount(CurrencyItemID); - } + int currencyAmount = GetAllInventoryItems(basePlayer).Where(IsCurrencyItem).Sum(item => item.amount); if (currencyAmount < amount) { - player.Reply(Lang("NeedMoreCurrency", player.Id, CurrencyName, currencyAmount)); + player.Reply(Lang("NeedMoreCurrency", player.Id, currencyName, currencyAmount)); return; } - // Reserve the currency by immediately removing it from the player's inventory - int reservedAmount = ReserveCurrencyWithSkin(basePlayer, amount); - if (reservedAmount == 0) + if (!TryReserveCurrency(basePlayer, amount)) { player.Reply(Lang("FailedToReserveCurrency", player.Id)); return; } - GetAuthToken(token => - { - if (!string.IsNullOrEmpty(token)) - { - QueryLightningAddressForInvoice(lightningAddress, reservedAmount * SatsPerCurrencyUnit, invoiceUrl => - { - if (string.IsNullOrEmpty(invoiceUrl)) - { - // If the invoice query fails, return the currency - ReturnCurrency(basePlayer, reservedAmount); - player.Reply(Lang("FailedToQueryLightningAddress", player.Id)); - LogSellTransaction(player.Id, lightningAddress, false, reservedAmount * SatsPerCurrencyUnit, 0, 0, null, null, true); - return; - } + player.Reply(Lang("PaymentProcessing", player.Id)); - SendPayment(invoiceUrl, token, reservedAmount * SatsPerCurrencyUnit, player, lightningAddress, success => + SendBitcoin(lightningAddress, amount * satsPerCurrencyUnit, (success, paymentHash) => + { + if (success && !string.IsNullOrEmpty(paymentHash)) + { + LogSellTransaction( + new SellInvoiceLogEntry { - if (success) - { - // Do nothing further as the currency is already removed on success - player.Reply(Lang("CurrencySentSuccess", player.Id, reservedAmount, CurrencyName)); - } - else - { - // If the payment fails, return the reserved currency - ReturnCurrency(basePlayer, reservedAmount); - player.Reply(Lang("FailedToProcessPayment", player.Id)); - LogSellTransaction(player.Id, lightningAddress, false, reservedAmount * SatsPerCurrencyUnit, 0, 0, null, null, true); - } - }); - }); + SteamID = GetPlayerId(player), + LightningAddress = lightningAddress, + Success = true, + SatsAmount = amount * satsPerCurrencyUnit, + PaymentHash = paymentHash, + CurrencyReturned = false, + Timestamp = DateTime.UtcNow, + RetryCount = 0 + } + ); + + var pendingInvoice = new PendingInvoice + { + RHash = paymentHash.ToLower(), + Player = player, + Amount = amount * satsPerCurrencyUnit, + Memo = $"Sending {amount} {currencyName} to {lightningAddress}", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.SendBitcoin + }; + pendingInvoices.Add(pendingInvoice); + + Puts($"Outbound payment to {lightningAddress} initiated. PaymentHash: {paymentHash}"); } else { - // If authentication fails, return the currency - ReturnCurrency(basePlayer, reservedAmount); - player.Reply(Lang("FailedToAuthenticate", player.Id)); - LogSellTransaction(player.Id, lightningAddress, false, reservedAmount * SatsPerCurrencyUnit, 0, 0, null, null, true); + player.Reply(Lang("FailedToProcessPayment", player.Id)); + + LogSellTransaction( + new SellInvoiceLogEntry + { + SteamID = GetPlayerId(player), + LightningAddress = lightningAddress, + Success = false, + SatsAmount = amount * satsPerCurrencyUnit, + PaymentHash = null, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = 0 + } + ); + + Puts($"Outbound payment to {lightningAddress} failed to initiate."); + + ReturnCurrency(basePlayer, amount); + Puts($"Returned {amount} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); } }); } - // Helper method to reserve currency with the specific skin ID - private int ReserveCurrencyWithSkin(BasePlayer player, int amount) + private bool IsLightningAddressAllowed(string lightningAddress) { - var items = player.inventory.FindItemsByItemID(CurrencyItemID); - int remaining = amount; - int reserved = 0; + string domain = GetDomainFromLightningAddress(lightningAddress); + if (string.IsNullOrEmpty(domain)) + return false; - foreach (var item in items) + if (whitelistedDomains.Any()) { - // Check if the item matches the defined CurrencySkinID, if applicable - if (CurrencySkinID > 0 && item.skin != CurrencySkinID) + return whitelistedDomains.Contains(domain.ToLower()); + } + else + { + return !blacklistedDomains.Contains(domain.ToLower()); + } + } + + private string GetDomainFromLightningAddress(string lightningAddress) + { + if (string.IsNullOrEmpty(lightningAddress)) + return null; + + var parts = lightningAddress.Split('@'); + return parts.Length == 2 ? parts[1].ToLower() : null; + } + + private void SendBitcoin(string lightningAddress, int satsAmount, Action callback) + { + ResolveLightningAddress(lightningAddress, satsAmount, bolt11 => + { + if (string.IsNullOrEmpty(bolt11)) { - continue; // Skip items without the defined skin ID + PrintError($"Failed to resolve Lightning Address: {lightningAddress}"); + callback(false, null); + return; } - if (item.amount > remaining) + SendPayment(bolt11, satsAmount, (success, paymentHash) => { - item.UseItem(remaining); - reserved += remaining; - remaining = 0; - break; - } - else - { - reserved += item.amount; - remaining -= item.amount; - item.Remove(); - } + if (success && !string.IsNullOrEmpty(paymentHash)) + { + StartPaymentStatusCheck(paymentHash, isPaid => + { + callback(isPaid, isPaid ? paymentHash : null); + }); + } + else + { + callback(false, null); + } + }); + }); + } + + private void StartPaymentStatusCheck(string paymentHash, Action callback) + { + if (!retryCounts.ContainsKey(paymentHash)) + { + retryCounts[paymentHash] = 0; } - if (remaining > 0) + Timer timerInstance = null; + timerInstance = timer.Repeat(checkIntervalSeconds, maxRetries, () => { - // Rollback if unable to remove the full amount - PrintWarning($"Could not reserve the full amount of {CurrencyName}. {remaining} remaining."); - ReturnCurrency(player, reserved); // Return whatever was taken - return 0; // Indicate failure to reserve - } + CheckInvoicePaid(paymentHash, isPaid => + { + if (isPaid) + { + callback(true); + timerInstance.Destroy(); + } + else + { + retryCounts[paymentHash]++; + Puts($"PaymentHash {paymentHash} not yet paid. Retry {retryCounts[paymentHash]} of {maxRetries}."); - return reserved; // Return the amount actually reserved + if (retryCounts[paymentHash] >= maxRetries) + { + callback(false); + timerInstance.Destroy(); + } + } + }); + }); } private void CmdBuyCurrency(IPlayer player, string command, string[] args) @@ -759,60 +800,32 @@ namespace Oxide.Plugins if (args.Length != 1 || !int.TryParse(args[0], out int amount) || amount <= 0) { - player.Reply(Lang("InvalidCommandUsage", player.Id, BuyCurrencyCommandName)); + player.Reply(Lang("InvalidCommandUsage", player.Id, buyCurrencyCommandName)); return; } - int amountSats = amount * PricePerCurrencyUnit; + int amountSats = amount * pricePerCurrencyUnit; - CreateInvoice(amountSats, $"Buying {amount} {CurrencyName}", invoiceResponse => + CreateInvoice(amountSats, $"Buying {amount} {currencyName}", invoiceResponse => { if (invoiceResponse != null) { - // Send the invoice via Discord webhook - SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {CurrencyName}"); + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {currencyName}"); - // Notify the player in chat to check the Discord channel - player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, DiscordChannelName)); + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); - // Add the pending invoice var pendingInvoice = new PendingInvoice { - RHash = BitConverter.ToString(invoiceResponse.RHash.Data).Replace("-", "").ToLower(), + RHash = invoiceResponse.PaymentHash.ToLower(), Player = player, Amount = amount, - Memo = $"Buying {amount} {CurrencyName}", + Memo = $"Buying {amount} {currencyName}", CreatedAt = DateTime.UtcNow, Type = PurchaseType.Currency }; pendingInvoices.Add(pendingInvoice); - // Start checking the invoice status after a delay - timer.Once(RetryDelaySeconds, () => - { - CheckPendingInvoices(); - }); - - // Expire invoice after timeout if unpaid - timer.Once(InvoiceTimeoutSeconds, () => - { - if (pendingInvoices.Contains(pendingInvoice)) - { - pendingInvoices.Remove(pendingInvoice); - PrintWarning($"Invoice for player {player.Id} expired (amount: {amountSats} sats)."); - - var logEntry = new BuyInvoiceLogEntry - { - SteamID = player.Id, - InvoiceID = pendingInvoice.RHash, - IsPaid = false, - Timestamp = DateTime.UtcNow, - Amount = amountSats, - CurrencyGiven = false - }; - LogBuyInvoice(logEntry); - } - }); + ScheduleInvoiceExpiry(pendingInvoice); } else { @@ -829,56 +842,28 @@ namespace Oxide.Plugins return; } - int amountSats = VipPrice; + int amountSats = vipPrice; - CreateInvoice(amountSats, $"Buying VIP Status", invoiceResponse => + CreateInvoice(amountSats, "Buying VIP Status", invoiceResponse => { if (invoiceResponse != null) { - // Send the invoice via Discord webhook - SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying VIP Status"); + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, "Buying VIP Status"); - // Notify the player in chat to check the Discord channel - player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, DiscordChannelName)); + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); - // Add the pending invoice var pendingInvoice = new PendingInvoice { - RHash = BitConverter.ToString(invoiceResponse.RHash.Data).Replace("-", "").ToLower(), + RHash = invoiceResponse.PaymentHash.ToLower(), Player = player, Amount = amountSats, - Memo = $"Buying VIP Status", + Memo = "Buying VIP Status", CreatedAt = DateTime.UtcNow, Type = PurchaseType.Vip }; pendingInvoices.Add(pendingInvoice); - // Start checking the invoice status after a delay - timer.Once(RetryDelaySeconds, () => - { - CheckPendingInvoices(); - }); - - // Expire invoice after timeout if unpaid - timer.Once(InvoiceTimeoutSeconds, () => - { - if (pendingInvoices.Contains(pendingInvoice)) - { - pendingInvoices.Remove(pendingInvoice); - PrintWarning($"VIP purchase invoice for player {player.Id} expired."); - - var logEntry = new BuyInvoiceLogEntry - { - SteamID = player.Id, - InvoiceID = pendingInvoice.RHash, - IsPaid = false, - Timestamp = DateTime.UtcNow, - Amount = amountSats, - VipGranted = false - }; - LogBuyInvoice(logEntry); - } - }); + ScheduleInvoiceExpiry(pendingInvoice); } else { @@ -887,194 +872,108 @@ namespace Oxide.Plugins }); } - private bool IsLightningAddressBlacklisted(string lightningAddress) + private void ScheduleInvoiceExpiry(PendingInvoice pendingInvoice) { - string domain = GetDomainFromLightningAddress(lightningAddress); - if (string.IsNullOrEmpty(domain)) - return false; - - return BlacklistedDomains.Contains(domain.ToLower()); - } - - private string GetDomainFromLightningAddress(string lightningAddress) - { - if (string.IsNullOrEmpty(lightningAddress)) - return null; - - var parts = lightningAddress.Split('@'); - if (parts.Length != 2) - return null; - - return parts[1].ToLower(); - } - - private void QueryLightningAddressForInvoice(string lightningAddress, int satsAmount, Action callback) - { - string[] parts = lightningAddress.Split('@'); - if (parts.Length != 2) + timer.Once(invoiceTimeoutSeconds, () => { - PrintError($"Invalid Lightning address: {lightningAddress}"); - callback(null); - return; - } - - string username = parts[0]; - string domain = parts[1]; - string lnurlUrl = $"https://{domain}/.well-known/lnurlp/{username}"; - - Puts($"Querying Lightning address: {lightningAddress} for {satsAmount} sat invoice at URL: {lnurlUrl}"); - - webrequest.Enqueue(lnurlUrl, null, (code, response) => - { - if (code != 200 || string.IsNullOrEmpty(response)) + if (pendingInvoices.Contains(pendingInvoice)) { - PrintError($"Error querying LNURL: HTTP {code}"); - PrintError($"Response: {response}"); - callback(null); - return; - } + pendingInvoices.Remove(pendingInvoice); + PrintWarning($"Invoice for player {GetPlayerId(pendingInvoice.Player)} expired (amount: {pendingInvoice.Amount} sats)."); - try - { - var lnurlResponse = JsonConvert.DeserializeObject>(response); + int finalRetryCount = retryCounts.ContainsKey(pendingInvoice.RHash) ? retryCounts[pendingInvoice.RHash] : 0; - if (lnurlResponse.ContainsKey("status") && lnurlResponse["status"].ToString() == "ERROR") + if (pendingInvoice.Type == PurchaseType.SendBitcoin) { - PrintError($"Error from LNURL provider: {lnurlResponse["reason"]}"); - callback(null); - return; - } + var basePlayer = pendingInvoice.Player.Object as BasePlayer; + if (basePlayer != null) + { + ReturnCurrency(basePlayer, pendingInvoice.Amount / satsPerCurrencyUnit); + Puts($"Refunded {pendingInvoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + else + { + PrintError($"Failed to find base player object for player {pendingInvoice.Player.Id} to refund currency."); + } - string callbackUrl = lnurlResponse["callback"].ToString(); - callback($"{callbackUrl}?amount={satsAmount * 1000}"); // Amount in millisatoshis - } - catch (Exception ex) - { - PrintError($"Failed to parse LNURL response: {ex.Message}"); - callback(null); - } - }, this, RequestMethod.GET); - } - - private void SendPayment(string invoiceUrl, string token, int satsAmount, IPlayer player, string lightningAddress, Action callback) - { - Puts($"Fetching invoice from: {invoiceUrl}"); - - webrequest.Enqueue(invoiceUrl, null, (code, response) => - { - if (code != 200 || string.IsNullOrEmpty(response)) - { - PrintError($"Error fetching invoice: HTTP {code}"); - PrintError($"Response: {response}"); - callback(false); - return; - } - - try - { - var invoiceResponse = JsonConvert.DeserializeObject>(response); - - if (invoiceResponse.ContainsKey("pr")) - { - string paymentRequest = invoiceResponse["pr"].ToString(); - Puts($"Fetched payment request: {paymentRequest}"); - - ProcessPayment(paymentRequest, token, satsAmount, player, lightningAddress, callback); + var logEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(pendingInvoice.Player), + LightningAddress = ExtractLightningAddress(pendingInvoice.Memo), + Success = false, + SatsAmount = pendingInvoice.Amount, + PaymentHash = pendingInvoice.RHash, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = finalRetryCount + }; + LogSellTransaction(logEntry); + Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); } else { - PrintError("Invalid invoice response from LNURL provider"); - callback(false); + var logEntry = CreateBuyInvoiceLogEntry( + player: pendingInvoice.Player, + invoiceID: pendingInvoice.RHash, + isPaid: false, + amount: pendingInvoice.Type == PurchaseType.SendBitcoin ? pendingInvoice.Amount : pendingInvoice.Amount * pricePerCurrencyUnit, + type: pendingInvoice.Type, + retryCount: finalRetryCount + ); + LogBuyInvoice(logEntry); + Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); } } - catch (Exception ex) - { - PrintError($"Failed to parse invoice response: {ex.Message}"); - callback(false); - } - }, this, RequestMethod.GET); + }); } - private void ProcessPayment(string paymentRequest, string token, int satsAmount, IPlayer player, string lightningAddress, Action callback) + private void SendPayment(string bolt11, int satsAmount, Action callback) { - string url = $"{config.BaseUrl}/payinvoice"; - + string url = $"{config.BaseUrl}/api/v1/payments"; var requestBody = new { - invoice = paymentRequest, + @out = true, + bolt11 = bolt11, + amount = satsAmount }; string jsonBody = JsonConvert.SerializeObject(requestBody); var headers = new Dictionary { - { "Authorization", $"Bearer {token}" }, + { "X-Api-Key", config.ApiKey }, { "Content-Type", "application/json" } }; - Puts($"Payment request URL: {url}"); - Puts($"Payment request headers: {string.Join(", ", headers)}"); - - webrequest.Enqueue(url, jsonBody, (code, response) => + MakeWebRequest(url, jsonBody, (code, response) => { - if (code != 200 || string.IsNullOrEmpty(response)) + if (code != 200 && code != 201) { PrintError($"Error processing payment: HTTP {code}"); - PrintError($"Response: {response}"); - callback(false); + callback(false, null); return; } try { - // Log the raw response before parsing - Puts($"Raw payment response: {response}"); - var paymentResponse = JsonConvert.DeserializeObject>(response); + string paymentHash = paymentResponse.ContainsKey("payment_hash") ? paymentResponse["payment_hash"].ToString() : null; - // Check for errors in the response - if (paymentResponse.ContainsKey("error") && paymentResponse["error"] != null) + if (!string.IsNullOrEmpty(paymentHash)) { - PrintError($"Payment failed: {paymentResponse["error"]}"); - callback(false); - return; + callback(true, paymentHash); } - - // Safely extract and log fee and fee_msat if they exist - int fee = 0; - if (paymentResponse.ContainsKey("fee") && paymentResponse["fee"] is long) + else { - fee = (int)(long)paymentResponse["fee"]; // Safe cast to handle long types + PrintError("Payment hash (rhash) is missing or invalid in the response."); + callback(false, null); } - else if (paymentResponse.ContainsKey("fee")) - { - Puts($"Unexpected fee type: {paymentResponse["fee"]?.GetType()}"); - } - - int feeMsat = 0; - if (paymentResponse.ContainsKey("fee_msat") && paymentResponse["fee_msat"] is long) - { - feeMsat = (int)(long)paymentResponse["fee_msat"]; // Safe cast to handle long types - } - else if (paymentResponse.ContainsKey("fee_msat")) - { - Puts($"Unexpected fee_msat type: {paymentResponse["fee_msat"]?.GetType()}"); - } - - string paymentHash = paymentResponse.ContainsKey("payment_hash") ? paymentResponse["payment_hash"].ToString() : "unknown"; - - Puts($"Payment processed successfully. Fee: {fee} msats, Fee (msat): {feeMsat}, Payment Hash: {paymentHash}"); - - // Log the successful transaction - LogSellTransaction(player.Id, lightningAddress, true, satsAmount, fee, feeMsat, paymentRequest, paymentHash, false); - callback(true); } catch (Exception ex) { - PrintError($"Failed to parse payment response: {ex.Message}"); - PrintError($"Raw response: {response}"); - callback(false); + PrintError($"Exception occurred while parsing payment response: {ex.Message}"); + callback(false, null); } - }, this, RequestMethod.POST, headers); + }, RequestMethod.POST, headers); } private void SendInvoiceToDiscord(IPlayer player, string invoice, int amountSats, string memo) @@ -1095,32 +994,23 @@ namespace Oxide.Plugins new { title = "Payment Invoice", - description = $"{memo}\n\nPlease pay the following Lightning invoice to complete your purchase:\n\n```{invoice}```", + description = $"{memo}\n\nPlease pay the following Lightning invoice to complete your purchase:\n\n```\n{invoice}\n```", image = new { url = qrCodeUrl }, fields = new[] { - new - { - name = "Amount", - value = $"{amountSats} sats", - inline = true - }, - new - { - name = "Steam ID", - value = player.Id, - inline = true - } + new { name = "Amount", value = $"{amountSats} sats", inline = true }, + new { name = "Steam ID", value = GetPlayerId(player), inline = true } } } } }; string jsonPayload = JsonConvert.SerializeObject(webhookPayload); - webrequest.Enqueue(config.DiscordWebhookUrl, jsonPayload, (code, response) => + + MakeWebRequest(config.DiscordWebhookUrl, jsonPayload, (code, response) => { if (code != 204) { @@ -1128,33 +1018,33 @@ namespace Oxide.Plugins } else { - Puts($"Invoice sent to Discord for player {player.Id}."); + Puts($"Invoice sent to Discord for player {GetPlayerId(player)}."); } - }, this, RequestMethod.POST, new Dictionary { { "Content-Type", "application/json" } }); + }, RequestMethod.POST, new Dictionary { { "Content-Type", "application/json" } }); } private void RewardPlayer(IPlayer player, int amount) { - player.Reply($"You have successfully purchased {amount} {CurrencyName}!"); + player.Reply($"You have successfully purchased {amount} {currencyName}!"); var basePlayer = player.Object as BasePlayer; if (basePlayer != null) { - var currencyItem = ItemManager.CreateByItemID(CurrencyItemID, amount); + var currencyItem = ItemManager.CreateByItemID(currencyItemID, amount); if (currencyItem != null) { - if (CurrencySkinID > 0) // Check if a skinID is defined and apply it + if (currencySkinID > 0) { - currencyItem.skin = (ulong)CurrencySkinID; + currencyItem.skin = currencySkinID; } basePlayer.GiveItem(currencyItem); - Puts($"Gave {amount} {CurrencyName} (skinID: {CurrencySkinID}) to player {player.Id}."); + Puts($"Gave {amount} {currencyName} (skinID: {currencySkinID}) to player {basePlayer.UserIDString}."); } else { - PrintError($"Failed to create {CurrencyName} item for player {player.Id}."); + PrintError($"Failed to create {currencyName} item for player {basePlayer.UserIDString}."); } } else @@ -1167,74 +1057,73 @@ namespace Oxide.Plugins { player.Reply("You have successfully purchased VIP status!"); - // Add the player to the VIP permission group - permission.AddUserGroup(player.Id, VipPermissionGroup); + permission.AddUserGroup(player.Id, vipPermissionGroup); - Puts($"Player {player.Id} added to VIP group '{VipPermissionGroup}'."); + Puts($"Player {GetPlayerId(player)} added to VIP group '{vipPermissionGroup}'."); } - private int ReserveCurrency(BasePlayer player, int amount) + private bool TryReserveCurrency(BasePlayer player, int amount) { - var items = player.inventory.FindItemsByItemID(CurrencyItemID); + var items = GetAllInventoryItems(player).Where(IsCurrencyItem).ToList(); + int totalCurrency = items.Sum(item => item.amount); + + if (totalCurrency < amount) + { + return false; + } + int remaining = amount; - int reserved = 0; foreach (var item in items) { if (item.amount > remaining) { item.UseItem(remaining); - reserved += remaining; - remaining = 0; break; } else { - reserved += item.amount; remaining -= item.amount; item.Remove(); } + + if (remaining <= 0) + { + break; + } } - if (remaining > 0) - { - // Rollback if unable to remove the full amount - PrintWarning($"Could not reserve the full amount of {CurrencyName}. {remaining} remaining."); - ReturnCurrency(player, reserved); // Return whatever was taken - return 0; // Indicate failure to reserve - } - - return reserved; // Return the amount actually reserved + return true; } private void ReturnCurrency(BasePlayer player, int amount) { - var returnedCurrency = ItemManager.CreateByItemID(CurrencyItemID, amount); + var returnedCurrency = ItemManager.CreateByItemID(currencyItemID, amount); if (returnedCurrency != null) { + if (currencySkinID > 0) + { + returnedCurrency.skin = currencySkinID; + } returnedCurrency.MoveToContainer(player.inventory.containerMain); } + else + { + PrintError($"Failed to create {currencyName} item to return to player {player.UserIDString}."); + } } - private void LogSellTransaction(string steamID, string lightningAddress, bool success, int satsAmount, int fee, int feeMsat, string paymentRequest, string paymentHash, bool currencyReturned) + private bool IsCurrencyItem(Item item) { - var logEntry = new SellInvoiceLogEntry - { - SteamID = steamID, - LightningAddress = lightningAddress, - Success = success, - SatsAmount = satsAmount, // Store sats sent - Fee = fee, // Store fee - FeeMsat = feeMsat, // Store fee in millisatoshis - PaymentRequest = paymentRequest, // Store BOLT11 payment request - PaymentHash = paymentHash, // Store payment hash - CurrencyReturned = currencyReturned, // Indicates if currency was returned - Timestamp = DateTime.UtcNow - }; + return item.info.itemid == currencyItemID && (currencySkinID == 0 || item.skin == currencySkinID); + } + private void LogSellTransaction(SellInvoiceLogEntry logEntry) + { var logs = LoadSellLogData(); logs.Add(logEntry); SaveSellLogData(logs); + Puts($"[Orangemart] Logged sell transaction: {JsonConvert.SerializeObject(logEntry)}"); } private List LoadSellLogData() @@ -1262,58 +1151,51 @@ namespace Oxide.Plugins if (!Directory.Exists(directory)) Directory.CreateDirectory(directory); - List invoiceLogs; - - if (File.Exists(logPath)) - { - invoiceLogs = JsonConvert.DeserializeObject>(File.ReadAllText(logPath)) ?? new List(); - } - else - { - invoiceLogs = new List(); - } + List invoiceLogs = File.Exists(logPath) + ? JsonConvert.DeserializeObject>(File.ReadAllText(logPath)) ?? new List() + : new List(); invoiceLogs.Add(logEntry); File.WriteAllText(logPath, JsonConvert.SerializeObject(invoiceLogs, Formatting.Indented)); + Puts($"[Orangemart] Logged buy invoice: {JsonConvert.SerializeObject(logEntry)}"); + } + + private BuyInvoiceLogEntry CreateBuyInvoiceLogEntry(IPlayer player, string invoiceID, bool isPaid, int amount, PurchaseType type, int retryCount) + { + return new BuyInvoiceLogEntry + { + SteamID = GetPlayerId(player), + InvoiceID = invoiceID, + IsPaid = isPaid, + Timestamp = DateTime.UtcNow, + Amount = type == PurchaseType.SendBitcoin ? amount : amount * pricePerCurrencyUnit, + CurrencyGiven = isPaid && type == PurchaseType.Currency, + VipGranted = isPaid && type == PurchaseType.Vip, + RetryCount = retryCount + }; } private void CreateInvoice(int amountSats, string memo, Action callback) { - if (string.IsNullOrEmpty(authToken)) - { - GetAuthToken(token => - { - if (!string.IsNullOrEmpty(token)) - { - CreateInvoice(amountSats, memo, callback); // Retry after getting token - } - else - { - callback(null); - } - }); - return; - } + string url = $"{config.BaseUrl}/api/v1/payments"; - string url = $"{config.BaseUrl}/addinvoice"; var requestBody = new { - amt = amountSats, + @out = false, + amount = amountSats, memo = memo }; string jsonBody = JsonConvert.SerializeObject(requestBody); var headers = new Dictionary { - { "Authorization", $"Bearer {authToken}" }, + { "X-Api-Key", config.ApiKey }, { "Content-Type", "application/json" } }; - webrequest.Enqueue(url, jsonBody, (code, response) => + MakeWebRequest(url, jsonBody, (code, response) => { - PrintWarning($"Raw LNDHub response: {response}"); - - if (code != 200 || string.IsNullOrEmpty(response)) + if (code != 200 && code != 201) { PrintError($"Error creating invoice: HTTP {code}"); callback(null); @@ -1323,16 +1205,161 @@ namespace Oxide.Plugins try { var invoiceResponse = JsonConvert.DeserializeObject(response); - string rHashString = BitConverter.ToString(invoiceResponse.RHash.Data).Replace("-", "").ToLower(); - PrintWarning($"Parsed r_hash: {rHashString}"); - - callback(invoiceResponse); + callback(invoiceResponse != null && !string.IsNullOrEmpty(invoiceResponse.PaymentHash) ? invoiceResponse : null); } catch (Exception ex) { PrintError($"Failed to deserialize invoice response: {ex.Message}"); + callback(null); } - }, this, RequestMethod.POST, headers); + }, RequestMethod.POST, headers); + } + + private string GetPlayerId(IPlayer player) + { + var basePlayer = player.Object as BasePlayer; + return basePlayer != null ? basePlayer.UserIDString : player.Id; + } + + private void MakeWebRequest(string url, string jsonData, Action callback, RequestMethod method = RequestMethod.GET, Dictionary headers = null) + { + webrequest.Enqueue(url, jsonData, (code, response) => + { + if (string.IsNullOrEmpty(response) && (code < 200 || code >= 300)) + { + PrintError($"Web request to {url} returned empty response or HTTP {code}"); + callback(code, null); + } + else + { + callback(code, response); + } + }, this, method, headers ?? new Dictionary { { "Content-Type", "application/json" } }); + } + + private void ResolveLightningAddress(string lightningAddress, int amountSats, Action callback) + { + var parts = lightningAddress.Split('@'); + if (parts.Length != 2) + { + PrintError($"Invalid Lightning Address format: {lightningAddress}"); + callback(null); + return; + } + + string user = parts[0]; + string domain = parts[1]; + + string lnurlEndpoint = $"https://{domain}/.well-known/lnurlp/{user}"; + + var headers = new Dictionary + { + { "Content-Type", "application/json" } + }; + + MakeWebRequest(lnurlEndpoint, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Failed to fetch LNURL for {lightningAddress}: HTTP {code}"); + callback(null); + return; + } + + try + { + var lnurlResponse = JsonConvert.DeserializeObject(response); + if (lnurlResponse == null || string.IsNullOrEmpty(lnurlResponse.Callback)) + { + PrintError($"Invalid LNURL response for {lightningAddress}"); + callback(null); + return; + } + + long amountMsat = (long)amountSats * 1000; + + string callbackUrl = lnurlResponse.Callback; + + string callbackUrlWithAmount = $"{callbackUrl}?amount={amountMsat}"; + + MakeWebRequest(callbackUrlWithAmount, null, (payCode, payResponse) => + { + if (payCode != 200 || string.IsNullOrEmpty(payResponse)) + { + PrintError($"Failed to perform LNURL Pay for {lightningAddress}: HTTP {payCode}"); + callback(null); + return; + } + + try + { + var payAction = JsonConvert.DeserializeObject(payResponse); + if (payAction == null || string.IsNullOrEmpty(payAction.Pr)) + { + PrintError($"Invalid LNURL Pay response for {lightningAddress}"); + callback(null); + return; + } + + callback(payAction.Pr); + } + catch (Exception ex) + { + PrintError($"Error parsing LNURL Pay response: {ex.Message}"); + callback(null); + } + }, RequestMethod.GET, headers); + } + catch (Exception ex) + { + PrintError($"Error parsing LNURL response: {ex.Message}"); + callback(null); + } + }, RequestMethod.GET, headers); + } + + private class LNURLResponse + { + [JsonProperty("tag")] + public string Tag { get; set; } + + [JsonProperty("callback")] + public string Callback { get; set; } + + [JsonProperty("minSendable")] + public long MinSendable { get; set; } + + [JsonProperty("maxSendable")] + public long MaxSendable { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("commentAllowed")] + public int CommentAllowed { get; set; } + + [JsonProperty("allowsNostr")] + public bool AllowsNostr { get; set; } + + [JsonProperty("nostrPubkey")] + public string NostrPubkey { get; set; } + } + + private class LNURLPayResponse + { + [JsonProperty("pr")] + public string Pr { get; set; } + + [JsonProperty("routes")] + public List Routes { get; set; } + } + + private string ExtractLightningAddress(string memo) + { + // Extract the Lightning Address from the memo + // Expected format: "Sending {amount} {currency} to {lightning_address}" + var parts = memo.Split(" to "); + return parts.Length == 2 ? parts[1] : "unknown@unknown.com"; } } } diff --git a/README.md b/README.md index 0fecb5e..6bbb31e 100644 --- a/README.md +++ b/README.md @@ -27,39 +27,125 @@ The following commands are available to players: --- +# Orangemart Plugin Configuration + +Below is a comprehensive list of key configuration variables that can be customized in the **Orangemart** plugin. +These settings allow you to tailor the plugin's behavior to fit your server's requirements. + ## Configuration -Below is a list of key configuration variables that can be customized in the plugin: +#### Commands -- **`CurrencyItemID`** - The item ID used for in-game currency transactions. +- **`BuyCurrencyCommandName`** + - The command players use to purchase in-game currency. For example, `/buyblood`. -- **`BuyCurrencyCommandName`** - The name of the command players use to buy in-game currency. +- **`SendCurrencyCommandName`** + - The command players use to send in-game currency to other players. For example, `/sendblood`. -- **`SendCurrencyCommandName`** - The name of the command players use to send in-game currency to other players. +- **`BuyVipCommandName`** + - The command players use to purchase VIP status. For example, `/buyvip`. -- **`BuyVipCommandName`** - The name of the command players use to purchase VIP status. +#### Currency Settings -- **`VipPrice`** - The price (in satoshis) for players to purchase VIP status. +- **`CurrencyItemID`** + - The item ID used for in-game currency transactions. -- **`VipPermissionGroup`** - The Oxide permission group that VIP players are added to. +- **`CurrencyName`** + - The name of the in-game currency displayed to players. -- **`CurrencyName`** - The name of the in-game currency. +- **`CurrencySkinID`** + - The skin ID applied to the in-game currency items. Set to `0` for default skin. -- **`SatsPerCurrencyUnit`** - The conversion rate between satoshis and in-game currency units. +- **`SatsPerCurrencyUnit`** + - The conversion rate between satoshis and in-game currency units. Determines how many satoshis equal one unit of in-game currency. -- **`PricePerCurrencyUnit`** - The price (in satoshis) per unit of in-game currency. +- **`PricePerCurrencyUnit`** + - The price (in satoshis) per unit of in-game currency. -- **`CheckIntervalSeconds`** - Interval time (in seconds) for checking pending Bitcoin transactions. +#### Discord Integration + +- **`DiscordChannelName`** + - The name of the Discord channel where payment invoices will be posted. + +- **`DiscordWebhookUrl`** + - The Discord webhook URL used to send invoice notifications to the specified channel. + +#### Invoice Settings + +- **`BlacklistedDomains`** + - A list of domains that are disallowed for use in Lightning addresses. Players cannot send currency to addresses from these domains. + +- **`WhitelistedDomains`** + - A list of domains that are exclusively allowed for use in Lightning addresses. If this list is populated, only addresses from these domains are permitted. + +- **`CheckIntervalSeconds`** + - The interval time (in seconds) for checking pending Bitcoin transactions. + +- **`InvoiceTimeoutSeconds`** + - The time (in seconds) after which an unpaid invoice expires. + +- **`LNbitsApiKey`** + - The API key for authenticating with your LNbits instance. + +- **`LNbitsBaseUrl`** + - The base URL of your LNbits instance. + +- **`MaxRetries`** + - The maximum number of retry attempts for checking invoice payments before considering them expired. + +#### VIP Settings + +Configure the VIP status purchase and permissions. + +- **`VipPermissionGroup`** + - The Oxide permission group that players are added to upon purchasing VIP status. + +- **`VipPrice`** + - The price (in satoshis) for players to purchase VIP status. + +--- + +### Example Configuration Snippet + +Here's how the configuration might look in your `config.json`: + +``` +{ + "Commands": { + "BuyCurrencyCommandName": "buyblood", + "SendCurrencyCommandName": "sendblood", + "BuyVipCommandName": "buyvip" + }, + "CurrencySettings": { + "CurrencyItemID": 1776460938, + "CurrencyName": "blood", + "CurrencySkinID": 0, + "PricePerCurrencyUnit": 1, + "SatsPerCurrencyUnit": 1 + }, + "Discord": { + "DiscordChannelName": "mart", + "DiscordWebhookUrl": "https://discord.com/api/webhooks/your_webhook_url" + }, + "InvoiceSettings": { + "BlacklistedDomains": ["example.com", "blacklisted.net"], + "WhitelistedDomains": [], + "CheckIntervalSeconds": 10, + "InvoiceTimeoutSeconds": 300, + "LNbitsApiKey": "your-lnbits-admin-api-key", + "LNbitsBaseUrl": "https://your-lnbits-instance.com", + "MaxRetries": 25 + }, + "VIPSettings": { + "VipPermissionGroup": "vip", + "VipPrice": 1000 + } +} +``` + +**Note:** +- Ensure that all URLs and API keys are correctly set to match your server and LNbits configurations. +- Adjust the `BlacklistedDomains` and `WhitelistedDomains` according to your server's policies regarding Lightning addresses. ---