PHP WebShell

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

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

<?php
// cron/bitgo_withdraw_worker.php

require_once __DIR__ . '/../config/db_config.php';
require_once __DIR__ . '/../config/bitgo_config.php';

if (!isset($conn) || !($conn instanceof mysqli)) {
  exit("DB connection missing or not MySQLi\n");
}

@date_default_timezone_set('UTC');
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

function is_web(): bool { return php_sapi_name() !== 'cli'; }

function out(array $data, int $code = 200): void {
  if (is_web()) {
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_SLASHES);
  } else {
    echo ($data['message'] ?? json_encode($data, JSON_UNESCAPED_SLASHES)) . "\n";
  }
  exit;
}

function logline(string $msg): void {
  $file = __DIR__ . '/../storage/logs/bitgo_withdraw_worker.log';
  @file_put_contents($file, '[' . date('Y-m-d H:i:s') . "] " . $msg . "\n", FILE_APPEND);
}

function bitgo_base_v2(string $base): string {
  $b = rtrim($base, '/');
  if (preg_match('~/api/v2$~', $b)) return $b;
  if (preg_match('~/api$~', $b)) return $b . '/v2';
  return $b . '/api/v2';
}

function bitgo_request(string $method, string $url, ?array $data = null): array
{
  $ch = curl_init($url);
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
      "Authorization: Bearer " . BITGO_ACCESS_TOKEN,
      "Content-Type: application/json"
    ],
    CURLOPT_TIMEOUT => 90,
  ]);

  if ($method === 'POST') {
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_SLASHES));
  }

  $resp = curl_exec($ch);
  $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);

  if ($resp === false) {
    $err = curl_error($ch);
    curl_close($ch);
    return ['code' => 0, 'body' => ['error' => 'curl_error', 'message' => $err]];
  }

  curl_close($ch);

  $decoded = json_decode($resp, true);
  if (!is_array($decoded)) $decoded = ['raw' => $resp];

  return ['code' => $code, 'body' => $decoded];
}

/**
 * Fetch user's wallet record for coin (needed for reversal row fields)
 */
