commit 91f9eb5746adbad5e8ada6afb2c3111f070eed56 Author: Patrick Ulrich Date: Wed Jan 29 10:46:00 2025 -0500 Initial Deployment diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d9ea602 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Patrick Ulrich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Orangemart.cs b/Orangemart.cs new file mode 100644 index 0000000..0182caa --- /dev/null +++ b/Orangemart.cs @@ -0,0 +1,1365 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Oxide.Core; +using Oxide.Core.Libraries.Covalence; +using Oxide.Core.Libraries; + +namespace Oxide.Plugins +{ + [Info("Orangemart", "saulteafarmer", "0.3.0")] + [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments via LNbits")] + public class Orangemart : CovalencePlugin + { + // Configuration sections and keys + private static class ConfigSections + { + public const string Commands = "Commands"; + public const string CurrencySettings = "CurrencySettings"; + public const string Discord = "Discord"; + public const string InvoiceSettings = "InvoiceSettings"; + public const string VIPSettings = "VIPSettings"; + } + + private static class ConfigKeys + { + // Commands + public const string BuyCurrencyCommandName = "BuyCurrencyCommandName"; + public const string SendCurrencyCommandName = "SendCurrencyCommandName"; + public const string BuyVipCommandName = "BuyVipCommandName"; + + // CurrencySettings + public const string CurrencyItemID = "CurrencyItemID"; + public const string CurrencyName = "CurrencyName"; + public const string CurrencySkinID = "CurrencySkinID"; + public const string PricePerCurrencyUnit = "PricePerCurrencyUnit"; + public const string SatsPerCurrencyUnit = "SatsPerCurrencyUnit"; + + // Discord + public const string DiscordChannelName = "DiscordChannelName"; + public const string DiscordWebhookUrl = "DiscordWebhookUrl"; + + // InvoiceSettings + public const string BlacklistedDomains = "BlacklistedDomains"; + public const string WhitelistedDomains = "WhitelistedDomains"; + public const string CheckIntervalSeconds = "CheckIntervalSeconds"; + public const string InvoiceTimeoutSeconds = "InvoiceTimeoutSeconds"; + public const string LNbitsApiKey = "LNbitsApiKey"; + public const string LNbitsBaseUrl = "LNbitsBaseUrl"; + public const string MaxRetries = "MaxRetries"; + + // VIPSettings + public const string VipPermissionGroup = "VipPermissionGroup"; + public const string VipPrice = "VipPrice"; + } + + // Configuration variables + private int currencyItemID; + private string buyCurrencyCommandName; + private string sendCurrencyCommandName; + private string buyVipCommandName; + private int vipPrice; + private string vipPermissionGroup; + private string currencyName; + private int satsPerCurrencyUnit; + private int pricePerCurrencyUnit; + private string discordChannelName; + private ulong currencySkinID; + private int checkIntervalSeconds; + private int invoiceTimeoutSeconds; + private int maxRetries; + private List blacklistedDomains = new List(); + private List whitelistedDomains = new List(); + private const string SellLogFile = "Orangemart/send_bitcoin.json"; + private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json"; + private LNbitsConfig config; + private List pendingInvoices = new List(); + private Dictionary retryCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // LNbits Configuration + private class LNbitsConfig + { + public string BaseUrl { get; set; } + public string ApiKey { get; set; } + public string DiscordWebhookUrl { 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."); + + return new LNbitsConfig + { + BaseUrl = trimmedBaseUrl, + ApiKey = apiKey, + DiscordWebhookUrl = discordWebhookUrl + }; + } + } + + // Invoice and Payment Classes + private class InvoiceResponse + { + [JsonProperty("payment_request")] + public string PaymentRequest { get; set; } + + [JsonProperty("payment_hash")] + public string PaymentHash { get; set; } + } + + private class SellInvoiceLogEntry + { + public string SteamID { get; set; } + public string LightningAddress { 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 int RetryCount { get; set; } + } + + private class BuyInvoiceLogEntry + { + public string SteamID { get; set; } + public string InvoiceID { get; set; } + public bool IsPaid { get; set; } + public DateTime Timestamp { get; set; } + public int Amount { get; set; } + public bool CurrencyGiven { get; set; } + public bool VipGranted { get; set; } + public int RetryCount { get; set; } + } + + private class PendingInvoice + { + 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); + + // Parse Command Names + buyCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyCurrencyCommandName, "buyblood", ref configChanged); + sendCurrencyCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.SendCurrencyCommandName, "sendblood", ref configChanged); + buyVipCommandName = GetConfigValue(ConfigSections.Commands, ConfigKeys.BuyVipCommandName, "buyvip", ref configChanged); + + // Parse VIP Settings + vipPrice = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPrice, 1000, ref configChanged); + vipPermissionGroup = GetConfigValue(ConfigSections.VIPSettings, ConfigKeys.VipPermissionGroup, "vip", ref configChanged); + + // Parse Discord Settings + discordChannelName = GetConfigValue(ConfigSections.Discord, ConfigKeys.DiscordChannelName, "mart", ref configChanged); + + // Parse Invoice Settings + checkIntervalSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.CheckIntervalSeconds, 10, ref configChanged); + invoiceTimeoutSeconds = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.InvoiceTimeoutSeconds, 300, ref configChanged); + maxRetries = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.MaxRetries, 25, ref configChanged); + + blacklistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.BlacklistedDomains, new List { "example.com", "blacklisted.net" }, ref configChanged) + .Select(d => d.ToLower()).ToList(); + + whitelistedDomains = GetConfigValue(ConfigSections.InvoiceSettings, ConfigKeys.WhitelistedDomains, new List(), ref configChanged) + .Select(d => d.ToLower()).ToList(); + + MigrateConfig(); + + if (configChanged) + { + SaveConfig(); + } + } + catch (Exception ex) + { + PrintError($"Failed to load configuration: {ex.Message}"); + } + } + + private void MigrateConfig() + { + bool configChanged = false; + + if (!(Config[ConfigSections.InvoiceSettings] is Dictionary invoiceSettings)) + { + invoiceSettings = new Dictionary(); + Config[ConfigSections.InvoiceSettings] = invoiceSettings; + configChanged = true; + } + + if (!invoiceSettings.ContainsKey(ConfigKeys.WhitelistedDomains)) + { + invoiceSettings[ConfigKeys.WhitelistedDomains] = new List(); + configChanged = true; + } + + if (configChanged) + { + SaveConfig(); + } + } + + private T GetConfigValue(string section, string key, T defaultValue, ref bool configChanged) + { + if (!(Config[section] is Dictionary data)) + { + data = new Dictionary(); + Config[section] = data; + configChanged = true; + } + + if (!data.TryGetValue(key, out var value)) + { + value = defaultValue; + data[key] = value; + configChanged = true; + } + + try + { + if (value is T tValue) + { + return tValue; + } + else if (typeof(T) == typeof(List)) + { + if (value is IEnumerable enumerable) + { + return (T)(object)enumerable.Select(item => item.ToString()).ToList(); + } + else if (value is string singleString) + { + return (T)(object)new List { singleString }; + } + else + { + PrintError($"Unexpected type for [{section}][{key}]. Using default value."); + data[key] = defaultValue; + configChanged = true; + return defaultValue; + } + } + else if (typeof(T) == typeof(ulong)) + { + if (value is long longVal) + { + return (T)(object)(ulong)longVal; + } + else if (value is ulong ulongVal) + { + return (T)(object)ulongVal; + } + else + { + return (T)Convert.ChangeType(value, typeof(T)); + } + } + else + { + return (T)Convert.ChangeType(value, typeof(T)); + } + } + catch (Exception ex) + { + PrintError($"Error converting config value for [{section}][{key}]: {ex.Message}. Using default value."); + data[key] = defaultValue; + configChanged = true; + return defaultValue; + } + } + + protected override void LoadDefaultConfig() + { + Config[ConfigSections.Commands] = new Dictionary + { + [ConfigKeys.BuyCurrencyCommandName] = "buyblood", + [ConfigKeys.BuyVipCommandName] = "buyvip", + [ConfigKeys.SendCurrencyCommandName] = "sendblood" + }; + + Config[ConfigSections.CurrencySettings] = new Dictionary + { + [ConfigKeys.CurrencyItemID] = 1776460938, + [ConfigKeys.CurrencyName] = "blood", + [ConfigKeys.CurrencySkinID] = 0UL, + [ConfigKeys.PricePerCurrencyUnit] = 1, + [ConfigKeys.SatsPerCurrencyUnit] = 1 + }; + + Config[ConfigSections.Discord] = new Dictionary + { + [ConfigKeys.DiscordChannelName] = "mart", + [ConfigKeys.DiscordWebhookUrl] = "https://discord.com/api/webhooks/your_webhook_url" + }; + + Config[ConfigSections.InvoiceSettings] = new Dictionary + { + [ConfigKeys.BlacklistedDomains] = new List { "example.com", "blacklisted.net" }, + [ConfigKeys.WhitelistedDomains] = new List(), + [ConfigKeys.CheckIntervalSeconds] = 10, + [ConfigKeys.InvoiceTimeoutSeconds] = 300, + [ConfigKeys.LNbitsApiKey] = "your-lnbits-admin-api-key", + [ConfigKeys.LNbitsBaseUrl] = "https://your-lnbits-instance.com", + [ConfigKeys.MaxRetries] = 25 + }; + + Config[ConfigSections.VIPSettings] = new Dictionary + { + [ConfigKeys.VipPermissionGroup] = "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"); + + // Start a timer to check pending invoices periodically + timer.Every(checkIntervalSeconds, CheckPendingInvoices); + } + + private void Unload() + { + pendingInvoices.Clear(); + retryCounts.Clear(); + } + + protected override void LoadDefaultMessages() + { + lang.RegisterMessages(new Dictionary + { + ["UsageSendCurrency"] = "Usage: /{0} ", + ["NeedMoreCurrency"] = "You need more {0}. You currently have {1}.", + ["FailedToReserveCurrency"] = "Failed to reserve currency. Please try again.", + ["FailedToQueryLightningAddress"] = "Failed to query Lightning address for an invoice.", + ["FailedToAuthenticate"] = "Failed to authenticate with LNbits.", + ["InvoiceCreatedCheckDiscord"] = "Invoice created! Please check the #{0} channel on Discord to complete your payment.", + ["FailedToCreateInvoice"] = "Failed to create an invoice. Please try again later.", + ["FailedToProcessPayment"] = "Failed to process payment. Please try again later.", + ["CurrencySentSuccess"] = "You have successfully sent {0} {1}!", + ["PurchaseSuccess"] = "You have successfully purchased {0} {1}!", + ["PurchaseVipSuccess"] = "You have successfully purchased VIP status!", + ["InvalidCommandUsage"] = "Usage: /{0} ", + ["NoPermission"] = "You do not have permission to use this command.", + ["FailedToFindBasePlayer"] = "Failed to find base player object for player {0}.", + ["FailedToCreateCurrencyItem"] = "Failed to create {0} item for player {1}.", + ["AddedToVipGroup"] = "Player {0} added to VIP group '{1}'.", + ["InvoiceExpired"] = "Your invoice for {0} sats has expired. Please try again.", + ["BlacklistedDomain"] = "The domain '{0}' is currently blacklisted. Please use a different Lightning address.", + ["NotWhitelistedDomain"] = "The domain '{0}' is not whitelisted. Please use a Lightning address from the following domains: {1}.", + ["InvalidLightningAddress"] = "The Lightning Address provided is invalid or cannot be resolved.", + ["PaymentProcessing"] = "Your payment is being processed. You will receive a confirmation once it's complete." + }, this); + } + + private string Lang(string key, string userId = null, params object[] args) + { + return string.Format(lang.GetMessage(key, this, userId), args); + } + + private List GetAllInventoryItems(BasePlayer player) + { + List allItems = new List(); + + // Main Inventory + if (player.inventory.containerMain != null) + allItems.AddRange(player.inventory.containerMain.itemList); + + // Belt (Hotbar) + if (player.inventory.containerBelt != null) + allItems.AddRange(player.inventory.containerBelt.itemList); + + // Wear (Clothing) + if (player.inventory.containerWear != null) + allItems.AddRange(player.inventory.containerWear.itemList); + + return allItems; + } + + private void CheckPendingInvoices() + { + foreach (var invoice in pendingInvoices.ToList()) + { + string localPaymentHash = invoice.RHash.ToLower(); + CheckInvoicePaid(localPaymentHash, isPaid => + { + if (isPaid) + { + pendingInvoices.Remove(invoice); + + switch (invoice.Type) + { + case PurchaseType.Currency: + RewardPlayer(invoice.Player, invoice.Amount); + break; + case PurchaseType.Vip: + GrantVip(invoice.Player); + break; + case PurchaseType.SendBitcoin: + invoice.Player.Reply(Lang("CurrencySentSuccess", invoice.Player.Id, invoice.Amount, currencyName)); + break; + } + + if (invoice.Type == PurchaseType.SendBitcoin) + { + var logEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(invoice.Player), + LightningAddress = ExtractLightningAddress(invoice.Memo), + Success = true, + SatsAmount = invoice.Amount, + PaymentHash = invoice.RHash, + CurrencyReturned = false, + Timestamp = DateTime.UtcNow, + RetryCount = retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 + }; + LogSellTransaction(logEntry); + + Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); + } + else + { + var logEntry = CreateBuyInvoiceLogEntry( + player: invoice.Player, + invoiceID: invoice.RHash, + isPaid: true, + amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, + type: invoice.Type, + retryCount: retryCounts.ContainsKey(invoice.RHash) ? retryCounts[invoice.RHash] : 0 + ); + LogBuyInvoice(logEntry); + Puts($"Invoice {invoice.RHash} marked as paid. RetryCount: {logEntry.RetryCount}"); + } + + retryCounts.Remove(invoice.RHash); + } + else + { + if (!retryCounts.ContainsKey(localPaymentHash)) + { + retryCounts[localPaymentHash] = 0; + Puts($"Initialized retry count for paymentHash: {localPaymentHash}"); + } + + retryCounts[localPaymentHash]++; + Puts($"Retry count for paymentHash {localPaymentHash}: {retryCounts[localPaymentHash]} of {maxRetries}"); + + if (retryCounts[localPaymentHash] >= maxRetries) + { + pendingInvoices.Remove(invoice); + int finalRetryCount = retryCounts[localPaymentHash]; + retryCounts.Remove(localPaymentHash); + PrintWarning($"Invoice for player {GetPlayerId(invoice.Player)} expired (amount: {invoice.Amount} sats)."); + + invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); + + if (invoice.Type == PurchaseType.SendBitcoin) + { + var basePlayer = invoice.Player.Object as BasePlayer; + if (basePlayer != null) + { + ReturnCurrency(basePlayer, invoice.Amount / satsPerCurrencyUnit); + Puts($"Refunded {invoice.Amount / satsPerCurrencyUnit} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + else + { + PrintError($"Failed to find base player object for player {invoice.Player.Id} to refund currency."); + } + + var failedLogEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(invoice.Player), + LightningAddress = ExtractLightningAddress(invoice.Memo), + Success = false, + SatsAmount = invoice.Amount, + PaymentHash = invoice.RHash, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = finalRetryCount + }; + LogSellTransaction(failedLogEntry); + Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); + } + else + { + var failedLogEntry = CreateBuyInvoiceLogEntry( + player: invoice.Player, + invoiceID: localPaymentHash, + isPaid: false, + amount: invoice.Type == PurchaseType.SendBitcoin ? invoice.Amount : invoice.Amount * pricePerCurrencyUnit, + type: invoice.Type, + retryCount: finalRetryCount + ); + LogBuyInvoice(failedLogEntry); + Puts($"Invoice {localPaymentHash} expired after {finalRetryCount} retries."); + } + } + else + { + PrintWarning($"Retrying invoice {localPaymentHash}. Attempt {retryCounts[localPaymentHash]} of {maxRetries}."); + } + } + }); + } + } + + private void CheckInvoicePaid(string paymentHash, Action callback) + { + string normalizedPaymentHash = paymentHash.ToLower(); + string url = $"{config.BaseUrl}/api/v1/payments/{normalizedPaymentHash}"; + + var headers = new Dictionary + { + { "Content-Type", "application/json" }, + { "X-Api-Key", config.ApiKey } + }; + + MakeWebRequest(url, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error checking invoice status: HTTP {code}"); + callback(false); + return; + } + + try + { + var paymentStatus = JsonConvert.DeserializeObject(response); + callback(paymentStatus != null && paymentStatus.Paid); + } + catch (Exception ex) + { + PrintError($"Failed to parse invoice status response: {ex.Message}"); + callback(false); + } + }, RequestMethod.GET, headers); + } + + private void CmdSendCurrency(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.sendcurrency")) + { + player.Reply(Lang("NoPermission", player.Id)); + return; + } + + if (args.Length != 2 || !int.TryParse(args[0], out int amount) || amount <= 0) + { + player.Reply(Lang("UsageSendCurrency", player.Id, sendCurrencyCommandName)); + 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; + } + + player.Reply(Lang("PaymentProcessing", player.Id)); + + SendBitcoin(lightningAddress, amount * satsPerCurrencyUnit, (success, paymentHash) => + { + if (success && !string.IsNullOrEmpty(paymentHash)) + { + LogSellTransaction( + new SellInvoiceLogEntry + { + SteamID = GetPlayerId(player), + LightningAddress = lightningAddress, + Success = true, + SatsAmount = amount * satsPerCurrencyUnit, + PaymentHash = paymentHash, + CurrencyReturned = false, + Timestamp = DateTime.UtcNow, + RetryCount = 0 + } + ); + + var pendingInvoice = new PendingInvoice + { + RHash = paymentHash.ToLower(), + Player = player, + Amount = amount * satsPerCurrencyUnit, + Memo = $"Sending {amount} {currencyName} to {lightningAddress}", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.SendBitcoin + }; + pendingInvoices.Add(pendingInvoice); + + Puts($"Outbound payment to {lightningAddress} initiated. PaymentHash: {paymentHash}"); + } + else + { + player.Reply(Lang("FailedToProcessPayment", player.Id)); + + LogSellTransaction( + new SellInvoiceLogEntry + { + SteamID = GetPlayerId(player), + LightningAddress = lightningAddress, + Success = false, + SatsAmount = amount * satsPerCurrencyUnit, + PaymentHash = null, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = 0 + } + ); + + Puts($"Outbound payment to {lightningAddress} failed to initiate."); + + ReturnCurrency(basePlayer, amount); + Puts($"Returned {amount} {currencyName} to player {basePlayer.UserIDString} due to failed payment."); + } + }); + } + + private bool IsLightningAddressAllowed(string lightningAddress) + { + string domain = GetDomainFromLightningAddress(lightningAddress); + if (string.IsNullOrEmpty(domain)) + return false; + + if (whitelistedDomains.Any()) + { + return whitelistedDomains.Contains(domain.ToLower()); + } + else + { + return !blacklistedDomains.Contains(domain.ToLower()); + } + } + + private string GetDomainFromLightningAddress(string lightningAddress) + { + if (string.IsNullOrEmpty(lightningAddress)) + return null; + + var parts = lightningAddress.Split('@'); + return parts.Length == 2 ? parts[1].ToLower() : null; + } + + private void SendBitcoin(string lightningAddress, int satsAmount, Action callback) + { + ResolveLightningAddress(lightningAddress, satsAmount, bolt11 => + { + if (string.IsNullOrEmpty(bolt11)) + { + PrintError($"Failed to resolve Lightning Address: {lightningAddress}"); + callback(false, null); + return; + } + + SendPayment(bolt11, satsAmount, (success, paymentHash) => + { + if (success && !string.IsNullOrEmpty(paymentHash)) + { + StartPaymentStatusCheck(paymentHash, isPaid => + { + callback(isPaid, isPaid ? paymentHash : null); + }); + } + else + { + callback(false, null); + } + }); + }); + } + + private void StartPaymentStatusCheck(string paymentHash, Action callback) + { + if (!retryCounts.ContainsKey(paymentHash)) + { + retryCounts[paymentHash] = 0; + } + + Timer timerInstance = null; + timerInstance = timer.Repeat(checkIntervalSeconds, maxRetries, () => + { + CheckInvoicePaid(paymentHash, isPaid => + { + if (isPaid) + { + callback(true); + timerInstance.Destroy(); + } + else + { + retryCounts[paymentHash]++; + Puts($"PaymentHash {paymentHash} not yet paid. Retry {retryCounts[paymentHash]} of {maxRetries}."); + + if (retryCounts[paymentHash] >= maxRetries) + { + callback(false); + timerInstance.Destroy(); + } + } + }); + }); + } + + private void CmdBuyCurrency(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.buycurrency")) + { + player.Reply(Lang("NoPermission", player.Id)); + return; + } + + if (args.Length != 1 || !int.TryParse(args[0], out int amount) || amount <= 0) + { + player.Reply(Lang("InvalidCommandUsage", player.Id, buyCurrencyCommandName)); + return; + } + + int amountSats = amount * pricePerCurrencyUnit; + + CreateInvoice(amountSats, $"Buying {amount} {currencyName}", invoiceResponse => + { + if (invoiceResponse != null) + { + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {currencyName}"); + + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); + + var pendingInvoice = new PendingInvoice + { + RHash = invoiceResponse.PaymentHash.ToLower(), + Player = player, + Amount = amount, + Memo = $"Buying {amount} {currencyName}", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.Currency + }; + pendingInvoices.Add(pendingInvoice); + + ScheduleInvoiceExpiry(pendingInvoice); + } + else + { + player.Reply(Lang("FailedToCreateInvoice", player.Id)); + } + }); + } + + private void CmdBuyVip(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.buyvip")) + { + player.Reply(Lang("NoPermission", player.Id)); + return; + } + + int amountSats = vipPrice; + + CreateInvoice(amountSats, "Buying VIP Status", invoiceResponse => + { + if (invoiceResponse != null) + { + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, "Buying VIP Status"); + + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, discordChannelName)); + + var pendingInvoice = new PendingInvoice + { + RHash = invoiceResponse.PaymentHash.ToLower(), + Player = player, + Amount = amountSats, + Memo = "Buying VIP Status", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.Vip + }; + pendingInvoices.Add(pendingInvoice); + + ScheduleInvoiceExpiry(pendingInvoice); + } + else + { + player.Reply(Lang("FailedToCreateInvoice", player.Id)); + } + }); + } + + 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)."); + + 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."); + } + + var logEntry = new SellInvoiceLogEntry + { + SteamID = GetPlayerId(pendingInvoice.Player), + LightningAddress = ExtractLightningAddress(pendingInvoice.Memo), + Success = false, + SatsAmount = pendingInvoice.Amount, + PaymentHash = pendingInvoice.RHash, + CurrencyReturned = true, + Timestamp = DateTime.UtcNow, + RetryCount = finalRetryCount + }; + LogSellTransaction(logEntry); + Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); + } + else + { + var logEntry = CreateBuyInvoiceLogEntry( + player: pendingInvoice.Player, + invoiceID: pendingInvoice.RHash, + isPaid: false, + amount: pendingInvoice.Type == PurchaseType.SendBitcoin ? pendingInvoice.Amount : pendingInvoice.Amount * pricePerCurrencyUnit, + type: pendingInvoice.Type, + retryCount: finalRetryCount + ); + LogBuyInvoice(logEntry); + Puts($"Invoice {pendingInvoice.RHash} for player {GetPlayerId(pendingInvoice.Player)} expired and logged."); + } + } + }); + } + + private void SendPayment(string bolt11, int satsAmount, Action callback) + { + string url = $"{config.BaseUrl}/api/v1/payments"; + var requestBody = new + { + @out = true, + bolt11 = bolt11, + amount = satsAmount + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "X-Api-Key", config.ApiKey }, + { "Content-Type", "application/json" } + }; + + MakeWebRequest(url, jsonBody, (code, response) => + { + if (code != 200 && code != 201) + { + PrintError($"Error processing payment: HTTP {code}"); + callback(false, null); + return; + } + + try + { + var paymentResponse = JsonConvert.DeserializeObject>(response); + string paymentHash = paymentResponse.ContainsKey("payment_hash") ? paymentResponse["payment_hash"].ToString() : null; + + if (!string.IsNullOrEmpty(paymentHash)) + { + callback(true, paymentHash); + } + else + { + PrintError("Payment hash (rhash) is missing or invalid in the response."); + callback(false, null); + } + } + catch (Exception ex) + { + PrintError($"Exception occurred while parsing payment response: {ex.Message}"); + callback(false, null); + } + }, RequestMethod.POST, headers); + } + + private void SendInvoiceToDiscord(IPlayer player, string invoice, int amountSats, string memo) + { + if (string.IsNullOrEmpty(config.DiscordWebhookUrl)) + { + PrintError("Discord webhook URL is not configured."); + return; + } + + string qrCodeUrl = $"https://api.qrserver.com/v1/create-qr-code/?data={Uri.EscapeDataString(invoice)}&size=200x200"; + + var webhookPayload = new + { + content = $"**{player.Name}**, please pay **{amountSats} sats** using the Lightning Network.", + embeds = new[] + { + new + { + title = "Payment Invoice", + description = $"{memo}\n\nPlease pay the following Lightning invoice to complete your purchase:\n\n```\n{invoice}\n```", + image = new + { + url = qrCodeUrl + }, + fields = new[] + { + new { name = "Amount", value = $"{amountSats} sats", inline = true }, + new { name = "Steam ID", value = GetPlayerId(player), inline = true } + } + } + } + }; + + string jsonPayload = JsonConvert.SerializeObject(webhookPayload); + + MakeWebRequest(config.DiscordWebhookUrl, jsonPayload, (code, response) => + { + if (code != 204) + { + PrintError($"Failed to send invoice to Discord webhook: HTTP {code}"); + } + else + { + Puts($"Invoice sent to Discord for player {GetPlayerId(player)}."); + } + }, RequestMethod.POST, new Dictionary { { "Content-Type", "application/json" } }); + } + + private void RewardPlayer(IPlayer player, int amount) + { + player.Reply($"You have successfully purchased {amount} {currencyName}!"); + + var basePlayer = player.Object as BasePlayer; + + if (basePlayer != null) + { + var currencyItem = ItemManager.CreateByItemID(currencyItemID, amount); + if (currencyItem != null) + { + if (currencySkinID > 0) + { + currencyItem.skin = currencySkinID; + } + + basePlayer.GiveItem(currencyItem); + Puts($"Gave {amount} {currencyName} (skinID: {currencySkinID}) to player {basePlayer.UserIDString}."); + } + else + { + PrintError($"Failed to create {currencyName} item for player {basePlayer.UserIDString}."); + } + } + else + { + PrintError($"Failed to find base player object for player {player.Id}."); + } + } + + private void GrantVip(IPlayer player) + { + player.Reply("You have successfully purchased VIP status!"); + + permission.AddUserGroup(player.Id, vipPermissionGroup); + + Puts($"Player {GetPlayerId(player)} added to VIP group '{vipPermissionGroup}'."); + } + + 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; + } + + private void ReturnCurrency(BasePlayer player, int amount) + { + var returnedCurrency = ItemManager.CreateByItemID(currencyItemID, amount); + if (returnedCurrency != null) + { + if (currencySkinID > 0) + { + returnedCurrency.skin = currencySkinID; + } + returnedCurrency.MoveToContainer(player.inventory.containerMain); + } + else + { + PrintError($"Failed to create {currencyName} item to return to player {player.UserIDString}."); + } + } + + private bool IsCurrencyItem(Item item) + { + return item.info.itemid == currencyItemID && (currencySkinID == 0 || item.skin == currencySkinID); + } + + private void LogSellTransaction(SellInvoiceLogEntry logEntry) + { + var logs = LoadSellLogData(); + logs.Add(logEntry); + SaveSellLogData(logs); + Puts($"[Orangemart] Logged sell transaction: {JsonConvert.SerializeObject(logEntry)}"); + } + + private List LoadSellLogData() + { + var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); + return File.Exists(path) + ? JsonConvert.DeserializeObject>(File.ReadAllText(path)) + : new List(); + } + + private void SaveSellLogData(List data) + { + var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile); + var directory = Path.GetDirectoryName(path); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, JsonConvert.SerializeObject(data, Formatting.Indented)); + } + + private void LogBuyInvoice(BuyInvoiceLogEntry logEntry) + { + var logPath = Path.Combine(Interface.Oxide.DataDirectory, BuyInvoiceLogFile); + var directory = Path.GetDirectoryName(logPath); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + List invoiceLogs = File.Exists(logPath) + ? JsonConvert.DeserializeObject>(File.ReadAllText(logPath)) ?? new List() + : new List(); + + invoiceLogs.Add(logEntry); + File.WriteAllText(logPath, JsonConvert.SerializeObject(invoiceLogs, Formatting.Indented)); + Puts($"[Orangemart] Logged buy invoice: {JsonConvert.SerializeObject(logEntry)}"); + } + + private BuyInvoiceLogEntry CreateBuyInvoiceLogEntry(IPlayer player, string invoiceID, bool isPaid, int amount, PurchaseType type, int retryCount) + { + return new BuyInvoiceLogEntry + { + SteamID = GetPlayerId(player), + InvoiceID = invoiceID, + IsPaid = isPaid, + Timestamp = DateTime.UtcNow, + Amount = type == PurchaseType.SendBitcoin ? amount : amount * pricePerCurrencyUnit, + CurrencyGiven = isPaid && type == PurchaseType.Currency, + VipGranted = isPaid && type == PurchaseType.Vip, + RetryCount = retryCount + }; + } + + private void CreateInvoice(int amountSats, string memo, Action callback) + { + string url = $"{config.BaseUrl}/api/v1/payments"; + + var requestBody = new + { + @out = false, + amount = amountSats, + memo = memo + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "X-Api-Key", config.ApiKey }, + { "Content-Type", "application/json" } + }; + + MakeWebRequest(url, jsonBody, (code, response) => + { + if (code != 200 && code != 201) + { + PrintError($"Error creating invoice: HTTP {code}"); + callback(null); + return; + } + + try + { + var invoiceResponse = JsonConvert.DeserializeObject(response); + callback(invoiceResponse != null && !string.IsNullOrEmpty(invoiceResponse.PaymentHash) ? invoiceResponse : null); + } + catch (Exception ex) + { + PrintError($"Failed to deserialize invoice response: {ex.Message}"); + callback(null); + } + }, RequestMethod.POST, headers); + } + + private string GetPlayerId(IPlayer player) + { + var basePlayer = player.Object as BasePlayer; + return basePlayer != null ? basePlayer.UserIDString : player.Id; + } + + private void MakeWebRequest(string url, string jsonData, Action callback, RequestMethod method = RequestMethod.GET, Dictionary headers = null) + { + webrequest.Enqueue(url, jsonData, (code, response) => + { + if (string.IsNullOrEmpty(response) && (code < 200 || code >= 300)) + { + PrintError($"Web request to {url} returned empty response or HTTP {code}"); + callback(code, null); + } + else + { + callback(code, response); + } + }, this, method, headers ?? new Dictionary { { "Content-Type", "application/json" } }); + } + + private void ResolveLightningAddress(string lightningAddress, int amountSats, Action callback) + { + var parts = lightningAddress.Split('@'); + if (parts.Length != 2) + { + PrintError($"Invalid Lightning Address format: {lightningAddress}"); + callback(null); + return; + } + + string user = parts[0]; + string domain = parts[1]; + + string lnurlEndpoint = $"https://{domain}/.well-known/lnurlp/{user}"; + + var headers = new Dictionary + { + { "Content-Type", "application/json" } + }; + + MakeWebRequest(lnurlEndpoint, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Failed to fetch LNURL for {lightningAddress}: HTTP {code}"); + callback(null); + return; + } + + try + { + var lnurlResponse = JsonConvert.DeserializeObject(response); + if (lnurlResponse == null || string.IsNullOrEmpty(lnurlResponse.Callback)) + { + PrintError($"Invalid LNURL response for {lightningAddress}"); + callback(null); + return; + } + + long amountMsat = (long)amountSats * 1000; + + string callbackUrl = lnurlResponse.Callback; + + string callbackUrlWithAmount = $"{callbackUrl}?amount={amountMsat}"; + + MakeWebRequest(callbackUrlWithAmount, null, (payCode, payResponse) => + { + if (payCode != 200 || string.IsNullOrEmpty(payResponse)) + { + PrintError($"Failed to perform LNURL Pay for {lightningAddress}: HTTP {payCode}"); + callback(null); + return; + } + + try + { + var payAction = JsonConvert.DeserializeObject(payResponse); + if (payAction == null || string.IsNullOrEmpty(payAction.Pr)) + { + PrintError($"Invalid LNURL Pay response for {lightningAddress}"); + callback(null); + return; + } + + callback(payAction.Pr); + } + catch (Exception ex) + { + PrintError($"Error parsing LNURL Pay response: {ex.Message}"); + callback(null); + } + }, RequestMethod.GET, headers); + } + catch (Exception ex) + { + PrintError($"Error parsing LNURL response: {ex.Message}"); + callback(null); + } + }, RequestMethod.GET, headers); + } + + private class LNURLResponse + { + [JsonProperty("tag")] + public string Tag { get; set; } + + [JsonProperty("callback")] + public string Callback { get; set; } + + [JsonProperty("minSendable")] + public long MinSendable { get; set; } + + [JsonProperty("maxSendable")] + public long MaxSendable { get; set; } + + [JsonProperty("metadata")] + public string Metadata { get; set; } + + [JsonProperty("commentAllowed")] + public int CommentAllowed { get; set; } + + [JsonProperty("allowsNostr")] + public bool AllowsNostr { get; set; } + + [JsonProperty("nostrPubkey")] + public string NostrPubkey { get; set; } + } + + private class LNURLPayResponse + { + [JsonProperty("pr")] + public string Pr { get; set; } + + [JsonProperty("routes")] + public List Routes { get; set; } + } + + private string ExtractLightningAddress(string memo) + { + // Extract the Lightning Address from the memo + // Expected format: "Sending {amount} {currency} to {lightning_address}" + var parts = memo.Split(" to "); + return parts.Length == 2 ? parts[1] : "unknown@unknown.com"; + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bbb31e --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +## Overview: +The **Orangemart** plugin allows players on your Rust server to buy and sell in-game units and VIP status using Bitcoin payments through the Lightning Network. This plugin integrates LNBits into the game, enabling secure transactions for game items and services. + +--- + +## Features + +- **In-Game Currency Purchase:** Players can purchase in-game currency using Bitcoin payments. +- **Send In-Game Currency:** Players can send currency to others, facilitating peer-to-peer transactions. +- **VIP Status Purchase:** Players can purchase VIP status through Bitcoin payments, unlocking special privileges. +- **Configurable:** Server admins can set up command names, currency items, prices, and more through the configuration file. + +--- + +## Commands + +The following commands are available to players: + +- **`/buyblood`** + Players can purchase in-game currency using Bitcoin. The amount purchased is configurable. + +- **`/sendblood `** + Players can send a specified amount of in-game currency to another player. + +- **`/buyvip`** + Players can purchase VIP status using Bitcoin. The VIP price and associated permission group are configurable. + +--- + +# Orangemart Plugin Configuration + +Below is a comprehensive list of key configuration variables that can be customized in the **Orangemart** plugin. +These settings allow you to tailor the plugin's behavior to fit your server's requirements. + +## Configuration + +#### Commands + +- **`BuyCurrencyCommandName`** + - The command players use to purchase in-game currency. For example, `/buyblood`. + +- **`SendCurrencyCommandName`** + - The command players use to send in-game currency to other players. For example, `/sendblood`. + +- **`BuyVipCommandName`** + - The command players use to purchase VIP status. For example, `/buyvip`. + +#### Currency Settings + +- **`CurrencyItemID`** + - The item ID used for in-game currency transactions. + +- **`CurrencyName`** + - The name of the in-game currency displayed to players. + +- **`CurrencySkinID`** + - The skin ID applied to the in-game currency items. Set to `0` for default skin. + +- **`SatsPerCurrencyUnit`** + - The conversion rate between satoshis and in-game currency units. Determines how many satoshis equal one unit of in-game currency. + +- **`PricePerCurrencyUnit`** + - The price (in satoshis) per unit of in-game currency. + +#### Discord Integration + +- **`DiscordChannelName`** + - The name of the Discord channel where payment invoices will be posted. + +- **`DiscordWebhookUrl`** + - The Discord webhook URL used to send invoice notifications to the specified channel. + +#### Invoice Settings + +- **`BlacklistedDomains`** + - A list of domains that are disallowed for use in Lightning addresses. Players cannot send currency to addresses from these domains. + +- **`WhitelistedDomains`** + - A list of domains that are exclusively allowed for use in Lightning addresses. If this list is populated, only addresses from these domains are permitted. + +- **`CheckIntervalSeconds`** + - The interval time (in seconds) for checking pending Bitcoin transactions. + +- **`InvoiceTimeoutSeconds`** + - The time (in seconds) after which an unpaid invoice expires. + +- **`LNbitsApiKey`** + - The API key for authenticating with your LNbits instance. + +- **`LNbitsBaseUrl`** + - The base URL of your LNbits instance. + +- **`MaxRetries`** + - The maximum number of retry attempts for checking invoice payments before considering them expired. + +#### VIP Settings + +Configure the VIP status purchase and permissions. + +- **`VipPermissionGroup`** + - The Oxide permission group that players are added to upon purchasing VIP status. + +- **`VipPrice`** + - The price (in satoshis) for players to purchase VIP status. + +--- + +### Example Configuration Snippet + +Here's how the configuration might look in your `config.json`: + +``` +{ + "Commands": { + "BuyCurrencyCommandName": "buyblood", + "SendCurrencyCommandName": "sendblood", + "BuyVipCommandName": "buyvip" + }, + "CurrencySettings": { + "CurrencyItemID": 1776460938, + "CurrencyName": "blood", + "CurrencySkinID": 0, + "PricePerCurrencyUnit": 1, + "SatsPerCurrencyUnit": 1 + }, + "Discord": { + "DiscordChannelName": "mart", + "DiscordWebhookUrl": "https://discord.com/api/webhooks/your_webhook_url" + }, + "InvoiceSettings": { + "BlacklistedDomains": ["example.com", "blacklisted.net"], + "WhitelistedDomains": [], + "CheckIntervalSeconds": 10, + "InvoiceTimeoutSeconds": 300, + "LNbitsApiKey": "your-lnbits-admin-api-key", + "LNbitsBaseUrl": "https://your-lnbits-instance.com", + "MaxRetries": 25 + }, + "VIPSettings": { + "VipPermissionGroup": "vip", + "VipPrice": 1000 + } +} +``` + +**Note:** +- Ensure that all URLs and API keys are correctly set to match your server and LNbits configurations. +- Adjust the `BlacklistedDomains` and `WhitelistedDomains` according to your server's policies regarding Lightning addresses. + +--- + +## Installation + +1. **Download the Plugin** + Place the `Orangemart.cs` file in your server's `oxide/plugins` folder. + +2. **Configuration** + Modify the plugin’s configuration file to fit your server’s settings (currency item, prices, VIP group, etc.). The configuration file will be automatically generated upon running the plugin for the first time. + +3. **Create VIP Group (Optional)** + Create a VIP group to assign permssions to. + +4. **Reload the Plugin** + Once configured, reload the plugin using the command: + ``` + oxide.reload Orangemart + ``` + +--- + +## Permissions + +The plugin uses the following permissions: + +- **`orangemart.buycurrency`** + Grants permission to players who are allowed to buy your currency item via Bitcoin. + +- **`orangemart.sendcurrency`** + Grants permission to players who are allowed to send Bitcoin for your in-game currency unit. + +- **`orangemart.buyvip`** + Grants permission to players to purchase VIP via Bitcoin. + +--- + +## Logging and Troubleshooting + +- **Logs:** + Transaction details, such as purchases and currency sends, are logged for auditing purposes. Logs can be found in the `oxide/data/Orangemart` directory. + +- **Troubleshooting:** + If any issues arise, check the server logs for errors related to the plugin. Ensure that the configuration file is correctly set up and that Bitcoin payment services are running as expected. + +--- + +## License + +This plugin is licensed under the MIT License.