PHP WebShell

Текущая директория: /var/www/bitcardoApp/user/crypto

Просмотр файла: send_crypto.php

<?php
include '../common/header.php';

/**
 * Assumptions:
 * - $conn (mysqli) and $wallets are prepared by header.php / user.php
 * - $wallets: array of user wallets with keys: coin, balance, label, type
 * - You’ve created and seeded `withdraw_fees` as agreed.
 */

$selected_coin = isset($_GET['coin']) ? strtoupper(trim($_GET['coin'])) : '';

// Build balances for JS and PHP usage
$wallet_balances = [];
$coins_for_fees  = [];
foreach ($wallets as $w) {
    $coin = strtoupper($w['coin']);
    $wallet_balances[$coin] = [
        'balance' => (float)$w['balance'],
        'label'   => $w['label'],
        'type'    => isset($w['type']) ? strtolower($w['type']) : 'crypto',
    ];
    // We only need fee tiers for coins that can be sent (exclude pure fiat unless NGN)
    if ($coin === 'NGN' || (isset($w['type']) && strtolower($w['type']) !== 'fiat')) {
        $coins_for_fees[$coin] = true;
    }
}

// Fetch fee tiers for all relevant coins (crypto = USD tiers; NGN = native)
$feeRules = []; // coin => [ {threshold, percent_fee, flat_fee, max_fee}, ... ] ordered
if (!empty($coins_for_fees)) {
    // Build list for IN clause
    $in  = implode(',', array_fill(0, count($coins_for_fees), '?'));
    $sql = "SELECT coin, type, threshold, percent_fee, flat_fee, max_fee
              FROM withdraw_fees
             WHERE coin IN ($in)";

    $stmt = $conn->prepare($sql);
    // bind dynamic params
    $types = str_repeat('s', count($coins_for_fees));
    $coins = array_keys($coins_for_fees);
    $stmt->bind_param($types, ...$coins);
    $stmt->execute();
    $res = $stmt->get_result();
    while ($r = $res->fetch_assoc()) {
        $coin = strtoupper($r['coin']);
        $feeRules[$coin][] = [
            'type'        => $r['type'],                          // 'crypto' or 'fiat'
            'threshold'   => is_null($r['threshold']) ? null : (float)$r['threshold'], // USD for crypto; NGN for fiat
            'percent_fee' => (float)$r['percent_fee'],            // %
            'flat_fee'    => (float)$r['flat_fee'],               // USD for crypto; NGN for fiat
            'max_fee'     => is_null($r['max_fee']) ? null : (float)$r['max_fee'],     // USD for crypto; NGN for fiat
        ];
    }
    $stmt->close();

    // Sort tiers: smallest threshold first, keep NULL last (default)
    foreach ($feeRules as $coin => &$tiers) {
        usort($tiers, function($a, $b) {
            if ($a['threshold'] === null && $b['threshold'] === null) return 0;
            if ($a['threshold'] === null) return 1;   // null goes last
            if ($b['threshold'] === null) return -1;
            return $a['threshold'] <=> $b['threshold'];
        });
    }
    unset($tiers);
}

// In case some coin has no fee rows, provide a zero-fee default client-side fallback
foreach ($wallet_balances as $coin => $_) {
    if (!isset($feeRules[$coin])) {
        $feeRules[$coin] = [[
            'type'        => ($coin === 'NGN' ? 'fiat' : 'crypto'),
            'threshold'   => null,
            'percent_fee' => 0.0,
            'flat_fee'    => 0.0,
            'max_fee'     => null,
        ]];
    }
}

