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>
Выполнить команду
Для локальной разработки. Не используйте в интернете!