PHP WebShell

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

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

<?php
// models/crypto/withdraw_quote.php
declare(strict_types=1);
@ini_set('display_errors', '0');
header('Content-Type: application/json');

session_start();
require_once __DIR__ . '/../../config/db_config.php';
require_once __DIR__ . '/../../config/bitgo_config.php';

function out(int $code, array $data) {
  http_response_code($code);
  echo json_encode($data, JSON_UNESCAPED_SLASHES);
  exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
  out(405, ['error' => 'method_not_allowed']);
}
if (empty($_SESSION['user_id'])) {
  out(401, ['error' => 'unauthorized']);
}
$userId = (int)$_SESSION['user_id'];

$coin     = strtoupper(trim($_POST['coin'] ?? ''));
$amountU  = (float)($_POST['amount'] ?? 0);      // USD (for crypto UI)
$toAddr   = trim($_POST['recipient'] ?? '');

if ($coin === '' || $amountU <= 0 || $toAddr === '') {
  out(400, ['error'=>'bad_request','message'=>'coin, amount, recipient required']);
}

/* ---------- helpers ----------- */
function http_get_json(string $url, float $timeout = 1.8): ?array {
  $ch = curl_init($url);
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => $timeout,
    CURLOPT_CONNECTTIMEOUT => $timeout,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
    CURLOPT_USERAGENT      => 'bitcardo/quote/1.0'
  ]);
  $res  = curl_exec($ch);
  $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);
  if ($code !== 200 || !$res) return null;
  $d = json_decode($res, true);
  return (json_last_error() === JSON_ERROR_NONE) ? $d : null;
}

function price_from_kraken(string $coin): ?float {
  $pair = match (strtoupper($coin)) {
    'BTC' => 'XBTUSD', 'ETH'=>'ETHUSD', 'SOL'=>'SOLUSD', 'USDT'=>'USDTUSD',
    default => strtoupper($coin) . 'USD',
  };
  $d = http_get_json("https://api.kraken.com/0/public/Ticker?pair={$pair}");
  if (!$d || !empty($d['error'])) return null;
  if (empty($d['result']) || !is_array($d['result'])) return null;
  $first = reset($d['result']);
  $price = is_array($first) && isset($first['c'][0]) ? (float)$first['c'][0] : null;
  return ($price && $price > 0) ? $price : null;
}
function price_from_binance(string $coin): ?float {
  $sym = strtoupper($coin);
  if ($sym === 'USDT') return 1.0;
  $pair = $sym . 'USDT';
  $d = http_get_json("https://api.binance.com/api/v3/ticker/price?symbol={$pair}");
  if (!$d || empty($d['price'])) return null;
  $p = (float)$d['price']; return $p > 0 ? $p : null;
}
function price_from_bitfinex(string $coin): ?float {
  $sym = strtoupper($coin);
  if ($sym === 'USDT') return 1.0;
  $pair = 't' . $sym . 'USD';
  $d = http_get_json("https://api-pub.bitfinex.com/v2/ticker/{$pair}");
  if (!$d || !is_array($d) || count($d) < 7) return null;
  $last = (float)$d[6]; return $last > 0 ? $last : null;
}
function price_from_okx(string $coin): ?float {
  $sym = strtoupper($coin);
  $inst = $sym . '-USD';
  $d = http_get_json("https://www.okx.com/api/v5/market/ticker?instId={$inst}");
  if (!$d || ($d['code'] ?? '') !== '0' || empty($d['data'][0]['last'])) return null;
  $p = (float)$d['data'][0]['last']; return $p > 0 ? $p : null;
}
function price_from_gemini(string $coin): ?float {
  $sym = strtoupper($coin);
  if ($sym === 'USDT') return 1.0;
  $d = http_get_json("https://api.gemini.com/v1/pricefeed");
  if (!$d || !is_array($d)) return null;
  $pair = $sym . 'USD';
  foreach ($d as $row) {
    if (!empty($row['pair']) && $row['pair'] === $pair && !empty($row['price'])) {
      $p = (float)$row['price']; if ($p > 0) return $p;
    }
  }
  return null;
}
function price_from_coinpaprika(string $coin): ?float {
  $map = ['BTC'=>'btc-bitcoin','ETH'=>'eth-ethereum','SOL'=>'sol-solana','USDT'=>'usdt-tether'];
  $id = $map[strtoupper($coin)] ?? null;
  if (!$id) return null;
  $d = http_get_json("https://api.coinpaprika.com/v1/tickers/{$id}");
  $p = $d['quotes']['USD']['price'] ?? null; $p = $p !== null ? (float)$p : null;
  return ($p && $p > 0) ? $p : null;
}
function fetch_usd_rate(mysqli $conn, string $coin): ?float {
  $coin = strtoupper($coin);

  $key = "fx_usd_quote_{$coin}";
  if (function_exists('apcu_fetch')) {
    $cached = apcu_fetch($key, $ok);
    if ($ok && is_numeric($cached) && $cached > 0) return (float)$cached;
  }

  $providers = [
    'kraken'=>'price_from_kraken','binance'=>'price_from_binance','bitfinex'=>'price_from_bitfinex',
    'okx'=>'price_from_okx','gemini'=>'price_from_gemini','coinpaprika'=>'price_from_coinpaprika'
  ];
  foreach ($providers as $fn) {
    $p = $fn($coin);
    if ($p && $p > 0) {
      if (function_exists('apcu_store')) apcu_store($key, $p, 20);
      return $p;
    }
  }

  // DB fallback (keeps flow working if all providers fail)
  $stmt = $conn->prepare("SELECT sell_rate FROM coin_rates WHERE coin = ? LIMIT 1");
  if ($stmt) {
    $stmt->bind_param('s', $coin);
    $stmt->execute();
    $stmt->bind_result($rateUsd);
    $rate = $stmt->fetch() ? (float)$rateUsd : null;
    $stmt->close();
    if ($rate && $rate > 0) {
      if (function_exists('apcu_store')) apcu_store($key, $rate, 20);
      return $rate;
    }
  }
  return null;
}