?>
<!-- Main Container -->
<div class="container mt-3">
    <div class="row">
        <?php include '../common/nav.php'; ?>
        <!-- Main Content -->
        <main class="col-md-9 col-lg-10 px-md-5 mb-5">
            <?php include '../common/page-header.php'; ?>
            <div class="container my-5">
                <div class="row g-4">
                    <div class="offset-md-3 col-md-6 mt-2">
                        <h3>Send Crypto</h3>
                        <form action="../../models/crypto/send_crypto_processor.php" method="POST" class="p-3 border rounded" id="sendCryptoForm" autocomplete="off">
                            <div class="mb-3">
                                <label for="coinSelect" class="form-label">Coin</label>
                                <select name="coin" id="coinSelect" class="form-select" required>
                                    <option value="" disabled <?= !$selected_coin ? 'selected' : '' ?>>Select Coin</option>
                                    <?php foreach ($wallets as $wallet):
                                        // Skip if this is a fiat wallet other than NGN
                                        $isFiat = isset($wallet['type']) && strtolower($wallet['type']) === 'fiat';
                                        $coin   = strtoupper(trim($wallet['coin']));
                                        if ($isFiat && $coin !== 'NGN') continue;
                                    ?>
                                        <option value="<?= htmlspecialchars($coin) ?>" <?= ($coin === $selected_coin) ? 'selected' : '' ?>>
                                            <?= htmlspecialchars($wallet['label'] ?: $coin) ?>
                                        </option>
                                    <?php endforeach; ?>
                                </select>
                                <div id="balanceInFiat" class="small mt-1 text-primary"></div>
                            </div>

                            <div class="mb-3">
                                <label for="amountInput" id="amountLabel" class="form-label">Amount</label>
                                <input type="number" step="0.00000001" min="0" name="amount" id="amountInput" class="form-control" required>
                                <div id="amountHelp" class="form-text"></div>
                                <div id="amountError" class="text-danger small mt-1" style="display:none;"></div>
                            </div>

                            <div class="mb-3">
                                <label class="form-label">Recipient Address</label>
                                <input type="text" name="recipient" class="form-control" required>
                            </div>

                            <!-- Estimations -->
                            <div id="estimates" class="border rounded p-2 mb-3" style="display:none;">
                                <div class="small">
                                    <div id="feeLine"></div>
                                    <div id="totalLine"></div>
                                </div>
                            </div>

                            <!-- Hidden fields for server (optional; server should re-compute anyway) -->
                            <input type="hidden" name="client_rate_usd" id="client_rate_usd" value="">
                            <input type="hidden" name="client_fee_coin" id="client_fee_coin" value="">
                            <input type="hidden" name="client_fee_usd"  id="client_fee_usd"  value="">
                            <!-- Holds the server quote_id for exact fees -->
                            <input type="hidden" name="quote_id" id="quote_id" value="">

                            <div class="d-grid gap-2 d-flex mt-4">
                                <button type="submit" class="btn btn-primary flex-fill" id="sendBtn" disabled>Send</button>
                                <button type="reset" class="btn btn-outline-secondary flex-fill" id="resetBtn">Reset</button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </main>
    </div>
</div>

<!-- Review & Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
  <div class="modal-dialog modal-dialog-centered">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Review & Confirm</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <div class="mb-2"><small class="text-muted">From</small><div id="cm_from" class="text-break"></div></div>
        <div class="mb-2"><small class="text-muted">To</small><div id="cm_to" class="text-break"></div></div>
        <hr class="my-2">
        <div class="row">
          <div class="col-6 mb-2"><small class="text-muted">Amount (Crypto)</small><div id="cm_amt_coin"></div></div>
          <div class="col-6 mb-2"><small class="text-muted">Amount (USD)</small><div id="cm_amt_usd"></div></div>
        </div>
        <div class="row">
          <!-- Platform fee row is wrapped so we can hide it globally/per-coin -->
          <div class="col-6 mb-2" id="cm_pf_wrap"><small class="text-muted">Platform Fee</small><div id="cm_pf"></div></div>
          <div class="col-6 mb-2"><small class="text-muted">Network Fee</small><div id="cm_nf"></div></div>
        </div>
        <div class="mb-2"><small class="text-muted">Total Debited</small><div id="cm_total" class="fw-bold"></div></div>
        <div class="text-muted small">Quote valid for ~60 seconds.</div>
        <div id="cm_error" class="text-danger small mt-1" style="display:none;"></div>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
        <button type="button" class="btn btn-primary" id="cm_confirm_btn">Confirm & Send</button>
      </div>
    </div>
  </div>
</div>

<?php include '../common/footer.php'; ?>

<script>
/** Wallet balances from PHP */
const walletBalances = <?= json_encode($wallet_balances) ?>;

/** Fee rules from PHP: { COIN: [ {type, threshold, percent_fee, flat_fee, max_fee}, ... ] } */
const feeRules = <?= json_encode($feeRules) ?>;

/** Price cache (USD per coin) */
let rates = {}; // {COIN: USD_RATE}
let lastCoin = null;

/** Map coin -> CoinGecko id */
const idMap = {
  'BTC': 'bitcoin',
  'ETH': 'ethereum',
  'SOL': 'solana',
  'USDT': 'tether'
};

