PHP WebShell

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

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

<?php
// models/crypto/tron_sweeper.php

namespace Models\Crypto;

use Elliptic\EC;

class TronSweeper
{
    /** @var \mysqli */
    private $db;
    private string $baseUrl;
    private string $apiKey;
    private string $usdtContract;

    private ?array $primaryTrx  = null;
    private ?array $primaryUsdt = null;

    // Sweep thresholds
    private const MIN_TRX_SWEEP   = 3.0; // sweep if TRX > 3
    private const TRX_GAS_BUFFER  = 2.0; // leave 2 TRX for gas
    private const MIN_USDT_SWEEP  = 1.0; // sweep USDT if >= 1

    public function __construct(\mysqli $conn)
    {
        $this->db = $conn;

        $config = require __DIR__ . '/../../config/tron_config.php';
        $this->baseUrl      = rtrim($config['network'], '/'); // e.g. https://api.trongrid.io
        $this->apiKey       = $config['api_key'] ?? '';
        $this->usdtContract = $config['usdt_contract'] ?? 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';

        // load primary TRX & USDT-TRC20 hot wallets from cwallet
        $this->primaryTrx  = $this->loadPrimaryWallet('TRX');
        $this->primaryUsdt = $this->loadPrimaryWallet('USDT-TRC20');
    }

    private function loadPrimaryWallet(string $coin): ?array
    {
        $sql = "SELECT cwallet_id, coin, wallet_add 
                FROM cwallet 
                WHERE coin = ? 
                LIMIT 1";
        $stmt = $this->db->prepare($sql);
        if (!$stmt) {
            throw new \RuntimeException(
                "DB error preparing loadPrimaryWallet: " . $this->db->error
            );
        }
        $stmt->bind_param("s", $coin);
        $stmt->execute();
        $res = $stmt->get_result();
        $row = $res->fetch_assoc();
        $stmt->close();

        return $row ?: null;
    }

    /**
     * Simple TronGrid GET helper.
     */
    private function httpGetJson(string $path): array
    {
        $url = $this->baseUrl . $path;

        $ch  = curl_init($url);
        $headers = [];
        if ($this->apiKey) {
            $headers[] = 'TRON-PRO-API-KEY: ' . $this->apiKey;
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => 20,
        ]);

        $body = curl_exec($ch);
        if ($body === false) {
            $err = curl_error($ch);
            curl_close($ch);
            throw new \RuntimeException("HTTP error calling TronGrid GET: {$err}");
        }

        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $data = json_decode($body, true);
        if (!is_array($data)) {
            throw new \RuntimeException(
                "Invalid JSON from TronGrid GET ({$code}): {$body}"
            );
        }