function coin_decimals(string $coin): int {
  return match (strtoupper($coin)) {
    'BTC' => 8, 'ETH' => 18, 'SOL' => 9, 'USDT' => 6, default => 8,
  };
}
function pick_platform_fee_rule(mysqli $conn, string $coin, float $amountUsd): array {
  $stmt = $conn->prepare("
    SELECT percent_fee, flat_fee, max_fee
      FROM withdraw_fees
     WHERE coin = ?
       AND type = 'crypto'
       AND (threshold IS NULL OR ? <= threshold)
     ORDER BY threshold ASC
     LIMIT 1
  ");
  if (!$stmt) return ['percent'=>0.0,'flat'=>0.0,'cap'=>null];
  $c = strtoupper($coin);
  $stmt->bind_param('sd', $c, $amountUsd);
  $stmt->execute();
  $stmt->bind_result($percent, $flat, $cap);
  $found = $stmt->fetch();
  $stmt->close();

  if (!$found) {
    $stmt = $conn->prepare("
      SELECT percent_fee, flat_fee, max_fee
        FROM withdraw_fees
       WHERE coin = ? AND type = 'crypto'
       ORDER BY threshold DESC
       LIMIT 1
    ");
    if ($stmt) {
      $stmt->bind_param('s', $c);
      $stmt->execute();
      $stmt->bind_result($percent, $flat, $cap);
      $found = $stmt->fetch();
      $stmt->close();
    }
  }
  if (!$found) return ['percent'=>0.0,'flat'=>0.0,'cap'=>null];
  return ['percent'=>(float)$percent, 'flat'=>(float)$flat, 'cap'=>$cap !== null ? (float)$cap : null];
}

/* ---------- sender wallet & balance ---------- */
$stmt = $conn->prepare("SELECT wallet_id, wallet_add, balance FROM user_wallets WHERE user_id = ? AND UPPER(coin) = ? LIMIT 1");
if (!$stmt) out(500, ['error'=>'db_error','stage'=>'find_sender']);
$stmt->bind_param('is', $userId, $coin);
$stmt->execute();
$sender = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$sender) out(404, ['error'=>'wallet_not_found']);

$senderWalletId = (string)$sender['wallet_id'];
$senderBal      = (float)$sender['balance'];
$senderAddr     = $sender['wallet_add'];

/* ---------- internal vs external ---------- */
$stmt = $conn->prepare("SELECT user_id, wallet_id FROM user_wallets WHERE UPPER(wallet_add) = UPPER(?) AND UPPER(coin) = ? LIMIT 1");
if (!$stmt) out(500, ['error'=>'db_error','stage'=>'find_receiver']);
$stmt->bind_param('ss', $toAddr, $coin);
$stmt->execute();
$recv = $stmt->get_result()->fetch_assoc();
$stmt->close();
$isInternal = (bool)$recv;

/* ---------- FX + amount ---------- */
$rateUsd = fetch_usd_rate($conn, $coin);
if (!$rateUsd || $rateUsd <= 0) out(500, ['error'=>'fx_missing','message'=>"No USD rate for $coin"]);
$decimals   = coin_decimals($coin);
$amountCoin = round($amountU / $rateUsd, $decimals);

/* ---------- fees & totals ---------- */
$platformFeeUsd = 0.0; $platformFeeCoin = 0.0;
$networkFeeUsd  = 0.0; $networkFeeCoin  = 0.0;
$networkFeeBU   = 0;   $feeRate         = null;

if ($isInternal) {
  // no fees, debit only amount
  $totalDebitCoin = $amountCoin;

  if ($totalDebitCoin > $senderBal) {
    out(400, ['error'=>'insufficient_balance','message'=>'Not enough balance for amount']);
  }

  $quoteId = bin2hex(random_bytes(8));
  $_SESSION['send_quote'][$quoteId] = [
    'created_at' => time(),
    'expires_at' => time() + 60,
    'user_id'    => $userId,
    'coin'       => $coin,
    'recipient'  => $toAddr,
    'amount_usd' => round($amountU,2),
    'usd_rate'   => round($rateUsd,8),
    'amount_coin'=> $amountCoin,
    'amount_base_units' => null,
    'platform_fee' => ['usd'=>0.0,'coin'=>0.0],
    'network_fee'  => ['usd'=>0.0,'coin'=>0.0,'base_units'=>0,'feeRate'=>null],
    'total_debit_coin' => $totalDebitCoin,
    'provider'   => 'internal',
    'wallet_id'  => null,
    'decimals'   => $decimals,
  ];

  out(200, [
    'ok' => true,
    'quote_id' => $quoteId,
    'coin' => $coin,
    'is_internal' => true,
    'amount_coin' => $amountCoin,
    'amount_usd'  => round($amountU,2),
    'platform_fee_coin' => 0.0,
    'platform_fee_usd'  => 0.0,
    'network_fee_coin'  => 0.0,
    'network_fee_usd'   => 0.0,
    'total_debit_coin'  => $totalDebitCoin,
    'expires_in'        => 60
  ]);
}

/* ---------- external: BitGo build for exact fee ---------- */
$stmt = $conn->prepare("SELECT wallet_add_id, encrypted_phrase FROM cwallet WHERE coin = ? LIMIT 1");
if (!$stmt) out(500, ['error'=>'db_error','stage'=>'cwallet']);
$stmt->bind_param('s', $coin);
$stmt->execute();
$cw = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$cw) out(500, ['error'=>'config_missing','message'=>"Primary wallet not configured for $coin"]);