function get_user_wallet(mysqli $conn, int $userId, string $coin): ?array {
  $stmt = $conn->prepare("
    SELECT wallet_id, wallet_add, balance
    FROM user_wallets
    WHERE user_id = ?
      AND coin = ?
    LIMIT 1
  ");
  $stmt->bind_param('is', $userId, $coin);
  $stmt->execute();
  $res = $stmt->get_result();
  $row = $res ? $res->fetch_assoc() : null;
  $stmt->close();
  return $row ?: null;
}

/**
 * Fail + reversal:
 * - marks original tx failed
 * - credits user_wallets.balance back (user_id + coin)
 * - inserts reversal transaction row with required-ish fields populated
 */
function fail_and_reverse(mysqli $conn, int $transId, array $providerMeta, array $origTx): array
{
  $coin      = (string)($origTx['coin'] ?? 'BTC');
  $userId    = (int)($origTx['user_id'] ?? 0);
  $amountDec = (string)($origTx['amount'] ?? '0');
  $metaJson  = json_encode($providerMeta, JSON_UNESCAPED_SLASHES);

  $result = [
    'trans_id' => $transId,
    'reversal_attempted' => false,
    'reversal_credited' => false,
    'reversal_tx_inserted' => false,
    'reversal_error' => null,
  ];

  // 1) Mark failed always
  $st = $conn->prepare("UPDATE transactions SET status='failed', provider_meta=? WHERE trans_id=?");
  $st->bind_param('si', $metaJson, $transId);
  $st->execute();
  $st->close();

  if ($userId <= 0) {
    $result['reversal_error'] = 'missing_user_id';
    logline("TX {$transId} failed; reversal skipped (missing user_id). meta={$metaJson}");
    return $result;
  }

  $result['reversal_attempted'] = true;

  $conn->begin_transaction();
  try {
    // 2) Credit user_wallets balance
    $credit = $conn->prepare("
      UPDATE user_wallets
      SET balance = balance + ?
      WHERE user_id = ?
        AND coin = ?
      LIMIT 1
    ");
    $credit->bind_param('sis', $amountDec, $userId, $coin);
    $credit->execute();
    $credited = ($credit->affected_rows > 0);
    $credit->close();
    $result['reversal_credited'] = $credited;

    // 3) Insert reversal transaction row (populate fields)
    $uw = get_user_wallet($conn, $userId, $coin);
    $userWalletId = $uw['wallet_id'] ?? null;      // transactions.wallet_id (varchar)
    $userAddress  = $uw['wallet_add'] ?? null;     // transactions.receiver_address

    $revMeta = json_encode([
      'reversal_of' => $transId,
      'credited' => $credited,
      'provider_meta' => $providerMeta
    ], JSON_UNESCAPED_SLASHES);

    $reference = 'REV-' . $transId . '-' . date('YmdHis');
    $note = $credited
      ? "Auto reversal: credited user_wallets for failed withdrawal #{$transId}"
      : "Auto reversal: FAILED to credit user_wallets (wallet not found) for withdrawal #{$transId}";

    // Insert only columns that exist in YOUR screenshot (safe set)
    $ins = $conn->prepare("
      INSERT INTO transactions
        (coin, user_id, wallet_id, sender_address, receiver_address, amount,
         type, txid, reference, provider, provider_meta, confirmation, status, applied, note)
      VALUES
        (?, ?, ?, ?, ?, ?, 'reversal', NULL, ?, 'internal', ?, 0, 'completed', 1, ?)
    ");

    $senderAddr = 'SYSTEM';
    $receiverAddr = $userAddress ?: ($origTx['sender_address'] ?? ''); // fallback

    // wallet_id can be null; but bind_param needs string. Use empty string if null.
    $walletIdStr = $userWalletId ? (string)$userWalletId : '';

    $ins->bind_param(
      'sisssdsss',
      $coin,
      $userId,
      $walletIdStr,
      $senderAddr,
      $receiverAddr,
      $amountDec,
      $reference,
      $revMeta,
      $note
    );

    $ins->execute();
    $ins->close();

    $result['reversal_tx_inserted'] = true;

    $conn->commit();
    logline("TX {$transId} failed; reversal inserted. user_id={$userId} coin={$coin} amount={$amountDec} credited=" . ($credited ? 'yes' : 'no'));
    return $result;

  } catch (Throwable $e) {
    $conn->rollback();
    $result['reversal_error'] = $e->getMessage();
    logline("TX {$transId} failed; reversal rolled back: " . $e->getMessage());
    return $result;
  }
}

/* -------------------------
 * Setup
 * ------------------------- */
$base = bitgo_base_v2(BITGO_API_BASE_URL);
$coin = 'BTC';

/* BTC cwallet */
$stmt = $conn->prepare("SELECT wallet_add_id, encrypted_phrase FROM cwallet WHERE coin='BTC' LIMIT 1");
$stmt->execute();
$res = $stmt->get_result();
$wallet = $res ? $res->fetch_assoc() : null;
$stmt->close();

if (!$wallet) out(['ok'=>false,'message'=>'BTC cwallet not found'], 500);

$walletId = (string)$wallet['wallet_add_id'];
$walletPassphrase = (string)$wallet['encrypted_phrase'];
if ($walletId === '' || $walletPassphrase === '') {
  out(['ok'=>false,'message'=>'BTC walletId or wallet passphrase missing in cwallet'], 500);
}

/* Pending txs */
$stmt = $conn->prepare("
  SELECT trans_id, coin, user_id, wallet_id, sender_address, receiver_address, amount
  FROM transactions
  WHERE coin='BTC'
    AND type='send'
    AND provider='bitgo'
    AND status='pending'
    AND applied=0
  ORDER BY trans_id ASC
");
$stmt->execute();
$res = $stmt->get_result();
$txs = $res ? $res->fetch_all(MYSQLI_ASSOC) : [];
$stmt->close();

if (!$txs) out(['ok'=>true,'message'=>'No pending BTC withdrawals','processed'=>0,'completed'=>0,'failed'=>0], 200);

/* Wallet balance */
$balUrl = $base . "/btc/wallet/{$walletId}";
$bal = bitgo_request('GET', $balUrl);
logline("BAL GET {$balUrl} HTTP {$bal['code']}");

if ($bal['code'] !== 200) {
  out(['ok'=>false,'message'=>'Failed to fetch BitGo wallet balance','http'=>$bal['code'],'body'=>$bal['body']], 500);
}
$spendableSats = (int)($bal['body']['spendableBalance'] ?? 0);

/* success update */
$doneStmt = $conn->prepare("
  UPDATE transactions
  SET status='completed',
      applied=1,
      txid=?,
      provider_meta=?
  WHERE trans_id=?
");

/* -------------------------
 * Run: build -> sign -> send
 * ------------------------- */
$processed = 0; $completed = 0; $failed = 0; $reversals = [];

foreach ($txs as $tx) {
  $processed++;

  $transId   = (int)$tx['trans_id'];
  $to        = trim((string)$tx['receiver_address']);
  $amountDec = (string)$tx['amount'];
  $userId    = (int)($tx['user_id'] ?? 0);

  $amountSats = function_exists('bcmul')
    ? (int)bcmul($amountDec, '100000000', 0)
    : (int)round(((float)$amountDec) * 100000000);

  if ($to === '' || $amountSats <= 0) {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'validate','error'=>'invalid address/amount','tx'=>$tx], $tx);
    continue;
  }

  // build
  $buildUrl = $base . "/btc/wallet/{$walletId}/tx/build";
  $buildPayload = [
    'recipients' => [[ 'address' => $to, 'amount' => (int)$amountSats ]],
    'feeLevel' => 'low'
  ];
  logline("TX {$transId} POST {$buildUrl} payload=" . json_encode($buildPayload, JSON_UNESCAPED_SLASHES));
  $build = bitgo_request('POST', $buildUrl, $buildPayload);

  if ($build['code'] !== 200) {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'build','payload'=>$buildPayload,'resp'=>$build], $tx);
    continue;
  }

  $txHex   = (string)($build['body']['txHex'] ?? '');
  $feeSats = (int)($build['body']['fee'] ?? 0);

  if ($txHex === '') {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'build','error'=>'missing_txHex','resp'=>$build], $tx);
    continue;
  }

  $totalSats = $amountSats + max(0, $feeSats);
  if ($spendableSats < $totalSats) {
    logline("TX {$transId} skipped: insufficient for amount+fee. need={$totalSats} have={$spendableSats}");
    continue;
  }

  // sign (THIS is what your wallet needs)
  $signUrl = $base . "/btc/wallet/{$walletId}/tx/sign";
  $signPayload = [
    'txHex' => $txHex,
    'walletPassphrase' => $walletPassphrase
  ];
  logline("TX {$transId} POST {$signUrl} txHexLen=" . strlen($txHex));
  $sign = bitgo_request('POST', $signUrl, $signPayload);

  if ($sign['code'] !== 200) {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'sign','payload'=>['txHexLen'=>strlen($txHex)],'resp'=>$sign], $tx);
    continue;
  }

  $signedHex = (string)($sign['body']['txHex'] ?? '');
  if ($signedHex === '') {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'sign','error'=>'missing_signed_txHex','resp'=>$sign], $tx);
    continue;
  }

  // send (now it should pass signature checks)
  $sendUrl = $base . "/btc/wallet/{$walletId}/tx/send";
  $sendPayload = [
    'txHex' => $signedHex
  ];
  logline("TX {$transId} POST {$sendUrl} signedHexLen=" . strlen($signedHex));
  $send = bitgo_request('POST', $sendUrl, $sendPayload);

  if ($send['code'] !== 200) {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'send','payload'=>['signedHexLen'=>strlen($signedHex)],'resp'=>$send], $tx);
    continue;
  }

  $txid = (string)($send['body']['txid'] ?? '');
  if ($txid === '') {
    $failed++;
    $reversals[] = fail_and_reverse($conn, $transId, ['step'=>'send','error'=>'missing_txid','resp'=>$send], $tx);
    continue;
  }

  $providerMeta = json_encode([
    'txid' => $txid,
    'fee' => $feeSats,
    'amount_sats' => $amountSats,
    'amount_btc' => $amountDec,
    'address' => $to
  ], JSON_UNESCAPED_SLASHES);

  $doneStmt->bind_param('ssi', $txid, $providerMeta, $transId);
  $doneStmt->execute();

  $completed++;
  $spendableSats -= $totalSats;
  logline("TX {$transId} completed txid={$txid} feeSats={$feeSats}");
}

$doneStmt->close();

out([
  'ok' => true,
  'message' => 'BTC withdraw worker completed',
  'processed' => $processed,
  'completed' => $completed,
  'failed' => $failed,
  'reversals' => $reversals,
], 200);

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


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