        return $data;
    }

    /**
     * Simple TronGrid POST helper.
     */
    private function httpPostJson(string $path, array $payload): array
    {
        $url = $this->baseUrl . $path;

        $ch  = curl_init($url);
        $headers = ['Content-Type: application/json'];
        if ($this->apiKey) {
            $headers[] = 'TRON-PRO-API-KEY: ' . $this->apiKey;
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => 20,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($payload),
        ]);

        $body = curl_exec($ch);
        if ($body === false) {
            $err = curl_error($ch);
            curl_close($ch);
            throw new \RuntimeException("HTTP error calling TronGrid POST: {$err}");
        }

        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $data = json_decode($body, true);
        if (!is_array($data)) {
            throw new \RuntimeException(
                "Invalid JSON from TronGrid POST ({$code}): {$body}"
            );
        }

        return $data;
    }

    /**
     * Get on-chain TRX + USDT balance for a TRON address (Base58)
     * using TronGrid /v1/accounts/{address}.
     */
    private function getOnchainBalances(string $address): array
    {
        $data = $this->httpGetJson('/v1/accounts/' . $address);

        if (empty($data['data']) || empty($data['data'][0])) {
            return [
                'trx_sun'  => 0,
                'trx'      => 0.0,
                'usdt_sun' => 0,
                'usdt'     => 0.0,
            ];
        }

        $acc = $data['data'][0];

        // TRX in SUN
        $trxSun = (int)($acc['balance'] ?? 0);
        $trx    = $trxSun / 1_000_000;

        // USDT TRC20
        $usdtSun = 0;
        $trc20   = $acc['trc20'] ?? [];

        foreach ($trc20 as $entry) {
            if (isset($entry[$this->usdtContract])) {
                $usdtSun = (int)$entry[$this->usdtContract];
                break;
            }
        }

        $usdt = $usdtSun / 1_000_000;

        return [
            'trx_sun'  => $trxSun,
            'trx'      => $trx,
            'usdt_sun' => $usdtSun,
            'usdt'     => $usdt,
        ];
    }

    /**
     * Fetch private key from wallet_keys table for a given user + address.
     */
    private function getPrivateKeyForAddress(int $userId, string $address): ?string
    {
        $sql = "SELECT private_key 
                FROM wallet_keys 
                WHERE user_id = ? AND wallet_add = ? 
                LIMIT 1";
        $stmt = $this->db->prepare($sql);
        if (!$stmt) {
            throw new \RuntimeException(
                "DB error preparing getPrivateKeyForAddress: " . $this->db->error
            );
        }
        $stmt->bind_param("is", $userId, $address);
        $stmt->execute();
        $res = $stmt->get_result();
        $row = $res->fetch_assoc();
        $stmt->close();

        return $row['private_key'] ?? null;
    }

    /**
     * Log a sweep attempt into crypto_sweep_log.
     */
    private function logSweep(
        string $coin,
        int $userId,
        int $fromWalletId,
        string $fromAddr,
        int $toCwalletId,
        string $toAddr,
        float $amountDec,
        int $amountRaw,
        ?string $txid,
        string $status,
        ?string $reason = null
    ): void {
        $sql = "INSERT INTO crypto_sweep_log
            (network, coin, user_id, from_wallet_id, from_address,
             to_cwallet_id, to_address,
             amount_dec, amount_raw, txid, status, fail_reason)
            VALUES (
             'TRON', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
            )";

        $stmt = $this->db->prepare($sql);
        if (!$stmt) {
            throw new \RuntimeException(
                "DB error preparing logSweep: " . $this->db->error
            );
        }

        $amountDecStr = number_format($amountDec, 6, '.', '');
        $amountRawStr = (string)$amountRaw;
        $txidStr      = $txid ?? '';
        $reasonStr    = $reason ?? '';

        $stmt->bind_param(
            "siissssssss",
            $coin,
            $userId,
            $fromWalletId,
            $fromAddr,
            $toCwalletId,
            $toAddr,
            $amountDecStr,
            $amountRawStr,
            $txidStr,
            $status,
            $reasonStr
        );
        $stmt->execute();
        $stmt->close();
    }

    /**
     * Sign a TRON transaction using ECDSA secp256k1.
     *
     * Signature format: r(32 bytes) || s(32 bytes) || v(1 byte),
     * where v = 27 + recoveryParam. (TRC120-style)
     */
    private function signTransaction(array $tx, string $privateKeyHex): array
    {
        if (empty($tx['raw_data_hex'])) {
            throw new \RuntimeException('Missing raw_data_hex in transaction');
        }

        $rawHex = $tx['raw_data_hex'];

        // Tron signs SHA-256(raw_data bytes)
        $hashBin = hash('sha256', hex2bin($rawHex), true);
        $hashHex = bin2hex($hashBin);

        $ec  = new EC('secp256k1');
        $key = $ec->keyFromPrivate($privateKeyHex, 'hex');

        $sig = $key->sign($hashHex, 'hex', ['canonical' => true]);

        // r, s -> 32-byte hex each
        $rHex = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
        $sHex = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);

        // v = 27 + recId
        $recId = $sig->recoveryParam; // 0 or 1
        $v     = 27 + $recId;
        $vHex  = str_pad(dechex($v), 2, '0', STR_PAD_LEFT);

        $signatureHex = $rHex . $sHex . $vHex;

        $tx['signature'] = [$signatureHex];

        return $tx;
    }

    /**
     * Send TRX from a user wallet to the central TRX wallet.
     *
     * Flow:
     *  1) /wallet/createtransaction (unsigned)
     *  2) locally sign raw_data_hex
     *  3) /wallet/broadcasttransaction (signed tx)
     *
     * @return string|null txid on success, null on failure
     */
    private function sendTrx(
        string $fromAddress,
        string $privateKeyHex,
        string $toAddress,
        int $amountSun
    ): ?string {
        try {
            // 1) Create unsigned transaction
            $payload = [
                'to_address'    => $toAddress,
                'owner_address' => $fromAddress,
                'amount'        => $amountSun,
                'visible'       => true, // Base58 addresses
            ];

            $tx = $this->httpPostJson('/wallet/createtransaction', $payload);

            if (!empty($tx['Error']) || !empty($tx['code'])) {
                $msg = $tx['Error'] ?? ($tx['message'] ?? $tx['code'] ?? 'unknown');
                echo "    ! createtransaction error: {$msg}\n";
                return null;
            }

            if (empty($tx['raw_data_hex'])) {
                echo "    ! createtransaction missing raw_data_hex\n";
                return null;
            }

            // 2) Sign locally
            $signedTx = $this->signTransaction($tx, $privateKeyHex);

            // 3) Broadcast signed transaction
            $resp = $this->httpPostJson('/wallet/broadcasttransaction', $signedTx);

            if (!(isset($resp['result']) && $resp['result'] === true)) {
                $msg = $resp['message'] ?? $resp['code'] ?? 'unknown';
                echo "    ! broadcasttransaction error: {$msg}\n";
                return null;
            }

            if (!empty($resp['txid'])) {
                return $resp['txid'];
            }
            if (!empty($tx['txID'])) {
                return $tx['txID'];
            }

            echo "    ! broadcasttransaction success but no txid in response\n";
            return null;

        } catch (\Throwable $e) {
            echo "    ! sendTrx exception: " . $e->getMessage() . "\n";
            return null;
        }
    }

    /**
     * Placeholder: USDT-TRC20 sweep (not yet implemented).
     * We still log as 'pending', no on-chain move yet.
     */
    private function sendUsdt(
        string $fromAddress,
        string $privateKeyHex,
        string $toAddress,
        int $amountSun
    ): ?string {
        echo "    (USDT sweep not implemented yet - leaving as pending)\n";
        return null;
    }

    /**
     * Main sweeper:
     *  - loops all TRX user wallets
     *  - checks on-chain TRX & USDT balances via /v1/accounts/{address}
     *  - decides what to sweep
     *  - logs into crypto_sweep_log
     *
     * User balances in user_wallets are NOT touched here.
     */
    public function run(): void
    {
        if (!$this->primaryTrx || !$this->primaryUsdt) {
            echo "Missing primary TRX/USDT-TRC20 rows in cwallet table.\n";
            echo "Make sure you have cwallet.coin = 'TRX' and 'USDT-TRC20'.\n";
            return;
        }

        $sql = "SELECT wallet_id, user_id, wallet_add, balance
                FROM user_wallets
                WHERE coin = 'TRX' AND type = 'crypto' AND wallet_status = 'active'";
        $res = $this->db->query($sql);
        if (!$res) {
            throw new \RuntimeException(
                "DB error selecting TRX wallets: " . $this->db->error
            );
        }

        echo "Sweeper starting...\n";

        while ($row = $res->fetch_assoc()) {
            $walletId = (int)$row['wallet_id'];
            $userId   = (int)$row['user_id'];
            $address  = $row['wallet_add'];

            echo "  → Checking {$address} (wallet_id={$walletId}, user_id={$userId})\n";

            try {
                $bal = $this->getOnchainBalances($address);
            } catch (\Throwable $e) {
                $this->logSweep(
                    'TRX',
                    $userId,
                    $walletId,
                    $address,
                    (int)$this->primaryTrx['cwallet_id'],
                    $this->primaryTrx['wallet_add'],
                    0.0,
                    0,
                    null,
                    'failed',
                    'balance_fetch: ' . $e->getMessage()
                );
                echo "    ! Failed to fetch balances: " . $e->getMessage() . "\n";
                continue;
            }

            $onchainTrx  = $bal['trx'];
            $onchainSun  = $bal['trx_sun'];
            $onchainUsdt = $bal['usdt'];
            $usdtSun     = $bal['usdt_sun'];

            echo "    On-chain TRX = {$onchainTrx}, USDT = {$onchainUsdt}\n";

            $privKey = $this->getPrivateKeyForAddress($userId, $address);
            if (!$privKey) {
                echo "    ! No private key in wallet_keys for this address, skip.\n";
                continue;
            }

            // ---- TRX SWEEP ----
            if ($onchainTrx > self::MIN_TRX_SWEEP) {
                $sweepTrxAmount = max(0.0, $onchainTrx - self::TRX_GAS_BUFFER);

                if ($sweepTrxAmount > 0) {
                    $sweepTrxSun = (int)round($sweepTrxAmount * 1_000_000);

                    $txid = $this->sendTrx(
                        $address,
                        $privKey,
                        $this->primaryTrx['wallet_add'],
                        $sweepTrxSun
                    );

                    $status = $txid ? 'broadcast' : 'failed';
                    $reason = $txid ? null : 'broadcast_failed';

                    $this->logSweep(
                        'TRX',
                        $userId,
                        $walletId,
                        $address,
                        (int)$this->primaryTrx['cwallet_id'],
                        $this->primaryTrx['wallet_add'],
                        $sweepTrxAmount,
                        $sweepTrxSun,
                        $txid,
                        $status,
                        $reason
                    );

                    if ($txid) {
                        echo "    TRX sweep broadcasted: {$sweepTrxAmount} → {$this->primaryTrx['wallet_add']} (txid={$txid})\n";
                    } else {
                        echo "    ! TRX sweep broadcast failed\n";
                    }
                }
            }

            // ---- USDT SWEEP (plan + log only) ----
            if ($onchainUsdt >= self::MIN_USDT_SWEEP) {
                if ($onchainTrx <= self::TRX_GAS_BUFFER) {
                    echo "    USDT present but not enough TRX for gas → skip for now.\n";
                    $this->logSweep(
                        'USDT-TRC20',
                        $userId,
                        $walletId,
                        $address,
                        (int)$this->primaryUsdt['cwallet_id'],
                        $this->primaryUsdt['wallet_add'],
                        $onchainUsdt,
                        $usdtSun,
                        null,
                        'failed',
                        'insufficient_gas_trx'
                    );
                } else {
                    $sweepUsdtAmount = $onchainUsdt;
                    $sweepUsdtSun    = $usdtSun;

                    $txid = $this->sendUsdt(
                        $address,
                        $privKey,
                        $this->primaryUsdt['wallet_add'],
                        $sweepUsdtSun
                    );

                    $status = $txid ? 'broadcast' : 'pending';
                    $reason = $txid ? null : 'not_broadcast_yet';

                    $this->logSweep(
                        'USDT-TRC20',
                        $userId,
                        $walletId,
                        $address,
                        (int)$this->primaryUsdt['cwallet_id'],
                        $this->primaryUsdt['wallet_add'],
                        $sweepUsdtAmount,
                        $sweepUsdtSun,
                        $txid,
                        $status,
                        $reason
                    );

                    echo "    Planned USDT sweep {$sweepUsdtAmount} → {$this->primaryUsdt['wallet_add']} (txid=" . ($txid ?? 'PENDING') . ")\n";
                }
            }
        }

        $res->free();

        echo "Sweeper finished.\n";
    }
}

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


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