PHP WebShell
Текущая директория: /var/www/bitcardoApp/cron
Просмотр файла: sol_consolidator.php
<?php
/**
* cron/sol_consolidator.php (MAINNET SOL)
*
* Uses:
* - BITGO_ACCESS_TOKEN
* - BITGO_ENTERPRISE_ID (not required here but available)
* - BITGO_API_BASE_URL (local BitGo Express; may not support SOL)
* - BITGO_CLOUD_BASE_URL (BitGo cloud v2; supports SOL mainnet)
* - BITGO_WALLET_PASSPHRASE (needed for consolidateAccount when supported by endpoint)
*
* Reads wallet from DB:
* - table: cwallet
* - columns: cwallet_id, coin, wallet_add_id, wallet_add(optional), wallet_balance(optional)
*
* SOL IMPORTANT:
* - BitGo does NOT support /addresses/balances for SOL (returns "operation not supported").
* - So we use:
* - GET /wallet/{walletId}/addresses
* - GET /wallet/{walletId}/address/{address} for per-address balance
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/db_config.php';
require_once __DIR__ . '/../config/bitgo_config.php';
date_default_timezone_set('UTC');
/** =========================
* Config
* ========================= */
const COIN_TICKER = 'sol'; // MAINNET ONLY
const COIN_UI = 'SOL';
const THRESHOLD_SOL = '0.0074';
const LAMPORTS_PER_SOL = 1000000000;
const MAX_LOOPS_PER_RUN = 25;
const PAGE_LIMIT = 200;
const MAX_PAGES_PER_SCAN = 50;
const ADDRESS_DETAIL_LIMIT = 400; // max number of address-detail calls per scan loop (safety)
const LOG_FILE = __DIR__ . '/../storage/logs/sol_consolidator.log';
const LOCK_FILE = __DIR__ . '/../storage/locks/sol_consolidator.lock';
/** =========================
* Helpers
* ========================= */
function ensure_dir(string $path): void {
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
}
function log_line(string $msg): void {
ensure_dir(LOG_FILE);
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
@file_put_contents(LOG_FILE, $line, FILE_APPEND);
}
function acquire_lock() {
ensure_dir(LOCK_FILE);
$fp = @fopen(LOCK_FILE, 'c+');
if (!$fp) return null;
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
return null;
}
ftruncate($fp, 0);
fwrite($fp, (string)getmypid());
fflush($fp);
return $fp; // resource
}
function release_lock($fp): void {
if (is_resource($fp)) {
flock($fp, LOCK_UN);
fclose($fp);
}
}
/**
* HTTP request returning decoded JSON and raw body (to detect HTML UI responses).
*/
function bitgo_request(string $method, string $url, ?array $jsonBody = null): array {
$ch = curl_init($url);
$headers = [
'Accept: application/json',
'Content-Type: application/json',
'Authorization: Bearer ' . BITGO_ACCESS_TOKEN,
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 60,
CURLOPT_HEADER => true,
]);
if ($jsonBody !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jsonBody));
}
$resp = curl_exec($ch);
$err = curl_error($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$hLen = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($resp === false) {
return ['ok' => false, 'code' => 0, 'error' => $err ?: 'curl_exec failed', 'data' => null, 'raw' => null, 'headers' => null];
}
$rawHeaders = substr($resp, 0, $hLen);
$rawBody = substr($resp, $hLen);
$data = json_decode($rawBody, true);
$isJson = (json_last_error() === JSON_ERROR_NONE);
if ($code < 200 || $code >= 300) {
$msg = null;
if ($isJson) {
$msg = ($data['error'] ?? $data['message'] ?? null);
}
if (!$msg) {
$msg = trim(substr($rawBody, 0, 240));
}
return ['ok' => false, 'code' => $code, 'error' => $msg ?: 'HTTP error', 'data' => $isJson ? $data : null, 'raw' => $rawBody, 'headers' => $rawHeaders];
}
if (!$isJson) {
return ['ok' => false, 'code' => $code, 'error' => 'Non-JSON response received', 'data' => null, 'raw' => $rawBody, 'headers' => $rawHeaders];
}
return ['ok' => true, 'code' => $code, 'error' => null, 'data' => $data, 'raw' => $rawBody, 'headers' => $rawHeaders];
}
function sol_to_lamports(string $sol): int {
if (function_exists('bcmul')) {
$lamports = bcmul($sol, (string)LAMPORTS_PER_SOL, 0);
return (int)$lamports;
}
return (int)round(((float)$sol) * LAMPORTS_PER_SOL);
}
function parse_next_page_cursor(array $data): ?string {
foreach (['nextBatchPrevId', 'nextBatch', 'nextCursor', 'cursor', 'next'] as $k) {
if (!empty($data[$k]) && is_string($data[$k])) return $data[$k];
}
return null;
}
function parse_addresses_list(array $data): array {
if (isset($data['addresses']) && is_array($data['addresses'])) return $data['addresses'];
if (isset($data['entries']) && is_array($data['entries'])) return $data['entries'];
if (isset($data['results']) && is_array($data['results'])) return $data['results'];
return [];
}
/**
* MySQL-safe column existence check using INFORMATION_SCHEMA (works with placeholders).
*/
function column_exists(mysqli $conn, string $table, string $column): bool {
$sql = "
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
LIMIT 1
";
$stmt = $conn->prepare($sql);
if (!$stmt) return false;
$stmt->bind_param('ss', $table, $column);
$stmt->execute();
$res = $stmt->get_result();
$ok = ($res && $res->num_rows > 0);
$stmt->close();
return $ok;
}
/**
* Choose which base URL to use for SOL:
* - Try BITGO_API_BASE_URL first (Express)
* - If it fails with "Coin type sol not supported" or non-json, fall back to BITGO_CLOUD_BASE_URL
*/
function choose_sol_base_url(string $walletId): string {
if (!defined('BITGO_API_BASE_URL') || !defined('BITGO_CLOUD_BASE_URL')) {
throw new Exception('Missing BITGO_API_BASE_URL or BITGO_CLOUD_BASE_URL in bitgo_config.php');
}
$coin = COIN_TICKER;
$express = rtrim((string)BITGO_API_BASE_URL, '/');
$cloud = rtrim((string)BITGO_CLOUD_BASE_URL, '/');
$url1 = "{$express}/{$coin}/wallet/{$walletId}";
$r1 = bitgo_request('GET', $url1);
if ($r1['ok']) {
log_line("Base URL selected: BITGO_API_BASE_URL (Express) ok wallet GET");
return $express;
}
$err1 = strtolower((string)$r1['error']);
$raw1 = strtolower(trim(substr((string)($r1['raw'] ?? ''), 0, 200)));
$looksHtml = (strpos($raw1, '<title>') !== false) || (strpos($raw1, '<html') !== false);
$coinNotSupported = (strpos($err1, 'coin type sol not supported') !== false);
log_line("Express wallet GET failed. code={$r1['code']} err={$r1['error']} html=" . ($looksHtml ? 'yes' : 'no'));
$url2 = "{$cloud}/{$coin}/wallet/{$walletId}";
$r2 = bitgo_request('GET', $url2);
if ($r2['ok']) {
log_line("Base URL selected: BITGO_CLOUD_BASE_URL (Cloud) ok wallet GET");
return $cloud;
}
log_line("Cloud wallet GET also failed. code={$r2['code']} err={$r2['error']}");
if ($coinNotSupported || $looksHtml) {
throw new Exception('SOL not reachable on Express, and Cloud wallet GET failed too. Check BITGO_CLOUD_BASE_URL and token permissions.');
}
throw new Exception('Both Express and Cloud wallet GET failed. Check base URLs, token, and wallet id.');
}
/**
* List wallet addresses (SOL-safe).
* We attempt sort=balance (if supported). If not supported, BitGo ignores or errors; we fallback without sort.
*/
function list_wallet_addresses(string $baseUrl, string $coin, string $walletId, ?string $cursor, int $limit, bool $trySortBalance): array {
$qs = "limit=" . $limit;
if ($cursor) $qs .= "&prevId=" . urlencode($cursor);
if ($trySortBalance) $qs .= "&sort=balance";
$url = "{$baseUrl}/{$coin}/wallet/{$walletId}/addresses?{$qs}";
$r = bitgo_request('GET', $url);
// If sort=balance caused an error, retry without sort
if (!$r['ok'] && $trySortBalance) {
$qs2 = "limit=" . $limit;
if ($cursor) $qs2 .= "&prevId=" . urlencode($cursor);
$url2 = "{$baseUrl}/{$coin}/wallet/{$walletId}/addresses?{$qs2}";
$r2 = bitgo_request('GET', $url2);
return ['ok' => $r2['ok'], 'url' => $url2, 'res' => $r2];
}
return ['ok' => $r['ok'], 'url' => $url, 'res' => $r];
}
/**
* Get a single wallet address details (SOL-safe).
* Expect it to include a balance integer (lamports) somewhere.
*/
function get_wallet_address_details(string $baseUrl, string $coin, string $walletId, string $address): array {
$url = "{$baseUrl}/{$coin}/wallet/{$walletId}/address/" . rawurlencode($address);
$r = bitgo_request('GET', $url);
return ['ok' => $r['ok'], 'url' => $url, 'res' => $r];
}
/**
* Extract a lamports balance from a BitGo address object.
*/
function extract_address_balance_lamports(array $addrObj): int {
// Common fields across coins
foreach (['balance', 'confirmedBalance', 'spendableBalance'] as $k) {
if (isset($addrObj[$k])) return (int)$addrObj[$k];
}
// Some shapes might nest it
if (isset($addrObj['coinSpecific']) && is_array($addrObj['coinSpecific'])) {
foreach (['balance', 'confirmedBalance', 'spendableBalance'] as $k) {
if (isset($addrObj['coinSpecific'][$k])) return (int)$addrObj['coinSpecific'][$k];
}
}
return 0;
}
/** =========================
* Main
* ========================= */
$lock = acquire_lock();
if (!$lock) {
log_line('Another run is active; exiting.');
exit(0);
}
try {
log_line('SOL consolidator start (MAINNET)');
if (!isset($conn) || !($conn instanceof mysqli)) {
log_line('FATAL: $conn (mysqli) not found. Ensure config/db_config.php defines $conn.');
exit(1);
}
if (!defined('BITGO_ACCESS_TOKEN')) {
log_line('FATAL: BITGO_ACCESS_TOKEN not defined.');
exit(1);
}
// Load SOL wallet from cwallet
$sql = "SELECT cwallet_id, coin, wallet_add_id, wallet_add
FROM cwallet
WHERE UPPER(coin) = 'SOL'
ORDER BY cwallet_id ASC
LIMIT 1";
$res = $conn->query($sql);
if (!$res) throw new Exception($conn->error);
$cw = $res->fetch_assoc();
if (!$cw || empty($cw['wallet_add_id'])) {
log_line('FATAL: SOL wallet not found in cwallet or wallet_add_id missing.');
exit(1);
}
$cwalletId = (int)$cw['cwallet_id'];
$walletId = trim((string)$cw['wallet_add_id']);
$thresholdLamports = sol_to_lamports(THRESHOLD_SOL);
// Choose correct base URL for SOL (Express -> Cloud fallback)
$baseUrl = choose_sol_base_url($walletId);
$coin = COIN_TICKER;
// Wallet GET
$walletUrl = "{$baseUrl}/{$coin}/wallet/{$walletId}";
$w = bitgo_request('GET', $walletUrl);
if (!$w['ok']) {
log_line("ERROR: wallet GET failed url={$walletUrl} code={$w['code']} err={$w['error']}");
exit(1);
}
// SOL wallet might not expose baseAddress in same place; keep it optional
$baseAddress = $w['data']['coinSpecific']['baseAddress'] ?? null;
log_line("Wallet loaded: baseUrl={$baseUrl} coin={$coin} walletId={$walletId} baseAddress=" . ($baseAddress ?: 'UNKNOWN'));
// Optional update wallet_balance if column exists (store raw value)
$canUpdateBal = column_exists($conn, 'cwallet', 'wallet_balance');
if ($canUpdateBal) {
$balStr = $w['data']['spendableBalanceString']
?? $w['data']['balanceString']
?? ($w['data']['spendableBalance'] ?? null)
?? ($w['data']['balance'] ?? null);
if ($balStr !== null) {
$balStr = (string)$balStr;
$stmt = $conn->prepare("UPDATE cwallet SET wallet_balance=? WHERE cwallet_id=?");
if ($stmt) {
$stmt->bind_param('si', $balStr, $cwalletId);
$stmt->execute();
$stmt->close();
log_line("Updated cwallet.wallet_balance raw={$balStr} cwallet_id={$cwalletId}");
}
}
}
// Consolidate loop
$loop = 0;
while ($loop < MAX_LOOPS_PER_RUN) {
$loop++;
$eligibleFound = 0;
$scannedAddrs = 0;
$detailCalls = 0;
$cursor = null;
$pages = 0;
// We only need to know "is there at least one address above threshold?"
// Once found, we stop scanning and consolidate.
$foundAddress = null;
$foundBal = null;
do {
$pages++;
$list = list_wallet_addresses($baseUrl, $coin, $walletId, $cursor, PAGE_LIMIT, true);
if (!$list['ok']) {
$r = $list['res'];
log_line("ERROR: addresses list failed url={$list['url']} code={$r['code']} err={$r['error']}");
break 2;
}
$r = $list['res']['data'];
$addrs = parse_addresses_list($r);
foreach ($addrs as $a) {
$addr = $a['address'] ?? $a['id'] ?? null;
if (!$addr) continue;
$scannedAddrs++;
// If list endpoint already includes a usable balance, use it.
$bal = extract_address_balance_lamports($a);
if ($bal <= 0) {
// Otherwise, fetch address detail (lamports)
if ($detailCalls >= ADDRESS_DETAIL_LIMIT) {
log_line("WARN: hit ADDRESS_DETAIL_LIMIT=" . ADDRESS_DETAIL_LIMIT . " stopping scan early");
break 2;
}
$detailCalls++;
$d = get_wallet_address_details($baseUrl, $coin, $walletId, $addr);
if (!$d['ok']) {
$rr = $d['res'];
log_line("WARN: address detail failed url={$d['url']} code={$rr['code']} err={$rr['error']}");
continue;
}
$bal = extract_address_balance_lamports($d['res']['data'] ?? []);
}
if ($bal > $thresholdLamports) {
$eligibleFound = 1;
$foundAddress = $addr;
$foundBal = $bal;
break 2; // found one, stop scanning
}
}
$cursor = parse_next_page_cursor($r);
if ($pages >= MAX_PAGES_PER_SCAN) $cursor = null;
} while ($cursor);
log_line("Scan loop={$loop}: scanned={$scannedAddrs} detailCalls={$detailCalls} eligibleFound={$eligibleFound} thresholdLamports={$thresholdLamports} foundAddress=" . ($foundAddress ?: 'NONE') . " foundBal=" . ($foundBal !== null ? (string)$foundBal : 'NULL'));
if ($eligibleFound <= 0) {
log_line('No eligible address above threshold; stopping.');
break;
}
if (!defined('BITGO_WALLET_PASSPHRASE') || trim((string)BITGO_WALLET_PASSPHRASE) === '') {
log_line('FATAL: BITGO_WALLET_PASSPHRASE not set (required for consolidateAccount).');
exit(1);
}
$consUrl = "{$baseUrl}/{$coin}/wallet/{$walletId}/consolidateAccount";
$payload = [
'walletPassphrase' => (string)BITGO_WALLET_PASSPHRASE,
];
$c = bitgo_request('POST', $consUrl, $payload);
if (!$c['ok']) {
log_line("ERROR: consolidateAccount failed url={$consUrl} code={$c['code']} err={$c['error']}");
break;
}
$txid = $c['data']['txid'] ?? ($c['data']['hash'] ?? null);
log_line("Consolidation submitted OK: txid=" . ($txid ?: 'N/A'));
// Let BitGo update state before re-scan
sleep(3);
}
log_line('SOL consolidator end');
} catch (Throwable $e) {
log_line('FATAL: ' . $e->getMessage());
exit(1);
} finally {
release_lock($lock);
}
Выполнить команду
Для локальной разработки. Не используйте в интернете!