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);
}

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


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