/** Fetch USD price for a coin (cache for the session) */
async function fetchUsdRate(coin) {
  coin = coin.toUpperCase();
  if (rates[coin]) return rates[coin];

  const id = idMap[coin];
  if (!id) return null;

  try {
    const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd`;
    const res = await fetch(url);
    const data = await res.json();
    if (data[id] && data[id].usd) {
      rates[coin] = parseFloat(data[id].usd);
      return rates[coin];
    }
  } catch (e) {
    // ignore
  }
  return null;
}

/** Pick fee tier given compareAmount and tiers (compareAmount = USD for crypto; NGN for fiat) */
function pickFeeTier(compareAmount, tiers) {
  // tiers are sorted: smallest threshold first; NULL last (default)
  for (const t of tiers) {
    if (t.threshold === null) continue;
    if (compareAmount <= parseFloat(t.threshold)) return t;
  }
  // fallback to default (threshold null) or zero-fee if missing
  return tiers.find(t => t.threshold === null) || { percent_fee: 0, flat_fee: 0, max_fee: null };
}

/** Compute fee for crypto: amount in USD; tiers in USD; return {feeUsd, feeCoin} */
function computeCryptoFee(amountUsd, usdRate, coin, tiers) {
  const tier = pickFeeTier(amountUsd, tiers);
  const percent = parseFloat(tier.percent_fee || 0);
  const flatUsd = parseFloat(tier.flat_fee || 0);
  const capUsd  = (tier.max_fee === null || tier.max_fee === undefined) ? null : parseFloat(tier.max_fee);

  const pctUsd = amountUsd * (percent / 100.0);
  let feeUsd = pctUsd + flatUsd;
  if (capUsd !== null) feeUsd = Math.min(feeUsd, capUsd);

  const coinDecimals = ({BTC:8, ETH:10, SOL:9, USDT:6})[coin] ?? 8;
  const feeCoin = usdRate > 0 ? roundTo(feeUsd / usdRate, coinDecimals) : 0;

  return { feeUsd: roundTo(feeUsd, 2), feeCoin };
}

/** Compute fee for fiat NGN: amount in NGN; tiers in NGN; return {feeNgn} */
function computeFiatFee(amountNgn, tiers) {
  const tier = pickFeeTier(amountNgn, tiers);
  const percent = parseFloat(tier.percent_fee || 0);
  const flat    = parseFloat(tier.flat_fee || 0);
  const cap     = (tier.max_fee === null || tier.max_fee === undefined) ? null : parseFloat(tier.max_fee);

  let fee = (amountNgn * (percent / 100.0)) + flat;
  if (cap !== null) fee = Math.min(fee, cap);
  return roundTo(fee, 2);
}

/** Round helper */
function roundTo(val, dp) {
  const f = Math.pow(10, dp);
  return Math.round((val + Number.EPSILON) * f) / f;
}

/** Update UI for selected coin */
async function refreshForCoin() {
  const coin = document.getElementById('coinSelect').value.toUpperCase();
  const balDiv = document.getElementById('balanceInFiat');
  const amountLabel = document.getElementById('amountLabel');
  const help = document.getElementById('amountHelp');
  const amountInput = document.getElementById('amountInput');
  const estimates = document.getElementById('estimates');
  const feeLine = document.getElementById('feeLine');
  const totalLine = document.getElementById('totalLine');
  const err = document.getElementById('amountError');

  lastCoin = coin;
  err.style.display = 'none';
  estimates.style.display = 'none';
  feeLine.textContent = '';
  totalLine.textContent = '';
  amountInput.value = '';

  const wb = walletBalances[coin];
  if (!wb) {
    balDiv.innerHTML = '';
    amountLabel.textContent = 'Amount';
    amountInput.disabled = true;
    updateSendButtonState();
    return;
  }

  // NGN behavior (fiat)
  if (coin === 'NGN') {
    amountLabel.textContent = 'Amount (NGN)';
    help.textContent = 'Enter the amount in NGN. Fee is 1.8% + ₦100, capped at ₦500 (per your rules).';
    amountInput.placeholder = 'Enter amount in NGN';
    amountInput.disabled = false;
    balDiv.innerHTML = `Wallet Balance: <b>${parseFloat(wb.balance).toLocaleString(undefined, {maximumFractionDigits: 2})} NGN</b>`;
    updateSendButtonState();
    return;
  }

  // Crypto behavior — input in USD
  amountLabel.textContent = 'Amount (in USD)';
  help.textContent = 'Enter the amount to send in USD. We’ll convert to coin and apply withdrawal fees.';
  amountInput.placeholder = 'Enter amount in USD';
  amountInput.disabled = true;
  balDiv.innerHTML = 'Loading price…';

  const usdRate = await fetchUsdRate(coin);
  document.getElementById('client_rate_usd').value = usdRate || '';

  if (!usdRate) {
    balDiv.innerHTML = `<span class="text-danger">Could not fetch price.</span>`;
    updateSendButtonState();
    return;
  }

  const usdtEquivalent = parseFloat(wb.balance) * usdRate;
  balDiv.innerHTML = `Wallet Balance: <b>${parseFloat(wb.balance)} ${coin}</b> ≈ <b>${usdtEquivalent.toLocaleString(undefined, {maximumFractionDigits: 2})} USD</b>`;
  amountInput.disabled = false;
  updateSendButtonState();
}

/** Validate & recompute fee on amount change */
async function onAmountChanged() {
  const coin = document.getElementById('coinSelect').value.toUpperCase();
  const amountInput = document.getElementById('amountInput');
  const estimates = document.getElementById('estimates');
  const feeLine = document.getElementById('feeLine');
  const totalLine = document.getElementById('totalLine');
  const err = document.getElementById('amountError');

  err.style.display = 'none';
  estimates.style.display = 'none';
  feeLine.textContent = '';
  totalLine.textContent = '';

  if (!coin || !walletBalances[coin]) {
    updateSendButtonState();
    return;
  }

  const wb = walletBalances[coin];
  const entered = parseFloat(amountInput.value || '0');
  if (!(entered > 0)) {
    updateSendButtonState();
    return;
  }

  // NGN: amount is in NGN; fee in NGN; total in NGN; ensure total <= balance
  if (coin === 'NGN') {
    const tiers = feeRules['NGN'] || [];
    const feeNgn = computeFiatFee(entered, tiers);
    const totalNgn = entered + feeNgn;
    const over = totalNgn > parseFloat(wb.balance);

    estimates.style.display = 'block';
    feeLine.textContent = `Estimated Fee: ₦${feeNgn.toLocaleString(undefined, {maximumFractionDigits: 2})}`;
    totalLine.textContent = `Total Debit: ₦${totalNgn.toLocaleString(undefined, {maximumFractionDigits: 2})}`;

    document.getElementById('client_fee_usd').value = ''; // not used for NGN
    document.getElementById('client_fee_coin').value = feeNgn.toFixed(2);

    if (over) {
      err.textContent = 'Total (amount + fee) exceeds your NGN wallet balance.';
      err.style.display = 'block';
    }
    updateSendButtonState();
    return;
  }

  // Crypto: amount is in USD; convert to coin; fee in USD (tiers) then convert fee to coin; ensure amountCoin+feeCoin <= balance
  const usdRate = await fetchUsdRate(coin);
  document.getElementById('client_rate_usd').value = usdRate || '';
  if (!usdRate) {
    err.textContent = 'Could not fetch price.';
    err.style.display = 'block';
    updateSendButtonState();
    return;
  }

  const tiers = feeRules[coin] || [];
  const amountUsd = entered;
  const amountCoin = amountUsd / usdRate;

  const { feeUsd, feeCoin } = computeCryptoFee(amountUsd, usdRate, coin, tiers);
  const totalCoin = amountCoin + feeCoin;
  const over = totalCoin > parseFloat(wb.balance);

  estimates.style.display = 'block';
  feeLine.textContent = `Estimated Fee: $${feeUsd.toFixed(2)} ≈ ${feeCoin.toLocaleString(undefined, {maximumFractionDigits: 8})} ${coin}`;
  totalLine.textContent = `Total Debit: ${totalCoin.toLocaleString(undefined, {maximumFractionDigits: 8})} ${coin}`;

  document.getElementById('client_fee_usd').value  = feeUsd.toFixed(2);
  document.getElementById('client_fee_coin').value = feeCoin.toString();

  if (over) {
    err.textContent = 'Total (amount + fee) exceeds your wallet balance.';
    err.style.display = 'block';
  }
  updateSendButtonState();
}

/** Button enable/disable */
function updateSendButtonState() {
  const coin = document.getElementById('coinSelect').value;
  const amount = document.getElementById('amountInput').value;
  const recipient = document.querySelector('input[name="recipient"]').value.trim();
  const sendBtn = document.getElementById('sendBtn');
  const errorDiv = document.getElementById('amountError');

  sendBtn.disabled = !coin || !amount || !recipient || errorDiv.style.display === 'block';
}

/** Review & Confirm (server quote + modal) **/
const quoteInput = document.getElementById('quote_id');
const cmModalEl  = document.getElementById('confirmModal');
let cmModal;

document.addEventListener('DOMContentLoaded', async () => {
  if (window.bootstrap) cmModal = new bootstrap.Modal(cmModalEl);
  await refreshForCoin();

  document.getElementById('coinSelect').addEventListener('change', async () => {
    await refreshForCoin();
    await onAmountChanged();
  });

  document.getElementById('amountInput').addEventListener('input', async () => {
    await onAmountChanged();
  });

  document.querySelector('input[name="recipient"]').addEventListener('input', updateSendButtonState);

  // Intercept submit to fetch exact fees from server, then show confirm modal
  document.getElementById('sendCryptoForm').addEventListener('submit', async function(e) {
    e.preventDefault();

    const errorDiv = document.getElementById('amountError');
    if (errorDiv.style.display === 'block') {
      alert('Please fix the highlighted issues before sending.');
      return;
    }

    const coin = document.getElementById('coinSelect').value.toUpperCase();
    const amount = parseFloat(document.getElementById('amountInput').value || '0'); // USD for crypto UI
    const recipient = document.querySelector('input[name="recipient"]').value.trim();
    if (!coin || !(amount > 0) || !recipient) {
      alert('Please fill all fields correctly.');
      return;
    }

    try {
      const fd = new URLSearchParams();
      fd.set('coin', coin);
      fd.set('amount', String(amount));
      fd.set('recipient', recipient);

      const resp = await fetch('../../models/fees/estimate_withdraw_fee.php', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: fd.toString()
      });
      const q = await resp.json();

      if (!q || q.ok !== true) {
        alert((q && (q.message || q.error)) ? (q.message || q.error) : 'Unable to get a send quote.');
        return;
      }

      // Fill modal
      const fromWallet = (walletBalances[coin]?.label || coin);
      document.getElementById('cm_from').textContent = fromWallet;
      document.getElementById('cm_to').textContent = recipient;
      document.getElementById('cm_amt_coin').textContent = Number(q.amount_coin).toLocaleString(undefined, {maximumFractionDigits: 8}) + ' ' + coin;
      document.getElementById('cm_amt_usd').textContent  = '$' + Number(q.amount_usd).toLocaleString(undefined, {maximumFractionDigits: 2});

      const pfWrap = document.getElementById('cm_pf_wrap');

      if (q.is_internal) {
        // Internal: hide both fees
        pfWrap.style.display = 'none';
        document.getElementById('cm_nf').textContent = '—';
      } else {
        // Platform fee show/hide (GLOBAL + per-coin)
        if (q.platform_fee_enabled === false || q.ui_show_platform_fee === false) {
          pfWrap.style.display = 'none';
        } else {
          pfWrap.style.display = '';
          document.getElementById('cm_pf').textContent =
            Number(q.platform_fee_coin).toLocaleString(undefined, {maximumFractionDigits: 8}) + ' ' + coin +
            (q.platform_fee_usd ? ('  ($' + Number(q.platform_fee_usd).toLocaleString(undefined, {maximumFractionDigits: 2}) + ')') : '');
        }

        // Network fee always shown for external
        document.getElementById('cm_nf').textContent =
          Number(q.network_fee_coin).toLocaleString(undefined, {maximumFractionDigits: 8}) + ' ' + coin +
          (q.network_fee_usd ? ('  ($' + Number(q.network_fee_usd).toLocaleString(undefined, {maximumFractionDigits: 2}) + ')') : '');
      }

      document.getElementById('cm_total').textContent = Number(q.total_debit_coin).toLocaleString(undefined, {maximumFractionDigits: 8}) + ' ' + coin;

      quoteInput.value = q.quote_id || '';
      if (!quoteInput.value) {
        alert('Missing quote token. Please try again.');
        return;
      }

      if (cmModal) cmModal.show();
      else alert('Confirm dialog unavailable; please retry.');
    } catch (err) {
      alert('Network error while getting quote.');
    }
  });

  document.getElementById('resetBtn').addEventListener('click', function() {
    setTimeout(async () => {
      document.getElementById('amountError').style.display = 'none';
      document.getElementById('sendBtn').disabled = true;
      await refreshForCoin();
      await onAmountChanged();
    }, 10);
  });
});

// Finalize on Confirm
document.getElementById('cm_confirm_btn').addEventListener('click', () => {
  if (!quoteInput.value) {
    const errEl = document.getElementById('cm_error');
    errEl.textContent = 'Missing quote. Please try again.';
    errEl.style.display = 'block';
    return;
  }
  // Submit the original form with the quote_id included
  document.getElementById('sendCryptoForm').submit();
});
</script>

Выполнить команду


Для локальной разработки. Не используйте в интернете!