PHP WebShell
Текущая директория: /var/www/bitcardoApp/webhooks
Просмотр файла: bitgo_transfer.php
<?php
/**
* BitGo "transfer" webhook handler (multi-coin).
* MVP Option B: transactions.amount DECIMAL(30,10); user_wallets.balance DECIMAL(30,10)
* Dynamic wallet scale per asset when crediting (BTC=8, TRX=6, SOL=9, ETH/tokens=10)
*/
@ini_set('display_errors', '0');
header('Content-Type: application/json');
// ---------------- Logging (same folder) ----------------
const LOG_MAX_LEN = 20000;
$LOG_FILE = __DIR__ . '/bitgo_transfer.log';
if (!function_exists('log_event')) {
function log_event(string $level, string $message, array $ctx = []): void {
global $LOG_FILE;
$line = [
'ts' => gmdate('c'),
'lvl' => $level,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'n/a',
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? 'n/a',
'msg' => $message,
'ctx' => $ctx,
];
@file_put_contents($LOG_FILE, json_encode($line, JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}
// ---------------- Load configs (DB + BitGo) ----------------
$DB_CFG = __DIR__ . '/../config/db_config.php'; // defines $conn (mysqli)
$BG_CFG = __DIR__ . '/../config/bitgo_config.php'; // defines BITGO_* constants
if (!file_exists($DB_CFG)) {
log_event('error', 'db_config missing', ['expected' => $DB_CFG]);
http_response_code(500); echo json_encode(['error' => 'config-missing', 'file' => basename($DB_CFG)]); exit;
}
require_once $DB_CFG;
if (!file_exists($BG_CFG)) {
log_event('error', 'bitgo_config missing', ['expected' => $BG_CFG]);
http_response_code(500); echo json_encode(['error' => 'config-missing', 'file' => basename($BG_CFG)]); exit;
}
require_once $BG_CFG;
if (!isset($conn) || !($conn instanceof mysqli)) {
log_event('error', '$conn is not mysqli or not set', ['type' => isset($conn) ? gettype($conn) : 'unset']);
http_response_code(500); echo json_encode(['error' => 'db-conn']); exit;
}
// ---------------- Parse body & hit log ----------------
$raw = file_get_contents('php://input') ?: '';
$event = json_decode($raw, true);
log_event('hit', 'incoming webhook', ['content_len' => strlen($raw), 'body_preview' => substr($raw, 0, LOG_MAX_LEN)]);
// ---------------- Quick ack on malformed ----------------
if (!$event || empty($event['transfer'])) {
log_event('ignored', 'no transfer in payload');
http_response_code(200);
echo json_encode(['ok' => true, 'ignored' => 'no transfer in payload']);
exit;
}
// ---------------- Optional HMAC verification ----------------
if (defined('BITGO_WEBHOOK_SECRET') && BITGO_WEBHOOK_SECRET !== '') {
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? $_SERVER['HTTP_X_BITGO_SIGNATURE'] ?? '';
$calc = base64_encode(hash_hmac('sha256', $raw, BITGO_WEBHOOK_SECRET, true));
if (!hash_equals($calc, $sig)) {
log_event('warn', 'invalid signature', ['calc' => $calc, 'recv' => $sig]);
http_response_code(401);
echo json_encode(['error' => 'invalid signature']);
exit;
}
}
// ---------------- Extract transfer fields ----------------
$tr = $event['transfer'];
$coinTicker = strtolower($tr['coin'] ?? '');
$walletIdBg = $tr['wallet'] ?? null;
$txid = $tr['txid'] ?? null;
$type = $tr['type'] ?? '';
$state = $tr['state'] ?? '';
$confirms = (int)($tr['confirmations'] ?? 0);
$entries = $tr['entries'] ?? [];
$provider = 'bitgo';
if ($type !== 'receive') {
log_event('ignored', 'not a receive', ['type' => $type, 'txid' => $txid]);
http_response_code(200);
echo json_encode(['ok' => true, 'ignored' => 'not a receive']);
exit;
}
if (!$txid) {
log_event('ignored', 'missing txid');
http_response_code(200);
echo json_encode(['ok' => true, 'ignored' => 'missing txid']);
exit;
}
// ---------------- Resolve asset + decimals ----------------
$tokenSymbol = strtoupper($tr['token'] ?? ($tr['tokenInfo']['symbol'] ?? ''));
$tokenDecimals = isset($tr['tokenInfo']['decimals']) ? (int)$tr['tokenInfo']['decimals'] : null;
$assetCoin = strtoupper($tokenSymbol ?: $coinTicker);
$decimals = coin_decimals($coinTicker, $assetCoin, $tokenDecimals);
// ---------------- Confirmation threshold ----------------
$confirmNeed = confirm_threshold($coinTicker, $assetCoin);
// ---------------- Positive credited entry ----------------
if (!function_exists('is_positive_value')) {
function is_positive_value(string $v): bool {
if (function_exists('bccomp')) return bccomp($v, '0', 0) === 1;
return (float)$v > 0.0;
}
}
$toAddr = null; $amountBase = '0';
foreach ($entries as $e) {
$v = (string)($e['value'] ?? '0');
if (is_positive_value($v)) {
$toAddr = strtolower($e['address'] ?? '');
$amountBase = $v;
break;
}
}
if (!$toAddr) {
log_event('ignored', 'no positive entry', ['txid' => $txid]);
http_response_code(200);
echo json_encode(['ok' => true, 'ignored' => 'no positive entry']);
exit;
}
// ---------------- Convert to human string ----------------
$amountHuman = to_human($amountBase, $decimals, 12);
// ---------------- Map address -> user (ONLY user_id & wallet_add) ----------------
$userRow = find_user_by_address($conn, $toAddr, $assetCoin);
if (!$userRow) {
log_event('ignored', 'address not found in user_wallets', ['address' => $toAddr, 'coin' => $assetCoin, 'txid' => $txid]);
http_response_code(200);
echo json_encode(['ok' => true, 'ignored' => 'address not found in user_wallets', 'address' => $toAddr, 'coin' => $assetCoin]);
exit;
}
$userId = (int)$userRow['user_id'];
$uwAddr = $userRow['wallet_add'];
$walletId = (int)$userRow['wallet_id']; // <-- now using internal FK
// ---------------- Status calc ----------------
$status = ($confirms >= $confirmNeed || $state === 'confirmed') ? 'confirmed' : 'pending';
$applied = 0;
$transferId = empty($tr['id']) ? null : $tr['id'];
$sender = strtolower($tr['fromAddress'] ?? $tr['from'] ?? infer_from_evm($tr));
// ---------------- Upsert + credit-once ----------------
$conn->begin_transaction();
try {
$existing = find_tx($conn, $txid, $toAddr, $assetCoin);
if ($existing) {
$cid = (int)$existing['trans_id'];
$stmt = $conn->prepare("UPDATE transactions SET confirmation=?, status=?, updated_at=NOW() WHERE trans_id=?");
$stmt->bind_param('isi', $confirms, $status, $cid);
$stmt->execute();
$stmt->close();
$alreadyApplied = (int)$existing['applied'] === 1;
if (!$alreadyApplied && $status === 'confirmed') {
credit_user_wallet($conn, $userId, $assetCoin, $uwAddr, $amountHuman);
mark_applied($conn, $cid);
$applied = 1;
log_event('success', 'credited on confirm (existing)', ['txid' => $txid, 'user_id' => $userId, 'coin' => $assetCoin, 'amount' => $amountHuman]);
}
} else {
$stmt = $conn->prepare("
INSERT INTO transactions
(coin, user_id, wallet_id, transfer_id, sender_address, receiver_address,
amount, type, txid, reference, provider, confirmation, status, applied, created_at, updated_at)
VALUES
(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())
");
$tType = 'deposit';
$ref = null;
$stmt->bind_param(
'sisssssssssiii',
$assetCoin, // coin
$userId, // user_id
$walletId, // wallet_id (string/fk)
$transferId, // transfer_id (bigint)
$sender, // sender_address
$toAddr, // receiver_address
$amountHuman, // amount
$tType, // type ('deposit')
$txid, // txid
$ref, // reference
$provider, // provider
$confirms, // confirmation
$status, // status
$applied // applied
);
$stmt->execute();
$newId = $stmt->insert_id;
$stmt->close();
if ($status === 'confirmed') {
credit_user_wallet($conn, $userId, $assetCoin, $uwAddr, $amountHuman);
mark_applied($conn, $newId);
$applied = 1;
log_event('success', 'credited on insert (confirmed)', ['txid' => $txid, 'user_id' => $userId, 'coin' => $assetCoin, 'amount' => $amountHuman]);
}
}
$conn->commit();
} catch (Throwable $e) {
$conn->rollback();
log_event('error', 'db-failure', ['err' => $e->getMessage(), 'txid' => $txid]);
http_response_code(500);
echo json_encode(['error' => 'db-failure', 'message' => $e->getMessage()]);
exit;
}
http_response_code(200);
echo json_encode([
'ok' => true,
'coin' => $assetCoin,
'user_id' => $userId,
'wallet_id' => $walletId,
'address' => $uwAddr,
'amount' => $amountHuman,
'status' => $status,
'confirmations' => $confirms,
'applied' => $applied
]);
exit;
/* ================= helpers ================= */
function coin_decimals(string $network, string $asset, ?int $tokenDecimals): int {
$network = strtolower($network);
if ($tokenDecimals !== null) return $tokenDecimals;
return match ($network) { 'btc' => 8, 'eth' => 18, 'sol' => 9, 'trx' => 6, default => 8 };
}
function confirm_threshold(string $network, string $asset): int {
$network = strtolower($network);
$asset = strtoupper($asset);
$map = ['BTC' => 2, 'ETH' => 12, 'SOL' => 32, 'TRX' => 19];
if ($asset === 'USDT') return $map[strtoupper($network)] ?? 6;
return $map[$asset] ?? ($map[strtoupper($network)] ?? 1);
}
function to_human(string $valueBase, int $decimals, int $scale = 12): string {
if (!function_exists('bcscale')) {
$div = 1;
for ($i = 0; $i < $decimals; $i++) $div *= 10;
return number_format(((float)$valueBase) / $div, min($scale, $decimals), '.', '');
}
bcscale($scale);
$div = bcpow('10', (string)$decimals);
$res = bcdiv($valueBase, $div, $scale);
return rtrim(rtrim($res, '0'), '.') ?: '0';
}
function wallet_scale_for_asset(string $asset): int {
return match (strtoupper($asset)) {
'BTC' => 8,
'TRX' => 6,
'SOL' => 9,
'ETH' => 10,
default => 10,
};
}
function decimal_clause_for_scale(int $scale): string {
$s = max(0, min(18, $scale));
return "DECIMAL(30,$s)";
}
function find_user_by_address(mysqli $conn, string $addrLower, string $asset): ?array {
$sql = "SELECT user_id, wallet_id, wallet_add
FROM user_wallets
WHERE TRIM(LOWER(wallet_add)) = TRIM(LOWER(?))
AND UPPER(coin) = UPPER(?)
LIMIT 1";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $addrLower, $asset);
$stmt->execute();
$res = $stmt->get_result();
$row = ($res && $res->num_rows) ? $res->fetch_assoc() : null;
$stmt->close();
return $row;
}
function find_tx(mysqli $conn, string $txid, string $receiverLower, string $asset): ?array {
$sql = "SELECT trans_id, applied
FROM transactions
WHERE txid = ? AND receiver_address = ? AND coin = ?
LIMIT 1";
$stmt = $conn->prepare($sql);
$stmt->bind_param('sss', $txid, $receiverLower, $asset);
$stmt->execute();
$res = $stmt->get_result();
$row = ($res && $res->num_rows) ? $res->fetch_assoc() : null;
$stmt->close();
return $row;
}
function credit_user_wallet(mysqli $conn, int $userId, string $asset, string $addr, string $amountHuman): void {
$scale = wallet_scale_for_asset($asset);
$dec = decimal_clause_for_scale($scale);
$sql = "
UPDATE user_wallets
SET balance = ROUND((CAST(balance AS $dec) + CAST(? AS $dec)), $scale),
updated_at = NOW()
WHERE user_id = ? AND coin = ? AND wallet_add = ?
LIMIT 1
";
$stmt = $conn->prepare($sql);
$stmt->bind_param('siss', $amountHuman, $userId, $asset, $addr);
$stmt->execute();
$stmt->close();
}
function mark_applied(mysqli $conn, int $transId): void {
$stmt = $conn->prepare("UPDATE transactions SET applied=1, updated_at=NOW() WHERE trans_id=?");
$stmt->bind_param('i', $transId);
$stmt->execute();
$stmt->close();
}
function infer_from_evm(array $tr): ?string {
$txid = $tr['txid'] ?? null;
if (!$txid) return null;
if (!defined('BITGO_API_BASE_URL') || !defined('BITGO_ACCESS_TOKEN')) return null;
$url = rtrim(BITGO_API_BASE_URL, '/') . '/eth/tx/' . rawurlencode($txid);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . BITGO_ACCESS_TOKEN],
]);
$res = curl_exec($ch);
curl_close($ch);
if (!$res) return null;
$tx = json_decode($res, true);
return strtolower($tx['from'] ?? '') ?: null;
}
Выполнить команду
Для локальной разработки. Не используйте в интернете!