mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
UnifyPayments (megachange)
This commit is contained in:
+59
-27
@@ -1,25 +1,66 @@
|
||||
# Wowgue Game Configuration
|
||||
|
||||
# Game Mode Selection (required)
|
||||
# PAID_SINGLE: Users pay per game, get 2x for escape, 3x with treasure
|
||||
# PAID_CREDITS: Users buy 10 credits, play 10 times, no payouts
|
||||
# FREE: Free play mode, no payments required
|
||||
GAME_MODE=PAID_SINGLE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Unified Payment System (recommended configuration)
|
||||
# -----------------------------------------------------------------------------
|
||||
PAYMENTS_ENABLED=true
|
||||
|
||||
# Cryptocurrency Configuration
|
||||
# Enable one or both modes (comma separated list: direct,credits)
|
||||
PAYMENT_MODES=direct,credits
|
||||
|
||||
# Direct mode settings
|
||||
DIRECT_PAYMENT_ENABLED=true
|
||||
DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units
|
||||
DIRECT_REQUIRES_ADDRESS=true
|
||||
DIRECT_ALLOW_GUEST_PLAY=false
|
||||
DIRECT_PAYOUTS_ENABLED=true
|
||||
DIRECT_PAYOUT_ESCAPE=2.0
|
||||
DIRECT_PAYOUT_TREASURE=3.0
|
||||
|
||||
# Credits mode settings
|
||||
CREDITS_ENABLED=true
|
||||
CREDITS_PER_GAME=1
|
||||
CREDITS_REQUIRES_ADDRESS=true
|
||||
ALLOW_MIXED_MODE=true
|
||||
PREFER_CREDITS_FIRST=true
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
CREDITS_PAYOUT_BASE=50000000000 # 0.5 WOW per credit payout baseline
|
||||
CREDITS_PAYOUT_ESCAPE=1.5
|
||||
CREDITS_PAYOUT_TREASURE=2.0
|
||||
CREDITS_PACKAGES='[
|
||||
{"id":"small","credits":10,"price":"500000000000","bonus":0},
|
||||
{"id":"medium","credits":25,"price":"1000000000000","bonus":2},
|
||||
{"id":"large","credits":100,"price":"3500000000000","bonus":15}
|
||||
]'
|
||||
|
||||
# Global payouts / limits
|
||||
PAYOUTS_ENABLED=true
|
||||
PAYOUT_MIN_AMOUNT=10000000000 # 0.1 WOW minimum payout
|
||||
PAYOUT_MAX_PER_GAME=10000000000000
|
||||
PAYOUT_BATCH_INTERVAL=300 # seconds (5 minutes)
|
||||
PAYOUT_MAX_RETRIES=3
|
||||
MAX_PAYOUT_BATCH_SIZE=50
|
||||
|
||||
# Anti-abuse limits
|
||||
MAX_GAMES_PER_HOUR=60
|
||||
MAX_PAYOUTS_PER_DAY=100
|
||||
MAX_CREDIT_PURCHASE_PER_DAY=100000000000000
|
||||
GAME_COOLDOWN_SECONDS=5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy overrides (optional). These mirror the old configuration style and
|
||||
# are primarily kept for backwards compatibility or quick testing.
|
||||
# -----------------------------------------------------------------------------
|
||||
# GAME_MODE accepts FREE, PAID_SINGLE, PAID_CREDITS. When set it overrides
|
||||
# PAYMENT_MODES. Leave unset to rely on unified config above.
|
||||
GAME_MODE=
|
||||
|
||||
# Legacy per-mode prices (still read as fallbacks if PAYMENT_MODES unset)
|
||||
CRYPTO_TYPE=WOW
|
||||
# Payment amounts in atomic units (piconeros for XMR, tacoshis for WOW)
|
||||
# XMR: 0.005 XMR = 5000000000 piconeros
|
||||
# WOW: 1 WOW = 100000000000 tacoshis
|
||||
# You can use plain integers, scientific notation (e.g. 5e9), or underscores (e.g. 5_000_000_000)
|
||||
# The parser will coerce to an integer atomic unit amount.
|
||||
SINGLE_GAME_PRICE=1e11 # 1 WOW
|
||||
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW
|
||||
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (was 5000000000)
|
||||
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (was 50000000000)
|
||||
# Alternative readable style:
|
||||
# SINGLE_GAME_PRICE=5_000_000_000
|
||||
# CREDITS_PACKAGE_PRICE=50_000_000_000
|
||||
SINGLE_GAME_PRICE=1e11 # 1 WOW (atomic units)
|
||||
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW (atomic units)
|
||||
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (5000000000)
|
||||
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (50000000000)
|
||||
|
||||
# Wallet RPC Configuration
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
|
||||
@@ -55,12 +96,3 @@ NODE_ENV=production
|
||||
BLOCK_SOURCE=daemon
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=false
|
||||
|
||||
# Payout Configuration
|
||||
PAYOUT_BATCH_INTERVAL=300000
|
||||
MAX_PAYOUT_BATCH_SIZE=50
|
||||
|
||||
# Credits Mode Optional Payouts
|
||||
# Set to true to enable payouts (same multipliers as single mode) when using PAID_CREDITS
|
||||
# Default is false meaning credits mode is purely consumptive / no rewards.
|
||||
CREDITS_PAYOUT_ENABLED=false
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Environment Validator
|
||||
* Emits friendly warnings when payment related environment variables look suspicious.
|
||||
*/
|
||||
|
||||
class EnvironmentValidator {
|
||||
constructor(options = {}) {
|
||||
this.logger = options.logger || console;
|
||||
}
|
||||
|
||||
validate(config) {
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
if (process.env.GAME_MODE && process.env.PAYMENT_MODES) {
|
||||
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
|
||||
}
|
||||
|
||||
if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
|
||||
warnings.push('paymentsEnabled is true but PRIMARY_WALLET_ENDPOINT is not set. Wallet RPC may fail.');
|
||||
}
|
||||
|
||||
if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) {
|
||||
warnings.push('paymentsEnabled is true but PRIMARY_RPC_ENDPOINT is missing. Blockchain RPC is required.');
|
||||
}
|
||||
|
||||
if (config.modes.direct.enabled && config.modes.direct.price <= 0n) {
|
||||
errors.push('Direct payment mode is enabled but direct price is not positive.');
|
||||
}
|
||||
|
||||
if (config.modes.credits.enabled && !config.modes.credits.packages.length) {
|
||||
errors.push('Credits mode is enabled but no credit packages are defined.');
|
||||
}
|
||||
|
||||
if (config.modes.credits.enabled && !process.env.CREDITS_PACKAGES) {
|
||||
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Consider defining CREDITS_PACKAGES for production.');
|
||||
}
|
||||
|
||||
if (!config.paymentsEnabled) {
|
||||
this.logger.info?.('ℹ️ Payments are currently disabled. Server will run in FREE mode.');
|
||||
}
|
||||
|
||||
if (warnings.length) {
|
||||
warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`));
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(msg => this.logger.error?.(`❌ ${msg}`));
|
||||
}
|
||||
|
||||
return { warnings, errors };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EnvironmentValidator;
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Payment Configuration Manager
|
||||
* Loads unified payment settings from defaults, environment variables, and (future) database overrides.
|
||||
*/
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const DEFAULT_CONFIG = Object.freeze({
|
||||
paymentsEnabled: true,
|
||||
currency: {
|
||||
symbol: 'WOW',
|
||||
decimals: 11,
|
||||
minPayment: 1000000000n, // 0.01 WOW in atomic units
|
||||
maxPayment: 100000000000000n // 1,000 WOW in atomic units
|
||||
},
|
||||
modes: {
|
||||
direct: {
|
||||
enabled: true,
|
||||
price: 100000000000n, // 1 WOW per game
|
||||
requiresAddress: true,
|
||||
allowGuestPlay: false
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
creditsPerGame: 1,
|
||||
requiresAddress: true,
|
||||
allowMixedMode: true,
|
||||
packages: [
|
||||
{ id: 'small', credits: 10, price: 500000000000n, bonus: 0 },
|
||||
{ id: 'medium', credits: 25, price: 1000000000000n, bonus: 2 },
|
||||
{ id: 'large', credits: 100, price: 3500000000000n, bonus: 15 }
|
||||
]
|
||||
}
|
||||
},
|
||||
payouts: {
|
||||
enabled: true,
|
||||
requiresKYC: false,
|
||||
rules: {
|
||||
direct: {
|
||||
enabled: true,
|
||||
multipliers: {
|
||||
escape: 2.0,
|
||||
escapeWithTreasure: 3.0,
|
||||
perfectRun: 5.0
|
||||
},
|
||||
minPayout: 1000000000n,
|
||||
maxPayout: 10000000000000n
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
multipliers: {
|
||||
escape: 1.5,
|
||||
escapeWithTreasure: 2.0,
|
||||
perfectRun: 3.0
|
||||
},
|
||||
baseValue: 50000000000n,
|
||||
minPayout: 1000000000n,
|
||||
maxPayout: 5000000000000n
|
||||
}
|
||||
},
|
||||
processing: {
|
||||
automatic: true,
|
||||
batchingEnabled: true,
|
||||
batchInterval: 300,
|
||||
confirmations: 1,
|
||||
maxRetries: 3
|
||||
}
|
||||
},
|
||||
promotions: {
|
||||
enabled: false,
|
||||
freeCredits: {
|
||||
onSignup: 0,
|
||||
onFirstPurchase: 0,
|
||||
referralBonus: 0
|
||||
},
|
||||
discounts: {
|
||||
enabled: false,
|
||||
bulkDiscount: 0,
|
||||
happyHour: null
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
maxGamesPerHour: 60,
|
||||
maxPayoutsPerDay: 100,
|
||||
maxCreditPurchasePerDay: 100000000000000n,
|
||||
cooldownBetweenGames: 5
|
||||
},
|
||||
preferences: {
|
||||
allowMixedMode: true,
|
||||
preferCreditsFirst: true
|
||||
}
|
||||
});
|
||||
|
||||
function cloneConfig(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(cloneConfig);
|
||||
}
|
||||
if (value && typeof value === 'object' && !(value instanceof EventEmitter)) {
|
||||
return Object.entries(value).reduce((acc, [key, val]) => {
|
||||
acc[key] = cloneConfig(val);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
const num = Number(normalized);
|
||||
if (!Number.isFinite(num)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(num);
|
||||
}
|
||||
|
||||
function parseAtomicValue(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return BigInt(Math.round(value));
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('0x')) {
|
||||
try {
|
||||
return BigInt(normalized);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const numeric = Number(normalized);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return BigInt(Math.round(numeric));
|
||||
}
|
||||
|
||||
try {
|
||||
return BigInt(normalized);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFloatValue(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
const num = Number(normalized);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function safeParseJson(value, fallback) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentConfigManager {
|
||||
constructor(options = {}) {
|
||||
this.logger = options.logger || console;
|
||||
this.eventBus = options.eventBus || new EventEmitter();
|
||||
this.persistenceAdapter = options.persistenceAdapter || null;
|
||||
this.config = this.loadConfig();
|
||||
this.validateConfig();
|
||||
this.legacyOverride = process.env.GAME_MODE || null;
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
const config = cloneConfig(DEFAULT_CONFIG);
|
||||
|
||||
config.paymentsEnabled = parseBoolean(process.env.PAYMENTS_ENABLED, config.paymentsEnabled);
|
||||
|
||||
const gameModeOverride = process.env.GAME_MODE ? process.env.GAME_MODE.toUpperCase() : null;
|
||||
if (gameModeOverride === 'FREE') {
|
||||
config.paymentsEnabled = false;
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = false;
|
||||
} else if (gameModeOverride === 'PAID_SINGLE') {
|
||||
config.paymentsEnabled = true;
|
||||
config.modes.direct.enabled = true;
|
||||
config.modes.credits.enabled = false;
|
||||
} else if (gameModeOverride === 'PAID_CREDITS') {
|
||||
config.paymentsEnabled = true;
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = true;
|
||||
}
|
||||
|
||||
if (process.env.PAYMENT_MODES) {
|
||||
const modes = process.env.PAYMENT_MODES.split(',').map(m => m.trim().toLowerCase()).filter(Boolean);
|
||||
config.modes.direct.enabled = modes.includes('direct');
|
||||
config.modes.credits.enabled = modes.includes('credits');
|
||||
config.paymentsEnabled = config.modes.direct.enabled || config.modes.credits.enabled;
|
||||
}
|
||||
|
||||
config.currency.symbol = process.env.CRYPTO_TYPE || config.currency.symbol;
|
||||
config.currency.minPayment = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.currency.minPayment);
|
||||
config.currency.maxPayment = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.currency.maxPayment);
|
||||
|
||||
const directPriceEnv = process.env.DIRECT_GAME_PRICE ?? process.env.SINGLE_GAME_PRICE;
|
||||
config.modes.direct.price = parseAtomicValue(directPriceEnv, config.modes.direct.price);
|
||||
config.modes.direct.enabled = parseBoolean(process.env.DIRECT_PAYMENT_ENABLED, config.modes.direct.enabled);
|
||||
config.modes.direct.requiresAddress = parseBoolean(process.env.DIRECT_REQUIRES_ADDRESS, config.modes.direct.requiresAddress);
|
||||
config.modes.direct.allowGuestPlay = parseBoolean(process.env.DIRECT_ALLOW_GUEST_PLAY, config.modes.direct.allowGuestPlay);
|
||||
|
||||
const creditsEnabled = parseBoolean(process.env.CREDITS_ENABLED, undefined);
|
||||
if (creditsEnabled !== undefined) {
|
||||
config.modes.credits.enabled = creditsEnabled;
|
||||
}
|
||||
config.modes.credits.creditsPerGame = parseInteger(process.env.CREDITS_PER_GAME, config.modes.credits.creditsPerGame);
|
||||
config.modes.credits.requiresAddress = parseBoolean(process.env.CREDITS_REQUIRES_ADDRESS, config.modes.credits.requiresAddress);
|
||||
config.modes.credits.allowMixedMode = parseBoolean(process.env.ALLOW_MIXED_MODE, config.modes.credits.allowMixedMode);
|
||||
|
||||
const packageJson = safeParseJson(process.env.CREDITS_PACKAGES, null);
|
||||
if (Array.isArray(packageJson) && packageJson.length > 0) {
|
||||
config.modes.credits.packages = packageJson.map(pkg => ({
|
||||
id: pkg.id,
|
||||
credits: parseInteger(pkg.credits, 0),
|
||||
price: parseAtomicValue(pkg.price, 0n),
|
||||
bonus: parseInteger(pkg.bonus || 0, 0)
|
||||
})).filter(pkg => pkg.id);
|
||||
} else if (process.env.CREDITS_PACKAGE_PRICE) {
|
||||
const overridePrice = parseAtomicValue(process.env.CREDITS_PACKAGE_PRICE, null);
|
||||
if (overridePrice !== null) {
|
||||
config.modes.credits.packages = config.modes.credits.packages.map((pkg, index) =>
|
||||
index === 0 ? { ...pkg, price: overridePrice } : pkg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.payouts.enabled = parseBoolean(process.env.PAYOUTS_ENABLED, config.payouts.enabled);
|
||||
config.payouts.rules.direct.enabled = parseBoolean(process.env.DIRECT_PAYOUTS_ENABLED, config.payouts.rules.direct.enabled);
|
||||
config.payouts.rules.direct.multipliers.escape = parseFloatValue(process.env.DIRECT_PAYOUT_ESCAPE, config.payouts.rules.direct.multipliers.escape);
|
||||
config.payouts.rules.direct.multipliers.escapeWithTreasure = parseFloatValue(process.env.DIRECT_PAYOUT_TREASURE, config.payouts.rules.direct.multipliers.escapeWithTreasure);
|
||||
config.payouts.rules.direct.minPayout = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.payouts.rules.direct.minPayout);
|
||||
config.payouts.rules.direct.maxPayout = parseAtomicValue(process.env.PAYOUT_MAX_PER_GAME, config.payouts.rules.direct.maxPayout);
|
||||
|
||||
const creditsPayoutEnabled = parseBoolean(process.env.CREDITS_PAYOUTS_ENABLED ?? process.env.CREDITS_PAYOUT_ENABLED, undefined);
|
||||
if (creditsPayoutEnabled !== undefined) {
|
||||
config.payouts.rules.credits.enabled = creditsPayoutEnabled;
|
||||
}
|
||||
config.payouts.rules.credits.multipliers.escape = parseFloatValue(process.env.CREDITS_PAYOUT_ESCAPE, config.payouts.rules.credits.multipliers.escape);
|
||||
config.payouts.rules.credits.multipliers.escapeWithTreasure = parseFloatValue(process.env.CREDITS_PAYOUT_TREASURE, config.payouts.rules.credits.multipliers.escapeWithTreasure);
|
||||
config.payouts.rules.credits.baseValue = parseAtomicValue(process.env.CREDITS_PAYOUT_BASE, config.payouts.rules.credits.baseValue);
|
||||
|
||||
config.payouts.processing.batchInterval = parseInteger(process.env.PAYOUT_BATCH_INTERVAL, config.payouts.processing.batchInterval);
|
||||
config.payouts.processing.maxRetries = parseInteger(process.env.PAYOUT_MAX_RETRIES, config.payouts.processing.maxRetries);
|
||||
|
||||
config.limits.maxGamesPerHour = parseInteger(process.env.MAX_GAMES_PER_HOUR, config.limits.maxGamesPerHour);
|
||||
config.limits.maxPayoutsPerDay = parseInteger(process.env.MAX_PAYOUTS_PER_DAY, config.limits.maxPayoutsPerDay);
|
||||
config.limits.maxCreditPurchasePerDay = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.limits.maxCreditPurchasePerDay);
|
||||
config.limits.cooldownBetweenGames = parseInteger(process.env.GAME_COOLDOWN_SECONDS, config.limits.cooldownBetweenGames);
|
||||
|
||||
config.preferences.allowMixedMode = config.modes.credits.allowMixedMode;
|
||||
config.preferences.preferCreditsFirst = parseBoolean(process.env.PREFER_CREDITS_FIRST, config.preferences.preferCreditsFirst);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return cloneConfig(this.config);
|
||||
}
|
||||
|
||||
getLegacyGameMode() {
|
||||
if (!this.config.paymentsEnabled) {
|
||||
return 'FREE';
|
||||
}
|
||||
if (this.legacyOverride) {
|
||||
return this.legacyOverride.toUpperCase();
|
||||
}
|
||||
const directEnabled = !!this.config.modes.direct.enabled;
|
||||
const creditsEnabled = !!this.config.modes.credits.enabled;
|
||||
if (directEnabled && !creditsEnabled) {
|
||||
return 'PAID_SINGLE';
|
||||
}
|
||||
if (!directEnabled && creditsEnabled) {
|
||||
return 'PAID_CREDITS';
|
||||
}
|
||||
if (!directEnabled && !creditsEnabled) {
|
||||
return 'FREE';
|
||||
}
|
||||
return this.config.preferences.preferCreditsFirst ? 'PAID_CREDITS' : 'PAID_SINGLE';
|
||||
}
|
||||
|
||||
isModeEnabled(mode) {
|
||||
return Boolean(this.config.modes[mode]?.enabled);
|
||||
}
|
||||
|
||||
validateConfig() {
|
||||
const directEnabled = this.config.modes.direct.enabled;
|
||||
const creditsEnabled = this.config.modes.credits.enabled;
|
||||
|
||||
if (!this.config.paymentsEnabled && (directEnabled || creditsEnabled)) {
|
||||
this.logger.warn?.('⚠️ paymentsEnabled is false but some payment modes are enabled. Payments will remain disabled.');
|
||||
}
|
||||
|
||||
if (!directEnabled && !creditsEnabled && this.config.paymentsEnabled) {
|
||||
this.logger.warn?.('⚠️ Payments enabled but no payment modes active. Falling back to FREE mode.');
|
||||
}
|
||||
|
||||
this.validatePrices();
|
||||
return true;
|
||||
}
|
||||
|
||||
validatePrices() {
|
||||
const { direct, credits } = this.config.modes;
|
||||
if (direct.enabled && direct.price <= 0n) {
|
||||
throw new Error('Direct game price must be a positive value');
|
||||
}
|
||||
if (credits.enabled) {
|
||||
for (const pkg of credits.packages) {
|
||||
if (!pkg.id) {
|
||||
throw new Error('Each credit package requires an id');
|
||||
}
|
||||
if (pkg.price <= 0n) {
|
||||
throw new Error(`Credit package ${pkg.id} has invalid price`);
|
||||
}
|
||||
if (pkg.credits <= 0) {
|
||||
throw new Error(`Credit package ${pkg.id} has invalid credit count`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateConfig(updates) {
|
||||
const newConfig = cloneConfig(this.config);
|
||||
Object.keys(updates || {}).forEach(key => {
|
||||
newConfig[key] = updates[key];
|
||||
});
|
||||
|
||||
const testManager = new PaymentConfigManager({
|
||||
logger: this.logger,
|
||||
eventBus: this.eventBus,
|
||||
persistenceAdapter: this.persistenceAdapter
|
||||
});
|
||||
testManager.config = cloneConfig(newConfig);
|
||||
testManager.validateConfig();
|
||||
|
||||
this.config = newConfig;
|
||||
if (this.persistenceAdapter && typeof this.persistenceAdapter.save === 'function') {
|
||||
await this.persistenceAdapter.save(newConfig);
|
||||
}
|
||||
this.broadcastConfigUpdate();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.config = this.loadConfig();
|
||||
this.validateConfig();
|
||||
this.broadcastConfigUpdate();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
broadcastConfigUpdate() {
|
||||
if (this.eventBus && typeof this.eventBus.emit === 'function') {
|
||||
this.eventBus.emit('paymentConfig:update', this.getConfig());
|
||||
}
|
||||
}
|
||||
|
||||
setPersistenceAdapter(adapter) {
|
||||
this.persistenceAdapter = adapter;
|
||||
}
|
||||
|
||||
setEventBus(eventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
summarize() {
|
||||
return {
|
||||
paymentsEnabled: this.config.paymentsEnabled,
|
||||
legacyMode: this.getLegacyGameMode(),
|
||||
directEnabled: this.config.modes.direct.enabled,
|
||||
creditsEnabled: this.config.modes.credits.enabled,
|
||||
directPrice: this.config.modes.direct.price,
|
||||
creditPackages: this.config.modes.credits.packages.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
PaymentConfigManager.DEFAULT_CONFIG = DEFAULT_CONFIG;
|
||||
|
||||
module.exports = PaymentConfigManager;
|
||||
+317
-63
@@ -3,36 +3,276 @@
|
||||
* Handles different game modes (FREE, PAID_SINGLE, PAID_CREDITS)
|
||||
*/
|
||||
|
||||
const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals
|
||||
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
|
||||
const DEFAULT_DECIMALS = 12;
|
||||
|
||||
const parseAtomicEnvValue = (val, fallback) => {
|
||||
if (val === undefined || val === null || val === '') return fallback;
|
||||
if (typeof val !== 'string') {
|
||||
const numVal = Number(val);
|
||||
return Number.isFinite(numVal) ? Math.trunc(numVal) : fallback;
|
||||
}
|
||||
const cleaned = val.replace(/_/g, '').trim();
|
||||
if (!cleaned) {
|
||||
return fallback;
|
||||
}
|
||||
const num = Number(cleaned);
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(num);
|
||||
};
|
||||
|
||||
class GameModeManager {
|
||||
constructor(databaseManager, walletRPCService, debugManager) {
|
||||
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) {
|
||||
this.db = databaseManager;
|
||||
this.walletService = walletRPCService; // Changed from moneroPayService
|
||||
this.debugManager = debugManager;
|
||||
this.gameMode = process.env.GAME_MODE || 'FREE';
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
// Flexible parsing: supports plain integers, scientific notation (e.g. 5e9), and underscores (e.g. 5_000_000_000)
|
||||
const parseAtomic = (val, fallback) => {
|
||||
if (val === undefined || val === null || val === '') return fallback;
|
||||
if (typeof val !== 'string') return Math.trunc(Number(val)) || fallback;
|
||||
const cleaned = val.replace(/_/g, '').trim();
|
||||
const num = Number(cleaned);
|
||||
if (!Number.isFinite(num) || num < 0) return fallback;
|
||||
// Ensure integer atomic units
|
||||
return Math.trunc(num);
|
||||
};
|
||||
this.paymentConfigManager = paymentConfigManager || null;
|
||||
|
||||
this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR
|
||||
this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR
|
||||
this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUT_ENABLED || 'false');
|
||||
|
||||
console.log(`🎮 Game Mode Manager initialized: ${this.gameMode} mode`);
|
||||
console.log(`💰 Currency: ${this.cryptoType}`);
|
||||
const toXMR = (atomic) => (atomic / 1e12).toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
|
||||
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${toXMR(this.singleGamePrice)} ${this.cryptoType})`);
|
||||
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${toXMR(this.creditsPackagePrice)} ${this.cryptoType})`);
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
||||
this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE;
|
||||
this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE;
|
||||
this.creditsPayoutEnabled = false;
|
||||
this.creditsPayoutBaseValue = DEFAULT_SINGLE_GAME_PRICE;
|
||||
this.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
||||
this.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
|
||||
this.creditsPerGameCost = 1;
|
||||
this.preferCreditsFirst = true;
|
||||
this.paymentsEnabled = false;
|
||||
this.directModeEnabled = false;
|
||||
this.creditsModeEnabled = false;
|
||||
this.configSnapshot = null;
|
||||
|
||||
this.applyLegacyEnvConfig();
|
||||
|
||||
if (this.paymentConfigManager) {
|
||||
const config = this.paymentConfigManager.getConfig();
|
||||
this.applyConfigSnapshot(config, { emitLog: false });
|
||||
this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode());
|
||||
|
||||
if (this.paymentConfigManager.eventBus && typeof this.paymentConfigManager.eventBus.on === 'function') {
|
||||
this.paymentConfigManager.eventBus.on('paymentConfig:update', (updatedConfig) => {
|
||||
this.applyConfigSnapshot(updatedConfig, { emitLog: true, context: 'updated' });
|
||||
this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logConfiguration('initialized');
|
||||
}
|
||||
|
||||
inferCurrencyDecimals(symbol) {
|
||||
if (!symbol) return DEFAULT_DECIMALS;
|
||||
const normalized = symbol.toUpperCase();
|
||||
if (normalized === 'WOW') return 11;
|
||||
if (normalized === 'XMR') return 12;
|
||||
return DEFAULT_DECIMALS;
|
||||
}
|
||||
|
||||
applyLegacyEnvConfig() {
|
||||
this.setLegacyGameMode(process.env.GAME_MODE || 'FREE');
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || this.cryptoType;
|
||||
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
||||
this.singleGamePrice = parseAtomicEnvValue(process.env.SINGLE_GAME_PRICE, this.singleGamePrice);
|
||||
this.creditsPackagePrice = parseAtomicEnvValue(process.env.CREDITS_PACKAGE_PRICE, this.creditsPackagePrice);
|
||||
this.creditsPerGameCost = parseAtomicEnvValue(process.env.CREDITS_PER_GAME, 1) || 1;
|
||||
this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUT_ENABLED || 'false');
|
||||
this.creditsPayoutBaseValue = this.singleGamePrice;
|
||||
process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost);
|
||||
|
||||
const directEscape = Number(process.env.DIRECT_PAYOUT_ESCAPE);
|
||||
if (Number.isFinite(directEscape) && directEscape > 0) {
|
||||
this.directPayoutMultipliers.escape = directEscape;
|
||||
}
|
||||
const directTreasure = Number(process.env.DIRECT_PAYOUT_TREASURE);
|
||||
if (Number.isFinite(directTreasure) && directTreasure > 0) {
|
||||
this.directPayoutMultipliers.escapeWithTreasure = directTreasure;
|
||||
}
|
||||
const creditsEscape = Number(process.env.CREDITS_PAYOUT_ESCAPE);
|
||||
if (Number.isFinite(creditsEscape) && creditsEscape > 0) {
|
||||
this.creditPayoutMultipliers.escape = creditsEscape;
|
||||
}
|
||||
const creditsTreasure = Number(process.env.CREDITS_PAYOUT_TREASURE);
|
||||
if (Number.isFinite(creditsTreasure) && creditsTreasure > 0) {
|
||||
this.creditPayoutMultipliers.escapeWithTreasure = creditsTreasure;
|
||||
}
|
||||
|
||||
if (process.env.PREFER_CREDITS_FIRST) {
|
||||
this.preferCreditsFirst = /^true$/i.test(process.env.PREFER_CREDITS_FIRST);
|
||||
}
|
||||
|
||||
this.paymentsEnabled = this.gameMode !== 'FREE';
|
||||
this.directModeEnabled = this.gameMode === 'PAID_SINGLE';
|
||||
this.creditsModeEnabled = this.gameMode === 'PAID_CREDITS';
|
||||
}
|
||||
|
||||
applyConfigSnapshot(config, options = {}) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configSnapshot = config;
|
||||
this.paymentsEnabled = !!config.paymentsEnabled;
|
||||
|
||||
if (config.currency) {
|
||||
if (config.currency.symbol) {
|
||||
this.cryptoType = config.currency.symbol;
|
||||
}
|
||||
if (config.currency.decimals !== undefined) {
|
||||
this.currencyDecimals = Number(config.currency.decimals);
|
||||
} else {
|
||||
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.modes && config.modes.direct) {
|
||||
const { price, enabled } = config.modes.direct;
|
||||
if (price !== undefined && price !== null) {
|
||||
this.singleGamePrice = Number(price);
|
||||
}
|
||||
this.directModeEnabled = !!enabled;
|
||||
}
|
||||
|
||||
if (config.modes && config.modes.credits) {
|
||||
const creditsMode = config.modes.credits;
|
||||
if (creditsMode.packages && creditsMode.packages.length > 0) {
|
||||
const primaryPackage = creditsMode.packages[0];
|
||||
if (primaryPackage.price !== undefined && primaryPackage.price !== null) {
|
||||
this.creditsPackagePrice = Number(primaryPackage.price);
|
||||
}
|
||||
if (primaryPackage.credits) {
|
||||
process.env.CREDITS_PER_PACKAGE = String(primaryPackage.credits);
|
||||
}
|
||||
}
|
||||
if (creditsMode.creditsPerGame !== undefined) {
|
||||
this.creditsPerGameCost = Number(creditsMode.creditsPerGame) || 1;
|
||||
}
|
||||
this.creditsModeEnabled = !!creditsMode.enabled;
|
||||
}
|
||||
|
||||
if (config.payouts && config.payouts.rules) {
|
||||
const directRule = config.payouts.rules.direct || {};
|
||||
const creditsRule = config.payouts.rules.credits || {};
|
||||
|
||||
if (directRule.multipliers) {
|
||||
if (directRule.multipliers.escape !== undefined) {
|
||||
this.directPayoutMultipliers.escape = Number(directRule.multipliers.escape);
|
||||
}
|
||||
if (directRule.multipliers.escapeWithTreasure !== undefined) {
|
||||
this.directPayoutMultipliers.escapeWithTreasure = Number(directRule.multipliers.escapeWithTreasure);
|
||||
}
|
||||
}
|
||||
|
||||
if (creditsRule.multipliers) {
|
||||
if (creditsRule.multipliers.escape !== undefined) {
|
||||
this.creditPayoutMultipliers.escape = Number(creditsRule.multipliers.escape);
|
||||
}
|
||||
if (creditsRule.multipliers.escapeWithTreasure !== undefined) {
|
||||
this.creditPayoutMultipliers.escapeWithTreasure = Number(creditsRule.multipliers.escapeWithTreasure);
|
||||
}
|
||||
}
|
||||
|
||||
if (creditsRule.baseValue !== undefined) {
|
||||
this.creditsPayoutBaseValue = Number(creditsRule.baseValue);
|
||||
}
|
||||
|
||||
if (creditsRule.enabled !== undefined) {
|
||||
this.creditsPayoutEnabled = !!creditsRule.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.preferences) {
|
||||
if (config.preferences.preferCreditsFirst !== undefined) {
|
||||
this.preferCreditsFirst = !!config.preferences.preferCreditsFirst;
|
||||
}
|
||||
}
|
||||
|
||||
process.env.SINGLE_GAME_PRICE = String(this.singleGamePrice);
|
||||
process.env.CREDITS_PACKAGE_PRICE = String(this.creditsPackagePrice);
|
||||
process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost);
|
||||
process.env.CREDITS_PAYOUT_ENABLED = this.creditsPayoutEnabled ? 'true' : 'false';
|
||||
process.env.CREDITS_PAYOUTS_ENABLED = process.env.CREDITS_PAYOUT_ENABLED;
|
||||
|
||||
if (options.emitLog) {
|
||||
this.logConfiguration(options.context || 'updated');
|
||||
}
|
||||
}
|
||||
|
||||
setLegacyGameMode(mode) {
|
||||
this.gameMode = (mode || 'FREE').toUpperCase();
|
||||
process.env.GAME_MODE = this.gameMode;
|
||||
}
|
||||
|
||||
formatAtomic(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
const decimals = Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS;
|
||||
const divisor = Math.pow(10, decimals);
|
||||
const quotient = Number(value) / divisor;
|
||||
return Number.isFinite(quotient)
|
||||
? quotient.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
|
||||
: value.toString();
|
||||
}
|
||||
|
||||
getDecimalDivisor() {
|
||||
const decimals = Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS;
|
||||
return Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
formatAtomicHuman(value, digits = 3) {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
const divisor = this.getDecimalDivisor();
|
||||
const quotient = Number(value) / divisor;
|
||||
if (!Number.isFinite(quotient)) {
|
||||
return value.toString();
|
||||
}
|
||||
return quotient.toFixed(digits);
|
||||
}
|
||||
|
||||
getPrimaryCreditPackage() {
|
||||
const packages = this.configSnapshot?.modes?.credits?.packages;
|
||||
if (Array.isArray(packages) && packages.length > 0) {
|
||||
return packages[0];
|
||||
}
|
||||
return {
|
||||
id: 'default',
|
||||
credits: 10,
|
||||
price: this.creditsPackagePrice,
|
||||
bonus: 0
|
||||
};
|
||||
}
|
||||
|
||||
calculatePayout(mode, { treasureFound = false } = {}) {
|
||||
const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase();
|
||||
const usingCredits = normalizedMode === 'PAID_CREDITS';
|
||||
const base = usingCredits ? (this.creditsPayoutBaseValue || this.singleGamePrice) : this.singleGamePrice;
|
||||
const multipliers = usingCredits ? this.creditPayoutMultipliers : this.directPayoutMultipliers;
|
||||
const multiplier = treasureFound
|
||||
? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0)
|
||||
: (multipliers.escape ?? 0);
|
||||
|
||||
const amount = Math.round(base * multiplier);
|
||||
return { amount, multiplier, base };
|
||||
}
|
||||
|
||||
logConfiguration(context = 'initialized') {
|
||||
console.log(`🎮 Game Mode Manager ${context}: ${this.gameMode} mode`);
|
||||
console.log(`💰 Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`);
|
||||
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${this.formatAtomic(this.singleGamePrice)} ${this.cryptoType})`);
|
||||
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${this.formatAtomic(this.creditsPackagePrice)} ${this.cryptoType})`);
|
||||
console.log(`🎯 Credits per game cost: ${this.creditsPerGameCost}`);
|
||||
console.log(`🧮 Payout multipliers - direct: ${JSON.stringify(this.directPayoutMultipliers)}, credits: ${JSON.stringify(this.creditPayoutMultipliers)}`);
|
||||
console.log(`🔁 Mode availability - direct: ${this.directModeEnabled}, credits: ${this.creditsModeEnabled}, preferCreditsFirst: ${this.preferCreditsFirst}`);
|
||||
if (this.creditsPayoutEnabled) {
|
||||
console.log('🎁 Credits payout mode ENABLED (will pay rewards in PAID_CREDITS).');
|
||||
}
|
||||
console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,13 +287,19 @@ class GameModeManager {
|
||||
return { allowed: true, reason: 'Free mode' };
|
||||
|
||||
case 'PAID_CREDITS':
|
||||
if (user.credits > 0) {
|
||||
return { allowed: true, reason: `${user.credits} credits remaining` };
|
||||
if (user.credits >= this.creditsPerGameCost) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: `${user.credits} credits remaining`,
|
||||
creditsRequired: this.creditsPerGameCost
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'No credits remaining',
|
||||
action: 'purchase_credits'
|
||||
reason: 'Insufficient credits',
|
||||
action: 'purchase_credits',
|
||||
creditsRequired: this.creditsPerGameCost,
|
||||
balance: user.credits
|
||||
};
|
||||
|
||||
case 'PAID_SINGLE':
|
||||
@@ -106,20 +352,21 @@ class GameModeManager {
|
||||
return { success: true };
|
||||
|
||||
case 'PAID_CREDITS':
|
||||
// Deduct one credit
|
||||
// Deduct required credits for this mode
|
||||
const creditsToSpend = this.creditsPerGameCost;
|
||||
const updateRes = await this.db.query(`
|
||||
UPDATE users
|
||||
SET credits = credits - 1,
|
||||
SET credits = credits - $1,
|
||||
total_games_played = total_games_played + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
WHERE id = $2
|
||||
RETURNING credits
|
||||
`, [user.id]);
|
||||
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - 1);
|
||||
`, [creditsToSpend, user.id]);
|
||||
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend);
|
||||
// Emit credits update asynchronously if an io ref was injected later (pattern: attach externally)
|
||||
try { this.io && this.io.to(socketId).emit('credits_update', { balance: remainingCredits }); } catch(_) {}
|
||||
console.log(`🎫 Deducted 1 credit from user ${user.id}, ${remainingCredits} remaining`);
|
||||
return { success: true, creditsRemaining: remainingCredits };
|
||||
console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`);
|
||||
return { success: true, creditsRemaining: remainingCredits, creditsSpent: creditsToSpend };
|
||||
|
||||
case 'PAID_SINGLE':
|
||||
// Link game to payment
|
||||
@@ -193,22 +440,20 @@ class GameModeManager {
|
||||
WHERE id = $4
|
||||
`, [outcome === 'escaped' ? 'won' : 'lost', outcome, treasureFound, gameId]);
|
||||
|
||||
// Handle payouts for PAID_SINGLE or (optionally) PAID_CREDITS mode
|
||||
const payoutEligibleMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
|
||||
const recordedMode = (game.payment_mode || game.game_mode || this.gameMode || 'FREE').toUpperCase();
|
||||
const payoutEligibleMode = (recordedMode === 'PAID_SINGLE') || (recordedMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
|
||||
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
|
||||
let payoutAmount;
|
||||
const base = (this.gameMode === 'PAID_CREDITS') ? this.singleGamePrice : this.singleGamePrice; // could make distinct; reuse singleGamePrice as base
|
||||
payoutAmount = treasureFound ? base * 3 : base * 2;
|
||||
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
|
||||
if (payoutAmount > 0) {
|
||||
await this.walletService.processPayout(
|
||||
game.user_id,
|
||||
gameId,
|
||||
game.payout_address,
|
||||
payoutAmount
|
||||
);
|
||||
|
||||
// Create payout record
|
||||
await this.walletService.processPayout(
|
||||
game.user_id,
|
||||
gameId,
|
||||
game.payout_address,
|
||||
payoutAmount
|
||||
);
|
||||
|
||||
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId}`);
|
||||
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user statistics
|
||||
@@ -241,19 +486,25 @@ class GameModeManager {
|
||||
try {
|
||||
const user = await this.getOrCreateUser(socketId);
|
||||
|
||||
let amount, description;
|
||||
|
||||
let amount;
|
||||
let description;
|
||||
let packageInfo = null;
|
||||
|
||||
switch (paymentType) {
|
||||
case 'single_game':
|
||||
case 'single_game': {
|
||||
amount = this.singleGamePrice;
|
||||
description = `Wowngeon single game entry (${this.cryptoType})`;
|
||||
break;
|
||||
|
||||
case 'credits_package':
|
||||
amount = this.creditsPackagePrice;
|
||||
description = `Wowngeon 10 credits package (${this.cryptoType})`;
|
||||
}
|
||||
case 'credits_package': {
|
||||
const primaryPackage = this.getPrimaryCreditPackage();
|
||||
const packagePrice = primaryPackage?.price ?? this.creditsPackagePrice;
|
||||
amount = Number(packagePrice);
|
||||
packageInfo = primaryPackage;
|
||||
const creditCount = primaryPackage?.credits ?? 10;
|
||||
description = `Wowngeon ${creditCount} credits package (${this.cryptoType})`;
|
||||
break;
|
||||
|
||||
}
|
||||
default:
|
||||
throw new Error('Invalid payment type');
|
||||
}
|
||||
@@ -280,9 +531,10 @@ class GameModeManager {
|
||||
id: paymentResult.id,
|
||||
address: paymentResult.address,
|
||||
amount: amount,
|
||||
amountFormatted: (amount / 1e12).toFixed(4), // Convert from atomic units
|
||||
amountFormatted: this.formatAtomicHuman(amount, 4),
|
||||
currency: this.cryptoType,
|
||||
expiresAt: paymentResult.expiresAt
|
||||
expiresAt: paymentResult.expiresAt,
|
||||
package: packageInfo
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
@@ -389,10 +641,14 @@ class GameModeManager {
|
||||
cryptoType: this.cryptoType,
|
||||
singleGamePrice: this.singleGamePrice,
|
||||
creditsPackagePrice: this.creditsPackagePrice,
|
||||
creditsPerGame: this.creditsPerGameCost,
|
||||
paymentsEnabled: this.paymentsEnabled,
|
||||
directModeEnabled: this.directModeEnabled,
|
||||
creditsModeEnabled: this.creditsModeEnabled,
|
||||
features: {
|
||||
paymentRequired: this.gameMode !== 'FREE',
|
||||
creditsSystem: this.gameMode === 'PAID_CREDITS',
|
||||
payouts: this.gameMode === 'PAID_SINGLE'
|
||||
paymentRequired: this.paymentsEnabled,
|
||||
creditsSystem: this.creditsModeEnabled,
|
||||
payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -429,9 +685,7 @@ class GameModeManager {
|
||||
// Handle payouts only in PAID_SINGLE mode and only on win (escaped)
|
||||
const payoutEligibleStartMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
|
||||
if (payoutEligibleStartMode && won) {
|
||||
// Derive payout multiplier
|
||||
const multiplier = treasureFound ? 3 : 2;
|
||||
const payoutAmount = this.singleGamePrice * multiplier;
|
||||
const { amount: payoutAmount, multiplier } = this.calculatePayout(this.gameMode, { treasureFound });
|
||||
|
||||
// Look up user record for payout address
|
||||
const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]);
|
||||
|
||||
+67
-14
@@ -13,12 +13,20 @@ const DatabaseManager = require('./db/databaseManager');
|
||||
const WalletRPCService = require('./payments/walletRPCService');
|
||||
const GameModeManager = require('./game/gameModeManager');
|
||||
const RpcService = require('./rpc/rpcService');
|
||||
const PaymentConfigManager = require('./config/paymentConfig');
|
||||
const EnvironmentValidator = require('./config/environmentValidator');
|
||||
|
||||
// Import modular components
|
||||
const BroadcastManager = require('./network/broadcastManager');
|
||||
const DebugManager = require('./debug/debugManager');
|
||||
const SocketHandlers = require('./network/socketHandlers');
|
||||
|
||||
// Initialize payment configuration
|
||||
const paymentConfigManager = new PaymentConfigManager({ logger: console });
|
||||
const environmentValidator = new EnvironmentValidator({ logger: console });
|
||||
environmentValidator.validate(paymentConfigManager.getConfig());
|
||||
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
|
||||
|
||||
// Initialize modular components first
|
||||
const broadcastManager = new BroadcastManager(io);
|
||||
const debugManager = new DebugManager(broadcastManager);
|
||||
@@ -27,7 +35,7 @@ const debugManager = new DebugManager(broadcastManager);
|
||||
const databaseManager = new DatabaseManager();
|
||||
const rpcService = new RpcService();
|
||||
const walletRPCService = new WalletRPCService(debugManager);
|
||||
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager);
|
||||
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
|
||||
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
|
||||
gameModeManager.io = io;
|
||||
|
||||
@@ -99,18 +107,41 @@ app.get('/api/user/:userId/credits', async (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/api/game-modes', (req, res) => {
|
||||
const config = paymentConfigManager.getConfig();
|
||||
const decimals = Number(config.currency?.decimals ?? 12);
|
||||
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
|
||||
const toDisplay = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
const numeric = typeof value === 'bigint' ? Number(value) : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric / divisor : 0;
|
||||
};
|
||||
|
||||
const directMode = config.modes.direct;
|
||||
const creditsMode = config.modes.credits;
|
||||
|
||||
res.json({
|
||||
FREE: { name: 'Free Play', cost: 0, payoutMultiplier: 0 },
|
||||
PAID_SINGLE: {
|
||||
name: 'Paid Single Game',
|
||||
cost: parseFloat(process.env.SINGLE_GAME_COST || '0.005'),
|
||||
payoutMultiplier: { escape: 2, treasure: 3 }
|
||||
FREE: {
|
||||
name: 'Free Play',
|
||||
cost: 0,
|
||||
payoutMultiplier: 0,
|
||||
enabled: !config.paymentsEnabled
|
||||
},
|
||||
PAID_CREDITS: {
|
||||
name: 'Credits Package',
|
||||
cost: parseFloat(process.env.CREDITS_PACKAGE_COST || '0.03'),
|
||||
credits: parseInt(process.env.CREDITS_PER_PACKAGE || '10'),
|
||||
payoutMultiplier: 0
|
||||
PAID_SINGLE: {
|
||||
name: 'Paid Single Game',
|
||||
cost: toDisplay(directMode.price),
|
||||
enabled: !!directMode.enabled,
|
||||
payoutMultiplier: config.payouts.rules.direct.multipliers
|
||||
},
|
||||
PAID_CREDITS: {
|
||||
name: 'Credits Package',
|
||||
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
|
||||
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
|
||||
enabled: !!creditsMode.enabled,
|
||||
payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled
|
||||
? config.payouts.rules.credits.multipliers
|
||||
: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -131,6 +162,11 @@ async function initializePaymentSystem() {
|
||||
if (!walletInitialized) {
|
||||
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
|
||||
process.env.GAME_MODE = 'FREE';
|
||||
paymentConfigManager.refresh();
|
||||
gameModeManager.setLegacyGameMode('FREE');
|
||||
gameModeManager.paymentsEnabled = false;
|
||||
gameModeManager.directModeEnabled = false;
|
||||
gameModeManager.creditsModeEnabled = false;
|
||||
}
|
||||
|
||||
// Initialize database connection and run migrations
|
||||
@@ -186,18 +222,35 @@ async function startServer() {
|
||||
console.log('🚀 Wowngeon server listening on *:3000');
|
||||
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
|
||||
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
|
||||
console.log(`🎮 Available game modes: ${paymentSystemReady ? 'FREE, PAID_SINGLE, PAID_CREDITS' : 'FREE ONLY'}`);
|
||||
const summary = paymentConfigManager.summarize();
|
||||
const enabledModes = [];
|
||||
if (!summary.paymentsEnabled || !paymentSystemReady) {
|
||||
enabledModes.push('FREE');
|
||||
}
|
||||
if (summary.directEnabled && paymentSystemReady) {
|
||||
enabledModes.push('PAID_SINGLE');
|
||||
}
|
||||
if (summary.creditsEnabled && paymentSystemReady) {
|
||||
enabledModes.push('PAID_CREDITS');
|
||||
}
|
||||
if (enabledModes.length === 0) {
|
||||
enabledModes.push(summary.legacyMode || 'FREE');
|
||||
}
|
||||
console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`);
|
||||
});
|
||||
|
||||
// Start batch payout processing if payment system is ready
|
||||
if (paymentSystemReady) {
|
||||
if (paymentSystemReady) {
|
||||
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
|
||||
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
|
||||
const payoutIntervalMs = payoutIntervalSeconds * 1000;
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await walletRPCService.processBatchPayouts();
|
||||
} catch (error) {
|
||||
console.error('Error in batch payout processing:', error);
|
||||
}
|
||||
}, 300000); // Every 5 minutes
|
||||
}, payoutIntervalMs);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Migration 005: Unified payment system foundation
|
||||
|
||||
-- Extend payments table to track unified payment metadata
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20);
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN payment_mode SET DEFAULT 'direct';
|
||||
UPDATE payments
|
||||
SET payment_mode = 'direct'
|
||||
WHERE payment_mode IS NULL;
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS credit_package_id VARCHAR(20);
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS credits_purchased INTEGER DEFAULT 0;
|
||||
UPDATE payments
|
||||
SET credits_purchased = 0
|
||||
WHERE credits_purchased IS NULL;
|
||||
|
||||
-- Extend user profile with payment preferences and stats
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS preferred_payment_mode VARCHAR(20) DEFAULT 'direct';
|
||||
UPDATE users
|
||||
SET preferred_payment_mode = 'direct'
|
||||
WHERE preferred_payment_mode IS NULL;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS total_credits_purchased INTEGER DEFAULT 0;
|
||||
UPDATE users
|
||||
SET total_credits_purchased = 0
|
||||
WHERE total_credits_purchased IS NULL;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS total_payouts_received BIGINT DEFAULT 0;
|
||||
UPDATE users
|
||||
SET total_payouts_received = 0
|
||||
WHERE total_payouts_received IS NULL;
|
||||
|
||||
-- Ensure total_games_played always has a sensible default
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN total_games_played SET DEFAULT 0;
|
||||
UPDATE users
|
||||
SET total_games_played = 0
|
||||
WHERE total_games_played IS NULL;
|
||||
|
||||
-- Ensure credit_transactions table exists for installs that missed migration 004
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'credit_transactions'
|
||||
) THEN
|
||||
CREATE TABLE credit_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
transaction_type VARCHAR(20) DEFAULT 'legacy',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
metadata JSONB
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Tag credit transactions with a transaction type
|
||||
ALTER TABLE credit_transactions
|
||||
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20);
|
||||
UPDATE credit_transactions
|
||||
SET transaction_type = COALESCE(transaction_type, 'legacy');
|
||||
ALTER TABLE credit_transactions
|
||||
ALTER COLUMN transaction_type SET DEFAULT 'legacy';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON credit_transactions(created_at);
|
||||
|
||||
-- Create payouts table if it was dropped in the past (fresh installs)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'payouts'
|
||||
) THEN
|
||||
CREATE TABLE payouts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
|
||||
payout_address VARCHAR(110) NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
multiplier DECIMAL(3,1) DEFAULT 0,
|
||||
reason VARCHAR(50) DEFAULT 'legacy' NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
batch_id VARCHAR(50),
|
||||
tx_hash VARCHAR(64),
|
||||
fee BIGINT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
processed_at TIMESTAMP,
|
||||
error_message TEXT
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add new payout tracking columns for existing installs
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS multiplier DECIMAL(3,1) DEFAULT 0;
|
||||
UPDATE payouts
|
||||
SET multiplier = 0
|
||||
WHERE multiplier IS NULL;
|
||||
ALTER TABLE payouts
|
||||
ALTER COLUMN multiplier SET NOT NULL;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS reason VARCHAR(50) DEFAULT 'legacy';
|
||||
UPDATE payouts
|
||||
SET reason = 'legacy'
|
||||
WHERE reason IS NULL;
|
||||
ALTER TABLE payouts
|
||||
ALTER COLUMN reason SET NOT NULL;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||
|
||||
-- Align payout address length with Wownero expectations
|
||||
DROP VIEW IF EXISTS pending_payouts;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ALTER COLUMN payout_address TYPE VARCHAR(110);
|
||||
|
||||
CREATE OR REPLACE VIEW pending_payouts AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.user_id,
|
||||
u.username,
|
||||
p.payout_address,
|
||||
p.amount,
|
||||
p.status,
|
||||
p.created_at,
|
||||
g.outcome AS game_outcome
|
||||
FROM payouts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN games g ON p.game_id = g.id
|
||||
WHERE p.status = 'pending'
|
||||
ORDER BY p.created_at ASC;
|
||||
|
||||
-- Add helpful indexes for new queries
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_user_id ON payouts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_payment_mode ON payments(payment_mode);
|
||||
@@ -50,6 +50,9 @@ class PaymentHandlers {
|
||||
paymentId: paymentRequest.id,
|
||||
address: paymentRequest.address,
|
||||
amount: paymentRequest.amount,
|
||||
amountFormatted: paymentRequest.amountFormatted,
|
||||
currency: this.gameModeManager.cryptoType,
|
||||
package: paymentRequest.package,
|
||||
gameMode: gameMode
|
||||
});
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
|
||||
@@ -81,17 +84,60 @@ class PaymentHandlers {
|
||||
// caller (SocketHandlers) will handle user existence; keep method generic
|
||||
const gameMode = this.gameModeManager.gameMode;
|
||||
const cryptoType = this.gameModeManager.cryptoType;
|
||||
let paymentType, amount, description;
|
||||
if (gameMode === 'PAID_SINGLE') { paymentType = 'single_game'; amount = this.gameModeManager.singleGamePrice; description = 'Single game entry'; }
|
||||
else if (gameMode === 'PAID_CREDITS') { paymentType = 'credits_package'; amount = this.gameModeManager.creditsPackagePrice; description = '10 game credits package'; }
|
||||
else { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; }
|
||||
let paymentType;
|
||||
let amount;
|
||||
let description;
|
||||
|
||||
if (gameMode === 'PAID_SINGLE') {
|
||||
paymentType = 'single_game';
|
||||
amount = this.gameModeManager.singleGamePrice;
|
||||
description = 'Single game entry';
|
||||
} else if (gameMode === 'PAID_CREDITS') {
|
||||
paymentType = 'credits_package';
|
||||
const primaryPackage = this.gameModeManager.getPrimaryCreditPackage();
|
||||
amount = Number(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice);
|
||||
const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10;
|
||||
description = `${credits} credit package`;
|
||||
} else {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType);
|
||||
let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); qrDataUrl = await generatePaymentQR(paymentRequest.address, amount, cryptoType, description); } catch(e) {}
|
||||
const humanAmount = (amount / 1000000000000).toFixed(3);
|
||||
this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, address: paymentRequest.address, amount: paymentRequest.amount, paymentType, gameMode, cryptoType, humanAmount, description, expiresAt: paymentRequest.expiresAt, qr: qrDataUrl });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 PAYMENT REQUIRED (${description})\n\nAmount: ${humanAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${humanAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`);
|
||||
this._monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser);
|
||||
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
|
||||
let qrDataUrl = null;
|
||||
try {
|
||||
const { generatePaymentQR } = require('../payments/qrService');
|
||||
qrDataUrl = await generatePaymentQR(
|
||||
paymentRequest.address,
|
||||
paymentRequest.amount,
|
||||
cryptoType,
|
||||
description,
|
||||
this.gameModeManager.currencyDecimals
|
||||
);
|
||||
} catch (e) {}
|
||||
|
||||
this.io.to(socket.id).emit('payment_created', {
|
||||
paymentId: paymentRequest.id,
|
||||
address: paymentRequest.address,
|
||||
amount: paymentRequest.amount,
|
||||
amountFormatted: formattedAmount,
|
||||
paymentType,
|
||||
gameMode,
|
||||
cryptoType,
|
||||
description,
|
||||
expiresAt: paymentRequest.expiresAt,
|
||||
qr: qrDataUrl,
|
||||
package: paymentRequest.package
|
||||
});
|
||||
|
||||
this.broadcastManager.sendStatusUpdate(
|
||||
socket.id,
|
||||
'payment',
|
||||
`💳 PAYMENT REQUIRED (${description})\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`
|
||||
);
|
||||
|
||||
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser);
|
||||
} catch (e) {
|
||||
console.error('Error creating payment request:', e);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to create payment request.');
|
||||
|
||||
@@ -8,15 +8,18 @@ try {
|
||||
QR = null;
|
||||
}
|
||||
|
||||
function formatAmount(atomic) {
|
||||
function formatAmount(atomic, decimals = 12) {
|
||||
if (!atomic || atomic <= 0) return '0';
|
||||
return (atomic / 1e12).toFixed(12).replace(/0+$/,'').replace(/\.$/,'');
|
||||
const divisor = Math.pow(10, decimals);
|
||||
return (Number(atomic) / divisor).toFixed(decimals)
|
||||
.replace(/0+$/, '')
|
||||
.replace(/\.$/, '');
|
||||
}
|
||||
|
||||
async function generatePaymentQR(address, amountAtomic, cryptoType, description) {
|
||||
async function generatePaymentQR(address, amountAtomic, cryptoType, description, decimals = 12) {
|
||||
if (!QR) return null; // Library not installed
|
||||
try {
|
||||
const amountStr = formatAmount(amountAtomic);
|
||||
const amountStr = formatAmount(amountAtomic, decimals);
|
||||
const scheme = cryptoType && cryptoType.toUpperCase() === 'WOW' ? 'wownero' : 'monero';
|
||||
// Basic URI; wallets usually understand amount (tx_amount) & description
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Reference in New Issue
Block a user