1126 lines
46 KiB
C#
1126 lines
46 KiB
C#
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<PendingInvoice> pendingInvoices = new List<PendingInvoice>();
|
|
private Dictionary<string, int> retryCounts = new Dictionary<string, int>();
|
|
|
|
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<string, string>
|
|
{
|
|
["UsageSendCurrency"] = "Usage: /{0} <amount> <lightning_address>",
|
|
["NeedMoreCurrency"] = "You need more {0}. You currently have {1}.",
|
|
["FailedToReserveCurrency"] = "Failed to reserve currency. Please try again.",
|
|
["FailedToQueryLightningAddress"] = "Failed to query Lightning address for an invoice.",
|
|
["FailedToAuthenticate"] = "Failed to authenticate with 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} <amount>",
|
|
["NoPermission"] = "You do not have permission to use this command.",
|
|
["FailedToFindBasePlayer"] = "Failed to find base player object for player {0}.",
|
|
["FailedToCreateCurrencyItem"] = "Failed to create {0} item for player {1}.",
|
|
["AddedToVipGroup"] = "Player {0} added to VIP group '{1}'.",
|
|
["InvoiceExpired"] = "Your invoice for {0} sats has expired. Please try again." // 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<bool, bool> 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<string, string>
|
|
{
|
|
{ "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<Dictionary<string, object>>(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<string> 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<string, string>
|
|
{
|
|
{ "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<AuthResponse>(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<string> 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<Dictionary<string, object>>(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<bool> 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<Dictionary<string, object>>(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<bool> callback)
|
|
{
|
|
string url = $"{config.BaseUrl}/payinvoice";
|
|
|
|
var requestBody = new
|
|
{
|
|
invoice = paymentRequest,
|
|
};
|
|
string jsonBody = JsonConvert.SerializeObject(requestBody);
|
|
|
|
var headers = new Dictionary<string, string>
|
|
{
|
|
{ "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<Dictionary<string, object>>(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<string, string> { { "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<SellInvoiceLogEntry> LoadSellLogData()
|
|
{
|
|
var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile);
|
|
return File.Exists(path)
|
|
? JsonConvert.DeserializeObject<List<SellInvoiceLogEntry>>(File.ReadAllText(path))
|
|
: new List<SellInvoiceLogEntry>();
|
|
}
|
|
|
|
private void SaveSellLogData(List<SellInvoiceLogEntry> data)
|
|
{
|
|
var path = Path.Combine(Interface.Oxide.DataDirectory, SellLogFile);
|
|
var directory = Path.GetDirectoryName(path);
|
|
if (!Directory.Exists(directory))
|
|
Directory.CreateDirectory(directory);
|
|
|
|
File.WriteAllText(path, JsonConvert.SerializeObject(data, Formatting.Indented));
|
|
}
|
|
|
|
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<BuyInvoiceLogEntry> invoiceLogs;
|
|
|
|
if (File.Exists(logPath))
|
|
{
|
|
invoiceLogs = JsonConvert.DeserializeObject<List<BuyInvoiceLogEntry>>(File.ReadAllText(logPath)) ?? new List<BuyInvoiceLogEntry>();
|
|
}
|
|
else
|
|
{
|
|
invoiceLogs = new List<BuyInvoiceLogEntry>();
|
|
}
|
|
|
|
invoiceLogs.Add(logEntry);
|
|
File.WriteAllText(logPath, JsonConvert.SerializeObject(invoiceLogs, Formatting.Indented));
|
|
}
|
|
|
|
private void CreateInvoice(int amountSats, string memo, Action<InvoiceResponse> 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<string, string>
|
|
{
|
|
{ "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<InvoiceResponse>(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);
|
|
}
|
|
}
|
|
}
|