orangemart.cs/Orangemart.cs
saulteafarmer eaddc06575 0.4.0
- Replaced legacy HTTP polling with real-time WebSocket monitoring for Lightning payments
- Improved transaction logging to follow each payment's lifecycle (INITIATED → COMPLETED/FAILED/EXPIRED) with no duplicate entries
- Fixed an issue where outbound payment hashes were not properly stored or matched, improving traceability
- Added configurable rate limits:
  • Max currency a player can buy or send
  • Max concurrent pending invoices per player
  • Command cooldowns per action (buy/send/vip)
- Updated VIP system to run a customizable command instead of hardcoding group assignment (compatible with plugins like Temporary Permissions)
- Improved inventory handling:
  • When purchasing blood with a full inventory, items are now dropped on the ground instead of being lost
2025-06-20 17:29:17 +00:00

2517 lines
103 KiB
C#

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<string> blacklistedDomains = new List<string>();
private List<string> whitelistedDomains = new List<string>();
// NEW: Protection and rate limiting variables
private int maxPurchaseAmount;
private int maxSendAmount;
private int commandCooldownSeconds;
private int maxPendingInvoicesPerPlayer;
private Dictionary<string, DateTime> lastCommandTime = new Dictionary<string, DateTime>();
private const string SellLogFile = "Orangemart/send_bitcoin.json";
private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json";
private LNbitsConfig config;
private List<PendingInvoice> pendingInvoices = new List<PendingInvoice>();
private Dictionary<string, int> retryCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
// WebSocket tracking
private Dictionary<string, WebSocketConnection> activeWebSockets = new Dictionary<string, WebSocketConnection>();
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<string, object> 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<string> { "example.com", "blacklisted.net" }, ref configChanged)
.Select(d => d.ToLower()).ToList();
whitelistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.WhitelistedDomains, new List<string>(), 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<string, object> invoiceSettings))
{
invoiceSettings = new Dictionary<string, object>();
Config[ConfigSections.InvoiceSettings] = invoiceSettings;
configChanged = true;
}
if (!invoiceSettings.ContainsKey(ConfigKeys.WhitelistedDomains))
{
invoiceSettings[ConfigKeys.WhitelistedDomains] = new List<string>();
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<string, object> vipSettings))
{
vipSettings = new Dictionary<string, object>();
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<string, object> currencySettings))
{
currencySettings = new Dictionary<string, object>();
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<T>(string section, string key, T defaultValue, ref bool configChanged)
{
if (!(Config[section] is Dictionary<string, object> data))
{
data = new Dictionary<string, object>();
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<string>))
{
if (value is IEnumerable<object> enumerable)
{
return (T)(object)enumerable.Select(item => item.ToString()).ToList();
}
else if (value is string singleString)
{
return (T)(object)new List<string> { 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<string, object>
{
[ConfigKeys.BuyCurrencyCommandName] = "buyblood",
[ConfigKeys.BuyVipCommandName] = "buyvip",
[ConfigKeys.SendCurrencyCommandName] = "sendblood"
};
Config[ConfigSections.CurrencySettings] = new Dictionary<string, object>
{
[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<string, object>
{
[ConfigKeys.DiscordChannelName] = "mart",
[ConfigKeys.DiscordWebhookUrl] = "https://discord.com/api/webhooks/your_webhook_url"
};
Config[ConfigSections.InvoiceSettings] = new Dictionary<string, object>
{
[ConfigKeys.BlacklistedDomains] = new List<string> { "example.com", "blacklisted.net" },
[ConfigKeys.WhitelistedDomains] = new List<string>(),
[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<string, object>
{
[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<string, string>
{
// Existing messages
["UsageSendCurrency"] = "Usage: /{0} <amount> <lightning_address>",
["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} <amount>",
["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<byte>(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<Dictionary<string, object>>(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<WebSocketPaymentUpdate>(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 <steamid>");
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 <steamid>");
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<Item> GetAllInventoryItems(BasePlayer player)
{
List<Item> allItems = new List<Item>();
// 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<bool> callback)
{
string normalizedPaymentHash = paymentHash.ToLower();
string url = $"{config.BaseUrl}/api/v1/payments/{normalizedPaymentHash}";
var headers = new Dictionary<string, string>
{
{ "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<PaymentStatusResponse>(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<bool, string> 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<bool, string> 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<string, string>
{
{ "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<InvoiceResponseWrapper>(response);
invoiceResponse = wrapper?.Data;
}
catch { }
// Fallback: try direct deserialization
if (invoiceResponse == null)
{
invoiceResponse = JsonConvert.DeserializeObject<InvoiceResponse>(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<InvoiceResponse> 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<string, string>
{
{ "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<InvoiceResponse>(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<int, string> callback, RequestMethod method = RequestMethod.GET, Dictionary<string, string> 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<string, string> { { "Content-Type", "application/json" } });
}
private void ResolveLightningAddress(string lightningAddress, int amountSats, Action<string> 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<string, string>
{
{ "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<LNURLResponse>(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<LNURLPayResponse>(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<object> 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<SellInvoiceLogEntry> LoadSellLogData()
{
var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile);
return File.Exists(path)
? JsonConvert.DeserializeObject<List<SellInvoiceLogEntry>>(File.ReadAllText(path))
: new List<SellInvoiceLogEntry>();
}
private void SaveSellLogData(List<SellInvoiceLogEntry> 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<BuyInvoiceLogEntry> LoadBuyLogData()
{
var path = Path.Combine(Interface.Oxide.DataDirectory, BuyInvoiceLogFile);
return File.Exists(path)
? JsonConvert.DeserializeObject<List<BuyInvoiceLogEntry>>(File.ReadAllText(path)) ?? new List<BuyInvoiceLogEntry>()
: new List<BuyInvoiceLogEntry>();
}
private void SaveBuyLogData(List<BuyInvoiceLogEntry> 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<string, string> { { "Content-Type", "application/json" } });
}
}
}