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