commit 6ec944bb44a0591e6deeafff9f66c37810de1d19 Author: Xelara Networks Date: Sat Jun 20 16:10:38 2026 -0400 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf1ce70 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# DirtCryptoStore + +DirtCryptoStore is a Paper 1.21.x crypto shop plugin. + +## Features + +- GUI crypto store +- Live BTC/USD pricing +- Unique BTC invoice amounts per player +- QR map generation +- QR placed in player off-hand +- SQLite storage +- Expired invoice pruning +- Auto payment watching +- Admin review tools + +## Requirements + +- Paper 1.21.x +- Java 21 +- Maven 3.8.7+ + +## Build + +mvn clean package + +Jar: +target/DirtCryptoStore.jar + +## Install + +1. Put the jar in your server plugins folder +2. Start the server +3. Edit plugins/DirtCryptoStore/config.yml +4. Restart the server or use /cryptostore reload + +## Player Commands + +/cryptostore +Opens the store GUI + +/cryptostore status +Shows the player’s pending invoice status + +## Admin Commands + +/cryptostore reload +Reloads plugin config and watcher + +/cryptostore review +Shows invoices marked UNDER_REVIEW + +/cryptostore forceconfirm +Manually fulfills an invoice + +/cryptostore cancelinvoice +Cancels an invoice + +## Permissions + +dirtcryptostore.use +Default: true + +dirtcryptostore.admin +Default: op + +dirtcryptostore.bypass +Reserved for future use + +## Payment Flow + +1. Player runs /cryptostore +2. Player clicks a product +3. Plugin converts USD price to BTC +4. Plugin adds a unique sat offset +5. Invoice is created +6. QR map is placed in off-hand +7. Player pays exact BTC amount +8. Plugin watches blockchain +9. After confirmations, commands run + +## Storage + +SQLite database file: +plugins/DirtCryptoStore/dirtcryptostore.db + +## Pruning + +Old invoices can be pruned with config options: +- prune-enabled +- prune-after-days +- prune-statuses + +## Notes + +- BTC-focused +- Live pricing uses CoinGecko +- Payment watching uses blockchain.info +- Large public deployments should later add provider fallback and audit logging diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5cbf213 --- /dev/null +++ b/pom.xml @@ -0,0 +1,88 @@ + + 4.0.0 + + com.bitnix + DirtCryptoStore + 1.0.0 + jar + + DirtCryptoStore + Crypto GUI shop plugin for Paper + + + UTF-8 + 21 + + + + + papermc-repo + https://repo.papermc.io/repository/maven-public/ + + + + + + io.papermc.paper + paper-api + 1.21.1-R0.1-SNAPSHOT + provided + + + + com.google.zxing + core + 3.5.3 + + + + com.google.zxing + javase + 3.5.3 + + + + com.google.code.gson + gson + 2.11.0 + + + + org.xerial + sqlite-jdbc + 3.46.1.3 + + + + + DirtCryptoStore + + + maven-compiler-plugin + 3.13.0 + + 21 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + false + + + + + + + diff --git a/src/main/java/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.java b/src/main/java/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.java new file mode 100644 index 0000000..d441867 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.java @@ -0,0 +1,144 @@ +package com.bitnix.dirtcryptostore; + +import com.bitnix.dirtcryptostore.command.CryptoStoreCommand; +import com.bitnix.dirtcryptostore.listener.StoreGuiListener; +import com.bitnix.dirtcryptostore.manager.ConfigManager; +import com.bitnix.dirtcryptostore.manager.InvoiceManager; +import com.bitnix.dirtcryptostore.manager.PaymentWatcher; +import com.bitnix.dirtcryptostore.manager.PricingManager; +import com.bitnix.dirtcryptostore.manager.QrMapManager; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +public final class DirtCryptoStorePlugin extends JavaPlugin { + + private static DirtCryptoStorePlugin instance; + + private ConfigManager configManager; + private InvoiceManager invoiceManager; + private PaymentWatcher paymentWatcher; + private QrMapManager qrMapManager; + private PricingManager pricingManager; + private BukkitTask watcherTask; + + @Override + public void onEnable() { + instance = this; + + saveDefaultConfig(); + + this.configManager = new ConfigManager(this); + this.configManager.load(); + + this.pricingManager = new PricingManager(this); + + this.invoiceManager = new InvoiceManager(this); + this.invoiceManager.init(); + this.invoiceManager.load(); + + this.qrMapManager = new QrMapManager(this); + + registerCommands(); + registerListeners(); + startWatcher(); + + getLogger().info("DirtCryptoStore enabled."); + } + + @Override + public void onDisable() { + if (watcherTask != null) { + watcherTask.cancel(); + watcherTask = null; + } + + if (invoiceManager != null) { + invoiceManager.save(); + invoiceManager.clearAll(); + } + + if (qrMapManager != null) { + qrMapManager.clearAll(); + } + + if (pricingManager != null) { + pricingManager.clearCache(); + } + + getLogger().info("DirtCryptoStore disabled."); + instance = null; + } + + private void registerCommands() { + PluginCommand command = getCommand("cryptostore"); + if (command == null) { + getLogger().severe("Command 'cryptostore' is missing from plugin.yml"); + return; + } + + CryptoStoreCommand cryptoStoreCommand = new CryptoStoreCommand(this); + command.setExecutor(cryptoStoreCommand); + command.setTabCompleter(cryptoStoreCommand); + } + + private void registerListeners() { + getServer().getPluginManager().registerEvents(new StoreGuiListener(this), this); + } + + private void startWatcher() { + this.paymentWatcher = new PaymentWatcher(this); + + long pollSeconds = getConfig().getLong("settings.payment.poll-seconds", 30L); + long periodTicks = Math.max(20L, pollSeconds * 20L); + + watcherTask = getServer().getScheduler().runTaskTimerAsynchronously(this, paymentWatcher, periodTicks, periodTicks); + } + + public void reloadPlugin() { + reloadConfig(); + + if (watcherTask != null) { + watcherTask.cancel(); + watcherTask = null; + } + + configManager.load(); + + if (pricingManager != null) { + pricingManager.clearCache(); + } + + invoiceManager.load(); + + if (qrMapManager != null) { + qrMapManager.clearAll(); + } + + startWatcher(); + } + + public static DirtCryptoStorePlugin getInstance() { + return instance; + } + + public ConfigManager getConfigManager() { + return configManager; + } + + public InvoiceManager getInvoiceManager() { + return invoiceManager; + } + + public PaymentWatcher getPaymentWatcher() { + return paymentWatcher; + } + + public QrMapManager getQrMapManager() { + return qrMapManager; + } + + public PricingManager getPricingManager() { + return pricingManager; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.java b/src/main/java/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.java new file mode 100644 index 0000000..d01f2cd --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.java @@ -0,0 +1,227 @@ +package com.bitnix.dirtcryptostore.command; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.gui.StoreGui; +import com.bitnix.dirtcryptostore.model.BlockchainPaymentMatch; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import com.bitnix.dirtcryptostore.util.ColorUtil; +import com.bitnix.dirtcryptostore.util.MessageUtil; +import com.bitnix.dirtcryptostore.util.TimeUtil; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public class CryptoStoreCommand implements CommandExecutor, TabCompleter { + + private final DirtCryptoStorePlugin plugin; + + public CryptoStoreCommand(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length > 0) { + String sub = args[0].toLowerCase(Locale.ROOT); + + if (sub.equals("reload")) { + if (!sender.hasPermission("dirtcryptostore.admin")) { + MessageUtil.send(plugin, sender, "messages.no-permission", "&cYou do not have permission."); + return true; + } + + plugin.reloadPlugin(); + MessageUtil.send(plugin, sender, "messages.reloaded", "&aDirtCryptoStore reloaded."); + return true; + } + + if (sub.equals("status")) { + if (!(sender instanceof Player player)) { + MessageUtil.send(plugin, sender, "messages.player-only", "&cOnly players can use this command."); + return true; + } + + PendingInvoice invoice = plugin.getInvoiceManager().getPendingInvoice(player.getUniqueId()); + if (invoice == null) { + MessageUtil.send(plugin, player, "messages.no-pending-payment", "&cYou do not have a pending payment."); + return true; + } + + sendInvoiceStatus(sender, invoice); + return true; + } + + if (sub.equals("review")) { + if (!sender.hasPermission("dirtcryptostore.admin")) { + MessageUtil.send(plugin, sender, "messages.no-permission", "&cYou do not have permission."); + return true; + } + + int shown = 0; + Collection all = plugin.getInvoiceManager().getAllInvoices(); + sender.sendMessage(ColorUtil.color("&6--- DirtCryptoStore Review Queue ---")); + for (PendingInvoice invoice : all) { + if (invoice.getStatus() != PendingInvoice.Status.UNDER_REVIEW) { + continue; + } + + shown++; + sender.sendMessage(ColorUtil.color("&eInvoice: &f" + invoice.getInvoiceId())); + sender.sendMessage(ColorUtil.color("&7Player: &f" + invoice.getPlayerName())); + sender.sendMessage(ColorUtil.color("&7Product: &f" + invoice.getProductId())); + sender.sendMessage(ColorUtil.color("&7Amount: &f" + TimeUtil.formatBtcAmount(invoice.getAmountSats()))); + sender.sendMessage(ColorUtil.color("&7TXID: &f" + (invoice.getTxid().isEmpty() ? "N/A" : invoice.getTxid()))); + sender.sendMessage(ColorUtil.color("&7Notes: &f" + invoice.getNotes())); + sender.sendMessage(ColorUtil.color("&7Use: &f/cryptostore forceconfirm " + invoice.getInvoiceId())); + sender.sendMessage(ColorUtil.color("&7Use: &f/cryptostore cancelinvoice " + invoice.getInvoiceId())); + } + + if (shown == 0) { + sender.sendMessage(ColorUtil.color("&aNo invoices are currently under review.")); + } + return true; + } + + if (sub.equals("forceconfirm")) { + if (!sender.hasPermission("dirtcryptostore.admin")) { + MessageUtil.send(plugin, sender, "messages.no-permission", "&cYou do not have permission."); + return true; + } + + if (args.length < 2) { + sender.sendMessage(ColorUtil.color("&cUsage: /cryptostore forceconfirm ")); + return true; + } + + PendingInvoice invoice = plugin.getInvoiceManager().getInvoiceById(args[1]); + if (invoice == null) { + sender.sendMessage(ColorUtil.color("&cInvoice not found.")); + return true; + } + + Product product = plugin.getConfigManager().getProduct(invoice.getProductId()); + WalletConfig wallet = plugin.getConfigManager().getWallet(invoice.getWalletId()); + + if (product == null) { + sender.sendMessage(ColorUtil.color("&cProduct config missing for that invoice.")); + return true; + } + + BlockchainPaymentMatch match = new BlockchainPaymentMatch( + true, + invoice.getTxid().isEmpty() ? "MANUAL_REVIEW" : invoice.getTxid(), + Math.max(invoice.getConfirmations(), wallet == null ? 2 : wallet.getMinConfirmations()), + 0 + ); + + plugin.getPaymentWatcher().forceFulfillFromAdmin(invoice, match); + + sender.sendMessage(ColorUtil.color("&aInvoice force-confirmed: &f" + invoice.getInvoiceId())); + return true; + } + + if (sub.equals("cancelinvoice")) { + if (!sender.hasPermission("dirtcryptostore.admin")) { + MessageUtil.send(plugin, sender, "messages.no-permission", "&cYou do not have permission."); + return true; + } + + if (args.length < 2) { + sender.sendMessage(ColorUtil.color("&cUsage: /cryptostore cancelinvoice ")); + return true; + } + + PendingInvoice invoice = plugin.getInvoiceManager().getInvoiceById(args[1]); + if (invoice == null) { + sender.sendMessage(ColorUtil.color("&cInvoice not found.")); + return true; + } + + plugin.getInvoiceManager().expireInvoice(invoice, "Cancelled by admin."); + sender.sendMessage(ColorUtil.color("&aInvoice cancelled: &f" + invoice.getInvoiceId())); + + Player player = Bukkit.getPlayer(invoice.getPlayerUuid()); + if (player != null) { + MessageUtil.send(plugin, player, "messages.invoice-cancelled", "&eYour pending payment was cancelled."); + } + return true; + } + } + + if (!(sender instanceof Player player)) { + MessageUtil.send(plugin, sender, "messages.player-only", "&cOnly players can use this command."); + return true; + } + + if (!player.hasPermission("dirtcryptostore.use")) { + MessageUtil.send(plugin, player, "messages.no-permission", "&cYou do not have permission."); + return true; + } + + MessageUtil.send(plugin, player, "messages.opening-store", "&aOpening crypto store..."); + new StoreGui(plugin).open(player); + return true; + } + + private void sendInvoiceStatus(CommandSender sender, PendingInvoice invoice) { + String product = invoice.getProductId(); + String amount = TimeUtil.formatBtcAmount(invoice.getAmountSats()); + String status = invoice.getStatus().name(); + String txid = invoice.getTxid().isEmpty() ? "N/A" : invoice.getTxid(); + String time = TimeUtil.formatRemaining(invoice.getRemainingMillis()); + + MessageUtil.send(plugin, sender, "messages.product-line", "&7Product: &f%product%", "%product%", product); + MessageUtil.send(plugin, sender, "messages.amount-line", "&7Amount: &f%amount%", "%amount%", amount); + MessageUtil.send(plugin, sender, "messages.txid-line", "&7TXID: &f%txid%", "%txid%", txid); + sender.sendMessage(ColorUtil.color("&7Status: &f" + status)); + sender.sendMessage(ColorUtil.color("&7Expires in: &f" + time)); + sender.sendMessage(ColorUtil.color("&7Notes: &f" + invoice.getNotes())); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + List suggestions = new ArrayList<>(); + suggestions.add("status"); + + if (sender.hasPermission("dirtcryptostore.admin")) { + suggestions.add("reload"); + suggestions.add("review"); + suggestions.add("forceconfirm"); + suggestions.add("cancelinvoice"); + } + + String input = args[0].toLowerCase(Locale.ROOT); + suggestions.removeIf(s -> !s.toLowerCase(Locale.ROOT).startsWith(input)); + Collections.sort(suggestions); + return suggestions; + } + + if (args.length == 2 && sender.hasPermission("dirtcryptostore.admin")) { + String sub = args[0].toLowerCase(Locale.ROOT); + if (sub.equals("forceconfirm") || sub.equals("cancelinvoice")) { + List suggestions = new ArrayList<>(); + for (PendingInvoice invoice : plugin.getInvoiceManager().getAllInvoices()) { + suggestions.add(invoice.getInvoiceId()); + } + String input = args[1].toLowerCase(Locale.ROOT); + suggestions.removeIf(s -> !s.toLowerCase(Locale.ROOT).startsWith(input)); + Collections.sort(suggestions); + return suggestions; + } + } + + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/gui/PaymentGui.java b/src/main/java/com/bitnix/dirtcryptostore/gui/PaymentGui.java new file mode 100644 index 0000000..e45afb3 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/gui/PaymentGui.java @@ -0,0 +1,273 @@ +package com.bitnix.dirtcryptostore.gui; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import com.bitnix.dirtcryptostore.util.ColorUtil; +import com.bitnix.dirtcryptostore.util.QrMapUtil; +import com.bitnix.dirtcryptostore.util.QrPlaceholderUtil; +import com.bitnix.dirtcryptostore.util.TimeUtil; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.List; + +public class PaymentGui { + + private final DirtCryptoStorePlugin plugin; + + public PaymentGui(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + public void open(Player player, PendingInvoice invoice) { + String title = ColorUtil.color(plugin.getConfig().getString("gui.payment.title", "&8Crypto Payment")); + int size = plugin.getConfig().getInt("gui.payment.size", 27); + + Inventory inventory = Bukkit.createInventory(null, size, title); + + applyStaticItems(inventory); + applyDynamicItems(player, inventory, invoice); + + player.openInventory(inventory); + } + + private void applyStaticItems(Inventory inventory) { + ConfigurationSection itemsSection = plugin.getConfig().getConfigurationSection("gui.payment.items"); + if (itemsSection == null) { + return; + } + + for (String key : itemsSection.getKeys(false)) { + ConfigurationSection section = itemsSection.getConfigurationSection(key); + if (section == null || !section.getBoolean("enabled", true)) { + continue; + } + + if (key.equalsIgnoreCase("filler")) { + ItemStack filler = createItem( + section.getString("material", "BLACK_STAINED_GLASS_PANE"), + section.getString("name", " "), + section.getStringList("lore"), + 0, + false + ); + + for (int slot : section.getIntegerList("slots")) { + if (slot >= 0 && slot < inventory.getSize()) { + inventory.setItem(slot, filler); + } + } + } + } + } + + private void applyDynamicItems(Player player, Inventory inventory, PendingInvoice invoice) { + ConfigurationSection itemsSection = plugin.getConfig().getConfigurationSection("gui.payment.items"); + if (itemsSection == null) { + return; + } + + Product product = plugin.getConfigManager().getProduct(invoice.getProductId()); + WalletConfig wallet = plugin.getConfigManager().getWallet(invoice.getWalletId()); + + String productName = product == null ? invoice.getProductId() : product.getDisplayName(); + String walletName = wallet == null ? invoice.getWalletId() : wallet.getDisplayName(); + String amount = TimeUtil.formatBtcAmount(invoice.getAmountSats()); + String rawAmount = TimeUtil.formatBtcRaw(invoice.getAmountSats()); + String status = invoice.getStatus().name(); + String txid = invoice.getTxid() == null || invoice.getTxid().isEmpty() ? "N/A" : invoice.getTxid(); + String time = TimeUtil.formatRemaining(invoice.getRemainingMillis()); + String address = invoice.getWalletAddress(); + int requiredConfirmations = wallet == null + ? plugin.getConfig().getInt("settings.payment.confirmations-required", 2) + : wallet.getMinConfirmations(); + String bitcoinUri = QrPlaceholderUtil.buildBitcoinUri(invoice); + + placeDynamicItem(inventory, itemsSection, "product", + replaceLore(itemsSection, "product", productName, walletName, amount, rawAmount, status, txid, time, address, invoice.getConfirmations(), requiredConfirmations, bitcoinUri)); + + placeQrItem(player, inventory, itemsSection, invoice, productName, walletName, amount, rawAmount, status, txid, time, address, requiredConfirmations, bitcoinUri); + + placeDynamicItem(inventory, itemsSection, "status", + replaceLore(itemsSection, "status", productName, walletName, amount, rawAmount, status, txid, time, address, invoice.getConfirmations(), requiredConfirmations, bitcoinUri)); + + placeConfiguredButton(inventory, itemsSection, "back"); + placeConfiguredButton(inventory, itemsSection, "cancel"); + placeConfiguredButton(inventory, itemsSection, "refresh"); + } + + private void placeQrItem(Player player, + Inventory inventory, + ConfigurationSection itemsSection, + PendingInvoice invoice, + String productName, + String walletName, + String amount, + String rawAmount, + String status, + String txid, + String time, + String address, + int requiredConfirmations, + String bitcoinUri) { + ConfigurationSection section = itemsSection.getConfigurationSection("qr"); + if (section == null || !section.getBoolean("enabled", true)) { + return; + } + + int slot = section.getInt("slot", -1); + if (slot < 0 || slot >= inventory.getSize()) { + return; + } + + List lore = replaceLore(itemsSection, "qr", productName, walletName, amount, rawAmount, status, txid, time, address, invoice.getConfirmations(), requiredConfirmations, bitcoinUri); + + ItemStack qrItem = QrMapUtil.createQrMapItem( + plugin, + player, + bitcoinUri, + ColorUtil.color(section.getString("name", "&aScan QR To Pay")), + ColorUtil.color(lore), + plugin.getConfig().getString("settings.qr.fallback-material-if-map-fails", "PAPER") + ); + + inventory.setItem(slot, qrItem); + } + + private void placeDynamicItem(Inventory inventory, ConfigurationSection itemsSection, String key, List lore) { + ConfigurationSection section = itemsSection.getConfigurationSection(key); + if (section == null || !section.getBoolean("enabled", true)) { + return; + } + + int slot = section.getInt("slot", -1); + if (slot < 0 || slot >= inventory.getSize()) { + return; + } + + inventory.setItem(slot, createItem( + section.getString("material", "PAPER"), + section.getString("name", "&fItem"), + lore, + section.getInt("custom-model-data", 0), + section.getBoolean("glow", false) + )); + } + + private void placeConfiguredButton(Inventory inventory, ConfigurationSection itemsSection, String key) { + ConfigurationSection section = itemsSection.getConfigurationSection(key); + if (section == null || !section.getBoolean("enabled", true)) { + return; + } + + int slot = section.getInt("slot", -1); + if (slot < 0 || slot >= inventory.getSize()) { + return; + } + + inventory.setItem(slot, createItem( + section.getString("material", "STONE"), + section.getString("name", "&fButton"), + section.getStringList("lore"), + section.getInt("custom-model-data", 0), + section.getBoolean("glow", false) + )); + } + + private List replaceLore(ConfigurationSection itemsSection, + String key, + String product, + String wallet, + String amount, + String rawAmount, + String status, + String txid, + String time, + String address, + int currentConfirmations, + int requiredConfirmations, + String bitcoinUri) { + ConfigurationSection section = itemsSection.getConfigurationSection(key); + if (section == null) { + return new ArrayList<>(); + } + + List lore = new ArrayList<>(); + for (String line : section.getStringList("lore")) { + lore.add(line + .replace("%product%", ColorUtil.color(product)) + .replace("%wallet%", ColorUtil.color(wallet)) + .replace("%amount%", amount) + .replace("%amount_raw%", rawAmount) + .replace("%amount_sats%", String.valueOf(parseSatsFromRaw(rawAmount))) + .replace("%status%", status) + .replace("%txid%", txid) + .replace("%time%", time) + .replace("%address%", address) + .replace("%bitcoin_uri%", bitcoinUri) + .replace("%current_confirmations%", String.valueOf(currentConfirmations)) + .replace("%confirmations%", String.valueOf(requiredConfirmations))); + } + return lore; + } + + private long parseSatsFromRaw(String rawAmount) { + String[] split = rawAmount.split("\\."); + if (split.length != 2) { + return 0L; + } + + try { + long whole = Long.parseLong(split[0]); + String fraction = split[1]; + while (fraction.length() < 8) { + fraction += "0"; + } + if (fraction.length() > 8) { + fraction = fraction.substring(0, 8); + } + long frac = Long.parseLong(fraction); + return (whole * 100_000_000L) + frac; + } catch (NumberFormatException ex) { + return 0L; + } + } + + private ItemStack createItem(String materialName, String name, List lore, int customModelData, boolean glow) { + Material material = Material.matchMaterial(materialName); + if (material == null) { + material = Material.STONE; + } + + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + + meta.setDisplayName(ColorUtil.color(name)); + meta.setLore(ColorUtil.color(lore)); + + if (customModelData > 0) { + meta.setCustomModelData(customModelData); + } + + if (glow) { + meta.addEnchant(Enchantment.LUCK_OF_THE_SEA, 1, true); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + } + + item.setItemMeta(meta); + return item; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/gui/StoreGui.java b/src/main/java/com/bitnix/dirtcryptostore/gui/StoreGui.java new file mode 100644 index 0000000..10a1ff1 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/gui/StoreGui.java @@ -0,0 +1,156 @@ +package com.bitnix.dirtcryptostore.gui; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import com.bitnix.dirtcryptostore.util.ColorUtil; +import com.bitnix.dirtcryptostore.util.TimeUtil; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.ArrayList; +import java.util.List; + +public class StoreGui { + + private final DirtCryptoStorePlugin plugin; + + public StoreGui(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + public void open(Player player) { + String title = ColorUtil.color(plugin.getConfig().getString("gui.store.title", "&8Crypto Store")); + int size = plugin.getConfig().getInt("gui.store.size", 27); + + Inventory inventory = Bukkit.createInventory(null, size, title); + + applyStaticItems(inventory); + applyProducts(player, inventory); + + player.openInventory(inventory); + } + + private void applyStaticItems(Inventory inventory) { + ConfigurationSection itemsSection = plugin.getConfig().getConfigurationSection("gui.store.items"); + if (itemsSection == null) { + return; + } + + for (String key : itemsSection.getKeys(false)) { + ConfigurationSection section = itemsSection.getConfigurationSection(key); + if (section == null || !section.getBoolean("enabled", true)) { + continue; + } + + if (key.equalsIgnoreCase("filler")) { + ItemStack filler = createItem( + section.getString("material", "GRAY_STAINED_GLASS_PANE"), + section.getString("name", " "), + section.getStringList("lore"), + 0, + false + ); + + for (int slot : section.getIntegerList("slots")) { + if (slot >= 0 && slot < inventory.getSize()) { + inventory.setItem(slot, filler); + } + } + continue; + } + + int slot = section.getInt("slot", -1); + if (slot < 0 || slot >= inventory.getSize()) { + continue; + } + + inventory.setItem(slot, createItem( + section.getString("material", "STONE"), + section.getString("name", "&fItem"), + section.getStringList("lore"), + section.getInt("custom-model-data", 0), + section.getBoolean("glow", false) + )); + } + } + + private void applyProducts(Player player, Inventory inventory) { + for (Product product : plugin.getConfigManager().getProducts()) { + if (!product.isEnabled()) { + continue; + } + + if (!product.getPermissionRequired().isEmpty() && !player.hasPermission(product.getPermissionRequired())) { + continue; + } + + if (product.getSlot() < 0 || product.getSlot() >= inventory.getSize()) { + continue; + } + + WalletConfig wallet = plugin.getConfigManager().getWallet(product.getWallet()); + if (wallet == null || !wallet.isEnabled()) { + continue; + } + + if (!plugin.getConfigManager().isWalletAllowedForProduct(wallet, product.getId())) { + continue; + } + + List lore = new ArrayList<>(); + for (String line : product.getLore()) { + lore.add(line + .replace("%wallet%", ColorUtil.color(wallet.getDisplayName())) + .replace("%wallet_address%", wallet.getAddress()) + .replace("%amount%", TimeUtil.formatBtcAmount(product.getBaseSats())) + .replace("%amount_sats%", String.valueOf(product.getBaseSats())) + .replace("%price_usd%", String.format("%.2f", product.getPriceUsd())) + .replace("%product%", ColorUtil.color(product.getDisplayName()))); + } + + inventory.setItem(product.getSlot(), createItem( + product.getMaterialName(), + product.getDisplayName(), + lore, + product.getCustomModelData(), + product.isGlow() + )); + } + } + + private ItemStack createItem(String materialName, String name, List lore, int customModelData, boolean glow) { + Material material = Material.matchMaterial(materialName); + if (material == null) { + material = Material.STONE; + } + + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + + meta.setDisplayName(ColorUtil.color(name)); + meta.setLore(ColorUtil.color(lore)); + + if (customModelData > 0) { + meta.setCustomModelData(customModelData); + } + + if (glow) { + meta.addEnchant(Enchantment.LUCK_OF_THE_SEA, 1, true); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + } + + item.setItemMeta(meta); + return item; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/listener/StoreGuiListener.java b/src/main/java/com/bitnix/dirtcryptostore/listener/StoreGuiListener.java new file mode 100644 index 0000000..21d8e01 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/listener/StoreGuiListener.java @@ -0,0 +1,217 @@ +package com.bitnix.dirtcryptostore.listener; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.gui.PaymentGui; +import com.bitnix.dirtcryptostore.gui.StoreGui; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import com.bitnix.dirtcryptostore.util.ColorUtil; +import com.bitnix.dirtcryptostore.util.EffectUtil; +import com.bitnix.dirtcryptostore.util.MessageUtil; +import com.bitnix.dirtcryptostore.util.TitleUtil; +import org.bukkit.Sound; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; + +public class StoreGuiListener implements Listener { + + private final DirtCryptoStorePlugin plugin; + + public StoreGuiListener(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + String title = event.getView().getTitle(); + String storeTitle = ColorUtil.color(plugin.getConfig().getString("gui.store.title", "&8Crypto Store")); + String paymentTitle = ColorUtil.color(plugin.getConfig().getString("gui.payment.title", "&8Crypto Payment")); + + if (title.equals(storeTitle)) { + handleStoreClick(event); + return; + } + + if (title.equals(paymentTitle)) { + handlePaymentClick(event); + } + } + + private void handleStoreClick(InventoryClickEvent event) { + event.setCancelled(true); + + HumanEntity clicker = event.getWhoClicked(); + if (!(clicker instanceof Player player)) { + return; + } + + if (event.getRawSlot() < 0 || event.getRawSlot() >= event.getInventory().getSize()) { + return; + } + + ItemStack clicked = event.getCurrentItem(); + if (clicked == null) { + return; + } + + ConfigurationSection storeItems = plugin.getConfig().getConfigurationSection("gui.store.items"); + if (storeItems != null) { + ConfigurationSection close = storeItems.getConfigurationSection("close"); + if (close != null && close.getBoolean("enabled", true) && event.getRawSlot() == close.getInt("slot", -1)) { + playConfiguredSound(player, "click"); + player.closeInventory(); + return; + } + + ConfigurationSection refresh = storeItems.getConfigurationSection("refresh"); + if (refresh != null && refresh.getBoolean("enabled", true) && event.getRawSlot() == refresh.getInt("slot", -1)) { + playConfiguredSound(player, "click"); + new StoreGui(plugin).open(player); + return; + } + } + + for (Product product : plugin.getConfigManager().getProducts()) { + if (!product.isEnabled()) { + continue; + } + + if (product.getSlot() != event.getRawSlot()) { + continue; + } + + if (!product.getPermissionRequired().isEmpty() && !player.hasPermission(product.getPermissionRequired())) { + MessageUtil.send(plugin, player, "messages.no-permission", "&cYou do not have permission."); + return; + } + + PendingInvoice existing = plugin.getInvoiceManager().getPendingInvoice(player.getUniqueId()); + if (existing != null) { + plugin.getQrMapManager().giveToOffhand(player, existing); + MessageUtil.send(plugin, player, "messages.already-has-pending", "&cYou already have a pending payment."); + player.sendMessage(ColorUtil.color("&aYour QR payment map has been placed in your off-hand.")); + new PaymentGui(plugin).open(player, existing); + return; + } + + WalletConfig wallet = plugin.getConfigManager().getWallet(product.getWallet()); + if (wallet == null || !wallet.isEnabled() || wallet.getAddress().isEmpty()) { + MessageUtil.send(plugin, player, "messages.payment-under-review", "&eA matching payment was found but needs review."); + return; + } + + if (!plugin.getConfigManager().isWalletAllowedForProduct(wallet, product.getId())) { + MessageUtil.send(plugin, player, "messages.payment-under-review", "&eA matching payment was found but needs review."); + return; + } + + long previewBaseSats = plugin.getPricingManager().resolveBaseSats(product); + if (previewBaseSats <= 0L) { + MessageUtil.send(plugin, player, "messages.pricing-unavailable", "&cLive BTC pricing is currently unavailable. Please try again in a moment."); + return; + } + + PendingInvoice invoice = plugin.getInvoiceManager().createInvoice(player, product, wallet); + if (invoice == null) { + MessageUtil.send(plugin, player, "messages.pricing-unavailable", "&cLive BTC pricing is currently unavailable. Please try again in a moment."); + return; + } + + plugin.getQrMapManager().giveToOffhand(player, invoice); + + playConfiguredSound(player, "invoice-created"); + EffectUtil.playEffect(plugin, player, "invoice-created"); + TitleUtil.sendTitle(plugin, player, "invoice-created"); + MessageUtil.send(plugin, player, "messages.invoice-created", "&aInvoice created for &f%product%&a.", + "%product%", product.getDisplayName()); + player.sendMessage(ColorUtil.color("&aYour QR payment map has been placed in your off-hand.")); + + new PaymentGui(plugin).open(player, invoice); + return; + } + } + + private void handlePaymentClick(InventoryClickEvent event) { + event.setCancelled(true); + + HumanEntity clicker = event.getWhoClicked(); + if (!(clicker instanceof Player player)) { + return; + } + + PendingInvoice invoice = plugin.getInvoiceManager().getPendingInvoice(player.getUniqueId()); + if (invoice == null) { + MessageUtil.send(plugin, player, "messages.no-pending-payment", "&cYou do not have a pending payment."); + player.closeInventory(); + return; + } + + ConfigurationSection paymentItems = plugin.getConfig().getConfigurationSection("gui.payment.items"); + if (paymentItems == null) { + return; + } + + int slot = event.getRawSlot(); + + ConfigurationSection back = paymentItems.getConfigurationSection("back"); + if (back != null && back.getBoolean("enabled", true) && slot == back.getInt("slot", -1)) { + playConfiguredSound(player, "click"); + new StoreGui(plugin).open(player); + return; + } + + ConfigurationSection cancel = paymentItems.getConfigurationSection("cancel"); + if (cancel != null && cancel.getBoolean("enabled", true) && slot == cancel.getInt("slot", -1)) { + if (!plugin.getConfig().getBoolean("settings.payment.allow-cancel", true)) { + return; + } + + plugin.getInvoiceManager().cancelInvoice(player.getUniqueId()); + plugin.getQrMapManager().clear(player); + playConfiguredSound(player, "cancelled"); + EffectUtil.playEffect(plugin, player, "cancelled"); + TitleUtil.sendTitle(plugin, player, "cancelled"); + MessageUtil.send(plugin, player, "messages.invoice-cancelled", "&eYour pending payment was cancelled."); + new StoreGui(plugin).open(player); + return; + } + + ConfigurationSection refresh = paymentItems.getConfigurationSection("refresh"); + if (refresh != null && refresh.getBoolean("enabled", true) && slot == refresh.getInt("slot", -1)) { + playConfiguredSound(player, "click"); + plugin.getQrMapManager().giveToOffhand(player, invoice); + player.sendMessage(ColorUtil.color("&aYour QR payment map has been refreshed in your off-hand.")); + PendingInvoice refreshed = plugin.getInvoiceManager().getPendingInvoice(player.getUniqueId()); + if (refreshed != null) { + new PaymentGui(plugin).open(player, refreshed); + } else { + player.closeInventory(); + } + } + } + + private void playConfiguredSound(Player player, String key) { + if (!plugin.getConfig().getBoolean("sounds.enabled", true)) { + return; + } + + ConfigurationSection section = plugin.getConfig().getConfigurationSection("sounds." + key); + if (section == null) { + return; + } + + try { + Sound sound = Sound.valueOf(section.getString("sound", "UI_BUTTON_CLICK")); + float volume = (float) section.getDouble("volume", 1.0D); + float pitch = (float) section.getDouble("pitch", 1.0D); + player.playSound(player.getLocation(), sound, volume, pitch); + } catch (IllegalArgumentException ignored) { + } + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/manager/BlockchainApi.java b/src/main/java/com/bitnix/dirtcryptostore/manager/BlockchainApi.java new file mode 100644 index 0000000..777a871 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/manager/BlockchainApi.java @@ -0,0 +1,110 @@ +package com.bitnix.dirtcryptostore.manager; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.BlockchainPaymentMatch; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class BlockchainApi { + + private final DirtCryptoStorePlugin plugin; + + public BlockchainApi(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + public BlockchainPaymentMatch findPayment(PendingInvoice invoice) { + try { + int timeout = plugin.getConfig().getInt("settings.blockchain.request-timeout-seconds", 10); + String userAgent = plugin.getConfig().getString("settings.blockchain.user-agent", "DirtCryptoStore/1.0.0"); + + String latestBlockJson = com.bitnix.dirtcryptostore.util.HttpUtil.get( + "https://blockchain.info/latestblock", + timeout, + userAgent + ); + + JsonObject latestBlockObject = JsonParser.parseString(latestBlockJson).getAsJsonObject(); + int latestHeight = latestBlockObject.has("height") ? latestBlockObject.get("height").getAsInt() : 0; + + String address = invoice.getWalletAddress(); + String rawAddrJson = com.bitnix.dirtcryptostore.util.HttpUtil.get( + "https://blockchain.info/rawaddr/" + address, + timeout, + userAgent + ); + + JsonObject root = JsonParser.parseString(rawAddrJson).getAsJsonObject(); + if (!root.has("txs") || !root.get("txs").isJsonArray()) { + return new BlockchainPaymentMatch(false, "", 0, 0); + } + + JsonArray txs = root.getAsJsonArray("txs"); + + for (JsonElement txElement : txs) { + if (!txElement.isJsonObject()) { + continue; + } + + JsonObject tx = txElement.getAsJsonObject(); + String txid = tx.has("hash") ? tx.get("hash").getAsString() : ""; + int blockHeight = tx.has("block_height") ? tx.get("block_height").getAsInt() : 0; + + if (!tx.has("out") || !tx.get("out").isJsonArray()) { + continue; + } + + JsonArray outs = tx.getAsJsonArray("out"); + for (JsonElement outElement : outs) { + if (!outElement.isJsonObject()) { + continue; + } + + JsonObject out = outElement.getAsJsonObject(); + + if (!out.has("value")) { + continue; + } + + long value = out.get("value").getAsLong(); + + String outAddress = ""; + if (out.has("addr") && !out.get("addr").isJsonNull()) { + outAddress = out.get("addr").getAsString(); + } + + if (!outAddress.equalsIgnoreCase(address)) { + continue; + } + + if (value != invoice.getAmountSats()) { + continue; + } + + int confirmations = 0; + if (blockHeight > 0 && latestHeight > 0) { + confirmations = Math.max(1, (latestHeight - blockHeight) + 1); + } + + if (plugin.getConfig().getBoolean("settings.debug", false)) { + plugin.getLogger().info("Matched invoice " + invoice.getInvoiceId() + + " to txid=" + txid + + " confirmations=" + confirmations + + " blockHeight=" + blockHeight); + } + + return new BlockchainPaymentMatch(true, txid, confirmations, blockHeight); + } + } + } catch (Exception ex) { + if (plugin.getConfig().getBoolean("settings.debug", false)) { + plugin.getLogger().warning("Blockchain lookup failed for invoice " + invoice.getInvoiceId() + ": " + ex.getMessage()); + } + } + + return new BlockchainPaymentMatch(false, "", 0, 0); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/manager/ConfigManager.java b/src/main/java/com/bitnix/dirtcryptostore/manager/ConfigManager.java new file mode 100644 index 0000000..674f00e --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/manager/ConfigManager.java @@ -0,0 +1,125 @@ +package com.bitnix.dirtcryptostore.manager; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import org.bukkit.configuration.ConfigurationSection; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ConfigManager { + + private final DirtCryptoStorePlugin plugin; + private final Map products = new LinkedHashMap<>(); + private final Map wallets = new LinkedHashMap<>(); + + public ConfigManager(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + public void load() { + products.clear(); + wallets.clear(); + loadWallets(); + loadProducts(); + } + + private void loadWallets() { + ConfigurationSection section = plugin.getConfig().getConfigurationSection("wallets"); + if (section == null) { + return; + } + + for (String key : section.getKeys(false)) { + ConfigurationSection walletSection = section.getConfigurationSection(key); + if (walletSection == null) { + continue; + } + + WalletConfig wallet = new WalletConfig( + key, + walletSection.getBoolean("enabled", true), + walletSection.getString("display-name", key), + walletSection.getString("coin", "BTC"), + walletSection.getString("address", ""), + walletSection.getString("explorer-type", "BTC"), + walletSection.getInt("min-confirmations", 2), + walletSection.getStringList("allow-products") + ); + + wallets.put(key.toLowerCase(), wallet); + } + } + + private void loadProducts() { + ConfigurationSection section = plugin.getConfig().getConfigurationSection("products"); + if (section == null) { + return; + } + + for (String key : section.getKeys(false)) { + ConfigurationSection productSection = section.getConfigurationSection(key); + if (productSection == null) { + continue; + } + + Product product = new Product( + key, + productSection.getBoolean("enabled", true), + productSection.getString("display-name", key), + productSection.getString("material", "STONE"), + productSection.getInt("custom-model-data", 0), + productSection.getBoolean("glow", false), + productSection.getInt("slot", -1), + productSection.getString("wallet", "default"), + productSection.getDouble("price-usd", 0.0D), + productSection.getLong("base-sats", 0L), + productSection.getString("permission-required", ""), + productSection.getBoolean("purchasable-once", false), + productSection.getStringList("commands"), + productSection.getStringList("lore") + ); + + products.put(key.toLowerCase(), product); + } + } + + public Collection getProducts() { + return Collections.unmodifiableCollection(products.values()); + } + + public Product getProduct(String id) { + if (id == null) { + return null; + } + return products.get(id.toLowerCase()); + } + + public WalletConfig getWallet(String id) { + if (id == null) { + return null; + } + return wallets.get(id.toLowerCase()); + } + + public boolean isWalletAllowedForProduct(WalletConfig wallet, String productId) { + if (wallet == null || productId == null) { + return false; + } + + if (wallet.getAllowProducts().isEmpty()) { + return true; + } + + for (String allowed : wallet.getAllowProducts()) { + if (allowed.equalsIgnoreCase(productId)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/manager/InvoiceManager.java b/src/main/java/com/bitnix/dirtcryptostore/manager/InvoiceManager.java new file mode 100644 index 0000000..d8f47ea --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/manager/InvoiceManager.java @@ -0,0 +1,312 @@ +package com.bitnix.dirtcryptostore.manager; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import com.bitnix.dirtcryptostore.storage.SQLiteStorage; +import org.bukkit.entity.Player; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +public class InvoiceManager { + + private final DirtCryptoStorePlugin plugin; + private final SQLiteStorage storage; + + private final Map pendingByPlayer = new HashMap<>(); + private final Map allByInvoiceId = new HashMap<>(); + private final Map invoiceIdByTxid = new HashMap<>(); + private final Set reservedActiveAmounts = new HashSet<>(); + + private long sequentialCounter = 1L; + + public InvoiceManager(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + this.storage = new SQLiteStorage(plugin); + } + + public synchronized void init() { + storage.init(); + } + + public synchronized void load() { + clearAllInternal(false); + + List invoices = storage.loadAllInvoices(); + for (PendingInvoice invoice : invoices) { + allByInvoiceId.put(invoice.getInvoiceId(), invoice); + + if (!invoice.isFinalState()) { + pendingByPlayer.put(invoice.getPlayerUuid(), invoice); + reservedActiveAmounts.add(invoice.getAmountSats()); + } + + if (!invoice.getTxid().isEmpty()) { + invoiceIdByTxid.put(invoice.getTxid(), invoice.getInvoiceId()); + } + } + + pruneExpiredAndOld(); + } + + public synchronized void save() { + for (PendingInvoice invoice : allByInvoiceId.values()) { + storage.saveInvoice(invoice); + } + } + + public synchronized PendingInvoice getPendingInvoice(UUID uuid) { + PendingInvoice invoice = pendingByPlayer.get(uuid); + if (invoice == null) { + return null; + } + + if (invoice.getStatus() == PendingInvoice.Status.PENDING && invoice.isExpired()) { + expireInvoice(invoice, "Expired while checking pending invoice."); + return null; + } + + return invoice.isFinalState() ? null : invoice; + } + + public synchronized PendingInvoice getInvoiceById(String invoiceId) { + return allByInvoiceId.get(invoiceId); + } + + public synchronized boolean isTxidAlreadyClaimed(String txid, String invoiceId) { + String existing = invoiceIdByTxid.get(txid); + return existing != null && !existing.equalsIgnoreCase(invoiceId); + } + + public synchronized PendingInvoice createInvoice(Player player, Product product, WalletConfig wallet) { + PendingInvoice existing = getPendingInvoice(player.getUniqueId()); + if (existing != null) { + return null; + } + + long resolvedBaseSats = plugin.getPricingManager().resolveBaseSats(product); + if (resolvedBaseSats <= 0L) { + return null; + } + + long amountSats = generateUniqueAmount(resolvedBaseSats); + long now = System.currentTimeMillis(); + long expireMinutes = plugin.getConfig().getLong("settings.payment.invoice-expire-minutes", 30L); + long expiresAt = now + (expireMinutes * 60_000L); + + PendingInvoice invoice = new PendingInvoice( + UUID.randomUUID().toString(), + player.getUniqueId(), + player.getName(), + product.getId(), + wallet.getId(), + wallet.getAddress(), + amountSats, + now, + expiresAt, + PendingInvoice.Status.PENDING, + "", + 0, + false, + 0L, + 0L, + "Invoice created." + ); + + pendingByPlayer.put(player.getUniqueId(), invoice); + allByInvoiceId.put(invoice.getInvoiceId(), invoice); + reservedActiveAmounts.add(amountSats); + storage.saveInvoice(invoice); + return invoice; + } + + public synchronized void cancelInvoice(UUID uuid) { + PendingInvoice invoice = pendingByPlayer.get(uuid); + if (invoice == null || invoice.isFinalState()) { + return; + } + + invoice.setStatus(PendingInvoice.Status.CANCELLED); + invoice.setNotes("Cancelled by player."); + reservedActiveAmounts.remove(invoice.getAmountSats()); + pendingByPlayer.remove(uuid); + storage.saveInvoice(invoice); + } + + public synchronized void expireInvoice(PendingInvoice invoice, String note) { + if (invoice == null || invoice.isFinalState()) { + return; + } + + invoice.setStatus(PendingInvoice.Status.EXPIRED); + invoice.setNotes(note == null ? "Expired." : note); + reservedActiveAmounts.remove(invoice.getAmountSats()); + pendingByPlayer.remove(invoice.getPlayerUuid()); + storage.saveInvoice(invoice); + } + + public synchronized void markDetected(PendingInvoice invoice, String txid, int confirmations, String note) { + if (invoice == null || invoice.isFinalState()) { + return; + } + + invoice.setStatus(PendingInvoice.Status.DETECTED); + invoice.setTxid(txid); + invoice.setConfirmations(confirmations); + if (invoice.getMatchedAt() <= 0L) { + invoice.setMatchedAt(System.currentTimeMillis()); + } + invoice.setNotes(note == null ? "Payment detected." : note); + + if (txid != null && !txid.isEmpty()) { + invoiceIdByTxid.put(txid, invoice.getInvoiceId()); + } + + storage.saveInvoice(invoice); + } + + public synchronized void markFulfilled(PendingInvoice invoice, String txid, int confirmations, String note) { + if (invoice == null || invoice.isFulfilled()) { + return; + } + + invoice.setStatus(PendingInvoice.Status.FULFILLED); + invoice.setTxid(txid); + invoice.setConfirmations(confirmations); + invoice.setFulfilled(true); + if (invoice.getMatchedAt() <= 0L) { + invoice.setMatchedAt(System.currentTimeMillis()); + } + invoice.setFulfilledAt(System.currentTimeMillis()); + invoice.setNotes(note == null ? "Payment fulfilled." : note); + + if (txid != null && !txid.isEmpty()) { + invoiceIdByTxid.put(txid, invoice.getInvoiceId()); + } + + reservedActiveAmounts.remove(invoice.getAmountSats()); + pendingByPlayer.remove(invoice.getPlayerUuid()); + storage.saveInvoice(invoice); + } + + public synchronized void markUnderReview(PendingInvoice invoice, String txid, int confirmations, String note) { + if (invoice == null || invoice.isFinalState()) { + return; + } + + invoice.setStatus(PendingInvoice.Status.UNDER_REVIEW); + invoice.setTxid(txid == null ? "" : txid); + invoice.setConfirmations(confirmations); + if (invoice.getMatchedAt() <= 0L && txid != null && !txid.isEmpty()) { + invoice.setMatchedAt(System.currentTimeMillis()); + } + invoice.setNotes(note == null ? "Marked under review." : note); + + if (txid != null && !txid.isEmpty()) { + invoiceIdByTxid.put(txid, invoice.getInvoiceId()); + } + + reservedActiveAmounts.remove(invoice.getAmountSats()); + pendingByPlayer.remove(invoice.getPlayerUuid()); + storage.saveInvoice(invoice); + } + + public synchronized Collection getAllInvoices() { + return new ArrayList<>(allByInvoiceId.values()); + } + + public synchronized void cleanupExpiredInvoices() { + for (PendingInvoice invoice : new ArrayList<>(allByInvoiceId.values())) { + if (invoice.getStatus() == PendingInvoice.Status.PENDING && invoice.isExpired()) { + expireInvoice(invoice, "Expired by cleanup task."); + } + } + } + + public synchronized void pruneExpiredAndOld() { + cleanupExpiredInvoices(); + + if (!plugin.getConfig().getBoolean("settings.storage.prune-enabled", true)) { + return; + } + + int pruneAfterDays = plugin.getConfig().getInt("settings.storage.prune-after-days", 30); + long cutoff = System.currentTimeMillis() - (pruneAfterDays * 24L * 60L * 60L * 1000L); + + List statuses = plugin.getConfig().getStringList("settings.storage.prune-statuses"); + if (statuses.isEmpty()) { + return; + } + + List removeIds = new ArrayList<>(); + for (PendingInvoice invoice : allByInvoiceId.values()) { + if (invoice.getCreatedAt() >= cutoff) { + continue; + } + + if (statuses.contains(invoice.getStatus().name())) { + removeIds.add(invoice.getInvoiceId()); + } + } + + for (String invoiceId : removeIds) { + PendingInvoice removed = allByInvoiceId.remove(invoiceId); + if (removed != null) { + pendingByPlayer.remove(removed.getPlayerUuid()); + reservedActiveAmounts.remove(removed.getAmountSats()); + if (!removed.getTxid().isEmpty()) { + invoiceIdByTxid.remove(removed.getTxid()); + } + storage.deleteInvoice(invoiceId); + } + } + + storage.pruneOldInvoices(cutoff, statuses); + } + + public synchronized void clearAll() { + clearAllInternal(true); + } + + private void clearAllInternal(boolean resetCounter) { + pendingByPlayer.clear(); + allByInvoiceId.clear(); + invoiceIdByTxid.clear(); + reservedActiveAmounts.clear(); + if (resetCounter) { + sequentialCounter = 1L; + } + } + + private long generateUniqueAmount(long baseSats) { + String mode = plugin.getConfig().getString("settings.micro-amount.mode", "RANDOM_RANGE"); + + if ("SEQUENTIAL".equalsIgnoreCase(mode)) { + long candidate = baseSats + Math.max(1L, sequentialCounter++); + while (reservedActiveAmounts.contains(candidate)) { + candidate++; + } + return candidate; + } + + long min = plugin.getConfig().getLong("settings.micro-amount.min-extra-sats", 1L); + long max = plugin.getConfig().getLong("settings.micro-amount.max-extra-sats", 200L); + long attempts = plugin.getConfig().getLong("settings.micro-amount.max-generation-attempts", 500L); + + for (int i = 0; i < attempts; i++) { + long randomExtra = ThreadLocalRandom.current().nextLong(min, max + 1); + long candidate = baseSats + randomExtra; + if (!reservedActiveAmounts.contains(candidate)) { + return candidate; + } + } + + long candidate = baseSats + max + 1L; + while (reservedActiveAmounts.contains(candidate)) { + candidate++; + } + return candidate; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/manager/PaymentWatcher.java b/src/main/java/com/bitnix/dirtcryptostore/manager/PaymentWatcher.java new file mode 100644 index 0000000..7b7a65d --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/manager/PaymentWatcher.java @@ -0,0 +1,185 @@ +package com.bitnix.dirtcryptostore.manager; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.BlockchainPaymentMatch; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.model.WalletConfig; +import com.bitnix.dirtcryptostore.util.EffectUtil; +import com.bitnix.dirtcryptostore.util.MessageUtil; +import com.bitnix.dirtcryptostore.util.TitleUtil; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +public class PaymentWatcher implements Runnable { + + private final DirtCryptoStorePlugin plugin; + private final BlockchainApi blockchainApi; + + public PaymentWatcher(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + this.blockchainApi = new BlockchainApi(plugin); + } + + @Override + public void run() { + plugin.getInvoiceManager().cleanupExpiredInvoices(); + + for (PendingInvoice invoice : plugin.getInvoiceManager().getAllInvoices()) { + if (!invoice.isMatchable()) { + continue; + } + + if (invoice.getStatus() == PendingInvoice.Status.PENDING && invoice.isExpired()) { + plugin.getInvoiceManager().expireInvoice(invoice, "Expired by watcher."); + notifyExpired(invoice); + continue; + } + + BlockchainPaymentMatch match = blockchainApi.findPayment(invoice); + if (!match.isFound()) { + continue; + } + + if (plugin.getInvoiceManager().isTxidAlreadyClaimed(match.getTxid(), invoice.getInvoiceId())) { + plugin.getInvoiceManager().markUnderReview( + invoice, + match.getTxid(), + match.getConfirmations(), + "TXID already claimed by another invoice." + ); + notifyUnderReview(invoice); + continue; + } + + WalletConfig wallet = plugin.getConfigManager().getWallet(invoice.getWalletId()); + int requiredConfirmations = wallet == null + ? plugin.getConfig().getInt("settings.payment.confirmations-required", 2) + : wallet.getMinConfirmations(); + + boolean firstDetection = invoice.getStatus() == PendingInvoice.Status.PENDING; + + if (match.getConfirmations() >= requiredConfirmations) { + fulfill(invoice, match, false); + } else { + plugin.getInvoiceManager().markDetected( + invoice, + match.getTxid(), + match.getConfirmations(), + "Payment detected, waiting for confirmations." + ); + if (firstDetection) { + notifyDetected(invoice); + } + } + } + } + + public void forceFulfillFromAdmin(PendingInvoice invoice, BlockchainPaymentMatch match) { + fulfill(invoice, match, true); + } + + private void fulfill(PendingInvoice invoice, BlockchainPaymentMatch match, boolean manual) { + if (invoice.isFulfilled()) { + return; + } + + Product product = plugin.getConfigManager().getProduct(invoice.getProductId()); + WalletConfig wallet = plugin.getConfigManager().getWallet(invoice.getWalletId()); + + if (product == null) { + plugin.getInvoiceManager().markUnderReview( + invoice, + match.getTxid(), + match.getConfirmations(), + "Product config missing during fulfillment." + ); + notifyUnderReview(invoice); + return; + } + + plugin.getInvoiceManager().markFulfilled( + invoice, + match.getTxid(), + match.getConfirmations(), + manual ? "Payment fulfilled manually by admin." : "Payment fulfilled automatically." + ); + + Player player = Bukkit.getPlayer(invoice.getPlayerUuid()); + if (player != null) { + Bukkit.getScheduler().runTask(plugin, () -> { + MessageUtil.send(plugin, player, "messages.payment-confirmed", + "&aYour crypto payment for &f%product% &ahas been confirmed.", + "%product%", product.getDisplayName()); + MessageUtil.send(plugin, player, "messages.payment-fulfilled", + "&aYour purchase has been delivered."); + EffectUtil.playEffect(plugin, player, "payment-confirmed"); + TitleUtil.sendTitle(plugin, player, "payment-confirmed"); + }); + } + + boolean dispatchAsConsole = plugin.getConfig().getBoolean("settings.commands.dispatch-as-console", true); + + for (String command : product.getCommands()) { + String parsed = command + .replace("%player%", invoice.getPlayerName()) + .replace("%uuid%", invoice.getPlayerUuid().toString()) + .replace("%product%", product.getId()) + .replace("%wallet%", wallet == null ? invoice.getWalletId() : wallet.getId()) + .replace("%txid%", match.getTxid()) + .replace("%confirmations%", String.valueOf(match.getConfirmations())); + + if (dispatchAsConsole) { + Bukkit.getScheduler().runTask(plugin, () -> Bukkit.dispatchCommand(Bukkit.getConsoleSender(), parsed)); + } + } + + if (plugin.getConfig().getBoolean("settings.debug", false)) { + plugin.getLogger().info((manual ? "[MANUAL] " : "[AUTO] ") + + "Fulfilled invoice " + invoice.getInvoiceId() + + " txid=" + match.getTxid() + + " confirmations=" + match.getConfirmations()); + } + } + + private void notifyExpired(PendingInvoice invoice) { + Player player = Bukkit.getPlayer(invoice.getPlayerUuid()); + if (player == null) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> { + MessageUtil.send(plugin, player, "messages.invoice-expired", "&cThis payment request expired."); + EffectUtil.playEffect(plugin, player, "expired"); + TitleUtil.sendTitle(plugin, player, "cancelled"); + }); + } + + private void notifyDetected(PendingInvoice invoice) { + Player player = Bukkit.getPlayer(invoice.getPlayerUuid()); + Product product = plugin.getConfigManager().getProduct(invoice.getProductId()); + if (player == null || product == null) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> { + MessageUtil.send(plugin, player, "messages.payment-detected", + "&aPayment detected for &f%product%&a. Waiting for confirmations...", + "%product%", product.getDisplayName()); + EffectUtil.playEffect(plugin, player, "payment-detected"); + TitleUtil.sendTitle(plugin, player, "payment-detected"); + }); + } + + private void notifyUnderReview(PendingInvoice invoice) { + Player player = Bukkit.getPlayer(invoice.getPlayerUuid()); + if (player == null) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> { + MessageUtil.send(plugin, player, "messages.payment-under-review", + "&eA matching payment was found but needs review."); + }); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/manager/PricingManager.java b/src/main/java/com/bitnix/dirtcryptostore/manager/PricingManager.java new file mode 100644 index 0000000..57b2e17 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/manager/PricingManager.java @@ -0,0 +1,109 @@ +package com.bitnix.dirtcryptostore.manager; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.Product; +import com.bitnix.dirtcryptostore.util.HttpUtil; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class PricingManager { + + private final DirtCryptoStorePlugin plugin; + + private volatile double cachedBtcUsdPrice = -1.0D; + private volatile long lastFetchTime = 0L; + + public PricingManager(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + public long resolveBaseSats(Product product) { + String mode = plugin.getConfig().getString("settings.pricing.mode", "BASE_SATS"); + + if (!"LIVE_USD".equalsIgnoreCase(mode)) { + return product.getBaseSats(); + } + + Double btcUsdPrice = getLiveBtcUsdPrice(); + if (btcUsdPrice == null || btcUsdPrice <= 0.0D) { + boolean fallback = plugin.getConfig().getBoolean("settings.pricing.fallback-to-base-sats", true); + return fallback ? product.getBaseSats() : -1L; + } + + double usdPrice = product.getPriceUsd(); + if (usdPrice <= 0.0D) { + return product.getBaseSats(); + } + + double btcAmount = usdPrice / btcUsdPrice; + double sats = btcAmount * 100_000_000.0D; + + boolean roundUp = plugin.getConfig().getBoolean("settings.pricing.round-up-to-sat", true); + long resolved = roundUp ? (long) Math.ceil(sats) : Math.round(sats); + + if (resolved <= 0L) { + return -1L; + } + + return resolved; + } + + public Double getLiveBtcUsdPrice() { + long cacheSeconds = plugin.getConfig().getLong("settings.pricing.cache-seconds", 60L); + long now = System.currentTimeMillis(); + + if (cachedBtcUsdPrice > 0.0D && (now - lastFetchTime) < (cacheSeconds * 1000L)) { + return cachedBtcUsdPrice; + } + + synchronized (this) { + if (cachedBtcUsdPrice > 0.0D && (now - lastFetchTime) < (cacheSeconds * 1000L)) { + return cachedBtcUsdPrice; + } + + try { + String provider = plugin.getConfig().getString("settings.pricing.live-rate-provider", "COINGECKO"); + if (!"COINGECKO".equalsIgnoreCase(provider)) { + return cachedBtcUsdPrice > 0.0D ? cachedBtcUsdPrice : null; + } + + int timeout = plugin.getConfig().getInt("settings.blockchain.request-timeout-seconds", 10); + String userAgent = plugin.getConfig().getString("settings.blockchain.user-agent", "DirtCryptoStore/1.0.0"); + String url = plugin.getConfig().getString( + "settings.pricing.coingecko-url", + "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + ); + + String json = HttpUtil.get(url, timeout, userAgent); + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + JsonObject bitcoin = root.getAsJsonObject("bitcoin"); + if (bitcoin == null || !bitcoin.has("usd")) { + return cachedBtcUsdPrice > 0.0D ? cachedBtcUsdPrice : null; + } + + double price = bitcoin.get("usd").getAsDouble(); + if (price > 0.0D) { + cachedBtcUsdPrice = price; + lastFetchTime = now; + + if (plugin.getConfig().getBoolean("settings.debug", false)) { + plugin.getLogger().info("Updated live BTC/USD price: " + price); + } + + return price; + } + } catch (Exception ex) { + if (plugin.getConfig().getBoolean("settings.debug", false)) { + plugin.getLogger().warning("Failed to fetch live BTC price: " + ex.getMessage()); + } + } + + return cachedBtcUsdPrice > 0.0D ? cachedBtcUsdPrice : null; + } + } + + public void clearCache() { + cachedBtcUsdPrice = -1.0D; + lastFetchTime = 0L; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/manager/QrMapManager.java b/src/main/java/com/bitnix/dirtcryptostore/manager/QrMapManager.java new file mode 100644 index 0000000..5cd6f81 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/manager/QrMapManager.java @@ -0,0 +1,64 @@ +package com.bitnix.dirtcryptostore.manager; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.PendingInvoice; +import com.bitnix.dirtcryptostore.util.ColorUtil; +import com.bitnix.dirtcryptostore.util.QrMapUtil; +import com.bitnix.dirtcryptostore.util.QrPlaceholderUtil; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class QrMapManager { + + private final DirtCryptoStorePlugin plugin; + private final Map activeQrMaps = new HashMap<>(); + + public QrMapManager(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + } + + public ItemStack createQrMap(Player player, PendingInvoice invoice) { + String bitcoinUri = QrPlaceholderUtil.buildBitcoinUri(invoice); + + return QrMapUtil.createQrMapItem( + plugin, + player, + bitcoinUri, + ColorUtil.color("&aScan QR To Pay"), + java.util.List.of( + ColorUtil.color("&7Invoice: &f" + invoice.getInvoiceId()), + ColorUtil.color("&7Amount: &f" + com.bitnix.dirtcryptostore.util.TimeUtil.formatBtcAmount(invoice.getAmountSats())), + ColorUtil.color("&7Address: &f" + invoice.getWalletAddress()), + ColorUtil.color("&7Use /cryptostore to reopen controls.") + ), + plugin.getConfig().getString("settings.qr.fallback-material-if-map-fails", "PAPER") + ); + } + + public void giveToOffhand(Player player, PendingInvoice invoice) { + ItemStack qrMap = createQrMap(player, invoice); + activeQrMaps.put(player.getUniqueId(), qrMap); + player.getInventory().setItemInOffHand(qrMap); + player.updateInventory(); + } + + public void clear(Player player) { + activeQrMaps.remove(player.getUniqueId()); + } + + public ItemStack getActiveMap(UUID uuid) { + return activeQrMaps.get(uuid); + } + + public boolean hasActiveMap(UUID uuid) { + return activeQrMaps.containsKey(uuid); + } + + public void clearAll() { + activeQrMaps.clear(); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.java b/src/main/java/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.java new file mode 100644 index 0000000..e20e912 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.java @@ -0,0 +1,32 @@ +package com.bitnix.dirtcryptostore.model; + +public class BlockchainPaymentMatch { + + private final boolean found; + private final String txid; + private final int confirmations; + private final int blockHeight; + + public BlockchainPaymentMatch(boolean found, String txid, int confirmations, int blockHeight) { + this.found = found; + this.txid = txid; + this.confirmations = confirmations; + this.blockHeight = blockHeight; + } + + public boolean isFound() { + return found; + } + + public String getTxid() { + return txid; + } + + public int getConfirmations() { + return confirmations; + } + + public int getBlockHeight() { + return blockHeight; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/model/PendingInvoice.java b/src/main/java/com/bitnix/dirtcryptostore/model/PendingInvoice.java new file mode 100644 index 0000000..b2f051f --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/model/PendingInvoice.java @@ -0,0 +1,177 @@ +package com.bitnix.dirtcryptostore.model; + +import java.util.UUID; + +public class PendingInvoice { + + public enum Status { + PENDING, + DETECTED, + FULFILLED, + EXPIRED, + CANCELLED, + UNDER_REVIEW + } + + private final String invoiceId; + private final UUID playerUuid; + private final String playerName; + private final String productId; + private final String walletId; + private final String walletAddress; + private final long amountSats; + private final long createdAt; + private final long expiresAt; + + private Status status; + private String txid; + private int confirmations; + private boolean fulfilled; + private long matchedAt; + private long fulfilledAt; + private String notes; + + public PendingInvoice(String invoiceId, + UUID playerUuid, + String playerName, + String productId, + String walletId, + String walletAddress, + long amountSats, + long createdAt, + long expiresAt, + Status status, + String txid, + int confirmations, + boolean fulfilled, + long matchedAt, + long fulfilledAt, + String notes) { + this.invoiceId = invoiceId; + this.playerUuid = playerUuid; + this.playerName = playerName; + this.productId = productId; + this.walletId = walletId; + this.walletAddress = walletAddress; + this.amountSats = amountSats; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.status = status == null ? Status.PENDING : status; + this.txid = txid == null ? "" : txid; + this.confirmations = confirmations; + this.fulfilled = fulfilled; + this.matchedAt = matchedAt; + this.fulfilledAt = fulfilledAt; + this.notes = notes == null ? "" : notes; + } + + public String getInvoiceId() { + return invoiceId; + } + + public UUID getPlayerUuid() { + return playerUuid; + } + + public String getPlayerName() { + return playerName; + } + + public String getProductId() { + return productId; + } + + public String getWalletId() { + return walletId; + } + + public String getWalletAddress() { + return walletAddress; + } + + public long getAmountSats() { + return amountSats; + } + + public long getCreatedAt() { + return createdAt; + } + + public long getExpiresAt() { + return expiresAt; + } + + public Status getStatus() { + return status; + } + + public String getTxid() { + return txid; + } + + public int getConfirmations() { + return confirmations; + } + + public boolean isFulfilled() { + return fulfilled; + } + + public long getMatchedAt() { + return matchedAt; + } + + public long getFulfilledAt() { + return fulfilledAt; + } + + public String getNotes() { + return notes; + } + + public void setStatus(Status status) { + this.status = status == null ? Status.PENDING : status; + } + + public void setTxid(String txid) { + this.txid = txid == null ? "" : txid; + } + + public void setConfirmations(int confirmations) { + this.confirmations = confirmations; + } + + public void setFulfilled(boolean fulfilled) { + this.fulfilled = fulfilled; + } + + public void setMatchedAt(long matchedAt) { + this.matchedAt = matchedAt; + } + + public void setFulfilledAt(long fulfilledAt) { + this.fulfilledAt = fulfilledAt; + } + + public void setNotes(String notes) { + this.notes = notes == null ? "" : notes; + } + + public boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + + public long getRemainingMillis() { + return Math.max(0L, expiresAt - System.currentTimeMillis()); + } + + public boolean isFinalState() { + return status == Status.FULFILLED + || status == Status.EXPIRED + || status == Status.CANCELLED; + } + + public boolean isMatchable() { + return status == Status.PENDING || status == Status.DETECTED; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/model/Product.java b/src/main/java/com/bitnix/dirtcryptostore/model/Product.java new file mode 100644 index 0000000..6729f31 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/model/Product.java @@ -0,0 +1,121 @@ +package com.bitnix.dirtcryptostore.model; + +import java.util.ArrayList; +import java.util.List; + +public class Product { + + private final String id; + private final boolean enabled; + private final String displayName; + private final String materialName; + private final int customModelData; + private final boolean glow; + private final int slot; + private final String wallet; + private final double priceUsd; + private final long baseSats; + private final String permissionRequired; + private final boolean purchasableOnce; + private final List commands; + private final List lore; + + public Product(String id, + boolean enabled, + String displayName, + String materialName, + int customModelData, + boolean glow, + int slot, + String wallet, + double priceUsd, + long baseSats, + String permissionRequired, + boolean purchasableOnce, + List commands, + List lore) { + this.id = id; + this.enabled = enabled; + this.displayName = displayName; + this.materialName = materialName; + this.customModelData = customModelData; + this.glow = glow; + this.slot = slot; + this.wallet = wallet; + this.priceUsd = priceUsd; + this.baseSats = baseSats; + this.permissionRequired = permissionRequired == null ? "" : permissionRequired; + this.purchasableOnce = purchasableOnce; + this.commands = commands == null ? new ArrayList<>() : new ArrayList<>(commands); + this.lore = lore == null ? new ArrayList<>() : new ArrayList<>(lore); + } + + public String getId() { + return id; + } + + public boolean isEnabled() { + return enabled; + } + + public String getDisplayName() { + return displayName; + } + + public String getMaterialName() { + return materialName; + } + + public int getCustomModelData() { + return customModelData; + } + + public boolean isGlow() { + return glow; + } + + public int getSlot() { + return slot; + } + + public String getWallet() { + return wallet; + } + + public double getPriceUsd() { + return priceUsd; + } + + public long getBaseSats() { + return baseSats; + } + + public String getPermissionRequired() { + return permissionRequired; + } + + public boolean isPurchasableOnce() { + return purchasableOnce; + } + + public List getCommands() { + return new ArrayList<>(commands); + } + + public List getLore() { + return new ArrayList<>(lore); + } + + public String formatBtcAmount(long sats) { + long whole = sats / 100_000_000L; + long fractional = sats % 100_000_000L; + return whole + "." + String.format("%08d", fractional) + " BTC"; + } + + public String formatBitcoinUri(long sats, String address) { + long whole = sats / 100_000_000L; + long fractional = sats % 100_000_000L; + String amount = whole + "." + String.format("%08d", fractional); + return "bitcoin:" + address + "?amount=" + amount; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/model/WalletConfig.java b/src/main/java/com/bitnix/dirtcryptostore/model/WalletConfig.java new file mode 100644 index 0000000..e3820bd --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/model/WalletConfig.java @@ -0,0 +1,66 @@ +package com.bitnix.dirtcryptostore.model; + +import java.util.ArrayList; +import java.util.List; + +public class WalletConfig { + + private final String id; + private final boolean enabled; + private final String displayName; + private final String coin; + private final String address; + private final String explorerType; + private final int minConfirmations; + private final List allowProducts; + + public WalletConfig(String id, + boolean enabled, + String displayName, + String coin, + String address, + String explorerType, + int minConfirmations, + List allowProducts) { + this.id = id; + this.enabled = enabled; + this.displayName = displayName; + this.coin = coin; + this.address = address; + this.explorerType = explorerType; + this.minConfirmations = minConfirmations; + this.allowProducts = allowProducts == null ? new ArrayList<>() : new ArrayList<>(allowProducts); + } + + public String getId() { + return id; + } + + public boolean isEnabled() { + return enabled; + } + + public String getDisplayName() { + return displayName; + } + + public String getCoin() { + return coin; + } + + public String getAddress() { + return address; + } + + public String getExplorerType() { + return explorerType; + } + + public int getMinConfirmations() { + return minConfirmations; + } + + public List getAllowProducts() { + return new ArrayList<>(allowProducts); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/storage/SQLiteStorage.java b/src/main/java/com/bitnix/dirtcryptostore/storage/SQLiteStorage.java new file mode 100644 index 0000000..f72c9d1 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/storage/SQLiteStorage.java @@ -0,0 +1,196 @@ +package com.bitnix.dirtcryptostore.storage; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.bitnix.dirtcryptostore.model.PendingInvoice; + +import java.io.File; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +public class SQLiteStorage { + + private final DirtCryptoStorePlugin plugin; + private final File databaseFile; + + public SQLiteStorage(DirtCryptoStorePlugin plugin) { + this.plugin = plugin; + this.databaseFile = new File(plugin.getDataFolder(), + plugin.getConfig().getString("settings.storage.sqlite-file", "dirtcryptostore.db")); + } + + public void init() { + if (!plugin.getDataFolder().exists()) { + plugin.getDataFolder().mkdirs(); + } + + try (Connection connection = getConnection(); + Statement statement = connection.createStatement()) { + + statement.executeUpdate(""" + CREATE TABLE IF NOT EXISTS invoices ( + invoice_id TEXT PRIMARY KEY, + player_uuid TEXT NOT NULL, + player_name TEXT NOT NULL, + product_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + wallet_address TEXT NOT NULL, + amount_sats INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + status TEXT NOT NULL, + txid TEXT NOT NULL, + confirmations INTEGER NOT NULL, + fulfilled INTEGER NOT NULL, + matched_at INTEGER NOT NULL, + fulfilled_at INTEGER NOT NULL, + notes TEXT NOT NULL + ) + """); + + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_invoices_player_uuid ON invoices(player_uuid)"); + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)"); + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_invoices_txid ON invoices(txid)"); + statement.executeUpdate("CREATE INDEX IF NOT EXISTS idx_invoices_created_at ON invoices(created_at)"); + } catch (SQLException e) { + plugin.getLogger().severe("Failed to initialize SQLite storage: " + e.getMessage()); + } + } + + public List loadAllInvoices() { + List invoices = new ArrayList<>(); + + try (Connection connection = getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT * FROM invoices"); + ResultSet rs = statement.executeQuery()) { + + while (rs.next()) { + invoices.add(new PendingInvoice( + rs.getString("invoice_id"), + UUID.fromString(rs.getString("player_uuid")), + rs.getString("player_name"), + rs.getString("product_id"), + rs.getString("wallet_id"), + rs.getString("wallet_address"), + rs.getLong("amount_sats"), + rs.getLong("created_at"), + rs.getLong("expires_at"), + parseStatus(rs.getString("status")), + rs.getString("txid"), + rs.getInt("confirmations"), + rs.getInt("fulfilled") == 1, + rs.getLong("matched_at"), + rs.getLong("fulfilled_at"), + rs.getString("notes") + )); + } + } catch (Exception e) { + plugin.getLogger().severe("Failed to load invoices from SQLite: " + e.getMessage()); + } + + return invoices; + } + + public void saveInvoice(PendingInvoice invoice) { + String sql = """ + INSERT INTO invoices ( + invoice_id, player_uuid, player_name, product_id, wallet_id, wallet_address, + amount_sats, created_at, expires_at, status, txid, confirmations, + fulfilled, matched_at, fulfilled_at, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(invoice_id) DO UPDATE SET + player_uuid=excluded.player_uuid, + player_name=excluded.player_name, + product_id=excluded.product_id, + wallet_id=excluded.wallet_id, + wallet_address=excluded.wallet_address, + amount_sats=excluded.amount_sats, + created_at=excluded.created_at, + expires_at=excluded.expires_at, + status=excluded.status, + txid=excluded.txid, + confirmations=excluded.confirmations, + fulfilled=excluded.fulfilled, + matched_at=excluded.matched_at, + fulfilled_at=excluded.fulfilled_at, + notes=excluded.notes + """; + + try (Connection connection = getConnection(); + PreparedStatement ps = connection.prepareStatement(sql)) { + + ps.setString(1, invoice.getInvoiceId()); + ps.setString(2, invoice.getPlayerUuid().toString()); + ps.setString(3, invoice.getPlayerName()); + ps.setString(4, invoice.getProductId()); + ps.setString(5, invoice.getWalletId()); + ps.setString(6, invoice.getWalletAddress()); + ps.setLong(7, invoice.getAmountSats()); + ps.setLong(8, invoice.getCreatedAt()); + ps.setLong(9, invoice.getExpiresAt()); + ps.setString(10, invoice.getStatus().name()); + ps.setString(11, invoice.getTxid()); + ps.setInt(12, invoice.getConfirmations()); + ps.setInt(13, invoice.isFulfilled() ? 1 : 0); + ps.setLong(14, invoice.getMatchedAt()); + ps.setLong(15, invoice.getFulfilledAt()); + ps.setString(16, invoice.getNotes()); + ps.executeUpdate(); + + } catch (SQLException e) { + plugin.getLogger().severe("Failed to save invoice " + invoice.getInvoiceId() + " to SQLite: " + e.getMessage()); + } + } + + public void deleteInvoice(String invoiceId) { + try (Connection connection = getConnection(); + PreparedStatement ps = connection.prepareStatement("DELETE FROM invoices WHERE invoice_id = ?")) { + ps.setString(1, invoiceId); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().severe("Failed to delete invoice " + invoiceId + " from SQLite: " + e.getMessage()); + } + } + + public int pruneOldInvoices(long cutoffTimeMillis, List statuses) { + if (statuses == null || statuses.isEmpty()) { + return 0; + } + + StringBuilder placeholders = new StringBuilder(); + for (int i = 0; i < statuses.size(); i++) { + if (i > 0) placeholders.append(","); + placeholders.append("?"); + } + + String sql = "DELETE FROM invoices WHERE created_at < ? AND status IN (" + placeholders + ")"; + + try (Connection connection = getConnection(); + PreparedStatement ps = connection.prepareStatement(sql)) { + + ps.setLong(1, cutoffTimeMillis); + for (int i = 0; i < statuses.size(); i++) { + ps.setString(i + 2, statuses.get(i).toUpperCase(Locale.ROOT)); + } + + return ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().severe("Failed to prune old invoices from SQLite: " + e.getMessage()); + return 0; + } + } + + private Connection getConnection() throws SQLException { + return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.getAbsolutePath()); + } + + private PendingInvoice.Status parseStatus(String raw) { + try { + return PendingInvoice.Status.valueOf(raw.toUpperCase(Locale.ROOT)); + } catch (Exception ignored) { + return PendingInvoice.Status.PENDING; + } + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/ColorUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/ColorUtil.java new file mode 100644 index 0000000..1a5ca0e --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/ColorUtil.java @@ -0,0 +1,31 @@ +package com.bitnix.dirtcryptostore.util; + +import org.bukkit.ChatColor; + +import java.util.ArrayList; +import java.util.List; + +public final class ColorUtil { + + private ColorUtil() { + } + + public static String color(String text) { + if (text == null) { + return ""; + } + return ChatColor.translateAlternateColorCodes('&', text); + } + + public static List color(List lines) { + List result = new ArrayList<>(); + if (lines == null) { + return result; + } + + for (String line : lines) { + result.add(color(line)); + } + return result; + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/EffectUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/EffectUtil.java new file mode 100644 index 0000000..f688433 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/EffectUtil.java @@ -0,0 +1,37 @@ +package com.bitnix.dirtcryptostore.util; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +public final class EffectUtil { + + private EffectUtil() { + } + + public static void playEffect(DirtCryptoStorePlugin plugin, Player player, String key) { + if (!plugin.getConfig().getBoolean("effects.enabled", true)) { + return; + } + + ConfigurationSection section = plugin.getConfig().getConfigurationSection("effects." + key); + if (section == null || !section.getBoolean("enabled", false)) { + return; + } + + try { + Particle particle = Particle.valueOf(section.getString("type", "VILLAGER_HAPPY")); + int count = section.getInt("count", 10); + double offsetX = section.getDouble("offset-x", 0.3D); + double offsetY = section.getDouble("offset-y", 0.6D); + double offsetZ = section.getDouble("offset-z", 0.3D); + double extra = section.getDouble("extra", 0.0D); + + Location location = player.getLocation().clone().add(0, 1, 0); + player.getWorld().spawnParticle(particle, location, count, offsetX, offsetY, offsetZ, extra); + } catch (IllegalArgumentException ignored) { + } + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/HttpUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/HttpUtil.java new file mode 100644 index 0000000..0db3228 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/HttpUtil.java @@ -0,0 +1,42 @@ +package com.bitnix.dirtcryptostore.util; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +public final class HttpUtil { + + private HttpUtil() { + } + + public static String get(String url, int timeoutSeconds, String userAgent) throws Exception { + HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(timeoutSeconds * 1000); + connection.setReadTimeout(timeoutSeconds * 1000); + connection.setRequestProperty("User-Agent", userAgent); + connection.setRequestProperty("Accept", "application/json"); + + int code = connection.getResponseCode(); + BufferedReader reader = new BufferedReader(new InputStreamReader( + code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(), + StandardCharsets.UTF_8 + )); + + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + connection.disconnect(); + + if (code < 200 || code >= 300) { + throw new IllegalStateException("HTTP " + code + " response: " + response); + } + + return response.toString(); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/MessageUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/MessageUtil.java new file mode 100644 index 0000000..eae8f64 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/MessageUtil.java @@ -0,0 +1,31 @@ +package com.bitnix.dirtcryptostore.util; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import org.bukkit.command.CommandSender; + +public final class MessageUtil { + + private MessageUtil() { + } + + public static void send(DirtCryptoStorePlugin plugin, CommandSender sender, String path, String fallback) { + String prefix = plugin.getConfig().getString("messages.prefix", "&8[&6DirtCryptoStore&8] "); + String message = plugin.getConfig().getString(path, fallback); + sender.sendMessage(ColorUtil.color(prefix + message)); + } + + public static void send(DirtCryptoStorePlugin plugin, CommandSender sender, String path, String fallback, String... replacements) { + String prefix = plugin.getConfig().getString("messages.prefix", "&8[&6DirtCryptoStore&8] "); + String message = plugin.getConfig().getString(path, fallback); + + if (replacements != null) { + for (int i = 0; i + 1 < replacements.length; i += 2) { + String key = replacements[i]; + String value = replacements[i + 1]; + message = message.replace(key, ColorUtil.color(value)); + } + } + + sender.sendMessage(ColorUtil.color(prefix + message)); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/QrMapUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/QrMapUtil.java new file mode 100644 index 0000000..a756da0 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/QrMapUtil.java @@ -0,0 +1,103 @@ +package com.bitnix.dirtcryptostore.util; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.common.BitMatrix; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.map.*; + +import java.awt.image.BufferedImage; +import java.util.List; + +public final class QrMapUtil { + + private QrMapUtil() { + } + + public static ItemStack createQrMapItem(DirtCryptoStorePlugin plugin, + org.bukkit.entity.Player player, + String content, + String displayName, + List lore, + String fallbackMaterialName) { + try { + World world = player.getWorld(); + MapView mapView = Bukkit.createMap(world); + mapView.getRenderers().clear(); + mapView.setTrackingPosition(false); + mapView.setUnlimitedTracking(false); + mapView.setScale(MapView.Scale.CLOSE); + + BufferedImage image = generateQr(content, 128, 128); + mapView.addRenderer(new ImageRenderer(image)); + + ItemStack mapItem = new ItemStack(Material.FILLED_MAP); + MapMeta meta = (MapMeta) mapItem.getItemMeta(); + if (meta != null) { + meta.setDisplayName(displayName); + meta.setLore(lore); + meta.setMapView(mapView); + mapItem.setItemMeta(meta); + } + + return mapItem; + } catch (Exception ex) { + if (plugin.getConfig().getBoolean("settings.debug", false)) { + plugin.getLogger().warning("Failed to generate QR map: " + ex.getMessage()); + } + + Material fallback = Material.matchMaterial(fallbackMaterialName); + if (fallback == null) { + fallback = Material.PAPER; + } + + ItemStack item = new ItemStack(fallback); + org.bukkit.inventory.meta.ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(displayName); + meta.setLore(lore); + item.setItemMeta(meta); + } + return item; + } + } + + private static BufferedImage generateQr(String content, int width, int height) throws Exception { + BitMatrix matrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + image.setRGB(x, y, matrix.get(x, y) ? 0x000000 : 0xFFFFFF); + } + } + + return image; + } + + private static final class ImageRenderer extends MapRenderer { + + private final BufferedImage image; + private boolean rendered = false; + + private ImageRenderer(BufferedImage image) { + super(true); + this.image = image; + } + + @Override + public void render(MapView mapView, MapCanvas mapCanvas, org.bukkit.entity.Player player) { + if (rendered) { + return; + } + + mapCanvas.drawImage(0, 0, image); + rendered = true; + } + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.java new file mode 100644 index 0000000..26d1278 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.java @@ -0,0 +1,13 @@ +package com.bitnix.dirtcryptostore.util; + +import com.bitnix.dirtcryptostore.model.PendingInvoice; + +public final class QrPlaceholderUtil { + + private QrPlaceholderUtil() { + } + + public static String buildBitcoinUri(PendingInvoice invoice) { + return "bitcoin:" + invoice.getWalletAddress() + "?amount=" + TimeUtil.formatBtcRaw(invoice.getAmountSats()); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/SimpleJson.java b/src/main/java/com/bitnix/dirtcryptostore/util/SimpleJson.java new file mode 100644 index 0000000..d0060d3 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/SimpleJson.java @@ -0,0 +1,83 @@ +package com.bitnix.dirtcryptostore.util; + +public final class SimpleJson { + + private SimpleJson() { + } + + public static String findString(String json, String key) { + String pattern = "\"" + key + "\":"; + int start = json.indexOf(pattern); + if (start == -1) { + return null; + } + + int firstQuote = json.indexOf('"', start + pattern.length()); + if (firstQuote == -1) { + return null; + } + + int secondQuote = json.indexOf('"', firstQuote + 1); + if (secondQuote == -1) { + return null; + } + + return json.substring(firstQuote + 1, secondQuote); + } + + public static Integer findInt(String json, String key) { + String pattern = "\"" + key + "\":"; + int start = json.indexOf(pattern); + if (start == -1) { + return null; + } + + int index = start + pattern.length(); + while (index < json.length() && Character.isWhitespace(json.charAt(index))) { + index++; + } + + int end = index; + while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) { + end++; + } + + if (end == index) { + return null; + } + + try { + return Integer.parseInt(json.substring(index, end)); + } catch (NumberFormatException ignored) { + return null; + } + } + + public static Long findLong(String json, String key) { + String pattern = "\"" + key + "\":"; + int start = json.indexOf(pattern); + if (start == -1) { + return null; + } + + int index = start + pattern.length(); + while (index < json.length() && Character.isWhitespace(json.charAt(index))) { + index++; + } + + int end = index; + while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) { + end++; + } + + if (end == index) { + return null; + } + + try { + return Long.parseLong(json.substring(index, end)); + } catch (NumberFormatException ignored) { + return null; + } + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/TimeUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/TimeUtil.java new file mode 100644 index 0000000..cf202c0 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/TimeUtil.java @@ -0,0 +1,26 @@ +package com.bitnix.dirtcryptostore.util; + +public final class TimeUtil { + + private TimeUtil() { + } + + public static String formatRemaining(long millis) { + long totalSeconds = Math.max(0L, millis / 1000L); + long minutes = totalSeconds / 60L; + long seconds = totalSeconds % 60L; + return minutes + "m " + seconds + "s"; + } + + public static String formatBtcAmount(long sats) { + long whole = sats / 100_000_000L; + long fractional = sats % 100_000_000L; + return whole + "." + String.format("%08d", fractional) + " BTC"; + } + + public static String formatBtcRaw(long sats) { + long whole = sats / 100_000_000L; + long fractional = sats % 100_000_000L; + return whole + "." + String.format("%08d", fractional); + } +} diff --git a/src/main/java/com/bitnix/dirtcryptostore/util/TitleUtil.java b/src/main/java/com/bitnix/dirtcryptostore/util/TitleUtil.java new file mode 100644 index 0000000..0518a98 --- /dev/null +++ b/src/main/java/com/bitnix/dirtcryptostore/util/TitleUtil.java @@ -0,0 +1,30 @@ +package com.bitnix.dirtcryptostore.util; + +import com.bitnix.dirtcryptostore.DirtCryptoStorePlugin; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +public final class TitleUtil { + + private TitleUtil() { + } + + public static void sendTitle(DirtCryptoStorePlugin plugin, Player player, String key) { + if (!plugin.getConfig().getBoolean("titles.enabled", true)) { + return; + } + + ConfigurationSection section = plugin.getConfig().getConfigurationSection("titles." + key); + if (section == null) { + return; + } + + String title = ColorUtil.color(section.getString("title", "")); + String subtitle = ColorUtil.color(section.getString("subtitle", "")); + int fadeIn = section.getInt("fade-in", 10); + int stay = section.getInt("stay", 50); + int fadeOut = section.getInt("fade-out", 10); + + player.sendTitle(title, subtitle, fadeIn, stay, fadeOut); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..de04007 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,472 @@ +settings: + debug: false + + payment: + poll-seconds: 30 + invoice-expire-minutes: 30 + confirmations-required: 2 + max-pending-per-player: 1 + allow-cancel: true + auto-close-paid-invoice-seconds: 5 + mark-expired-on-open: true + strict-exact-amount-match: true + allow-late-match-if-unclaimed: true + late-match-grace-minutes: 120 + + pricing: + mode: "LIVE_USD" + live-rate-provider: "COINGECKO" + cache-seconds: 60 + fallback-to-base-sats: true + round-up-to-sat: true + coingecko-url: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + + micro-amount: + mode: "RANDOM_RANGE" + min-extra-sats: 1 + max-extra-sats: 200 + sequential-start-sats: 1 + sequential-reset-when-server-restarts: false + avoid-duplicate-active-amounts: true + max-generation-attempts: 500 + + storage: + type: "SQLITE" + sqlite-file: "dirtcryptostore.db" + prune-enabled: true + prune-after-days: 30 + prune-statuses: + - "FULFILLED" + - "EXPIRED" + - "CANCELLED" + + qr: + enabled: true + map-title: "&8Crypto QR" + include-bitcoin-uri-in-lore: true + include-wallet-address-in-lore: true + include-expiry-in-lore: true + include-confirmations-in-lore: true + show-qr-item-in-payment-gui: true + fallback-material-if-map-fails: "PAPER" + + blockchain: + watcher-mode: "API" + provider: "BLOCKCHAIN_INFO" + request-timeout-seconds: 10 + user-agent: "DirtCryptoStore/1.0.0" + wallet-cache-seconds: 20 + tx-cache-seconds: 20 + + commands: + dispatch-as-console: true + stop-on-command-failure: false + log-dispatch: true + + hooks: + use-placeholderapi-if-present: false + use-luckperms-context-if-present: false + +wallets: + default: + enabled: true + display-name: "&6Bitcoin Wallet" + coin: "BTC" + address: "PUT_YOUR_CAKE_WALLET_BTC_ADDRESS_HERE" + explorer-type: "BTC" + min-confirmations: 2 + allow-products: ["tip_jar", "gold_dust", "dirt_rich"] + +messages: + prefix: "&8[&6DirtCryptoStore&8] " + + no-permission: "&cYou do not have permission." + player-only: "&cOnly players can use this command." + reloaded: "&aDirtCryptoStore reloaded." + opening-store: "&aOpening crypto store..." + admin-reload-usage: "&eUse: /cryptostore reload" + + already-has-pending: "&cYou already have a pending payment." + no-pending-payment: "&cYou do not have a pending payment." + invoice-created: "&aInvoice created for &f%product%&a." + invoice-expired: "&cThis payment request expired." + invoice-cancelled: "&eYour pending payment was cancelled." + invoice-paid-already: "&eThis invoice is already paid." + invoice-view-only: "&eThis invoice can no longer be changed." + pricing-unavailable: "&cLive BTC pricing is currently unavailable. Please try again in a moment." + + payment-detected: "&aPayment detected for &f%product%&a. Waiting for confirmations..." + payment-confirmed: "&aYour crypto payment for &f%product% &ahas been confirmed." + payment-fulfilled: "&aYour purchase has been delivered." + payment-under-review: "&cThis payment requires staff review." + payment-not-found-yet: "&7No matching payment detected yet." + payment-status-waiting: "&eStatus: WAITING" + payment-status-detected: "&aStatus: DETECTED" + payment-status-confirming: "&6Status: CONFIRMING" + payment-status-paid: "&aStatus: PAID" + payment-status-expired: "&cStatus: EXPIRED" + payment-status-cancelled: "&cStatus: CANCELLED" + + qr-expiry-line: "&7Expires in: &f%time%" + confirm-line: "&7Required confirmations: &f%confirmations%" + wallet-line: "&7Wallet: &f%wallet%" + wallet-address-line: "&7Address: &f%address%" + amount-line: "&7Amount: &f%amount%" + amount-sats-line: "&7Amount (sats): &f%amount_sats%" + product-line: "&7Product: &f%product%" + txid-line: "&7TXID: &f%txid%" + + gui-cancel-button: "&cCancel Payment" + gui-back-button: "&eBack" + gui-close-button: "&cClose" + gui-refresh-button: "&bRefresh" + gui-confirmed-button: "&aPaid" + gui-expired-button: "&cExpired" + gui-pending-button: "&ePending" + +sounds: + enabled: true + + store-open: + sound: "BLOCK_ENDER_CHEST_OPEN" + volume: 1.0 + pitch: 1.0 + + click: + sound: "UI_BUTTON_CLICK" + volume: 1.0 + pitch: 1.0 + + invoice-created: + sound: "ENTITY_EXPERIENCE_ORB_PICKUP" + volume: 1.0 + pitch: 1.2 + + payment-detected: + sound: "BLOCK_NOTE_BLOCK_PLING" + volume: 1.0 + pitch: 1.5 + + payment-confirmed: + sound: "ENTITY_PLAYER_LEVELUP" + volume: 1.0 + pitch: 1.0 + + cancelled: + sound: "ENTITY_VILLAGER_NO" + volume: 1.0 + pitch: 1.0 + + expired: + sound: "BLOCK_ANVIL_LAND" + volume: 0.7 + pitch: 1.0 + +effects: + enabled: true + + store-open: + enabled: false + type: "VILLAGER_HAPPY" + count: 10 + offset-x: 0.3 + offset-y: 0.6 + offset-z: 0.3 + extra: 0.0 + + invoice-created: + enabled: true + type: "TOTEM_OF_UNDYING" + count: 12 + offset-x: 0.4 + offset-y: 0.8 + offset-z: 0.4 + extra: 0.0 + + payment-detected: + enabled: true + type: "COMPOSTER" + count: 8 + offset-x: 0.3 + offset-y: 1.0 + offset-z: 0.3 + extra: 0.0 + + payment-confirmed: + enabled: true + type: "FIREWORK" + count: 20 + offset-x: 0.5 + offset-y: 1.0 + offset-z: 0.5 + extra: 0.0 + + cancelled: + enabled: false + type: "SMOKE" + count: 8 + offset-x: 0.2 + offset-y: 0.4 + offset-z: 0.2 + extra: 0.0 + + expired: + enabled: false + type: "SMOKE" + count: 8 + offset-x: 0.2 + offset-y: 0.4 + offset-z: 0.2 + extra: 0.0 + +titles: + enabled: true + + invoice-created: + title: "&6Crypto Invoice" + subtitle: "&7Scan the QR and pay the exact amount" + fade-in: 10 + stay: 50 + fade-out: 10 + + payment-detected: + title: "&aPayment Detected" + subtitle: "&7Waiting for confirmations" + fade-in: 10 + stay: 40 + fade-out: 10 + + payment-confirmed: + title: "&aPayment Confirmed" + subtitle: "&fYour purchase has been delivered" + fade-in: 10 + stay: 60 + fade-out: 10 + + cancelled: + title: "&cPayment Cancelled" + subtitle: "&7Your pending invoice was cancelled" + fade-in: 10 + stay: 40 + fade-out: 10 + +gui: + use-custom-model-data: false + + store: + title: "&8Crypto Store" + size: 27 + open-sound: true + + items: + filler: + enabled: true + material: "GRAY_STAINED_GLASS_PANE" + name: " " + lore: [] + slots: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 17 + - 18 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 26 + + close: + enabled: true + material: "BARRIER" + name: "&cClose" + lore: + - "&7Close the store." + slot: 26 + + refresh: + enabled: true + material: "SUNFLOWER" + name: "&eRefresh" + lore: + - "&7Refresh the store view." + slot: 18 + + info: + enabled: true + material: "BOOK" + name: "&6How Crypto Payments Work" + lore: + - "&7Click a product to create a payment invoice." + - "&7You must send the exact amount shown." + - "&7Your QR expires after &f30 minutes&7." + - "&7You can cancel your payment from the payment menu." + slot: 22 + + payment: + title: "&8Crypto Payment" + size: 27 + + items: + filler: + enabled: true + material: "BLACK_STAINED_GLASS_PANE" + name: " " + lore: [] + slots: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 17 + - 18 + - 20 + - 24 + - 25 + - 26 + + product: + enabled: true + material: "CHEST" + name: "&6Purchase Details" + lore: + - "&7Product: &f%product%" + - "&7Price: &f%amount%" + - "&7Wallet: &f%wallet%" + - "&7Address: &f%address%" + - "&7Status: &f%status%" + - "&7Expires in: &f%time%" + slot: 11 + + qr: + enabled: true + material: "FILLED_MAP" + name: "&aScan QR To Pay" + lore: + - "&7Scan this QR with your wallet app." + - "&7" + - "&7Amount: &f%amount%" + - "&7Address: &f%address%" + - "&7" + - "&7If your wallet does not scan maps well," + - "&7use the URI manually:" + - "&f%bitcoin_uri%" + slot: 13 + + status: + enabled: true + material: "CLOCK" + name: "&ePayment Status" + lore: + - "&7Current: &f%status%" + - "&7TXID: &f%txid%" + - "&7Confirmations: &f%current_confirmations%/%confirmations%" + - "&7Expires in: &f%time%" + slot: 15 + + back: + enabled: true + material: "ARROW" + name: "&eBack" + lore: + - "&7Return to the store." + slot: 18 + + cancel: + enabled: true + material: "BARRIER" + name: "&cCancel Payment" + lore: + - "&7Cancel this pending payment." + slot: 22 + + refresh: + enabled: true + material: "SUNFLOWER" + name: "&bRefresh Status" + lore: + - "&7Refresh this payment screen." + slot: 26 + +products: + tip_jar: + enabled: true + display-name: "&aTip Jar" + material: "EMERALD" + custom-model-data: 0 + glow: true + slot: 11 + wallet: "default" + price-usd: 3.49 + base-sats: 15000 + permission-required: "" + purchasable-once: false + commands: + - "lp user %player% parent add tipjar" + lore: + - "&7Buy the &aTip Jar &7rank with crypto." + - "&7Normal store price: &c$4.99" + - "&7Crypto store price: &a$3.49" + - "&7Wallet: &f%wallet%" + - "&7Approx base amount: &f%amount%" + - "&7Click to generate a live-priced invoice." + + gold_dust: + enabled: true + display-name: "&bGold Dust" + material: "GOLD_INGOT" + custom-model-data: 0 + glow: true + slot: 13 + wallet: "default" + price-usd: 6.99 + base-sats: 27000 + permission-required: "" + purchasable-once: false + commands: + - "lp user %player% parent add golddust" + lore: + - "&7Buy the &bGold Dust &7rank with crypto." + - "&7Normal store price: &c$9.99" + - "&7Crypto store price: &a$6.99" + - "&7Wallet: &f%wallet%" + - "&7Approx base amount: &f%amount%" + - "&7Click to generate a live-priced invoice." + + dirt_rich: + enabled: true + display-name: "&6Dirt Rich" + material: "DIAMOND" + custom-model-data: 0 + glow: false + slot: 15 + wallet: "default" + price-usd: 13.99 + base-sats: 9750 + permission-required: "" + purchasable-once: false + commands: + - "lp user %player% parent add dirtrich" + lore: + - "&7Buy the &6Dirt Rich &7rank with crypto." + - "&7Normal store price: &c$19.99" + - "&7Crypto store price: &a$13.99" + - "&7Wallet: &f%wallet%" + - "&7Approx base amount: &f%amount%" + - "&7Click to generate a live-priced invoice." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..1e79311 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,25 @@ +name: DirtCryptoStore +version: 1.0.0 +main: com.bitnix.dirtcryptostore.DirtCryptoStorePlugin +api-version: '1.21' +description: Crypto GUI shop plugin for Paper +author: bitnix + +commands: + cryptostore: + description: Open the crypto store + usage: /cryptostore [reload|status|review|forceconfirm|cancelinvoice] + aliases: [cstore] + +permissions: + dirtcryptostore.use: + description: Allows use of the crypto store + default: true + + dirtcryptostore.admin: + description: Admin access to reload and manage the crypto store + default: op + + dirtcryptostore.bypass: + description: Bypass restrictions if used later + default: op diff --git a/target/DirtCryptoStore.jar b/target/DirtCryptoStore.jar new file mode 100644 index 0000000..ef236cc Binary files /dev/null and b/target/DirtCryptoStore.jar differ diff --git a/target/classes/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.class b/target/classes/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.class new file mode 100644 index 0000000..a633a61 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.class b/target/classes/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.class new file mode 100644 index 0000000..ee974d3 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/gui/PaymentGui.class b/target/classes/com/bitnix/dirtcryptostore/gui/PaymentGui.class new file mode 100644 index 0000000..dca8428 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/gui/PaymentGui.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/gui/StoreGui.class b/target/classes/com/bitnix/dirtcryptostore/gui/StoreGui.class new file mode 100644 index 0000000..dca88e2 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/gui/StoreGui.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/listener/StoreGuiListener.class b/target/classes/com/bitnix/dirtcryptostore/listener/StoreGuiListener.class new file mode 100644 index 0000000..6d3b73f Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/listener/StoreGuiListener.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/manager/BlockchainApi.class b/target/classes/com/bitnix/dirtcryptostore/manager/BlockchainApi.class new file mode 100644 index 0000000..89aedfb Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/manager/BlockchainApi.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/manager/ConfigManager.class b/target/classes/com/bitnix/dirtcryptostore/manager/ConfigManager.class new file mode 100644 index 0000000..79b9404 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/manager/ConfigManager.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/manager/InvoiceManager.class b/target/classes/com/bitnix/dirtcryptostore/manager/InvoiceManager.class new file mode 100644 index 0000000..dde4f41 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/manager/InvoiceManager.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/manager/PaymentWatcher.class b/target/classes/com/bitnix/dirtcryptostore/manager/PaymentWatcher.class new file mode 100644 index 0000000..8074dda Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/manager/PaymentWatcher.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/manager/PricingManager.class b/target/classes/com/bitnix/dirtcryptostore/manager/PricingManager.class new file mode 100644 index 0000000..b79cee4 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/manager/PricingManager.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/manager/QrMapManager.class b/target/classes/com/bitnix/dirtcryptostore/manager/QrMapManager.class new file mode 100644 index 0000000..df8e085 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/manager/QrMapManager.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.class b/target/classes/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.class new file mode 100644 index 0000000..a0c68af Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/model/PendingInvoice$Status.class b/target/classes/com/bitnix/dirtcryptostore/model/PendingInvoice$Status.class new file mode 100644 index 0000000..cf0380c Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/model/PendingInvoice$Status.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/model/PendingInvoice.class b/target/classes/com/bitnix/dirtcryptostore/model/PendingInvoice.class new file mode 100644 index 0000000..3127db1 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/model/PendingInvoice.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/model/Product.class b/target/classes/com/bitnix/dirtcryptostore/model/Product.class new file mode 100644 index 0000000..9f2b5f9 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/model/Product.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/model/WalletConfig.class b/target/classes/com/bitnix/dirtcryptostore/model/WalletConfig.class new file mode 100644 index 0000000..67d7b39 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/model/WalletConfig.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/storage/SQLiteStorage.class b/target/classes/com/bitnix/dirtcryptostore/storage/SQLiteStorage.class new file mode 100644 index 0000000..fee2270 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/storage/SQLiteStorage.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/ColorUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/ColorUtil.class new file mode 100644 index 0000000..3c8517d Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/ColorUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/EffectUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/EffectUtil.class new file mode 100644 index 0000000..daeed05 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/EffectUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/HttpUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/HttpUtil.class new file mode 100644 index 0000000..be7d4ef Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/HttpUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/MessageUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/MessageUtil.class new file mode 100644 index 0000000..23a4077 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/MessageUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/QrMapUtil$ImageRenderer.class b/target/classes/com/bitnix/dirtcryptostore/util/QrMapUtil$ImageRenderer.class new file mode 100644 index 0000000..ff4c2f8 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/QrMapUtil$ImageRenderer.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/QrMapUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/QrMapUtil.class new file mode 100644 index 0000000..7dcc764 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/QrMapUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.class new file mode 100644 index 0000000..3e44780 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/SimpleJson.class b/target/classes/com/bitnix/dirtcryptostore/util/SimpleJson.class new file mode 100644 index 0000000..b0ed8b0 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/SimpleJson.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/TimeUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/TimeUtil.class new file mode 100644 index 0000000..fd49ca8 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/TimeUtil.class differ diff --git a/target/classes/com/bitnix/dirtcryptostore/util/TitleUtil.class b/target/classes/com/bitnix/dirtcryptostore/util/TitleUtil.class new file mode 100644 index 0000000..aad6270 Binary files /dev/null and b/target/classes/com/bitnix/dirtcryptostore/util/TitleUtil.class differ diff --git a/target/classes/config.yml b/target/classes/config.yml new file mode 100644 index 0000000..de04007 --- /dev/null +++ b/target/classes/config.yml @@ -0,0 +1,472 @@ +settings: + debug: false + + payment: + poll-seconds: 30 + invoice-expire-minutes: 30 + confirmations-required: 2 + max-pending-per-player: 1 + allow-cancel: true + auto-close-paid-invoice-seconds: 5 + mark-expired-on-open: true + strict-exact-amount-match: true + allow-late-match-if-unclaimed: true + late-match-grace-minutes: 120 + + pricing: + mode: "LIVE_USD" + live-rate-provider: "COINGECKO" + cache-seconds: 60 + fallback-to-base-sats: true + round-up-to-sat: true + coingecko-url: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + + micro-amount: + mode: "RANDOM_RANGE" + min-extra-sats: 1 + max-extra-sats: 200 + sequential-start-sats: 1 + sequential-reset-when-server-restarts: false + avoid-duplicate-active-amounts: true + max-generation-attempts: 500 + + storage: + type: "SQLITE" + sqlite-file: "dirtcryptostore.db" + prune-enabled: true + prune-after-days: 30 + prune-statuses: + - "FULFILLED" + - "EXPIRED" + - "CANCELLED" + + qr: + enabled: true + map-title: "&8Crypto QR" + include-bitcoin-uri-in-lore: true + include-wallet-address-in-lore: true + include-expiry-in-lore: true + include-confirmations-in-lore: true + show-qr-item-in-payment-gui: true + fallback-material-if-map-fails: "PAPER" + + blockchain: + watcher-mode: "API" + provider: "BLOCKCHAIN_INFO" + request-timeout-seconds: 10 + user-agent: "DirtCryptoStore/1.0.0" + wallet-cache-seconds: 20 + tx-cache-seconds: 20 + + commands: + dispatch-as-console: true + stop-on-command-failure: false + log-dispatch: true + + hooks: + use-placeholderapi-if-present: false + use-luckperms-context-if-present: false + +wallets: + default: + enabled: true + display-name: "&6Bitcoin Wallet" + coin: "BTC" + address: "PUT_YOUR_CAKE_WALLET_BTC_ADDRESS_HERE" + explorer-type: "BTC" + min-confirmations: 2 + allow-products: ["tip_jar", "gold_dust", "dirt_rich"] + +messages: + prefix: "&8[&6DirtCryptoStore&8] " + + no-permission: "&cYou do not have permission." + player-only: "&cOnly players can use this command." + reloaded: "&aDirtCryptoStore reloaded." + opening-store: "&aOpening crypto store..." + admin-reload-usage: "&eUse: /cryptostore reload" + + already-has-pending: "&cYou already have a pending payment." + no-pending-payment: "&cYou do not have a pending payment." + invoice-created: "&aInvoice created for &f%product%&a." + invoice-expired: "&cThis payment request expired." + invoice-cancelled: "&eYour pending payment was cancelled." + invoice-paid-already: "&eThis invoice is already paid." + invoice-view-only: "&eThis invoice can no longer be changed." + pricing-unavailable: "&cLive BTC pricing is currently unavailable. Please try again in a moment." + + payment-detected: "&aPayment detected for &f%product%&a. Waiting for confirmations..." + payment-confirmed: "&aYour crypto payment for &f%product% &ahas been confirmed." + payment-fulfilled: "&aYour purchase has been delivered." + payment-under-review: "&cThis payment requires staff review." + payment-not-found-yet: "&7No matching payment detected yet." + payment-status-waiting: "&eStatus: WAITING" + payment-status-detected: "&aStatus: DETECTED" + payment-status-confirming: "&6Status: CONFIRMING" + payment-status-paid: "&aStatus: PAID" + payment-status-expired: "&cStatus: EXPIRED" + payment-status-cancelled: "&cStatus: CANCELLED" + + qr-expiry-line: "&7Expires in: &f%time%" + confirm-line: "&7Required confirmations: &f%confirmations%" + wallet-line: "&7Wallet: &f%wallet%" + wallet-address-line: "&7Address: &f%address%" + amount-line: "&7Amount: &f%amount%" + amount-sats-line: "&7Amount (sats): &f%amount_sats%" + product-line: "&7Product: &f%product%" + txid-line: "&7TXID: &f%txid%" + + gui-cancel-button: "&cCancel Payment" + gui-back-button: "&eBack" + gui-close-button: "&cClose" + gui-refresh-button: "&bRefresh" + gui-confirmed-button: "&aPaid" + gui-expired-button: "&cExpired" + gui-pending-button: "&ePending" + +sounds: + enabled: true + + store-open: + sound: "BLOCK_ENDER_CHEST_OPEN" + volume: 1.0 + pitch: 1.0 + + click: + sound: "UI_BUTTON_CLICK" + volume: 1.0 + pitch: 1.0 + + invoice-created: + sound: "ENTITY_EXPERIENCE_ORB_PICKUP" + volume: 1.0 + pitch: 1.2 + + payment-detected: + sound: "BLOCK_NOTE_BLOCK_PLING" + volume: 1.0 + pitch: 1.5 + + payment-confirmed: + sound: "ENTITY_PLAYER_LEVELUP" + volume: 1.0 + pitch: 1.0 + + cancelled: + sound: "ENTITY_VILLAGER_NO" + volume: 1.0 + pitch: 1.0 + + expired: + sound: "BLOCK_ANVIL_LAND" + volume: 0.7 + pitch: 1.0 + +effects: + enabled: true + + store-open: + enabled: false + type: "VILLAGER_HAPPY" + count: 10 + offset-x: 0.3 + offset-y: 0.6 + offset-z: 0.3 + extra: 0.0 + + invoice-created: + enabled: true + type: "TOTEM_OF_UNDYING" + count: 12 + offset-x: 0.4 + offset-y: 0.8 + offset-z: 0.4 + extra: 0.0 + + payment-detected: + enabled: true + type: "COMPOSTER" + count: 8 + offset-x: 0.3 + offset-y: 1.0 + offset-z: 0.3 + extra: 0.0 + + payment-confirmed: + enabled: true + type: "FIREWORK" + count: 20 + offset-x: 0.5 + offset-y: 1.0 + offset-z: 0.5 + extra: 0.0 + + cancelled: + enabled: false + type: "SMOKE" + count: 8 + offset-x: 0.2 + offset-y: 0.4 + offset-z: 0.2 + extra: 0.0 + + expired: + enabled: false + type: "SMOKE" + count: 8 + offset-x: 0.2 + offset-y: 0.4 + offset-z: 0.2 + extra: 0.0 + +titles: + enabled: true + + invoice-created: + title: "&6Crypto Invoice" + subtitle: "&7Scan the QR and pay the exact amount" + fade-in: 10 + stay: 50 + fade-out: 10 + + payment-detected: + title: "&aPayment Detected" + subtitle: "&7Waiting for confirmations" + fade-in: 10 + stay: 40 + fade-out: 10 + + payment-confirmed: + title: "&aPayment Confirmed" + subtitle: "&fYour purchase has been delivered" + fade-in: 10 + stay: 60 + fade-out: 10 + + cancelled: + title: "&cPayment Cancelled" + subtitle: "&7Your pending invoice was cancelled" + fade-in: 10 + stay: 40 + fade-out: 10 + +gui: + use-custom-model-data: false + + store: + title: "&8Crypto Store" + size: 27 + open-sound: true + + items: + filler: + enabled: true + material: "GRAY_STAINED_GLASS_PANE" + name: " " + lore: [] + slots: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 17 + - 18 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 26 + + close: + enabled: true + material: "BARRIER" + name: "&cClose" + lore: + - "&7Close the store." + slot: 26 + + refresh: + enabled: true + material: "SUNFLOWER" + name: "&eRefresh" + lore: + - "&7Refresh the store view." + slot: 18 + + info: + enabled: true + material: "BOOK" + name: "&6How Crypto Payments Work" + lore: + - "&7Click a product to create a payment invoice." + - "&7You must send the exact amount shown." + - "&7Your QR expires after &f30 minutes&7." + - "&7You can cancel your payment from the payment menu." + slot: 22 + + payment: + title: "&8Crypto Payment" + size: 27 + + items: + filler: + enabled: true + material: "BLACK_STAINED_GLASS_PANE" + name: " " + lore: [] + slots: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 17 + - 18 + - 20 + - 24 + - 25 + - 26 + + product: + enabled: true + material: "CHEST" + name: "&6Purchase Details" + lore: + - "&7Product: &f%product%" + - "&7Price: &f%amount%" + - "&7Wallet: &f%wallet%" + - "&7Address: &f%address%" + - "&7Status: &f%status%" + - "&7Expires in: &f%time%" + slot: 11 + + qr: + enabled: true + material: "FILLED_MAP" + name: "&aScan QR To Pay" + lore: + - "&7Scan this QR with your wallet app." + - "&7" + - "&7Amount: &f%amount%" + - "&7Address: &f%address%" + - "&7" + - "&7If your wallet does not scan maps well," + - "&7use the URI manually:" + - "&f%bitcoin_uri%" + slot: 13 + + status: + enabled: true + material: "CLOCK" + name: "&ePayment Status" + lore: + - "&7Current: &f%status%" + - "&7TXID: &f%txid%" + - "&7Confirmations: &f%current_confirmations%/%confirmations%" + - "&7Expires in: &f%time%" + slot: 15 + + back: + enabled: true + material: "ARROW" + name: "&eBack" + lore: + - "&7Return to the store." + slot: 18 + + cancel: + enabled: true + material: "BARRIER" + name: "&cCancel Payment" + lore: + - "&7Cancel this pending payment." + slot: 22 + + refresh: + enabled: true + material: "SUNFLOWER" + name: "&bRefresh Status" + lore: + - "&7Refresh this payment screen." + slot: 26 + +products: + tip_jar: + enabled: true + display-name: "&aTip Jar" + material: "EMERALD" + custom-model-data: 0 + glow: true + slot: 11 + wallet: "default" + price-usd: 3.49 + base-sats: 15000 + permission-required: "" + purchasable-once: false + commands: + - "lp user %player% parent add tipjar" + lore: + - "&7Buy the &aTip Jar &7rank with crypto." + - "&7Normal store price: &c$4.99" + - "&7Crypto store price: &a$3.49" + - "&7Wallet: &f%wallet%" + - "&7Approx base amount: &f%amount%" + - "&7Click to generate a live-priced invoice." + + gold_dust: + enabled: true + display-name: "&bGold Dust" + material: "GOLD_INGOT" + custom-model-data: 0 + glow: true + slot: 13 + wallet: "default" + price-usd: 6.99 + base-sats: 27000 + permission-required: "" + purchasable-once: false + commands: + - "lp user %player% parent add golddust" + lore: + - "&7Buy the &bGold Dust &7rank with crypto." + - "&7Normal store price: &c$9.99" + - "&7Crypto store price: &a$6.99" + - "&7Wallet: &f%wallet%" + - "&7Approx base amount: &f%amount%" + - "&7Click to generate a live-priced invoice." + + dirt_rich: + enabled: true + display-name: "&6Dirt Rich" + material: "DIAMOND" + custom-model-data: 0 + glow: false + slot: 15 + wallet: "default" + price-usd: 13.99 + base-sats: 9750 + permission-required: "" + purchasable-once: false + commands: + - "lp user %player% parent add dirtrich" + lore: + - "&7Buy the &6Dirt Rich &7rank with crypto." + - "&7Normal store price: &c$19.99" + - "&7Crypto store price: &a$13.99" + - "&7Wallet: &f%wallet%" + - "&7Approx base amount: &f%amount%" + - "&7Click to generate a live-priced invoice." diff --git a/target/classes/plugin.yml b/target/classes/plugin.yml new file mode 100644 index 0000000..1e79311 --- /dev/null +++ b/target/classes/plugin.yml @@ -0,0 +1,25 @@ +name: DirtCryptoStore +version: 1.0.0 +main: com.bitnix.dirtcryptostore.DirtCryptoStorePlugin +api-version: '1.21' +description: Crypto GUI shop plugin for Paper +author: bitnix + +commands: + cryptostore: + description: Open the crypto store + usage: /cryptostore [reload|status|review|forceconfirm|cancelinvoice] + aliases: [cstore] + +permissions: + dirtcryptostore.use: + description: Allows use of the crypto store + default: true + + dirtcryptostore.admin: + description: Admin access to reload and manage the crypto store + default: op + + dirtcryptostore.bypass: + description: Bypass restrictions if used later + default: op diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 0000000..1351f39 --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,5 @@ +#Generated by Maven +#Sat Jun 20 15:53:05 EDT 2026 +artifactId=DirtCryptoStore +groupId=com.bitnix +version=1.0.0 diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..c9ef55c --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,27 @@ +com/bitnix/dirtcryptostore/gui/StoreGui.class +com/bitnix/dirtcryptostore/util/QrMapUtil.class +com/bitnix/dirtcryptostore/util/EffectUtil.class +com/bitnix/dirtcryptostore/util/TitleUtil.class +com/bitnix/dirtcryptostore/model/WalletConfig.class +com/bitnix/dirtcryptostore/command/CryptoStoreCommand.class +com/bitnix/dirtcryptostore/manager/PricingManager.class +com/bitnix/dirtcryptostore/util/SimpleJson.class +com/bitnix/dirtcryptostore/util/HttpUtil.class +com/bitnix/dirtcryptostore/model/Product.class +com/bitnix/dirtcryptostore/gui/PaymentGui.class +com/bitnix/dirtcryptostore/model/PendingInvoice$Status.class +com/bitnix/dirtcryptostore/listener/StoreGuiListener.class +com/bitnix/dirtcryptostore/storage/SQLiteStorage.class +com/bitnix/dirtcryptostore/util/MessageUtil.class +com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.class +com/bitnix/dirtcryptostore/manager/InvoiceManager.class +com/bitnix/dirtcryptostore/manager/PaymentWatcher.class +com/bitnix/dirtcryptostore/util/ColorUtil.class +com/bitnix/dirtcryptostore/manager/QrMapManager.class +com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.class +com/bitnix/dirtcryptostore/manager/BlockchainApi.class +com/bitnix/dirtcryptostore/util/TimeUtil.class +com/bitnix/dirtcryptostore/util/QrMapUtil$ImageRenderer.class +com/bitnix/dirtcryptostore/model/PendingInvoice.class +com/bitnix/dirtcryptostore/manager/ConfigManager.class +com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.class diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..927f005 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,25 @@ +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/DirtCryptoStorePlugin.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/command/CryptoStoreCommand.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/gui/PaymentGui.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/gui/StoreGui.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/listener/StoreGuiListener.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/manager/BlockchainApi.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/manager/ConfigManager.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/manager/InvoiceManager.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/manager/PaymentWatcher.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/manager/PricingManager.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/manager/QrMapManager.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/model/BlockchainPaymentMatch.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/model/PendingInvoice.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/model/Product.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/model/WalletConfig.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/storage/SQLiteStorage.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/ColorUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/EffectUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/HttpUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/MessageUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/QrMapUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/QrPlaceholderUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/SimpleJson.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/TimeUtil.java +/home/bitnix/Desktop/DirtCryptoStore/src/main/java/com/bitnix/dirtcryptostore/util/TitleUtil.java diff --git a/target/original-DirtCryptoStore.jar b/target/original-DirtCryptoStore.jar new file mode 100644 index 0000000..4299941 Binary files /dev/null and b/target/original-DirtCryptoStore.jar differ