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 Orangemart", "0.7.3")]
[Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments via LNbits with Fiat/BTC pricing 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";
public const string ExternalApi = "ExternalApi"; // NEW
}
private static class ConfigKeys
{
// Commands
public const string BuyCurrencyCommandName = "BuyCurrencyCommandName";
public const string SendCurrencyCommandName = "SendCurrencyCommandName";
public const string BankCommandName = "BankCommandName";
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 CurrencyPriceCurrency = "CurrencyPriceCurrency";
public const string SatsPerCurrencyUnit = "SatsPerCurrencyUnit";
// 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";
public const string AdminDiscordWebhookUrl = "AdminDiscordWebhookUrl";
// 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 VipPriceCurrency = "VipPriceCurrency";
public const string VipCommand = "VipCommand";
// ExternalApi
public const string AddressLookupApiUrl = "AddressLookupApiUrl";
public const string AddressLookupApiKey = "AddressLookupApiKey";
}
// Configuration variables
private int currencyItemID;
private string buyCurrencyCommandName;
private string sendCurrencyCommandName;
private string bankCommandName;
private string buyVipCommandName;
private double vipPrice;
private string vipPriceCurrency;
private string vipCommand;
private string currencyName;
private int satsPerCurrencyUnit;
private double pricePerCurrencyUnit;
private string currencyPriceCurrency;
private string discordChannelName;
private string adminDiscordWebhookUrl;
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>();
private string addressLookupApiUrl; // NEW
private string addressLookupApiKey; // 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 const string OfflineQueueFile = "Orangemart/offline_rewards.json";
private LNbitsConfig config;
private List<PendingInvoice> pendingInvoices = new List<PendingInvoice>();
private Dictionary<string, int> retryCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, int> offlineCurrencyQueue = new Dictionary<string, int>();
// WebSocket tracking
private Dictionary<string, WebSocketConnection> activeWebSockets = new Dictionary<string, WebSocketConnection>();
private readonly object webSocketLock = new object();
// BTC Price Cache
private double cachedBtcPriceUsd = 0;
private DateTime lastBtcPriceFetch = DateTime.MinValue;
// 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";
}
// External API Response Model
private class AddressLookupResponse
{
[JsonProperty("lightningAddress")]
public string LightningAddress { get; set; }
[JsonProperty("error")]
public string Error { get; set; }
}
// Price Fetching Models
private class MempoolPriceResponse
{
[JsonProperty("USD")]
public double USD { get; set; }
}
// 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("payment_hash")]
public string PaymentHash { get; set; }
[JsonProperty("preimage")]
public string Preimage { 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.");
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; }
}
private class InvoiceResponseWrapper
{
[JsonProperty("data")]
public InvoiceResponse Data { get; set; }
}
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; }
}
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 int SatsAmount { get; set; }
public string Memo { get; set; }
public DateTime CreatedAt { get; set; }
public PurchaseType Type { get; set; }
public string DiscordMessageId { 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;
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)
);
// External API Settings
addressLookupApiUrl = GetConfigValue(ConfigSections.ExternalApi, ConfigKeys.AddressLookupApiUrl, "https://theorangemart.com/api/server/resolve-address", ref configChanged);
addressLookupApiKey = GetConfigValue(ConfigSections.ExternalApi, ConfigKeys.AddressLookupApiKey, "your_super_secret_api_key_here", ref configChanged);
// 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.0, ref configChanged);
currencyPriceCurrency = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencyPriceCurrency, "SATS", ref configChanged);
currencySkinID = GetConfigValue(ConfigSections.CurrencySettings, ConfigKeys.CurrencySkinID, 0UL, ref configChanged);
// 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);
if (maxPurchaseAmount < 0) maxPurchaseAmount = 0;
if (maxSendAmount < 0) maxSendAmount = 0;
if (commandCooldownSeconds < 0) commandCooldownSeconds = 0;
if (maxPendingInvoicesPerPlayer < 0) maxPendingInvoicesPerPlayer = 0;
// Command Names
buyCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyCurrencyCommandName, "buyblood", ref configChanged);
sendCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.SendCurrencyCommandName, "sendblood", ref configChanged);
bankCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BankCommandName, "bank", ref configChanged);
buyVipCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyVipCommandName, "buyvip", ref configChanged);
// VIP Settings
vipPrice = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPrice, 1000.0, ref configChanged);
vipPriceCurrency = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPriceCurrency, "SATS", ref configChanged);
vipCommand = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipCommand, "oxide.usergroup add {player} vip", ref configChanged);
// Discord Settings
discordChannelName = GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordChannelName, "mart", ref configChanged);
adminDiscordWebhookUrl = GetConfigValue(ConfigSections.Discord, ConfigKeys.AdminDiscordWebhookUrl, "", ref configChanged);
// 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();
if (configChanged)
{
SaveConfig();
}
Puts($"Protection Settings: MaxPurchase={maxPurchaseAmount}, MaxSend={maxSendAmount}, Cooldown={commandCooldownSeconds}s, MaxPending={maxPendingInvoicesPerPlayer}");
Puts($"Pricing Denominations: VIP={vipPriceCurrency}, Currency={currencyPriceCurrency}");
}
catch (Exception ex)
{
PrintError($"Failed to load configuration: {ex.Message}");
}
}
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;
if (typeof(T) == typeof(List<string>))
{
if (value is IEnumerable<object> enumerable)
return (T)(object)enumerable.Select(item => item.ToString()).ToList();
return (T)(object)new List<string> { value.ToString() };
}
if (typeof(T) == typeof(ulong)) return (T)(object)Convert.ToUInt64(value);
if (typeof(T) == typeof(double)) return (T)(object)Convert.ToDouble(value);
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
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.BankCommandName] = "bank",
[ConfigKeys.SendCurrencyCommandName] = "sendblood"
};
Config[ConfigSections.ExternalApi] = new Dictionary<string, object>
{
[ConfigKeys.AddressLookupApiUrl] = "https://theorangemart.com/api/server/resolve-address",
[ConfigKeys.AddressLookupApiKey] = "your_super_secret_api_key_here"
};
Config[ConfigSections.CurrencySettings] = new Dictionary<string, object>
{
[ConfigKeys.CurrencyItemID] = 1776460938,
[ConfigKeys.CurrencyName] = "blood",
[ConfigKeys.CurrencySkinID] = 0UL,
[ConfigKeys.PricePerCurrencyUnit] = 1.0,
[ConfigKeys.CurrencyPriceCurrency] = "SATS",
[ConfigKeys.SatsPerCurrencyUnit] = 1,
[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",
[ConfigKeys.AdminDiscordWebhookUrl] = ""
};
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.0,
[ConfigKeys.VipPriceCurrency] = "SATS"
};
}
private void Init()
{
permission.RegisterPermission("orangemart.buycurrency", this);
permission.RegisterPermission("orangemart.sendcurrency", this);
permission.RegisterPermission("orangemart.buyvip", this);
permission.RegisterPermission("orangemart.admin", this);
}
private void OnServerInitialized()
{
if (config == null)
{
PrintError("Plugin configuration is not properly set up.");
return;
}
LoadOfflineQueue();
AddCovalenceCommand(buyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency");
AddCovalenceCommand(sendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency");
AddCovalenceCommand(bankCommandName, nameof(CmdBank), "orangemart.sendcurrency");
AddCovalenceCommand(buyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip");
AddCovalenceCommand("giveblood", nameof(CmdGiveBlood), "orangemart.admin");
RecoverInterruptedTransactions();
timer.Every(checkIntervalSeconds, CheckPendingInvoices);
timer.Every(300f, CleanupOldCooldowns);
Puts($"Orangemart initialized.WebSockets: {(useWebSockets ? "Enabled" : "Disabled")}");
}
private void Unload()
{
CleanupAllWebSockets();
SaveOfflineQueue();
pendingInvoices.Clear();
retryCounts.Clear();
lastCommandTime.Clear();
}
// --- Offline Queue Methods ---
private void LoadOfflineQueue()
{
var path = Path.Combine(Interface.Oxide.DataDirectory, OfflineQueueFile);
if (File.Exists(path))
{
offlineCurrencyQueue = JsonConvert.DeserializeObject<Dictionary<string, int>>(File.ReadAllText(path)) ?? new Dictionary<string, int>();
}
}
private void SaveOfflineQueue()
{
var path = Path.Combine(Interface.Oxide.DataDirectory, OfflineQueueFile);
if (!Directory.Exists(Path.GetDirectoryName(path))) Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, JsonConvert.SerializeObject(offlineCurrencyQueue, Formatting.Indented));
}
private void AddOfflineCurrency(string playerId, int amount)
{
if (offlineCurrencyQueue.ContainsKey(playerId))
offlineCurrencyQueue[playerId] += amount;
else
offlineCurrencyQueue[playerId] = amount;
SaveOfflineQueue();
}
private void OnPlayerSleepEnded(BasePlayer player)
{
if (player == null || !offlineCurrencyQueue.ContainsKey(player.UserIDString)) return;
int pendingAmount = offlineCurrencyQueue[player.UserIDString];
if (pendingAmount > 0)
{
var currencyItem = ItemManager.CreateByItemID(currencyItemID, pendingAmount);
if (currencyItem != null)
{
if (currencySkinID > 0) currencyItem.skin = currencySkinID;
player.GiveItem(currencyItem);
player.ChatMessage($"Welcome back! You received {pendingAmount} {currencyName} from pending Orangemart transactions.");
Puts($"Delivered {pendingAmount} offline {currencyName} to {player.displayName} ({player.UserIDString}).");
}
}
offlineCurrencyQueue.Remove(player.UserIDString);
SaveOfflineQueue();
}
// --- Pricing Methods ---
private void GetBtcPriceUsd(Action<double> callback)
{
if ((DateTime.UtcNow - lastBtcPriceFetch).TotalMinutes < 5 && cachedBtcPriceUsd > 0)
{
callback(cachedBtcPriceUsd);
return;
}
webrequest.Enqueue("https://mempool.space/api/v1/prices", null, (code, response) =>
{
if (code == 200 && !string.IsNullOrEmpty(response))
{
try
{
var data = JsonConvert.DeserializeObject<MempoolPriceResponse>(response);
if (data != null && data.USD > 0)
{
cachedBtcPriceUsd = data.USD;
lastBtcPriceFetch = DateTime.UtcNow;
callback(cachedBtcPriceUsd);
return;
}
}
catch (Exception ex)
{
PrintError($"Failed to parse BTC price from mempool.space: {ex.Message}");
}
}
if (cachedBtcPriceUsd > 0) callback(cachedBtcPriceUsd);
else callback(0);
}, this, RequestMethod.GET);
}
private void CalculateSatsAmount(double fiatOrSatsPrice, string currencyType, Action<int> callback)
{
if (currencyType.Equals("USD", StringComparison.OrdinalIgnoreCase))
{
GetBtcPriceUsd(btcPrice => {
if (btcPrice <= 0) {
callback(-1);
return;
}
int sats = (int)Math.Ceiling((fiatOrSatsPrice / btcPrice) * 100_000_000);
callback(sats);
});
}
else
{
callback((int)Math.Ceiling(fiatOrSatsPrice));
}
}
// Protection Methods
private bool IsOnCooldown(IPlayer player, string commandType)
{
if (commandCooldownSeconds <= 0) return false;
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;
}
private bool HasTooManyPendingInvoices(IPlayer player)
{
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;
}
private bool ValidateSendAmount(IPlayer player, int amount, out int safeSats)
{
safeSats = 0;
if (amount <= 0)
{
player.Reply(Lang("InvalidAmount", player.Id));
return false;
}
if (maxSendAmount > 0 && amount > maxSendAmount)
{
player.Reply(Lang("SendAmountTooLarge", player.Id, amount, maxSendAmount, currencyName));
return false;
}
long amountSatsLong = (long)amount * satsPerCurrencyUnit;
if (amountSatsLong > int.MaxValue)
{
player.Reply(Lang("AmountCausesOverflow", player.Id));
return false;
}
safeSats = (int)amountSatsLong;
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);
}
private void CleanupAllWebSockets()
{
lock (webSocketLock)
{
foreach (var kvp in activeWebSockets)
{
try
{
kvp.Value.CancellationTokenSource?.Cancel();
kvp.Value.WebSocket?.Dispose();
}
catch { }
}
activeWebSockets.Clear();
}
}
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["UsageSendCurrency"] = "Usage: /{0} <amount> [optional_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.",
["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} has expired.Please try again.",
["BlacklistedDomain"] = "The domain '{0}' is currently blacklisted.",
["NotWhitelistedDomain"] = "The domain '{0}' is not whitelisted.Allowed: {1}.",
["InvalidLightningAddress"] = "The Lightning Address provided is invalid.",
["PaymentProcessing"] = "Your payment is being processed...",
["TransactionInitiated"] = "Transaction initiated.Processing your payment...",
["InvalidAmount"] = "Invalid amount.Please enter a positive number.",
["AmountTooLarge"] = "Amount {0} exceeds maximum limit of {1} {2}.",
["SendAmountTooLarge"] = "Send amount {0} exceeds maximum limit of {1} {2}.",
["AmountCausesOverflow"] = "Amount too large.Please use a smaller amount.",
["CommandOnCooldown"] = "Command '{0}' is on cooldown.Wait {1}s.",
["TooManyPendingInvoices"] = "You have {0} pending invoices (max: {1}).",
["VipPriceTooHigh"] = "VIP price is configured too high.",
["ProtectionLimits"] = "Orangemart Limits: Purchase max {0}, Send max {1}, Cooldown {2}s",
["FailedToFetchPrice"] = "Failed to fetch live exchange rate.Please try again.",
["ApiNotConfigured"] = "The server has not configured an automatic address lookup.Usage: /{0} <amount> <lightning_address>",
["FailedToResolveAddressApi"] = "Could not find your linked Lightning Address ({0}).Please link one at theorangemart.com or provide an address manually."
}, this);
}
private string Lang(string key, string userId = null, params object[] args)
{
return string.Format(lang.GetMessage(key, this, userId), args);
}
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) return;
if (invoice.Type == PurchaseType.SendBitcoin) 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
{
var wsUrl = $"{config.WebSocketUrl}/api/v1/ws/{invoice.RHash}";
await wsConnection.WebSocket.ConnectAsync(new Uri(wsUrl), wsConnection.CancellationTokenSource.Token);
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); }
}
}
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)
ProcessWebSocketMessage(connection, messageBuilder.ToString());
}
}
catch { }
finally
{
lock (webSocketLock)
{
if (activeWebSockets.ContainsKey(connection.InvoiceKey))
activeWebSockets.Remove(connection.InvoiceKey);
}
connection.WebSocket?.Dispose();
}
}
private void ProcessWebSocketMessage(WebSocketConnection connection, string message)
{
try
{
bool confirmed = false;
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();
if (!isPending && status == "success") confirmed = true;
}
}
catch { }
if (!confirmed)
{
try
{
var update = JsonConvert.DeserializeObject<WebSocketPaymentUpdate>(message);
if (update?.Payment != null)
{
if (!update.Payment.Pending && !string.IsNullOrEmpty(update.Payment.Preimage)) confirmed = true;
else if (!update.Payment.Pending && update.Payment.PaymentHash?.ToLower() == connection.InvoiceKey.ToLower()) confirmed = true;
}
}
catch { }
}
if (confirmed)
{
Interface.Oxide.NextTick(() => {
ProcessPaymentConfirmation(connection.Invoice);
});
connection.CancellationTokenSource?.Cancel();
}
}
catch (Exception ex)
{
PrintError($"Error processing WebSocket message: {ex.Message}");
}
}
private void ProcessPaymentConfirmation(PendingInvoice invoice)
{
if (!pendingInvoices.Contains(invoice)) return;
pendingInvoices.Remove(invoice);
string logMsg = $"Processing payment confirmation for {invoice.Player.Name} (Amount: ₿{invoice.SatsAmount}).Hash: {invoice.RHash}, Type: {invoice.Type}";
Puts($"[ProcessPayment] {logMsg}");
SendAdminNotification("Payment Confirmed", logMsg, 3066993);
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;
}
retryCounts.Remove(invoice.RHash);
lock (webSocketLock)
{
if (activeWebSockets.ContainsKey(invoice.RHash))
{
activeWebSockets[invoice.RHash].CancellationTokenSource?.Cancel();
activeWebSockets.Remove(invoice.RHash);
}
}
}
private void RecoverInterruptedTransactions()
{
Puts("Checking for interrupted transactions...");
var sellLogs = LoadSellLogData();
foreach (var log in sellLogs.Where(l => l.Status == TransactionStatus.INITIATED || l.Status == TransactionStatus.PROCESSING))
{
if (!string.IsNullOrEmpty(log.PaymentHash))
{
CheckInvoicePaid(log.PaymentHash, isPaid =>
{
if (isPaid) UpdateSellTransactionStatus(log.TransactionId, TransactionStatus.COMPLETED, true);
else UpdateSellTransactionStatus(log.TransactionId, TransactionStatus.FAILED, false, "Server interrupted");
});
}
else
{
UpdateSellTransactionStatus(log.TransactionId, TransactionStatus.FAILED, false, "Interrupted before init");
}
}
var buyLogs = LoadBuyLogData();
foreach (var log in buyLogs.Where(l => l.Status == TransactionStatus.INITIATED || l.Status == TransactionStatus.PROCESSING))
{
if (!string.IsNullOrEmpty(log.InvoiceID))
{
CheckInvoicePaid(log.InvoiceID, isPaid =>
{
if (isPaid) UpdateBuyTransactionStatus(log.TransactionId, TransactionStatus.COMPLETED, true);
else UpdateBuyTransactionStatus(log.TransactionId, TransactionStatus.EXPIRED, false);
});
}
}
}
private void CmdBuyCurrency(IPlayer player, string command, string[] args)
{
if (!player.HasPermission("orangemart.buycurrency")) { player.Reply(Lang("NoPermission", player.Id)); return; }
if (IsOnCooldown(player, "buy")) return;
if (HasTooManyPendingInvoices(player)) return;
if (args.Length != 1 || !int.TryParse(args[0], out int amount) || amount <= 0)
{
player.Reply(Lang("InvalidCommandUsage", player.Id, buyCurrencyCommandName));
return;
}
if (maxPurchaseAmount > 0 && amount > maxPurchaseAmount)
{
player.Reply(Lang("AmountTooLarge", player.Id, amount, maxPurchaseAmount, currencyName));
return;
}
player.Reply(Lang("PaymentProcessing", player.Id));
// Calculate total cost before converting
double totalCost = amount * pricePerCurrencyUnit;
CalculateSatsAmount(totalCost, currencyPriceCurrency, amountSats =>
{
if (amountSats <= 0)
{
player.Reply(Lang("FailedToFetchPrice", player.Id));
return;
}
string transactionId = GenerateTransactionId();
LogBuyInvoice(CreateBuyInvoiceLogEntry(player, null, false, amountSats, PurchaseType.Currency, 0));
string memo = $"[Orangemart] {player.Name} buying {amount} {currencyName} " +
(currencyPriceCurrency == "USD" ? $"(${totalCost:F2})" : "");
CreateInvoice(amountSats, memo, invoiceResponse =>
{
if (invoiceResponse != null)
{
UpdateBuyTransactionInvoiceId(transactionId, invoiceResponse.PaymentHash);
player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName));
var pendingInvoice = new PendingInvoice
{
TransactionId = transactionId,
RHash = invoiceResponse.PaymentHash.ToLower(),
Player = player,
Amount = amount,
SatsAmount = amountSats,
Memo = memo,
CreatedAt = DateTime.UtcNow,
Type = PurchaseType.Currency
};
pendingInvoices.Add(pendingInvoice);
SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, memo, pendingInvoice);
Task.Run(async () => await ConnectWebSocket(pendingInvoice));
ScheduleInvoiceExpiry(pendingInvoice);
}
else
{
player.Reply(Lang("FailedToCreateInvoice", player.Id));
UpdateBuyTransactionStatus(transactionId, TransactionStatus.FAILED, false);
}
});
});
}
private void FetchPlayerLightningAddress(string steamId, Action<string, string> callback)
{
if (string.IsNullOrEmpty(addressLookupApiUrl))
{
callback(null, "API not configured");
return;
}
string url = $"{addressLookupApiUrl}?steamId={steamId}";
var headers = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(addressLookupApiKey))
{
headers["Authorization"] = $"Bearer {addressLookupApiKey}";
}
MakeWebRequest(url, null, (code, response) =>
{
if (code == 200 && !string.IsNullOrEmpty(response))
{
try
{
var data = JsonConvert.DeserializeObject<AddressLookupResponse>(response);
if (data != null && !string.IsNullOrEmpty(data.LightningAddress))
{
callback(data.LightningAddress, null);
return;
}
if (data != null && !string.IsNullOrEmpty(data.Error))
{
callback(null, data.Error);
return;
}
}
catch { }
}
callback(null, "Server returned invalid response or player not found.");
// ... [Code preview truncated due to length. Full source in attached file] ...