using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Libraries; namespace Oxide.Plugins { [Info("Orangemart", "RustySats", "0.4.0")] [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments via LNbits with WebSocket support and comprehensive protection features")] public class Orangemart : CovalencePlugin { // 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"; // NEW: Protection Settings public const string MaxPurchaseAmount = "MaxPurchaseAmount"; public const string MaxSendAmount = "MaxSendAmount"; public const string CommandCooldownSeconds = "CommandCooldownSeconds"; public const string MaxPendingInvoicesPerPlayer = "MaxPendingInvoicesPerPlayer"; // 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"; public const string UseWebSockets = "UseWebSockets"; public const string WebSocketReconnectDelay = "WebSocketReconnectDelay"; // VIPSettings public const string VipPrice = "VipPrice"; public const string VipCommand = "VipCommand"; } // Configuration variables private int currencyItemID; private string buyCurrencyCommandName; private string sendCurrencyCommandName; private string buyVipCommandName; private int vipPrice; private string vipCommand; 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 bool useWebSockets; private int webSocketReconnectDelay; private List blacklistedDomains = new List(); private List whitelistedDomains = new List(); // NEW: Protection and rate limiting variables private int maxPurchaseAmount; private int maxSendAmount; private int commandCooldownSeconds; private int maxPendingInvoicesPerPlayer; private Dictionary lastCommandTime = new Dictionary(); 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); // WebSocket tracking private Dictionary activeWebSockets = new Dictionary(); private readonly object webSocketLock = new object(); // Transaction status constants private static class TransactionStatus { public const string INITIATED = "INITIATED"; public const string PROCESSING = "PROCESSING"; public const string COMPLETED = "COMPLETED"; public const string FAILED = "FAILED"; public const string EXPIRED = "EXPIRED"; public const string REFUNDED = "REFUNDED"; } // WebSocket connection wrapper private class WebSocketConnection { public ClientWebSocket WebSocket { get; set; } public CancellationTokenSource CancellationTokenSource { get; set; } public string InvoiceKey { get; set; } public PendingInvoice Invoice { get; set; } public DateTime ConnectedAt { get; set; } public int ReconnectAttempts { get; set; } public Task ListenTask { get; set; } } // WebSocket response structure private class WebSocketPaymentUpdate { [JsonProperty("balance")] public long Balance { get; set; } [JsonProperty("payment")] public WebSocketPayment Payment { get; set; } } private class WebSocketPayment { [JsonProperty("checking_id")] public string CheckingId { get; set; } [JsonProperty("pending")] public bool Pending { get; set; } [JsonProperty("amount")] public long Amount { get; set; } [JsonProperty("fee")] public long Fee { get; set; } [JsonProperty("memo")] public string Memo { get; set; } [JsonProperty("time")] public long Time { get; set; } [JsonProperty("bolt11")] public string Bolt11 { get; set; } [JsonProperty("preimage")] public string Preimage { get; set; } [JsonProperty("payment_hash")] public string PaymentHash { get; set; } [JsonProperty("expiry")] public long Expiry { get; set; } [JsonProperty("extra")] public Dictionary Extra { get; set; } } // LNbits Configuration private class LNbitsConfig { public string BaseUrl { get; set; } public string ApiKey { get; set; } public string DiscordWebhookUrl { get; set; } public string WebSocketUrl { get; set; } public static LNbitsConfig ParseLNbitsConnection(string baseUrl, string apiKey, string discordWebhookUrl) { var trimmedBaseUrl = baseUrl.TrimEnd('/'); if (!Uri.IsWellFormedUriString(trimmedBaseUrl, UriKind.Absolute)) throw new Exception("Invalid base URL in connection string."); // Convert HTTP URL to WebSocket URL var wsUrl = trimmedBaseUrl.Replace("https://", "wss://").Replace("http://", "ws://"); return new LNbitsConfig { BaseUrl = trimmedBaseUrl, ApiKey = apiKey, DiscordWebhookUrl = discordWebhookUrl, WebSocketUrl = wsUrl }; } } // Invoice and Payment Classes private class InvoiceResponse { [JsonProperty("bolt11")] public string PaymentRequest { get; set; } [JsonProperty("payment_hash")] public string PaymentHash { get; set; } } // Wrapper class for LNbits v1 responses private class InvoiceResponseWrapper { [JsonProperty("data")] public InvoiceResponse Data { get; set; } } // Enhanced SellInvoiceLogEntry with status tracking private class SellInvoiceLogEntry { public string TransactionId { get; set; } public string SteamID { get; set; } public string LightningAddress { get; set; } public string Status { get; set; } public bool Success { get; set; } public int SatsAmount { get; set; } public string PaymentHash { get; set; } public bool CurrencyReturned { get; set; } public DateTime Timestamp { get; set; } public DateTime? CompletedTimestamp { get; set; } public int RetryCount { get; set; } public string FailureReason { get; set; } } // Enhanced BuyInvoiceLogEntry with status tracking private class BuyInvoiceLogEntry { public string TransactionId { get; set; } public string SteamID { get; set; } public string InvoiceID { get; set; } public string Status { get; set; } public bool IsPaid { get; set; } public DateTime Timestamp { get; set; } public DateTime? CompletedTimestamp { get; set; } public int Amount { get; set; } public bool CurrencyGiven { get; set; } public bool VipGranted { get; set; } public int RetryCount { get; set; } public string PurchaseType { get; set; } } private class PendingInvoice { public string TransactionId { get; set; } public string RHash { get; set; } public IPlayer Player { get; set; } public int Amount { get; set; } public string Memo { get; set; } public DateTime CreatedAt { get; set; } public PurchaseType Type { get; set; } } private enum PurchaseType { Currency, Vip, SendBitcoin } private class PaymentStatusResponse { [JsonProperty("paid")] public bool Paid { get; set; } [JsonProperty("preimage")] public string Preimage { get; set; } } protected override void LoadConfig() { base.LoadConfig(); try { 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); // NEW: Parse Protection Settings maxPurchaseAmount = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.MaxPurchaseAmount, 10000, ref configChanged); maxSendAmount = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.MaxSendAmount, 10000, ref configChanged); commandCooldownSeconds = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CommandCooldownSeconds, 0, ref configChanged); maxPendingInvoicesPerPlayer = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.MaxPendingInvoicesPerPlayer, 1, ref configChanged); // Ensure non-negative values if (maxPurchaseAmount < 0) maxPurchaseAmount = 0; if (maxSendAmount < 0) maxSendAmount = 0; if (commandCooldownSeconds < 0) commandCooldownSeconds = 0; if (maxPendingInvoicesPerPlayer < 0) maxPendingInvoicesPerPlayer = 0; // 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); vipCommand = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipCommand, "oxide.usergroup add {player} 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); useWebSockets = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.UseWebSockets, true, ref configChanged); webSocketReconnectDelay = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.WebSocketReconnectDelay, 5, 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) { SaveConfig(); } // Log protection settings Puts($"Protection Settings: MaxPurchase={maxPurchaseAmount}, MaxSend={maxSendAmount}, Cooldown={commandCooldownSeconds}s, MaxPending={maxPendingInvoicesPerPlayer}"); } catch (Exception ex) { PrintError($"Failed to load configuration: {ex.Message}"); } } 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 (!invoiceSettings.ContainsKey(ConfigKeys.UseWebSockets)) { invoiceSettings[ConfigKeys.UseWebSockets] = true; configChanged = true; } if (!invoiceSettings.ContainsKey(ConfigKeys.WebSocketReconnectDelay)) { invoiceSettings[ConfigKeys.WebSocketReconnectDelay] = 5; configChanged = true; } // Migrate VIP settings from old format to new format if (!(Config[ConfigSections.VIPSettings] is Dictionary vipSettings)) { vipSettings = new Dictionary(); Config[ConfigSections.VIPSettings] = vipSettings; configChanged = true; } // Check if old VipPermissionGroup exists and migrate to VipCommand if (vipSettings.ContainsKey("VipPermissionGroup") && !vipSettings.ContainsKey(ConfigKeys.VipCommand)) { string oldGroup = vipSettings["VipPermissionGroup"].ToString(); vipSettings[ConfigKeys.VipCommand] = $"oxide.usergroup add {{player}} {oldGroup}"; vipSettings.Remove("VipPermissionGroup"); configChanged = true; Puts($"[Migration] Converted VipPermissionGroup '{oldGroup}' to VipCommand"); } // Ensure VipCommand exists with default value if (!vipSettings.ContainsKey(ConfigKeys.VipCommand)) { vipSettings[ConfigKeys.VipCommand] = "oxide.usergroup add {player} vip"; configChanged = true; } // NEW: Migrate protection settings if (!(Config[ConfigSections.CurrencySettings] is Dictionary currencySettings)) { currencySettings = new Dictionary(); Config[ConfigSections.CurrencySettings] = currencySettings; configChanged = true; } // Add new protection settings if missing if (!currencySettings.ContainsKey(ConfigKeys.MaxPurchaseAmount)) { currencySettings[ConfigKeys.MaxPurchaseAmount] = 10000; configChanged = true; Puts("[Migration] Added MaxPurchaseAmount = 10000"); } if (!currencySettings.ContainsKey(ConfigKeys.MaxSendAmount)) { currencySettings[ConfigKeys.MaxSendAmount] = 10000; configChanged = true; Puts("[Migration] Added MaxSendAmount = 10000"); } if (!currencySettings.ContainsKey(ConfigKeys.CommandCooldownSeconds)) { currencySettings[ConfigKeys.CommandCooldownSeconds] = 0; configChanged = true; Puts("[Migration] Added CommandCooldownSeconds = 0 (disabled)"); } if (!currencySettings.ContainsKey(ConfigKeys.MaxPendingInvoicesPerPlayer)) { currencySettings[ConfigKeys.MaxPendingInvoicesPerPlayer] = 1; configChanged = true; Puts("[Migration] Added MaxPendingInvoicesPerPlayer = 1"); } if (configChanged) { SaveConfig(); Puts("[Migration] Configuration updated with protection settings"); } } 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[ConfigSections.Commands] = new Dictionary { [ConfigKeys.BuyCurrencyCommandName] = "buyblood", [ConfigKeys.BuyVipCommandName] = "buyvip", [ConfigKeys.SendCurrencyCommandName] = "sendblood" }; Config[ConfigSections.CurrencySettings] = new Dictionary { [ConfigKeys.CurrencyItemID] = 1776460938, [ConfigKeys.CurrencyName] = "blood", [ConfigKeys.CurrencySkinID] = 0UL, [ConfigKeys.PricePerCurrencyUnit] = 1, [ConfigKeys.SatsPerCurrencyUnit] = 1, // NEW: Protection settings [ConfigKeys.MaxPurchaseAmount] = 10000, [ConfigKeys.MaxSendAmount] = 10000, [ConfigKeys.CommandCooldownSeconds] = 0, [ConfigKeys.MaxPendingInvoicesPerPlayer] = 1 }; Config[ConfigSections.Discord] = new Dictionary { [ConfigKeys.DiscordChannelName] = "mart", [ConfigKeys.DiscordWebhookUrl] = "https://discord.com/api/webhooks/your_webhook_url" }; Config[ConfigSections.InvoiceSettings] = new Dictionary { [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, [ConfigKeys.UseWebSockets] = true, [ConfigKeys.WebSocketReconnectDelay] = 5 }; Config[ConfigSections.VIPSettings] = new Dictionary { [ConfigKeys.VipCommand] = "oxide.usergroup add {steamid} vip", [ConfigKeys.VipPrice] = 1000 }; } private void Init() { // Register permissions permission.RegisterPermission("orangemart.buycurrency", this); permission.RegisterPermission("orangemart.sendcurrency", this); permission.RegisterPermission("orangemart.buyvip", this); } private void OnServerInitialized() { if (config == null) { PrintError("Plugin configuration is not properly set up. Please check your configuration file."); return; } // Register commands AddCovalenceCommand(buyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency"); AddCovalenceCommand(sendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency"); AddCovalenceCommand(buyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip"); // Recover interrupted transactions RecoverInterruptedTransactions(); // Start a timer to check pending invoices periodically (fallback for WebSocket failures) if (!useWebSockets || checkIntervalSeconds > 0) { timer.Every(checkIntervalSeconds, CheckPendingInvoices); } // Cleanup old cooldown entries every 5 minutes timer.Every(300f, CleanupOldCooldowns); Puts($"Orangemart initialized. WebSockets: {(useWebSockets ? "Enabled" : "Disabled")}"); } private void Unload() { // Clean up all WebSocket connections CleanupAllWebSockets(); pendingInvoices.Clear(); retryCounts.Clear(); lastCommandTime.Clear(); } // NEW: Protection Methods // Rate limiting system private bool IsOnCooldown(IPlayer player, string commandType) { if (commandCooldownSeconds <= 0) return false; // Cooldown disabled string key = $"{GetPlayerId(player)}:{commandType}"; if (lastCommandTime.TryGetValue(key, out DateTime lastTime)) { double secondsSince = (DateTime.UtcNow - lastTime).TotalSeconds; if (secondsSince < commandCooldownSeconds) { double remaining = commandCooldownSeconds - secondsSince; player.Reply(Lang("CommandOnCooldown", player.Id, commandType, Math.Ceiling(remaining))); return true; } } lastCommandTime[key] = DateTime.UtcNow; return false; } // Pending invoice limit check private bool HasTooManyPendingInvoices(IPlayer player) { // 0 = no limit if (maxPendingInvoicesPerPlayer == 0) return false; string playerId = GetPlayerId(player); int pendingCount = pendingInvoices.Count(inv => GetPlayerId(inv.Player) == playerId); if (pendingCount >= maxPendingInvoicesPerPlayer) { player.Reply(Lang("TooManyPendingInvoices", player.Id, pendingCount, maxPendingInvoicesPerPlayer)); return true; } return false; } // Amount validation with overflow protection private bool ValidatePurchaseAmount(IPlayer player, int amount, out int safeSats) { safeSats = 0; // Basic validation if (amount <= 0) { player.Reply(Lang("InvalidAmount", player.Id)); return false; } // Maximum amount check (0 = no limit) if (maxPurchaseAmount > 0 && amount > maxPurchaseAmount) { player.Reply(Lang("AmountTooLarge", player.Id, amount, maxPurchaseAmount, currencyName)); return false; } // Integer overflow protection long amountSatsLong = (long)amount * pricePerCurrencyUnit; if (amountSatsLong > int.MaxValue) { player.Reply(Lang("AmountCausesOverflow", player.Id)); return false; } safeSats = (int)amountSatsLong; return true; } // Send amount validation private bool ValidateSendAmount(IPlayer player, int amount, out int safeSats) { safeSats = 0; // Basic validation if (amount <= 0) { player.Reply(Lang("InvalidAmount", player.Id)); return false; } // Maximum amount check (0 = no limit) if (maxSendAmount > 0 && amount > maxSendAmount) { player.Reply(Lang("SendAmountTooLarge", player.Id, amount, maxSendAmount, currencyName)); return false; } // Integer overflow protection long amountSatsLong = (long)amount * satsPerCurrencyUnit; if (amountSatsLong > int.MaxValue) { player.Reply(Lang("AmountCausesOverflow", player.Id)); return false; } safeSats = (int)amountSatsLong; return true; } // VIP price validation private bool ValidateVipPrice(IPlayer player, out int safeSats) { safeSats = 0; // Check if VIP price would cause overflow if (vipPrice > int.MaxValue) { player.Reply(Lang("VipPriceTooHigh", player.Id)); PrintError($"VIP price {vipPrice} exceeds int.MaxValue"); return false; } safeSats = vipPrice; return true; } private void CleanupOldCooldowns() { var expiredKeys = lastCommandTime .Where(kvp => (DateTime.UtcNow - kvp.Value).TotalSeconds > commandCooldownSeconds * 2) .Select(kvp => kvp.Key) .ToList(); foreach (var key in expiredKeys) { lastCommandTime.Remove(key); } if (expiredKeys.Count > 0) { Puts($"Cleaned up {expiredKeys.Count} expired cooldown entries."); } } private void CleanupAllWebSockets() { lock (webSocketLock) { foreach (var kvp in activeWebSockets) { try { kvp.Value.CancellationTokenSource?.Cancel(); kvp.Value.WebSocket?.Dispose(); } catch (Exception ex) { PrintError($"Error cleaning up WebSocket for {kvp.Key}: {ex.Message}"); } } activeWebSockets.Clear(); } } protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { // Existing messages ["UsageSendCurrency"] = "Usage: /{0} ", ["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 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. 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!", ["InvalidCommandUsage"] = "Usage: /{0} ", ["NoPermission"] = "You do not have permission to use this command.", ["FailedToFindBasePlayer"] = "Failed to find base player object for player {0}.", ["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.", ["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.", ["TransactionInitiated"] = "Transaction initiated. Processing your payment...", // NEW: Protection and validation messages ["InvalidAmount"] = "Invalid amount. Please enter a positive number.", ["AmountTooLarge"] = "Amount {0} exceeds maximum limit of {1} {2}. Please use a smaller amount.", ["SendAmountTooLarge"] = "Send amount {0} exceeds maximum limit of {1} {2}. Please use a smaller amount.", ["AmountCausesOverflow"] = "Amount too large and would cause calculation errors. Please use a smaller amount.", ["CommandOnCooldown"] = "Command '{0}' is on cooldown. Please wait {1} more seconds.", ["TooManyPendingInvoices"] = "You have {0} pending invoices (max: {1}). Please complete or wait for them to expire.", ["VipPriceTooHigh"] = "VIP price is configured too high. Please contact an administrator.", ["ProtectionLimits"] = "Orangemart Limits: Purchase max {0}, Send max {1}, Cooldown {2}s" }, this); } private string Lang(string key, string userId = null, params object[] args) { return string.Format(lang.GetMessage(key, this, userId), args); } // Helper method to generate unique transaction IDs private string GenerateTransactionId() { return $"{DateTime.UtcNow.Ticks}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; } // WebSocket connection management private async Task ConnectWebSocket(PendingInvoice invoice) { if (!useWebSockets) { Puts($"WebSockets disabled, using HTTP polling for invoice {invoice.RHash}"); return; } var wsConnection = new WebSocketConnection { WebSocket = new ClientWebSocket(), CancellationTokenSource = new CancellationTokenSource(), InvoiceKey = invoice.RHash, Invoice = invoice, ConnectedAt = DateTime.UtcNow, ReconnectAttempts = 0 }; wsConnection.WebSocket.Options.SetRequestHeader("X-Api-Key", config.ApiKey); lock (webSocketLock) { if (activeWebSockets.ContainsKey(invoice.RHash)) { var existing = activeWebSockets[invoice.RHash]; existing.CancellationTokenSource?.Cancel(); existing.WebSocket?.Dispose(); } activeWebSockets[invoice.RHash] = wsConnection; } try { // Try the payment hash endpoint first var wsUrl = $"{config.WebSocketUrl}/api/v1/ws/{invoice.RHash}"; Puts($"[WebSocket] Attempting to connect to: {wsUrl}"); await wsConnection.WebSocket.ConnectAsync(new Uri(wsUrl), wsConnection.CancellationTokenSource.Token); Puts($"WebSocket connected for invoice {invoice.RHash}"); wsConnection.ListenTask = Task.Run(async () => await ListenToWebSocket(wsConnection), wsConnection.CancellationTokenSource.Token); } catch (Exception ex) { PrintError($"Failed to connect WebSocket for invoice {invoice.RHash}: {ex.Message}"); lock (webSocketLock) { activeWebSockets.Remove(invoice.RHash); } // Enable HTTP polling as fallback immediately Puts($"[WebSocket] Falling back to HTTP polling for {invoice.RHash}"); if (wsConnection.ReconnectAttempts < 3) { timer.Once(webSocketReconnectDelay, () => { if (pendingInvoices.Contains(invoice)) { wsConnection.ReconnectAttempts++; Task.Run(async () => await ConnectWebSocket(invoice)); } }); } } } private async Task ListenToWebSocket(WebSocketConnection connection) { var buffer = new ArraySegment(new byte[4096]); var messageBuilder = new StringBuilder(); try { while (connection.WebSocket.State == WebSocketState.Open && !connection.CancellationTokenSource.Token.IsCancellationRequested) { WebSocketReceiveResult result; messageBuilder.Clear(); do { result = await connection.WebSocket.ReceiveAsync(buffer, connection.CancellationTokenSource.Token); if (result.MessageType == WebSocketMessageType.Text) { messageBuilder.Append(Encoding.UTF8.GetString(buffer.Array, 0, result.Count)); } else if (result.MessageType == WebSocketMessageType.Close) { await connection.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); break; } } while (!result.EndOfMessage); if (messageBuilder.Length > 0) { var message = messageBuilder.ToString(); ProcessWebSocketMessage(connection, message); } } } catch (WebSocketException wsEx) { PrintError($"WebSocket error for invoice {connection.InvoiceKey}: {wsEx.Message}"); } catch (TaskCanceledException) { // Expected when cancellation is requested } catch (Exception ex) { PrintError($"Unexpected error in WebSocket listener for invoice {connection.InvoiceKey}: {ex.Message}"); } finally { // Clean up lock (webSocketLock) { if (activeWebSockets.ContainsKey(connection.InvoiceKey)) { activeWebSockets.Remove(connection.InvoiceKey); } } if (connection.WebSocket?.State == WebSocketState.Open) { try { await connection.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); } catch { } } connection.WebSocket?.Dispose(); } } private void ProcessWebSocketMessage(WebSocketConnection connection, string message) { try { // Log all WebSocket messages for debugging Puts($"[WebSocket] Raw message for {connection.InvoiceKey}: {message}"); // Try to parse as the simple format first: {"pending": false, "status": "success"} try { var simpleUpdate = JsonConvert.DeserializeObject>(message); if (simpleUpdate != null && simpleUpdate.ContainsKey("pending") && simpleUpdate.ContainsKey("status")) { bool isPending = Convert.ToBoolean(simpleUpdate["pending"]); string status = simpleUpdate["status"]?.ToString(); Puts($"[WebSocket] Simple format - Pending: {isPending}, Status: {status}"); if (!isPending && status == "success") { Puts($"[WebSocket] Payment confirmed via simple format for {connection.InvoiceKey}"); ProcessPaymentConfirmation(connection.Invoice); connection.CancellationTokenSource?.Cancel(); return; } } } catch (Exception) { // Fall through to try the complex format } // Try to parse as the complex format: {"payment": {...}} try { var update = JsonConvert.DeserializeObject(message); if (update?.Payment != null) { Puts($"[WebSocket] Complex format - Hash: {update.Payment.PaymentHash}, Pending: {update.Payment.Pending}, Preimage: {update.Payment.Preimage}"); // Check if payment is confirmed (not pending) if (!update.Payment.Pending && !string.IsNullOrEmpty(update.Payment.Preimage)) { Puts($"[WebSocket] Payment confirmed via complex format (preimage) for {connection.InvoiceKey}"); ProcessPaymentConfirmation(connection.Invoice); connection.CancellationTokenSource?.Cancel(); return; } // Alternative check: if payment hash matches and not pending else if (!update.Payment.Pending && update.Payment.PaymentHash?.ToLower() == connection.InvoiceKey.ToLower()) { Puts($"[WebSocket] Payment confirmed via complex format (hash match) for {connection.InvoiceKey}"); ProcessPaymentConfirmation(connection.Invoice); connection.CancellationTokenSource?.Cancel(); return; } } } catch (Exception) { // Neither format worked } Puts($"[WebSocket] Message did not indicate payment completion for {connection.InvoiceKey}"); } catch (Exception ex) { PrintError($"Error processing WebSocket message for invoice {connection.InvoiceKey}: {ex.Message}"); Puts($"[WebSocket] Problematic message: {message}"); } } private void ProcessPaymentConfirmation(PendingInvoice invoice) { // Check if this invoice was already processed to prevent duplicates if (!pendingInvoices.Contains(invoice)) { Puts($"[ProcessPayment] Invoice {invoice.RHash} already processed, skipping"); return; } // Remove from pending list immediately to prevent duplicate processing pendingInvoices.Remove(invoice); Puts($"[ProcessPayment] Processing payment confirmation for {invoice.RHash}, Type: {invoice.Type}"); // Process based on type switch (invoice.Type) { case PurchaseType.Currency: RewardPlayer(invoice.Player, invoice.Amount); UpdateBuyTransactionStatus(invoice.TransactionId, TransactionStatus.COMPLETED, true); break; case PurchaseType.Vip: GrantVip(invoice.Player); UpdateBuyTransactionStatus(invoice.TransactionId, TransactionStatus.COMPLETED, true); break; case PurchaseType.SendBitcoin: invoice.Player.Reply(Lang("CurrencySentSuccess", invoice.Player.Id, invoice.Amount / satsPerCurrencyUnit, currencyName)); UpdateSellTransactionStatus(invoice.TransactionId, TransactionStatus.COMPLETED, true); break; } // Clean up retryCounts.Remove(invoice.RHash); // Close WebSocket lock (webSocketLock) { if (activeWebSockets.ContainsKey(invoice.RHash)) { var ws = activeWebSockets[invoice.RHash]; ws.CancellationTokenSource?.Cancel(); activeWebSockets.Remove(invoice.RHash); } } Puts($"Payment confirmed for invoice {invoice.RHash}, TransactionId: {invoice.TransactionId}"); } // Recovery logic for interrupted transactions private void RecoverInterruptedTransactions() { Puts("Checking for interrupted transactions..."); // Recover sell transactions var sellLogs = LoadSellLogData(); var interruptedSells = sellLogs.Where(l => l.Status == TransactionStatus.INITIATED || l.Status == TransactionStatus.PROCESSING).ToList(); foreach (var log in interruptedSells) { Puts($"Found interrupted sell transaction: {log.TransactionId} for player {log.SteamID}"); // Mark as failed and refund if payment hash exists if (!string.IsNullOrEmpty(log.PaymentHash)) { // Check if payment was actually completed CheckInvoicePaid(log.PaymentHash, isPaid => { if (isPaid) { // Payment was completed, update status UpdateSellTransactionStatus(log.TransactionId, TransactionStatus.COMPLETED, true); Puts($"Recovered completed sell transaction: {log.TransactionId}"); } else { // Payment failed, mark as failed UpdateSellTransactionStatus(log.TransactionId, TransactionStatus.FAILED, false, "Server interrupted"); Puts($"Marked interrupted sell transaction as failed: {log.TransactionId}"); } }); } else { // No payment hash, mark as failed UpdateSellTransactionStatus(log.TransactionId, TransactionStatus.FAILED, false, "Server interrupted before payment initiation"); } } // Recover buy transactions var buyLogs = LoadBuyLogData(); var interruptedBuys = buyLogs.Where(l => l.Status == TransactionStatus.INITIATED || l.Status == TransactionStatus.PROCESSING).ToList(); foreach (var log in interruptedBuys) { Puts($"Found interrupted buy transaction: {log.TransactionId} for player {log.SteamID}"); // Check if invoice was paid if (!string.IsNullOrEmpty(log.InvoiceID)) { CheckInvoicePaid(log.InvoiceID, isPaid => { if (isPaid) { // Payment was completed, update status UpdateBuyTransactionStatus(log.TransactionId, TransactionStatus.COMPLETED, true); Puts($"Recovered completed buy transaction: {log.TransactionId}"); } else { // Payment failed, mark as expired UpdateBuyTransactionStatus(log.TransactionId, TransactionStatus.EXPIRED, false); Puts($"Marked interrupted buy transaction as expired: {log.TransactionId}"); } }); } } } // PROTECTED COMMAND METHODS // Protected CmdBuyCurrency method private void CmdBuyCurrency(IPlayer player, string command, string[] args) { if (!player.HasPermission("orangemart.buycurrency")) { player.Reply(Lang("NoPermission", player.Id)); return; } // Rate limiting check if (IsOnCooldown(player, "buy")) return; // Pending invoice limit check if (HasTooManyPendingInvoices(player)) return; if (args.Length != 1 || !int.TryParse(args[0], out int amount)) { player.Reply(Lang("InvalidCommandUsage", player.Id, buyCurrencyCommandName)); return; } // Amount validation with overflow protection if (!ValidatePurchaseAmount(player, amount, out int amountSats)) return; string transactionId = GenerateTransactionId(); // Log transaction initiation var initialLogEntry = new BuyInvoiceLogEntry { TransactionId = transactionId, SteamID = GetPlayerId(player), InvoiceID = null, Status = TransactionStatus.INITIATED, IsPaid = false, Timestamp = DateTime.UtcNow, CompletedTimestamp = null, Amount = amountSats, CurrencyGiven = false, VipGranted = false, RetryCount = 0, PurchaseType = "Currency" }; LogBuyInvoice(initialLogEntry); CreateInvoice(amountSats, $"Buying {amount} {currencyName}", invoiceResponse => { if (invoiceResponse != null) { // Update log entry with invoice ID UpdateBuyTransactionInvoiceId(transactionId, invoiceResponse.PaymentHash); SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {currencyName}"); player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); var pendingInvoice = new PendingInvoice { TransactionId = transactionId, RHash = invoiceResponse.PaymentHash.ToLower(), Player = player, Amount = amount, Memo = $"Buying {amount} {currencyName}", CreatedAt = DateTime.UtcNow, Type = PurchaseType.Currency }; pendingInvoices.Add(pendingInvoice); // Connect WebSocket for monitoring Task.Run(async () => await ConnectWebSocket(pendingInvoice)); ScheduleInvoiceExpiry(pendingInvoice); } else { player.Reply(Lang("FailedToCreateInvoice", player.Id)); // Update transaction as failed UpdateBuyTransactionStatus(transactionId, TransactionStatus.FAILED, false); } }); } // Protected CmdSendCurrency method private void CmdSendCurrency(IPlayer player, string command, string[] args) { if (!player.HasPermission("orangemart.sendcurrency")) { player.Reply(Lang("NoPermission", player.Id)); return; } // Rate limiting check if (IsOnCooldown(player, "send")) return; // Pending invoice limit check if (HasTooManyPendingInvoices(player)) return; if (args.Length != 2 || !int.TryParse(args[0], out int amount)) { player.Reply(Lang("UsageSendCurrency", player.Id, sendCurrencyCommandName)); return; } // Amount validation with overflow protection if (!ValidateSendAmount(player, amount, out int satsAmount)) return; string lightningAddress = args[1]; if (!IsLightningAddressAllowed(lightningAddress)) { string domain = GetDomainFromLightningAddress(lightningAddress); 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)); return; } int currencyAmount = GetAllInventoryItems(basePlayer).Where(IsCurrencyItem).Sum(item => item.amount); if (currencyAmount < amount) { player.Reply(Lang("NeedMoreCurrency", player.Id, currencyName, currencyAmount)); return; } if (!TryReserveCurrency(basePlayer, amount)) { player.Reply(Lang("FailedToReserveCurrency", player.Id)); return; } // Generate transaction ID and log initiation immediately string transactionId = GenerateTransactionId(); // Log transaction initiation var initialLogEntry = new SellInvoiceLogEntry { TransactionId = transactionId, SteamID = GetPlayerId(player), LightningAddress = lightningAddress, Status = TransactionStatus.INITIATED, Success = false, SatsAmount = satsAmount, PaymentHash = null, CurrencyReturned = false, Timestamp = DateTime.UtcNow, CompletedTimestamp = null, RetryCount = 0, FailureReason = null }; LogSellTransaction(initialLogEntry); player.Reply(Lang("TransactionInitiated", player.Id)); SendBitcoin(lightningAddress, satsAmount, (success, paymentHash) => { if (success && !string.IsNullOrEmpty(paymentHash)) { // Update log entry with payment hash UpdateSellTransactionPaymentHash(transactionId, paymentHash); var pendingInvoice = new PendingInvoice { TransactionId = transactionId, RHash = paymentHash.ToLower(), Player = player, Amount = satsAmount, Memo = $"Sending {amount} {currencyName} to {lightningAddress}", CreatedAt = DateTime.UtcNow, Type = PurchaseType.SendBitcoin }; pendingInvoices.Add(pendingInvoice); // Connect WebSocket for monitoring Task.Run(async () => await ConnectWebSocket(pendingInvoice)); Puts($"Outbound payment to {lightningAddress} initiated. PaymentHash: {paymentHash}, TransactionId: {transactionId}"); } else { player.Reply(Lang("FailedToProcessPayment", player.Id)); // Update transaction as failed UpdateSellTransactionStatus(transactionId, TransactionStatus.FAILED, false, "Failed to initiate payment", true); Puts($"Outbound payment to {lightningAddress} failed to initiate. TransactionId: {transactionId}"); ReturnCurrency(basePlayer, amount); Puts($"Returned {amount} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); } }); } // Protected CmdBuyVip method private void CmdBuyVip(IPlayer player, string command, string[] args) { if (!player.HasPermission("orangemart.buyvip")) { player.Reply(Lang("NoPermission", player.Id)); return; } // Rate limiting check if (IsOnCooldown(player, "vip")) return; // Pending invoice limit check if (HasTooManyPendingInvoices(player)) return; // VIP price validation if (!ValidateVipPrice(player, out int amountSats)) return; string transactionId = GenerateTransactionId(); // Log transaction initiation var initialLogEntry = new BuyInvoiceLogEntry { TransactionId = transactionId, SteamID = GetPlayerId(player), InvoiceID = null, Status = TransactionStatus.INITIATED, IsPaid = false, Timestamp = DateTime.UtcNow, CompletedTimestamp = null, Amount = amountSats, CurrencyGiven = false, VipGranted = false, RetryCount = 0, PurchaseType = "VIP" }; LogBuyInvoice(initialLogEntry); CreateInvoice(amountSats, "Buying VIP Status", invoiceResponse => { if (invoiceResponse != null) { // Update log entry with invoice ID UpdateBuyTransactionInvoiceId(transactionId, invoiceResponse.PaymentHash); SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, "Buying VIP Status"); player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); var pendingInvoice = new PendingInvoice { TransactionId = transactionId, RHash = invoiceResponse.PaymentHash.ToLower(), Player = player, Amount = amountSats, Memo = "Buying VIP Status", CreatedAt = DateTime.UtcNow, Type = PurchaseType.Vip }; pendingInvoices.Add(pendingInvoice); // Connect WebSocket for monitoring Task.Run(async () => await ConnectWebSocket(pendingInvoice)); ScheduleInvoiceExpiry(pendingInvoice); } else { player.Reply(Lang("FailedToCreateInvoice", player.Id)); // Update transaction as failed UpdateBuyTransactionStatus(transactionId, TransactionStatus.FAILED, false); } }); } // TEMPORARILY COMMENTED OUT - ADMIN COMMANDS // Uncomment these once the core plugin is working /* [ConsoleCommand("orangemart.limits")] private void CmdShowLimits(ConsoleSystem.Arg arg) { BasePlayer player = arg.connection?.player as BasePlayer; if (player != null && !player.IsAdmin) return; arg.ReplyWith("=== Orangemart Protection Limits ==="); arg.ReplyWith($"Max Purchase Amount: {maxPurchaseAmount} {currencyName}"); arg.ReplyWith($"Max Send Amount: {maxSendAmount} {currencyName}"); arg.ReplyWith($"Command Cooldown: {commandCooldownSeconds} seconds"); arg.ReplyWith($"Max Pending Invoices: {maxPendingInvoicesPerPlayer} per player"); arg.ReplyWith($"Active Pending Invoices: {pendingInvoices.Count}"); arg.ReplyWith($"Players on Cooldown: {lastCommandTime.Count(kvp => (DateTime.UtcNow - kvp.Value).TotalSeconds < commandCooldownSeconds)}"); } [ConsoleCommand("orangemart.clearcooldowns")] private void CmdClearCooldowns(ConsoleSystem.Arg arg) { BasePlayer player = arg.connection?.player as BasePlayer; if (player != null && !player.IsAdmin) return; int cleared = lastCommandTime.Count; lastCommandTime.Clear(); arg.ReplyWith($"Cleared {cleared} command cooldowns."); Puts($"Admin {player?.displayName ?? "Console"} cleared all command cooldowns."); } [ConsoleCommand("orangemart.clearcooldown")] private void CmdClearPlayerCooldown(ConsoleSystem.Arg arg) { BasePlayer player = arg.connection?.player as BasePlayer; if (player != null && !player.IsAdmin) return; if (arg.Args == null || arg.Args.Length == 0) { arg.ReplyWith("Usage: orangemart.clearcooldown "); return; } string steamId = arg.Args[0]; int cleared = 0; var keysToRemove = lastCommandTime.Keys.Where(k => k.StartsWith(steamId + ":")).ToList(); foreach (var key in keysToRemove) { lastCommandTime.Remove(key); cleared++; } arg.ReplyWith($"Cleared {cleared} cooldowns for player {steamId}."); Puts($"Admin {player?.displayName ?? "Console"} cleared cooldowns for player {steamId}."); } [ConsoleCommand("orangemart.playerstats")] private void CmdPlayerStats(ConsoleSystem.Arg arg) { BasePlayer player = arg.connection?.player as BasePlayer; if (player != null && !player.IsAdmin) return; if (arg.Args == null || arg.Args.Length == 0) { arg.ReplyWith("Usage: orangemart.playerstats "); return; } string steamId = arg.Args[0]; int pendingCount = pendingInvoices.Count(inv => GetPlayerId(inv.Player) == steamId); bool onCooldown = false; DateTime lastTime = DateTime.MinValue; foreach (var kvp in lastCommandTime) { if (kvp.Key.StartsWith(steamId + ":")) { if (kvp.Value > lastTime) { lastTime = kvp.Value; onCooldown = (DateTime.UtcNow - kvp.Value).TotalSeconds < commandCooldownSeconds; } } } arg.ReplyWith($"Player {steamId} stats:"); arg.ReplyWith($"- Pending invoices: {pendingCount}/{maxPendingInvoicesPerPlayer}"); arg.ReplyWith($"- On cooldown: {onCooldown}"); arg.ReplyWith($"- Last command: {(lastTime == DateTime.MinValue ? "Never" : lastTime.ToString())}"); } */ [ChatCommand("orangelimits")] private void CmdPlayerLimits(BasePlayer player, string command, string[] args) { var covalencePlayer = players.FindPlayerById(player.UserIDString); if (covalencePlayer == null || (!covalencePlayer.HasPermission("orangemart.buycurrency") && !covalencePlayer.HasPermission("orangemart.sendcurrency") && !covalencePlayer.HasPermission("orangemart.buyvip"))) { player.ChatMessage("You do not have permission to use Orangemart commands."); return; } player.ChatMessage(Lang("ProtectionLimits", player.UserIDString, maxPurchaseAmount, maxSendAmount, commandCooldownSeconds)); // Show player's current status string playerId = player.UserIDString; int pendingCount = pendingInvoices.Count(inv => GetPlayerId(inv.Player) == playerId); bool onCooldown = false; string cooldownCommands = ""; foreach (var kvp in lastCommandTime) { if (kvp.Key.StartsWith(playerId + ":")) { double remaining = commandCooldownSeconds - (DateTime.UtcNow - kvp.Value).TotalSeconds; if (remaining > 0) { onCooldown = true; string cmd = kvp.Key.Split(':')[1]; cooldownCommands += $"{cmd}({Math.Ceiling(remaining)}s) "; } } } player.ChatMessage($"Your status: {pendingCount}/{maxPendingInvoicesPerPlayer} pending invoices"); if (onCooldown) { player.ChatMessage($"Cooldowns: {cooldownCommands.Trim()}"); } } // HELPER METHODS 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 bool IsCurrencyItem(Item item) { return item.info.itemid == currencyItemID && (currencySkinID == 0 || item.skin == currencySkinID); } private bool TryReserveCurrency(BasePlayer player, int amount) { var items = GetAllInventoryItems(player).Where(IsCurrencyItem).ToList(); int totalCurrency = items.Sum(item => item.amount); if (totalCurrency < amount) { return false; } int remaining = amount; foreach (var item in items) { if (item.amount > remaining) { item.UseItem(remaining); break; } else { remaining -= item.amount; item.Remove(); } if (remaining <= 0) { break; } } return true; } // Fallback HTTP polling for when WebSockets are disabled or fail private void CheckPendingInvoices() { foreach (var invoice in pendingInvoices.ToList()) { string localPaymentHash = invoice.RHash; // Always check via HTTP as a fallback, but log differently for WebSocket vs HTTP-only bool hasActiveWebSocket = false; lock (webSocketLock) { hasActiveWebSocket = useWebSockets && activeWebSockets.ContainsKey(invoice.RHash); } string checkType = hasActiveWebSocket ? "HTTP Fallback" : "HTTP Polling"; Puts($"[{checkType}] Checking payment status for {localPaymentHash}"); CheckInvoicePaid(localPaymentHash, isPaid => { if (isPaid) { Puts($"[{checkType}] Payment confirmed for {localPaymentHash}"); ProcessPaymentConfirmation(invoice); } else { if (!retryCounts.ContainsKey(localPaymentHash)) { retryCounts[localPaymentHash] = 0; Puts($"Initialized retry count for paymentHash: {localPaymentHash}"); } retryCounts[localPaymentHash]++; if (retryCounts[localPaymentHash] == 1) { if (invoice.Type == PurchaseType.SendBitcoin) { UpdateSellTransactionStatus(invoice.TransactionId, TransactionStatus.PROCESSING, false); } else { UpdateBuyTransactionStatus(invoice.TransactionId, TransactionStatus.PROCESSING, false); } } Puts($"[{checkType}] 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."); } UpdateSellTransactionStatus(invoice.TransactionId, TransactionStatus.EXPIRED, false, "Payment timeout", true); } else { UpdateBuyTransactionStatus(invoice.TransactionId, TransactionStatus.EXPIRED, false); } } } }); } } private void CheckInvoicePaid(string paymentHash, Action callback) { string normalizedPaymentHash = paymentHash.ToLower(); string url = $"{config.BaseUrl}/api/v1/payments/{normalizedPaymentHash}"; var headers = new Dictionary { { "Content-Type", "application/json" }, { "X-Api-Key", config.ApiKey } }; MakeWebRequest(url, null, (code, response) => { if (code != 200 || string.IsNullOrEmpty(response)) { PrintError($"Error checking invoice status: HTTP {code}"); callback(false); return; } try { var paymentStatus = JsonConvert.DeserializeObject(response); callback(paymentStatus != null && paymentStatus.Paid); } catch (Exception ex) { PrintError($"Failed to parse invoice status response: {ex.Message}"); callback(false); } }, RequestMethod.GET, headers); } private bool IsLightningAddressAllowed(string lightningAddress) { string domain = GetDomainFromLightningAddress(lightningAddress); if (string.IsNullOrEmpty(domain)) return false; if (whitelistedDomains.Any()) { 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)) { PrintError($"Failed to resolve Lightning Address: {lightningAddress}"); callback(false, null); return; } SendPayment(bolt11, satsAmount, (success, paymentHash) => { if (success && !string.IsNullOrEmpty(paymentHash)) { // Return success immediately - payment status will be tracked by WebSocket/polling callback(true, paymentHash); } else { callback(false, null); } }); }); } private void ScheduleInvoiceExpiry(PendingInvoice pendingInvoice) { timer.Once(invoiceTimeoutSeconds, () => { if (pendingInvoices.Contains(pendingInvoice)) { pendingInvoices.Remove(pendingInvoice); PrintWarning($"Invoice for player {GetPlayerId(pendingInvoice.Player)} expired (amount: {pendingInvoice.Amount} sats)."); // Clean up WebSocket if exists lock (webSocketLock) { if (activeWebSockets.ContainsKey(pendingInvoice.RHash)) { var ws = activeWebSockets[pendingInvoice.RHash]; ws.CancellationTokenSource?.Cancel(); activeWebSockets.Remove(pendingInvoice.RHash); } } int finalRetryCount = retryCounts.ContainsKey(pendingInvoice.RHash) ? retryCounts[pendingInvoice.RHash] : 0; if (pendingInvoice.Type == PurchaseType.SendBitcoin) { 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."); } UpdateSellTransactionStatus(pendingInvoice.TransactionId, TransactionStatus.EXPIRED, false, "Invoice timeout", true); } else { UpdateBuyTransactionStatus(pendingInvoice.TransactionId, TransactionStatus.EXPIRED, false); } } }); } // SendPayment now properly handles the wrapper class private void SendPayment(string bolt11, int satsAmount, Action callback) { // For outbound payments, LNbits expects only "out" and "bolt11" string url = $"{config.BaseUrl}/api/v1/payments"; var requestBody = new { @out = true, bolt11 = bolt11 }; string jsonBody = JsonConvert.SerializeObject(requestBody); var headers = new Dictionary { { "X-Api-Key", config.ApiKey }, { "Content-Type", "application/json" } }; MakeWebRequest(url, jsonBody, (code, response) => { if (code != 200 && code != 201) { PrintError($"Error processing payment: HTTP {code}"); callback(false, null); return; } try { InvoiceResponse invoiceResponse = null; // First, attempt to deserialize using the wrapper (if present) try { var wrapper = JsonConvert.DeserializeObject(response); invoiceResponse = wrapper?.Data; } catch { } // Fallback: try direct deserialization if (invoiceResponse == null) { invoiceResponse = JsonConvert.DeserializeObject(response); } string paymentHash = invoiceResponse != null ? invoiceResponse.PaymentHash : null; if (!string.IsNullOrEmpty(paymentHash)) { callback(true, paymentHash); } else { PrintError("Payment hash (rhash) is missing or invalid in the response."); PrintWarning($"[SendPayment] Raw response: {response}"); callback(false, null); } } catch (Exception ex) { PrintError($"Exception occurred while parsing payment response: {ex.Message}"); callback(false, null); } }, RequestMethod.POST, headers); } // CreateInvoice now properly handles the wrapper class private void CreateInvoice(int amountSats, string memo, Action callback) { string url = $"{config.BaseUrl}/api/v1/payments"; var requestBody = new { @out = false, amount = amountSats, memo = memo }; string jsonBody = JsonConvert.SerializeObject(requestBody); var headers = new Dictionary { { "X-Api-Key", config.ApiKey }, { "Content-Type", "application/json" } }; MakeWebRequest(url, jsonBody, (code, response) => { if (code != 200 && code != 201) { PrintError($"Error creating invoice: HTTP {code}"); callback(null); return; } if (string.IsNullOrEmpty(response)) { PrintError("Empty response received when creating invoice."); callback(null); return; } // Log the raw response for debugging purposes. PrintWarning($"[CreateInvoice] Raw response: {response}"); try { var invoiceResponse = JsonConvert.DeserializeObject(response); callback(invoiceResponse != null && !string.IsNullOrEmpty(invoiceResponse.PaymentHash) ? invoiceResponse : null); } catch (Exception ex) { PrintError($"Failed to deserialize invoice response: {ex.Message}"); callback(null); } }, 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) { // Expected format: "Sending {amount} {currency} to {lightning_address}" var parts = memo.Split(" to "); return parts.Length == 2 ? parts[1] : "unknown@unknown.com"; } // RewardPlayer method to grant currency items to the player private void RewardPlayer(IPlayer player, int amount) { var basePlayer = player.Object as BasePlayer; if (basePlayer == null) { PrintError($"Failed to find base player object for player {player.Id}."); return; } var currencyItem = ItemManager.CreateByItemID(currencyItemID, amount); if (currencyItem != null) { if (currencySkinID > 0) { currencyItem.skin = currencySkinID; } // Check if player has inventory space first if (HasInventorySpace(basePlayer, amount)) { // Give the item to the player basePlayer.GiveItem(currencyItem); player.Reply($"You have successfully purchased {amount} {currencyName}!"); Puts($"Gave {amount} {currencyName} (skinID: {currencySkinID}) to player {basePlayer.UserIDString}."); } else { // Drop on ground if no inventory space var dropPosition = basePlayer.transform.position + new UnityEngine.Vector3(0f, 1.5f, 0f); var droppedItemEntity = currencyItem.CreateWorldObject(dropPosition); if (droppedItemEntity != null) { player.Reply($"Your inventory was full! {amount} {currencyName} dropped on the ground near you."); Puts($"Dropped {amount} {currencyName} on ground for player {basePlayer.UserIDString} (inventory full)."); } else { currencyItem.Remove(); PrintError($"Failed to drop {currencyName} item for player {basePlayer.UserIDString}."); player.Reply($"Your inventory was full and we couldn't drop the items. Please contact an administrator."); } } } else { PrintError($"Failed to create {currencyName} item for player {basePlayer.UserIDString}."); } } // GrantVip method to add the VIP permission group to the player private void GrantVip(IPlayer player) { player.Reply("You have successfully purchased VIP status!"); var basePlayer = player.Object as BasePlayer; if (basePlayer == null) { PrintError($"Failed to find base player object for player {player.Id} to grant VIP."); return; } // Replace placeholders in the command string commandToExecute = vipCommand .Replace("{player}", player.Name) .Replace("{steamid}", GetPlayerId(player)) .Replace("{userid}", GetPlayerId(player)) .Replace("{id}", GetPlayerId(player)); Puts($"[VIP] Executing command for player {player.Name} ({GetPlayerId(player)}): {commandToExecute}"); try { // Execute the command on the server using the correct Covalence method server.Command(commandToExecute); Puts($"[VIP] Successfully executed VIP command for player {player.Name}"); } catch (Exception ex) { PrintError($"[VIP] Failed to execute VIP command for player {player.Name}: {ex.Message}"); PrintError($"[VIP] Command was: {commandToExecute}"); } } // Fixed ReturnCurrency method with inventory space checking private void ReturnCurrency(BasePlayer player, int amount) { var returnedCurrency = ItemManager.CreateByItemID(currencyItemID, amount); if (returnedCurrency != null) { if (currencySkinID > 0) { returnedCurrency.skin = currencySkinID; } // Check if player has inventory space first if (HasInventorySpace(player, amount)) { // Move to main container returnedCurrency.MoveToContainer(player.inventory.containerMain); Puts($"Returned {amount} {currencyName} to player {player.UserIDString}."); } else { // Drop on ground if no inventory space var dropPosition = player.transform.position + new UnityEngine.Vector3(0f, 1.5f, 0f); var droppedItemEntity = returnedCurrency.CreateWorldObject(dropPosition); if (droppedItemEntity != null) { Puts($"Dropped {amount} {currencyName} on ground for player {player.UserIDString} (inventory full)."); } else { returnedCurrency.Remove(); PrintError($"Failed to return or drop {amount} {currencyName} for player {player.UserIDString}."); } } } else { PrintError($"Failed to create {currencyName} item to return to player {player.UserIDString}."); } } // Helper method to check if player has inventory space private bool HasInventorySpace(BasePlayer player, int amount) { if (player?.inventory?.containerMain == null) return false; int availableSpace = 0; var container = player.inventory.containerMain; // Check each slot in main inventory for (int i = 0; i < container.capacity; i++) { var slot = container.GetSlot(i); if (slot == null) { // Empty slot - can fit full stack var itemDefinition = ItemManager.FindItemDefinition(currencyItemID); if (itemDefinition != null) { availableSpace += itemDefinition.stackable; } } else if (IsCurrencyItem(slot)) { int maxStack = slot.info.stackable; if (slot.amount < maxStack) { // Existing currency stack with room availableSpace += maxStack - slot.amount; } } // If we have enough space, no need to check further if (availableSpace >= amount) return true; } return availableSpace >= amount; } // LOGGING METHODS // Enhanced LogSellTransaction helper private void LogSellTransaction(SellInvoiceLogEntry logEntry) { var logs = LoadSellLogData(); // Check if this is an update to existing transaction var existingIndex = logs.FindIndex(l => l.TransactionId == logEntry.TransactionId); if (existingIndex >= 0) { // Update existing entry logs[existingIndex] = logEntry; Puts($"[Orangemart] Updated sell transaction: {logEntry.TransactionId}"); } else { // Add new entry logs.Add(logEntry); Puts($"[Orangemart] Logged new sell transaction: {logEntry.TransactionId}"); } SaveSellLogData(logs); } // Update sell transaction status private void UpdateSellTransactionStatus(string transactionId, string status, bool success, string failureReason = null, bool currencyReturned = false) { var logs = LoadSellLogData(); var entry = logs.FirstOrDefault(l => l.TransactionId == transactionId); if (entry != null) { entry.Status = status; entry.Success = success; entry.CompletedTimestamp = DateTime.UtcNow; entry.CurrencyReturned = currencyReturned; if (!string.IsNullOrEmpty(failureReason)) { entry.FailureReason = failureReason; } SaveSellLogData(logs); Puts($"[Orangemart] Updated sell transaction status: {transactionId} -> {status}"); } else { PrintWarning($"[Orangemart] Could not find sell transaction to update: {transactionId}"); } } // Update sell transaction with payment hash private void UpdateSellTransactionPaymentHash(string transactionId, string paymentHash) { var logs = LoadSellLogData(); var entry = logs.FirstOrDefault(l => l.TransactionId == transactionId); if (entry != null) { entry.PaymentHash = paymentHash; SaveSellLogData(logs); Puts($"[Orangemart] Updated sell transaction with payment hash: {transactionId}"); } } private List LoadSellLogData() { var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); return File.Exists(path) ? JsonConvert.DeserializeObject>(File.ReadAllText(path)) : new List(); } private void SaveSellLogData(List data) { var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); var directory = Path.GetDirectoryName(path); if (!Directory.Exists(directory)) Directory.CreateDirectory(directory); File.WriteAllText(path, JsonConvert.SerializeObject(data, Formatting.Indented)); } // Enhanced LogBuyInvoice helper private void LogBuyInvoice(BuyInvoiceLogEntry logEntry) { var logs = LoadBuyLogData(); // Check if this is an update to existing transaction var existingIndex = logs.FindIndex(l => l.TransactionId == logEntry.TransactionId); if (existingIndex >= 0) { // Update existing entry logs[existingIndex] = logEntry; Puts($"[Orangemart] Updated buy transaction: {logEntry.TransactionId}"); } else { // Add new entry logs.Add(logEntry); Puts($"[Orangemart] Logged new buy transaction: {logEntry.TransactionId}"); } SaveBuyLogData(logs); } // Update buy transaction status private void UpdateBuyTransactionStatus(string transactionId, string status, bool isPaid) { var logs = LoadBuyLogData(); var entry = logs.FirstOrDefault(l => l.TransactionId == transactionId); if (entry != null) { entry.Status = status; entry.IsPaid = isPaid; entry.CompletedTimestamp = DateTime.UtcNow; if (isPaid) { if (entry.PurchaseType == "Currency") { entry.CurrencyGiven = true; } else if (entry.PurchaseType == "VIP") { entry.VipGranted = true; } } SaveBuyLogData(logs); Puts($"[Orangemart] Updated buy transaction status: {transactionId} -> {status}"); } else { PrintWarning($"[Orangemart] Could not find buy transaction to update: {transactionId}"); } } // Update buy transaction with invoice ID private void UpdateBuyTransactionInvoiceId(string transactionId, string invoiceId) { var logs = LoadBuyLogData(); var entry = logs.FirstOrDefault(l => l.TransactionId == transactionId); if (entry != null) { entry.InvoiceID = invoiceId; SaveBuyLogData(logs); Puts($"[Orangemart] Updated buy transaction with invoice ID: {transactionId}"); } } private List LoadBuyLogData() { var path = Path.Combine(Interface.Oxide.DataDirectory, BuyInvoiceLogFile); return File.Exists(path) ? JsonConvert.DeserializeObject>(File.ReadAllText(path)) ?? new List() : new List(); } private void SaveBuyLogData(List data) { var path = Path.Combine(Interface.Oxide.DataDirectory, BuyInvoiceLogFile); var directory = Path.GetDirectoryName(path); if (!Directory.Exists(directory)) Directory.CreateDirectory(directory); File.WriteAllText(path, JsonConvert.SerializeObject(data, Formatting.Indented)); } private BuyInvoiceLogEntry CreateBuyInvoiceLogEntry(IPlayer player, string invoiceID, bool isPaid, int amount, PurchaseType type, int retryCount) { return new BuyInvoiceLogEntry { TransactionId = GenerateTransactionId(), SteamID = GetPlayerId(player), InvoiceID = invoiceID, Status = isPaid ? TransactionStatus.COMPLETED : TransactionStatus.FAILED, IsPaid = isPaid, Timestamp = DateTime.UtcNow, CompletedTimestamp = DateTime.UtcNow, Amount = amount, CurrencyGiven = isPaid && type == PurchaseType.Currency, VipGranted = isPaid && type == PurchaseType.Vip, RetryCount = retryCount, PurchaseType = type == PurchaseType.Currency ? "Currency" : "VIP" }; } private void SendInvoiceToDiscord(IPlayer player, string invoice, int amountSats, string memo) { if (string.IsNullOrEmpty(config.DiscordWebhookUrl)) { PrintError("Discord webhook URL is not configured."); return; } string qrCodeUrl = $"https://api.qrserver.com/v1/create-qr-code/?data={Uri.EscapeDataString(invoice)}&size=200x200"; var webhookPayload = new { content = $"**{player.Name}**, please pay **{amountSats} sats** using the Lightning Network.", embeds = new[] { new { title = "Payment 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 = GetPlayerId(player), inline = true } } } } }; string jsonPayload = JsonConvert.SerializeObject(webhookPayload); MakeWebRequest(config.DiscordWebhookUrl, jsonPayload, (code, response) => { if (code != 204) { PrintError($"Failed to send invoice to Discord webhook: HTTP {code}"); } else { Puts($"Invoice sent to Discord for player {GetPlayerId(player)}."); } }, RequestMethod.POST, new Dictionary { { "Content-Type", "application/json" } }); } } }