diff --git a/BOUNTY.md b/BOUNTY.md new file mode 100644 index 0000000..1c4e60b --- /dev/null +++ b/BOUNTY.md @@ -0,0 +1,58 @@ +# orangemart.cs + +**Exciting Developer Bounty: Create a uMod Plugin for Nostr Wallet Connect Integration!** + +We are thrilled to announce an open-source project opportunity to create a plugin that will integrate Nostr Wallet Connect (NWC) into the popular game Rust. This plugin could revolutionize in-game commerce and server monetization, benefiting the entire Rust community, and we need passionate developers like you to bring it to life! + +### About the Project +Our goal is to develop a uMod (oxide) plugin that leverages NWC, as outlined in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) of the Nostr protocol. If you’re new to Nostr, check out our detailed post [here](https://www.orangem.art/blog/nostr) to get started and learn more about the protocol [here](https://nostr.org/). NWC documentation is available [here](https://docs.nwc.dev/), and you can join the NWC developer Discord [here](https://discord.gg/PRhQPZCmeF). + +The plugin will be server-side, built using the .NET Framework, and written in C#. Utilizing uMod’s capabilities, the plugin will dynamically enhance game functionality without requiring any client modifications. You can find uMod documentation [here](https://umod.org/documentation/api/overview) and join the uMod/Oxide developer Discord [here](https://discord.gg/HdhSD8aBXD), check out these guides from [@thethingtracks](https://medium.com/@thethingtracks/simple-rust-plugin-template-a0f405da8f64) and [kwamaking](https://github.com/kwamaking/rust-plugin-development). There is some NWC support in [NNostr](https://github.com/Kukks/NNostr) which is written in C# and could be a useful reference point. + +"The cool thing with NWC is that it's quite easy to implement, no matter which language / tech stack you are using. It's basically sending some (signed & encrypted) JSON messages over a websocket connection." - [@reneaaron](https://stacker.news/items/640244/r/TheOrangeMart?commentId=640348) + +### Initial Use Case +**Server Wallet Integration:** +- The server admin will input the NWC connection pairing secret into the plugin's configuration file. +- The plugin will use this pairing secret to interact with the server's wallet. + +**Player-to-Server Commerce Examples:** +1. **Buying VIP Status:** + - Players type /buyvip in-game. + - The plugin requests an invoice from the server's wallet using NWC. + - The invoice is displayed to the player as a QR code. Once paid, the player is added to the oxide permission group granting them VIP perks. + - The configuration file allows customization of command name, price, and the Oxide permission group granted. + +2. **Buying In-Game Currency:** + - If the currency item is blood, players could type /buyblood and specify the amount they wish to purchase. + - The plugin requests an invoice from the server's wallet and displays it to the player. + - Once paid, the player receives the specified quantity of blood (1 blood = 1 sat). + - The configuration file allows customization of currency item and command name. + +3. **Selling In-Game Currency (sending):** + - Players type /sendblood and provide a destination Lightning address. + - The plugin deletes the specified amount of blood (currency item) from the player's inventory. + - Using NWC, the plugin sends sats from the server's wallet to the destination address. + +### Future Expansion Ideas +**Player-to-Player Commerce:** +- Enable the plugin to handle P2P commerce, enhancing in-game trading mechanics like the Vending Machine and less reliance on an in-game currency item. Possibly by allowing players to input their NWC connection pairing secret. + +**Embedded Wallet:** +- Utilize the Alby Hub's new isolated apps feature to give all players an embedded wallet. Join the Alby discord [here](https://discord.gg/4a79bPPgBW). + +### Example Development Tasks: +- Develop the plugin as a C# code file. +- Ensure the plugin generates a JSON configuration file for customization. +- Implement chat commands for buying and selling in-game currency. +- Handle errors and notify players in chat with the lightning invoice. +- Check invoice status and handle unsuccessful payments. + +### Why Join Us? +We have set aside **1 million sats ($650 USD at today’s value)** for the initial development and ongoing support of this plugin. Orangemart’s proof of funds can be found at our [Geyser Fundraiser](https://geyser.fund/project/orange), where we have received over **25 million sats** in donations from over 100 contributors. Additionally, proof of our disbursement of these funds to our community is available on the [Lightsats leaderboard](https://lightsats.com/leaderboard), where we have gifted over **10 million sats** in over 7000 prizes to our community. + +This is a fantastic opportunity to contribute to an exciting project that could significantly impact the Rust community. Join our Discord to get started and collaborate with us on this thrilling journey: [Orangemart Discord](https://dsc.gg/orangemart). + +Let’s build something amazing together! 🚀 + +Bounty originally published at [orangem.art](https://www.orangem.art/blog/nwcplugin) 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 index 1c4e60b..0fecb5e 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,112 @@ -# orangemart.cs +## 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. -**Exciting Developer Bounty: Create a uMod Plugin for Nostr Wallet Connect Integration!** +--- -We are thrilled to announce an open-source project opportunity to create a plugin that will integrate Nostr Wallet Connect (NWC) into the popular game Rust. This plugin could revolutionize in-game commerce and server monetization, benefiting the entire Rust community, and we need passionate developers like you to bring it to life! +## Features -### About the Project -Our goal is to develop a uMod (oxide) plugin that leverages NWC, as outlined in [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) of the Nostr protocol. If you’re new to Nostr, check out our detailed post [here](https://www.orangem.art/blog/nostr) to get started and learn more about the protocol [here](https://nostr.org/). NWC documentation is available [here](https://docs.nwc.dev/), and you can join the NWC developer Discord [here](https://discord.gg/PRhQPZCmeF). +- **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. -The plugin will be server-side, built using the .NET Framework, and written in C#. Utilizing uMod’s capabilities, the plugin will dynamically enhance game functionality without requiring any client modifications. You can find uMod documentation [here](https://umod.org/documentation/api/overview) and join the uMod/Oxide developer Discord [here](https://discord.gg/HdhSD8aBXD), check out these guides from [@thethingtracks](https://medium.com/@thethingtracks/simple-rust-plugin-template-a0f405da8f64) and [kwamaking](https://github.com/kwamaking/rust-plugin-development). There is some NWC support in [NNostr](https://github.com/Kukks/NNostr) which is written in C# and could be a useful reference point. +--- -"The cool thing with NWC is that it's quite easy to implement, no matter which language / tech stack you are using. It's basically sending some (signed & encrypted) JSON messages over a websocket connection." - [@reneaaron](https://stacker.news/items/640244/r/TheOrangeMart?commentId=640348) +## Commands -### Initial Use Case -**Server Wallet Integration:** -- The server admin will input the NWC connection pairing secret into the plugin's configuration file. -- The plugin will use this pairing secret to interact with the server's wallet. +The following commands are available to players: -**Player-to-Server Commerce Examples:** -1. **Buying VIP Status:** - - Players type /buyvip in-game. - - The plugin requests an invoice from the server's wallet using NWC. - - The invoice is displayed to the player as a QR code. Once paid, the player is added to the oxide permission group granting them VIP perks. - - The configuration file allows customization of command name, price, and the Oxide permission group granted. +- **`/buyblood`** + Players can purchase in-game currency using Bitcoin. The amount purchased is configurable. -2. **Buying In-Game Currency:** - - If the currency item is blood, players could type /buyblood and specify the amount they wish to purchase. - - The plugin requests an invoice from the server's wallet and displays it to the player. - - Once paid, the player receives the specified quantity of blood (1 blood = 1 sat). - - The configuration file allows customization of currency item and command name. +- **`/sendblood `** + Players can send a specified amount of in-game currency to another player. -3. **Selling In-Game Currency (sending):** - - Players type /sendblood and provide a destination Lightning address. - - The plugin deletes the specified amount of blood (currency item) from the player's inventory. - - Using NWC, the plugin sends sats from the server's wallet to the destination address. +- **`/buyvip`** + Players can purchase VIP status using Bitcoin. The VIP price and associated permission group are configurable. -### Future Expansion Ideas -**Player-to-Player Commerce:** -- Enable the plugin to handle P2P commerce, enhancing in-game trading mechanics like the Vending Machine and less reliance on an in-game currency item. Possibly by allowing players to input their NWC connection pairing secret. +--- -**Embedded Wallet:** -- Utilize the Alby Hub's new isolated apps feature to give all players an embedded wallet. Join the Alby discord [here](https://discord.gg/4a79bPPgBW). +## Configuration -### Example Development Tasks: -- Develop the plugin as a C# code file. -- Ensure the plugin generates a JSON configuration file for customization. -- Implement chat commands for buying and selling in-game currency. -- Handle errors and notify players in chat with the lightning invoice. -- Check invoice status and handle unsuccessful payments. +Below is a list of key configuration variables that can be customized in the plugin: -### Why Join Us? -We have set aside **1 million sats ($650 USD at today’s value)** for the initial development and ongoing support of this plugin. Orangemart’s proof of funds can be found at our [Geyser Fundraiser](https://geyser.fund/project/orange), where we have received over **25 million sats** in donations from over 100 contributors. Additionally, proof of our disbursement of these funds to our community is available on the [Lightsats leaderboard](https://lightsats.com/leaderboard), where we have gifted over **10 million sats** in over 7000 prizes to our community. +- **`CurrencyItemID`** + The item ID used for in-game currency transactions. -This is a fantastic opportunity to contribute to an exciting project that could significantly impact the Rust community. Join our Discord to get started and collaborate with us on this thrilling journey: [Orangemart Discord](https://dsc.gg/orangemart). +- **`BuyCurrencyCommandName`** + The name of the command players use to buy in-game currency. -Let’s build something amazing together! 🚀 +- **`SendCurrencyCommandName`** + The name of the command players use to send in-game currency to other players. -Bounty originally published at [orangem.art](https://www.orangem.art/blog/nwcplugin) +- **`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. diff --git a/orangemart.cs b/orangemart.cs deleted file mode 100644 index 50e205d..0000000 --- a/orangemart.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Newtonsoft.Json; -using UnityEngine; -using System; -using System.Collections.Generic; -using Oxide.Core; -using Oxide.Core.Plugins; -using Oxide.Game.Rust.Cui; -using Oxide.Core.Libraries.Covalence; -namespace Oxide.Plugins -{ - [Info("Plugin Name", "Author/s", "0.0.1")] - [Description("One sentence plugin description.")] - class PluginName : CovalencePlugin - { - private void OnServerInitialized() - { - AddCovalenceCommand("ping", "PingPong"); - } - private void PingPong(IPlayer player, string command, string[] args) - { - player.Reply("Pong"); - } - } -}