From 7300ce474b1d991dee5de2c8d4a0d39a29b826e1 Mon Sep 17 00:00:00 2001 From: Patrick Ulrich Date: Tue, 24 Sep 2024 14:45:25 -0400 Subject: [PATCH] Initial Deployment --- LICENSE.txt | 21 + Orangemart.cs | 1125 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 112 +++++ 3 files changed, 1258 insertions(+) create mode 100644 LICENSE.txt create mode 100644 Orangemart.cs create mode 100644 README.md 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..ee199e5 --- /dev/null +++ b/Orangemart.cs @@ -0,0 +1,1125 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.IO; +using Newtonsoft.Json; +using Oxide.Core; +using Oxide.Core.Plugins; +using Oxide.Core.Libraries; +using Oxide.Core.Libraries.Covalence; + +namespace Oxide.Plugins +{ + [Info("Orangemart", "saulteafarmer", "0.1.0")] + [Description("Allows players to buy and sell in-game units and VIP status using Bitcoin Lightning Network payments")] + public class Orangemart : CovalencePlugin + { + // Configuration variables + private int CurrencyItemID; + private string BuyCurrencyCommandName; + private string SendCurrencyCommandName; + private string BuyVipCommandName; + private int VipPrice; + private string VipPermissionGroup; + private string CurrencyName; + private int SatsPerCurrencyUnit; + private int PricePerCurrencyUnit; + + // Transaction timing settings (moved to config) + private int CheckIntervalSeconds; + private int InvoiceTimeoutSeconds; + private int RetryDelaySeconds; + private int MaxRetries; + + // File names + private const string SellLogFile = "Orangemart/sell_log.json"; + private const string BuyInvoiceLogFile = "Orangemart/buy_invoices.json"; + + // LNDHub configuration + private LNDHubConfig config; + private string authToken; + + private List pendingInvoices = new List(); + private Dictionary retryCounts = new Dictionary(); + + private class LNDHubConfig + { + public string Username { get; set; } + public string Password { get; set; } + public string BaseUrl { get; set; } + public string DiscordWebhookUrl { get; set; } + + public static LNDHubConfig ParseLNDHubConnection(string connectionString) + { + try + { + var withoutScheme = connectionString.Replace("lndhub://", ""); + var parts = withoutScheme.Split('@'); + if (parts.Length != 2) + throw new Exception("Invalid connection string format."); + + var userInfoPart = parts[0]; + var baseUrlPart = parts[1]; + + var userInfo = userInfoPart.Split(':'); + if (userInfo.Length != 2) + throw new Exception("Invalid user info in connection string."); + + var username = userInfo[0]; + var password = userInfo[1]; + + var baseUrl = baseUrlPart.TrimEnd('/'); + if (!Uri.IsWellFormedUriString(baseUrl, UriKind.Absolute)) + throw new Exception("Invalid base URL in connection string."); + + return new LNDHubConfig + { + Username = username, + Password = password, + BaseUrl = baseUrl + }; + } + catch (Exception ex) + { + throw new Exception($"Error parsing LNDHub connection string: {ex.Message}"); + } + } + } + + private class AuthResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + } + + private class InvoiceResponse + { + [JsonProperty("payment_request")] + public string PaymentRequest { get; set; } + + [JsonProperty("r_hash")] + public RHashData RHash { get; set; } + + public class RHashData + { + [JsonProperty("data")] + public byte[] Data { get; set; } + } + } + + private class SellInvoiceLogEntry + { + public string SteamID { get; set; } + public string LightningAddress { get; set; } + public bool Success { get; set; } + public int SatsAmount { get; set; } // Log the amount of sats sent + public int Fee { get; set; } // Log the fee + public int FeeMsat { get; set; } // Log the fee in millisatoshis + public string PaymentRequest { get; set; } // Log the BOLT11 payment request + public string PaymentHash { get; set; } // Log the payment hash + public bool CurrencyReturned { get; set; } // Indicates if currency was returned + public DateTime Timestamp { 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; } // For currency purchases + public bool VipGranted { get; set; } // For VIP purchases + } + + 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 + } + + protected override void LoadConfig() + { + base.LoadConfig(); + try + { + // Check if config is loaded properly + if (Config == null || !Config.Exists()) + { + PrintError("Configuration file is missing or empty. Creating default configuration."); + LoadDefaultConfig(); + SaveConfig(); + } + + string lndhubConnectionString = Config["LNDHubConnection"]?.ToString(); + string discordWebhookUrl = Config["DiscordWebhookUrl"]?.ToString(); + + if (string.IsNullOrEmpty(lndhubConnectionString)) + { + PrintError("LNDHubConnection is not set in the configuration file."); + return; + } + + config = LNDHubConfig.ParseLNDHubConnection(lndhubConnectionString); + config.DiscordWebhookUrl = discordWebhookUrl; + + // Load configuration settings + CurrencyItemID = Convert.ToInt32(Config["CurrencyItemID"] ?? 1776460938); + BuyCurrencyCommandName = Config["BuyCurrencyCommandName"]?.ToString() ?? "buyblood"; + SendCurrencyCommandName = Config["SendCurrencyCommandName"]?.ToString() ?? "sendblood"; + BuyVipCommandName = Config["BuyVipCommandName"]?.ToString() ?? "buyvip"; + VipPrice = Convert.ToInt32(Config["VipPrice"] ?? 1000); + VipPermissionGroup = Config["VipPermissionGroup"]?.ToString() ?? "vip"; + CurrencyName = Config["CurrencyName"]?.ToString() ?? "blood"; + SatsPerCurrencyUnit = Convert.ToInt32(Config["SatsPerCurrencyUnit"] ?? 1); + PricePerCurrencyUnit = Convert.ToInt32(Config["PricePerCurrencyUnit"] ?? 1); + + // Load transaction timing settings from config + CheckIntervalSeconds = Convert.ToInt32(Config["CheckIntervalSeconds"] ?? 10); + InvoiceTimeoutSeconds = Convert.ToInt32(Config["InvoiceTimeoutSeconds"] ?? 300); + RetryDelaySeconds = Convert.ToInt32(Config["RetryDelaySeconds"] ?? 10); + MaxRetries = Convert.ToInt32(Config["MaxRetries"] ?? 25); + } + catch (Exception ex) + { + PrintError($"Failed to load configuration: {ex.Message}"); + } + } + + protected override void LoadDefaultConfig() + { + Config["LNDHubConnection"] = "lndhub://admin:password@sats.love/"; + Config["DiscordWebhookUrl"] = "https://discord.com/api/webhooks/your_webhook_url"; + Config["CurrencyItemID"] = 1776460938; + Config["BuyCurrencyCommandName"] = "buyblood"; + Config["SendCurrencyCommandName"] = "sendblood"; + Config["BuyVipCommandName"] = "buyvip"; + Config["VipPrice"] = 1000; // Default price for VIP + Config["VipPermissionGroup"] = "vip"; + Config["CurrencyName"] = "blood"; + Config["SatsPerCurrencyUnit"] = 1; // Default value + Config["PricePerCurrencyUnit"] = 1; // Default value + + // Default transaction timing settings + Config["CheckIntervalSeconds"] = 10; + Config["InvoiceTimeoutSeconds"] = 300; + Config["RetryDelaySeconds"] = 10; + Config["MaxRetries"] = 25; + } + + 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 dynamically + AddCovalenceCommand(BuyCurrencyCommandName, nameof(CmdBuyCurrency), "orangemart.buycurrency"); + AddCovalenceCommand(SendCurrencyCommandName, nameof(CmdSendCurrency), "orangemart.sendcurrency"); + AddCovalenceCommand(BuyVipCommandName, nameof(CmdBuyVip), "orangemart.buyvip"); + + timer.Every(CheckIntervalSeconds, CheckPendingInvoices); + } + + private void Unload() + { + pendingInvoices.Clear(); + retryCounts.Clear(); + authToken = null; + } + + 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 LNDHub.", + ["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.", + ["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." // New message for expired invoice + }, this); + } + + private string Lang(string key, string userId = null, params object[] args) + { + return string.Format(lang.GetMessage(key, this, userId), args); + } + + private void CheckPendingInvoices() + { + foreach (var invoice in pendingInvoices.ToArray()) + { + CheckInvoicePaid(invoice.RHash, (isPaid, isPending) => + { + if (isPaid) + { + pendingInvoices.Remove(invoice); + + if (invoice.Type == PurchaseType.Currency) + { + RewardPlayer(invoice.Player, invoice.Amount); + } + else if (invoice.Type == PurchaseType.Vip) + { + GrantVip(invoice.Player); + } + + var logEntry = new BuyInvoiceLogEntry + { + SteamID = invoice.Player.Id, + InvoiceID = invoice.RHash, + IsPaid = true, + Timestamp = DateTime.UtcNow, + Amount = invoice.Amount, + CurrencyGiven = invoice.Type == PurchaseType.Currency, + VipGranted = invoice.Type == PurchaseType.Vip + }; + LogBuyInvoice(logEntry); + } + else if (!isPending) + { + if (!retryCounts.ContainsKey(invoice.RHash)) + { + retryCounts[invoice.RHash] = 0; + } + + retryCounts[invoice.RHash]++; + if (retryCounts[invoice.RHash] >= MaxRetries) + { + pendingInvoices.Remove(invoice); + retryCounts.Remove(invoice.RHash); + PrintWarning($"Invoice for player {invoice.Player.Id} expired (amount: {invoice.Amount} sats)."); + + // Notify the player about the expired invoice + invoice.Player.Reply(Lang("InvoiceExpired", invoice.Player.Id, invoice.Amount)); + + var logEntry = new BuyInvoiceLogEntry + { + SteamID = invoice.Player.Id, + InvoiceID = invoice.RHash, + IsPaid = false, + Timestamp = DateTime.UtcNow, + Amount = invoice.Amount, + CurrencyGiven = false, + VipGranted = false + }; + LogBuyInvoice(logEntry); + } + else + { + PrintWarning($"Retrying invoice {invoice.RHash}. Attempt {retryCounts[invoice.RHash]} of {MaxRetries}."); + } + } + }); + } + } + + private void CheckInvoicePaid(string rHash, Action callback) + { + if (string.IsNullOrEmpty(authToken)) + { + GetAuthToken(token => + { + if (!string.IsNullOrEmpty(token)) + { + CheckInvoicePaid(rHash, callback); // Retry after getting token + } + else + { + callback(false, false); + } + }); + return; + } + + string baseUrlForCheckingInvoice = config.BaseUrl.Replace("/lndhub/ext", ""); // Remove any "/lndhub/ext" part + string url = $"{baseUrlForCheckingInvoice}/api/v1/payments/{rHash}"; + + PrintWarning($"Checking invoice at URL: {url}"); + PrintWarning($"rHash being checked: {rHash}"); + + var headers = new Dictionary + { + { "X-Api-Key", authToken }, + { "Content-Type", "application/json" } + }; + + webrequest.Enqueue(url, null, (code, response) => + { + if (code == 404) + { + PrintError($"Error checking invoice status: HTTP {code} (Not Found)"); + PrintWarning($"Ensure the correct rHash: {rHash}"); + callback(false, false); + return; + } + + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error checking invoice status: HTTP {code}"); + callback(false, false); + return; + } + + try + { + var jsonResponse = JsonConvert.DeserializeObject>(response); + + if (jsonResponse != null && jsonResponse.ContainsKey("paid")) + { + bool isPaid = (bool)jsonResponse["paid"]; + bool isPending = jsonResponse.ContainsKey("status") && jsonResponse["status"].ToString() == "pending"; + + if (isPaid) + { + callback(true, false); + } + else if (isPending) + { + callback(false, true); + } + else + { + callback(false, false); + } + } + else + { + callback(false, false); + } + } + catch (Exception ex) + { + PrintError($"Failed to parse invoice status response: {ex.Message}"); + callback(false, false); + } + }, this, RequestMethod.GET, headers); + } + + private void GetAuthToken(Action callback) + { + if (!string.IsNullOrEmpty(authToken)) + { + callback(authToken); + return; + } + + string url = $"{config.BaseUrl}/auth"; + Puts($"Attempting to authenticate with URL: {url}"); + + var requestBody = new + { + login = config.Username, + password = config.Password + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "Content-Type", "application/json" } + }; + + webrequest.Enqueue(url, jsonBody, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error getting auth token: HTTP {code}"); + PrintError($"Response: {response}"); + callback(null); + return; + } + + try + { + var authResponse = JsonConvert.DeserializeObject(response); + if (authResponse == null || string.IsNullOrEmpty(authResponse.AccessToken)) + { + PrintError("Invalid auth response."); + PrintError($"Response: {response}"); + callback(null); + return; + } + + authToken = authResponse.AccessToken; + callback(authToken); + } + catch (Exception ex) + { + PrintError($"Failed to parse auth response: {ex.Message}"); + PrintError($"Raw response: {response}"); + callback(null); + } + }, this, RequestMethod.POST, headers); + } + + private void CmdSendCurrency(IPlayer player, string command, string[] args) + { + if (!player.HasPermission("orangemart.sendcurrency")) + { + 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]; + + var basePlayer = player.Object as BasePlayer; + int currencyAmount = basePlayer.inventory.GetAmount(CurrencyItemID); + + if (currencyAmount < amount) + { + player.Reply(Lang("NeedMoreCurrency", player.Id, CurrencyName, currencyAmount)); + return; + } + + // Reserve the currency by immediately removing it from the player's inventory + int reservedAmount = ReserveCurrency(basePlayer, amount); + if (reservedAmount == 0) + { + player.Reply(Lang("FailedToReserveCurrency", player.Id)); + return; + } + + GetAuthToken(token => + { + if (!string.IsNullOrEmpty(token)) + { + QueryLightningAddressForInvoice(lightningAddress, reservedAmount * SatsPerCurrencyUnit, invoiceUrl => + { + if (string.IsNullOrEmpty(invoiceUrl)) + { + // If the invoice query fails, return the currency + ReturnCurrency(basePlayer, reservedAmount); + player.Reply(Lang("FailedToQueryLightningAddress", player.Id)); + LogSellTransaction(player.Id, lightningAddress, false, reservedAmount * SatsPerCurrencyUnit, 0, 0, null, null, true); + return; + } + + SendPayment(invoiceUrl, token, reservedAmount * SatsPerCurrencyUnit, player, lightningAddress, success => + { + if (success) + { + // Do nothing further as the currency is already removed on success + player.Reply(Lang("CurrencySentSuccess", player.Id, reservedAmount, CurrencyName)); + } + else + { + // If the payment fails, return the reserved currency + ReturnCurrency(basePlayer, reservedAmount); + player.Reply(Lang("FailedToProcessPayment", player.Id)); + LogSellTransaction(player.Id, lightningAddress, false, reservedAmount * SatsPerCurrencyUnit, 0, 0, null, null, true); + } + }); + }); + } + else + { + // If authentication fails, return the currency + ReturnCurrency(basePlayer, reservedAmount); + player.Reply(Lang("FailedToAuthenticate", player.Id)); + LogSellTransaction(player.Id, lightningAddress, false, reservedAmount * SatsPerCurrencyUnit, 0, 0, null, null, true); + } + }); + } + + 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) + { + // Send the invoice via Discord webhook + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying {amount} {CurrencyName}"); + + // Notify the player in chat to check the Discord channel + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, "buycurrency")); + + // Add the pending invoice + var pendingInvoice = new PendingInvoice + { + RHash = BitConverter.ToString(invoiceResponse.RHash.Data).Replace("-", "").ToLower(), + Player = player, + Amount = amount, + Memo = $"Buying {amount} {CurrencyName}", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.Currency + }; + pendingInvoices.Add(pendingInvoice); + + // Start checking the invoice status after a delay + timer.Once(RetryDelaySeconds, () => + { + CheckPendingInvoices(); + }); + + // Expire invoice after timeout if unpaid + timer.Once(InvoiceTimeoutSeconds, () => + { + if (pendingInvoices.Contains(pendingInvoice)) + { + pendingInvoices.Remove(pendingInvoice); + PrintWarning($"Invoice for player {player.Id} expired (amount: {amountSats} sats)."); + + var logEntry = new BuyInvoiceLogEntry + { + SteamID = player.Id, + InvoiceID = pendingInvoice.RHash, + IsPaid = false, + Timestamp = DateTime.UtcNow, + Amount = amountSats, + CurrencyGiven = false + }; + LogBuyInvoice(logEntry); + } + }); + } + 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) + { + // Send the invoice via Discord webhook + SendInvoiceToDiscord(player, invoiceResponse.PaymentRequest, amountSats, $"Buying VIP Status"); + + // Notify the player in chat to check the Discord channel + player.Reply(Lang("InvoiceCreatedCheckDiscord", player.Id, "buyvip")); + + // Add the pending invoice + var pendingInvoice = new PendingInvoice + { + RHash = BitConverter.ToString(invoiceResponse.RHash.Data).Replace("-", "").ToLower(), + Player = player, + Amount = amountSats, + Memo = $"Buying VIP Status", + CreatedAt = DateTime.UtcNow, + Type = PurchaseType.Vip + }; + pendingInvoices.Add(pendingInvoice); + + // Start checking the invoice status after a delay + timer.Once(RetryDelaySeconds, () => + { + CheckPendingInvoices(); + }); + + // Expire invoice after timeout if unpaid + timer.Once(InvoiceTimeoutSeconds, () => + { + if (pendingInvoices.Contains(pendingInvoice)) + { + pendingInvoices.Remove(pendingInvoice); + PrintWarning($"VIP purchase invoice for player {player.Id} expired."); + + var logEntry = new BuyInvoiceLogEntry + { + SteamID = player.Id, + InvoiceID = pendingInvoice.RHash, + IsPaid = false, + Timestamp = DateTime.UtcNow, + Amount = amountSats, + VipGranted = false + }; + LogBuyInvoice(logEntry); + } + }); + } + else + { + player.Reply(Lang("FailedToCreateInvoice", player.Id)); + } + }); + } + + private void QueryLightningAddressForInvoice(string lightningAddress, int satsAmount, Action callback) + { + string[] parts = lightningAddress.Split('@'); + if (parts.Length != 2) + { + PrintError($"Invalid Lightning address: {lightningAddress}"); + callback(null); + return; + } + + string username = parts[0]; + string domain = parts[1]; + string lnurlUrl = $"https://{domain}/.well-known/lnurlp/{username}"; + + Puts($"Querying Lightning address: {lightningAddress} for {satsAmount} sat invoice at URL: {lnurlUrl}"); + + webrequest.Enqueue(lnurlUrl, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error querying LNURL: HTTP {code}"); + PrintError($"Response: {response}"); + callback(null); + return; + } + + try + { + var lnurlResponse = JsonConvert.DeserializeObject>(response); + + if (lnurlResponse.ContainsKey("status") && lnurlResponse["status"].ToString() == "ERROR") + { + PrintError($"Error from LNURL provider: {lnurlResponse["reason"]}"); + callback(null); + return; + } + + string callbackUrl = lnurlResponse["callback"].ToString(); + callback($"{callbackUrl}?amount={satsAmount * 1000}"); // Amount in millisatoshis + } + catch (Exception ex) + { + PrintError($"Failed to parse LNURL response: {ex.Message}"); + callback(null); + } + }, this, RequestMethod.GET); + } + + private void SendPayment(string invoiceUrl, string token, int satsAmount, IPlayer player, string lightningAddress, Action callback) + { + Puts($"Fetching invoice from: {invoiceUrl}"); + + webrequest.Enqueue(invoiceUrl, null, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error fetching invoice: HTTP {code}"); + PrintError($"Response: {response}"); + callback(false); + return; + } + + try + { + var invoiceResponse = JsonConvert.DeserializeObject>(response); + + if (invoiceResponse.ContainsKey("pr")) + { + string paymentRequest = invoiceResponse["pr"].ToString(); + Puts($"Fetched payment request: {paymentRequest}"); + + ProcessPayment(paymentRequest, token, satsAmount, player, lightningAddress, callback); + } + else + { + PrintError("Invalid invoice response from LNURL provider"); + callback(false); + } + } + catch (Exception ex) + { + PrintError($"Failed to parse invoice response: {ex.Message}"); + callback(false); + } + }, this, RequestMethod.GET); + } + + private void ProcessPayment(string paymentRequest, string token, int satsAmount, IPlayer player, string lightningAddress, Action callback) + { + string url = $"{config.BaseUrl}/payinvoice"; + + var requestBody = new + { + invoice = paymentRequest, + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "Authorization", $"Bearer {token}" }, + { "Content-Type", "application/json" } + }; + + Puts($"Payment request URL: {url}"); + Puts($"Payment request headers: {string.Join(", ", headers)}"); + + webrequest.Enqueue(url, jsonBody, (code, response) => + { + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error processing payment: HTTP {code}"); + PrintError($"Response: {response}"); + callback(false); + return; + } + + try + { + // Log the raw response before parsing + Puts($"Raw payment response: {response}"); + + var paymentResponse = JsonConvert.DeserializeObject>(response); + + // Check for errors in the response + if (paymentResponse.ContainsKey("error") && paymentResponse["error"] != null) + { + PrintError($"Payment failed: {paymentResponse["error"]}"); + callback(false); + return; + } + + // Safely extract and log fee and fee_msat if they exist + int fee = 0; + if (paymentResponse.ContainsKey("fee") && paymentResponse["fee"] is long) + { + fee = (int)(long)paymentResponse["fee"]; // Safe cast to handle long types + } + else if (paymentResponse.ContainsKey("fee")) + { + Puts($"Unexpected fee type: {paymentResponse["fee"]?.GetType()}"); + } + + int feeMsat = 0; + if (paymentResponse.ContainsKey("fee_msat") && paymentResponse["fee_msat"] is long) + { + feeMsat = (int)(long)paymentResponse["fee_msat"]; // Safe cast to handle long types + } + else if (paymentResponse.ContainsKey("fee_msat")) + { + Puts($"Unexpected fee_msat type: {paymentResponse["fee_msat"]?.GetType()}"); + } + + string paymentHash = paymentResponse.ContainsKey("payment_hash") ? paymentResponse["payment_hash"].ToString() : "unknown"; + + Puts($"Payment processed successfully. Fee: {fee} msats, Fee (msat): {feeMsat}, Payment Hash: {paymentHash}"); + + // Log the successful transaction + LogSellTransaction(player.Id, lightningAddress, true, satsAmount, fee, feeMsat, paymentRequest, paymentHash, false); + callback(true); + } + catch (Exception ex) + { + PrintError($"Failed to parse payment response: {ex.Message}"); + PrintError($"Raw response: {response}"); + callback(false); + } + }, this, 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```{invoice}```", + image = new + { + url = qrCodeUrl + }, + fields = new[] + { + new + { + name = "Amount", + value = $"{amountSats} sats", + inline = true + }, + new + { + name = "Steam ID", + value = player.Id, + inline = true + } + } + } + } + }; + + string jsonPayload = JsonConvert.SerializeObject(webhookPayload); + webrequest.Enqueue(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 {player.Id}."); + } + }, this, 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) + { + basePlayer.GiveItem(currencyItem); + Puts($"Gave {amount} {CurrencyName} to player {player.Id}."); + } + else + { + PrintError($"Failed to create {CurrencyName} item for player {player.Id}."); + } + } + 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!"); + + // Add the player to the VIP permission group + permission.AddUserGroup(player.Id, VipPermissionGroup); + + Puts($"Player {player.Id} added to VIP group '{VipPermissionGroup}'."); + } + + private int ReserveCurrency(BasePlayer player, int amount) + { + var items = player.inventory.FindItemsByItemID(CurrencyItemID); + int remaining = amount; + int reserved = 0; + + foreach (var item in items) + { + if (item.amount > remaining) + { + item.UseItem(remaining); + reserved += remaining; + remaining = 0; + break; + } + else + { + reserved += item.amount; + remaining -= item.amount; + item.Remove(); + } + } + + if (remaining > 0) + { + // Rollback if unable to remove the full amount + PrintWarning($"Could not reserve the full amount of {CurrencyName}. {remaining} remaining."); + ReturnCurrency(player, reserved); // Return whatever was taken + return 0; // Indicate failure to reserve + } + + return reserved; // Return the amount actually reserved + } + + private void ReturnCurrency(BasePlayer player, int amount) + { + var returnedCurrency = ItemManager.CreateByItemID(CurrencyItemID, amount); + if (returnedCurrency != null) + { + returnedCurrency.MoveToContainer(player.inventory.containerMain); + } + } + + private void LogSellTransaction(string steamID, string lightningAddress, bool success, int satsAmount, int fee, int feeMsat, string paymentRequest, string paymentHash, bool currencyReturned) + { + var logEntry = new SellInvoiceLogEntry + { + SteamID = steamID, + LightningAddress = lightningAddress, + Success = success, + SatsAmount = satsAmount, // Store sats sent + Fee = fee, // Store fee + FeeMsat = feeMsat, // Store fee in millisatoshis + PaymentRequest = paymentRequest, // Store BOLT11 payment request + PaymentHash = paymentHash, // Store payment hash + CurrencyReturned = currencyReturned, // Indicates if currency was returned + Timestamp = DateTime.UtcNow + }; + + var logs = LoadSellLogData(); + logs.Add(logEntry); + SaveSellLogData(logs); + } + + 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; + + if (File.Exists(logPath)) + { + invoiceLogs = JsonConvert.DeserializeObject>(File.ReadAllText(logPath)) ?? new List(); + } + else + { + invoiceLogs = new List(); + } + + invoiceLogs.Add(logEntry); + File.WriteAllText(logPath, JsonConvert.SerializeObject(invoiceLogs, Formatting.Indented)); + } + + private void CreateInvoice(int amountSats, string memo, Action callback) + { + if (string.IsNullOrEmpty(authToken)) + { + GetAuthToken(token => + { + if (!string.IsNullOrEmpty(token)) + { + CreateInvoice(amountSats, memo, callback); // Retry after getting token + } + else + { + callback(null); + } + }); + return; + } + + string url = $"{config.BaseUrl}/addinvoice"; + var requestBody = new + { + amt = amountSats, + memo = memo + }; + string jsonBody = JsonConvert.SerializeObject(requestBody); + + var headers = new Dictionary + { + { "Authorization", $"Bearer {authToken}" }, + { "Content-Type", "application/json" } + }; + + webrequest.Enqueue(url, jsonBody, (code, response) => + { + PrintWarning($"Raw LNDHub response: {response}"); + + if (code != 200 || string.IsNullOrEmpty(response)) + { + PrintError($"Error creating invoice: HTTP {code}"); + callback(null); + return; + } + + try + { + var invoiceResponse = JsonConvert.DeserializeObject(response); + string rHashString = BitConverter.ToString(invoiceResponse.RHash.Data).Replace("-", "").ToLower(); + PrintWarning($"Parsed r_hash: {rHashString}"); + + callback(invoiceResponse); + } + catch (Exception ex) + { + PrintError($"Failed to deserialize invoice response: {ex.Message}"); + } + }, this, RequestMethod.POST, headers); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fecb5e --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +## 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. + +--- + +## Configuration + +Below is a list of key configuration variables that can be customized in the plugin: + +- **`CurrencyItemID`** + The item ID used for in-game currency transactions. + +- **`BuyCurrencyCommandName`** + The name of the command players use to buy in-game currency. + +- **`SendCurrencyCommandName`** + The name of the command players use to send in-game currency to other players. + +- **`BuyVipCommandName`** + The name of the command players use to purchase VIP status. + +- **`VipPrice`** + The price (in satoshis) for players to purchase VIP status. + +- **`VipPermissionGroup`** + The Oxide permission group that VIP players are added to. + +- **`CurrencyName`** + The name of the in-game currency. + +- **`SatsPerCurrencyUnit`** + The conversion rate between satoshis and in-game currency units. + +- **`PricePerCurrencyUnit`** + The price (in satoshis) per unit of in-game currency. + +- **`CheckIntervalSeconds`** + Interval time (in seconds) for checking pending Bitcoin transactions. + +--- + +## 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.