$walletId   = $cw['wallet_add_id'];
$coinPath   = strtolower($coin);

// base units (avoid scientific notation)
if (function_exists('bcpow') && function_exists('bcmul')) {
  $amtStr   = sprintf('%.' . $decimals . 'F', (float)$amountCoin);
  $amtStr   = rtrim(rtrim($amtStr, '0'), '.'); if ($amtStr === '' || $amtStr === '-0') $amtStr = '0';
  $pow10    = bcpow('10', (string)$decimals, 0);
  $baseUnit = bcmul($amtStr, $pow10, 0);
} else {
  $baseUnit = (string)(int)round($amountCoin * (10 ** $decimals), 0);
}

$buildUrl = rtrim(BITGO_API_BASE_URL, '/') . "/{$coinPath}/wallet/{$walletId}/tx/build";
$payload  = json_encode(['recipients' => [['address' => $toAddr, 'amount' => (int)$baseUnit]]]);

$ch = curl_init($buildUrl);
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST           => true,
  CURLOPT_HTTPHEADER     => [
    'Content-Type: application/json',
    'Authorization: Bearer ' . BITGO_ACCESS_TOKEN
  ],
  CURLOPT_POSTFIELDS     => $payload,
  CURLOPT_TIMEOUT        => 20,
]);
$resp = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http !== 200 || !$resp) {
  out(502, ['error'=>'bitgo_build_failed','message'=>$resp ?: "HTTP {$http}"]);
}
$build = json_decode($resp, true);
if (json_last_error() !== JSON_ERROR_NONE) {
  out(502, ['error'=>'bitgo_build_parse','message'=>'Invalid build response']);
}

$networkFeeBU = (int)($build['fee'] ?? 0);
$feeRate      = $build['feeRate'] ?? null;
$networkFeeCoin = $networkFeeBU > 0 ? $networkFeeBU / (10 ** $decimals) : 0.0;
$networkFeeUsd  = $networkFeeCoin * $rateUsd;

/* platform fee (USD tiers) */
$rule = pick_platform_fee_rule($conn, $coin, $amountU);
$pct     = (float)$rule['percent']; $flatUsd = (float)$rule['flat']; $capUsd = $rule['cap'];
$platformFeeUsd  = ($pct / 100.0) * $amountU + $flatUsd;
if ($capUsd !== null && $capUsd > 0) $platformFeeUsd = min($platformFeeUsd, (float)$capUsd);
$platformFeeCoin = $platformFeeUsd / $rateUsd;

$totalDebitCoin = round($amountCoin + $platformFeeCoin + $networkFeeCoin, $decimals);

/* ensure user ledger can cover */
if ($totalDebitCoin > $senderBal) {
  out(400, ['error'=>'insufficient_balance','message'=>'Insufficient wallet balance for amount + fees']);
}

/* ensure BitGo wallet can cover spendable >= amount + fee */
$wUrl = rtrim(BITGO_API_BASE_URL, '/') . "/{$coinPath}/wallet/{$walletId}";
$wd = http_get_json($wUrl, 2.5);
$spendable = 0;
if ($wd) {
  if (!empty($wd['spendableBalanceString'])) $spendable = (int)$wd['spendableBalanceString'];
  elseif (!empty($wd['spendableBalance']))   $spendable = (int)$wd['spendableBalance'];
}
if ($spendable <= 0 || $spendable < ((int)$baseUnit + (int)$networkFeeBU)) {
  out(400, ['error'=>'primary_wallet_insufficient','message'=>'Primary wallet cannot cover amount + network fee']);
}

/* save short-lived quote */
$quoteId = bin2hex(random_bytes(8));
$_SESSION['send_quote'][$quoteId] = [
  'created_at' => time(),
  'expires_at' => time() + 60,
  'user_id'    => $userId,
  'coin'       => $coin,
  'recipient'  => $toAddr,
  'amount_usd' => round($amountU,2),
  'usd_rate'   => round($rateUsd,8),
  'amount_coin'=> $amountCoin,
  'amount_base_units' => (int)$baseUnit,
  'platform_fee' => ['usd'=>round($platformFeeUsd,6),'coin'=>$platformFeeCoin],
  'network_fee'  => ['usd'=>round($networkFeeUsd,6),'coin'=>$networkFeeCoin,'base_units'=>(int)$networkFeeBU,'feeRate'=>$feeRate],
  'total_debit_coin' => $totalDebitCoin,
  'provider'   => 'bitgo',
  'wallet_id'  => $walletId,
  'decimals'   => $decimals,
];

out(200, [
  'ok' => true,
  'quote_id' => $quoteId,
  'coin' => $coin,
  'is_internal' => false,
  'amount_coin' => $amountCoin,
  'amount_usd'  => round($amountU,2),
  'platform_fee_coin' => $platformFeeCoin,
  'platform_fee_usd'  => round($platformFeeUsd,2),
  'network_fee_coin'  => $networkFeeCoin,
  'network_fee_usd'   => round($networkFeeUsd,2),
  'total_debit_coin'  => $totalDebitCoin,
  'expires_in'        => 60
]);

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


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