PHP WebShell

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

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

<?php
// models/crypto/tron_sweeper.php
//
// USDT-first sweeping (TRC20), then TRX sweeping.
// Also updates wallet_keys.trx_balance and wallet_keys.usdt_balance each run.
//
// REQUIRED DB COLUMNS (wallet_keys):
//   - trx_balance DECIMAL(20,6) NOT NULL DEFAULT 0.000000
//   - usdt_balance DECIMAL(20,6) NOT NULL DEFAULT 0.000000
//   - balances_updated_at TIMESTAMP NULL DEFAULT NULL
//
// (If you also add trx_sun/usdt_sun columns later, tell me and I will extend the update query.)

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;

    // ===== Your thresholds =====
    private const MIN_TRX_SWEEP   = 5.0; // sweep if TRX > 5
    private const MIN_USDT_SWEEP  = 3.0; // sweep if USDT > 3

    // ===== Gas buffers =====
    private const TRX_BUFFER_DEFAULT = 2.0;   // normal leftover after TRX sweep
    private const TRX_BUFFER_RETRY   = 10.0;  // if USDT sweep should happen but fails, keep more TRX for retries

    // USDT transfer feeLimit (SUN). 15 TRX.
    private const USDT_FEE_LIMIT_SUN = 15000000;

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

        $config = require __DIR__ . '/../../config/tron_config.php';

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

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

    // ---------------- HTTP helpers ----------------

    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        => 25,
        ]);

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

    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        => 25,
            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;
    }

    // ---------------- Chain reads ----------------

    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];

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

        $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,
        ];
    }

    // ---------------- DB reads & writes ----------------

    private function fetchWalletKeys(): \mysqli_result
    {
        $sql = "SELECT key_id, user_id, wallet_add, private_key
                FROM wallet_keys
                WHERE wallet_add IS NOT NULL AND wallet_add != ''
                  AND private_key IS NOT NULL AND private_key != ''
                ORDER BY key_id ASC";

        $res = $this->db->query($sql);
        if (!$res) {
            throw new \RuntimeException("DB error selecting wallet_keys: " . $this->db->error);
        }
        return $res;
    }

    private function findUserWalletIdByAddress(int $userId, string $address): int
    {
        $sql = "SELECT wallet_id
                FROM user_wallets
                WHERE user_id = ? AND wallet_add = ?
                LIMIT 1";
        $stmt = $this->db->prepare($sql);
        if (!$stmt) return 0;
        $stmt->bind_param("is", $userId, $address);
        $stmt->execute();
        $res = $stmt->get_result();
        $row = $res->fetch_assoc();
        $stmt->close();
        return (int)($row['wallet_id'] ?? 0);
    }

    /**
     * Update cached balances in wallet_keys.
     * Requires: trx_balance, usdt_balance, balances_updated_at columns.
     */
    private function updateWalletKeyBalances(int $keyId, float $trx, float $usdt): void
    {
        $sql = "UPDATE wallet_keys
                SET trx_balance = ?, usdt_balance = ?, balances_updated_at = NOW()
                WHERE key_id = ?
                LIMIT 1";
        $stmt = $this->db->prepare($sql);
        if (!$stmt) {
            throw new \RuntimeException("DB error preparing updateWalletKeyBalances: " . $this->db->error);
        }

        $trxStr  = number_format($trx, 6, '.', '');
        $usdtStr = number_format($usdt, 6, '.', '');

        $stmt->bind_param("ssi", $trxStr, $usdtStr, $keyId);
        $stmt->execute();
        $stmt->close();
    }

    // ---------------- Logging ----------------

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

    // ---------------- Crypto helpers ----------------

    private function normalizePrivateKey(string $privateKeyHex): string
    {
        $k = trim($privateKeyHex);
        if (stripos($k, '0x') === 0) $k = substr($k, 2);
        $k = strtolower($k);

        if (!preg_match('/^[0-9a-f]{64}$/', $k)) {
            throw new \RuntimeException("Invalid private key format (expected 64 hex chars).");
        }
        return $k;
    }

    private function base58decode(string $input): string
    {
        $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
        $num = gmp_init(0, 10);

        $len = strlen($input);
        for ($i = 0; $i < $len; $i++) {
            $p = strpos($alphabet, $input[$i]);
            if ($p === false) {
                throw new \RuntimeException("Invalid Base58 character in address.");
            }
            $num = gmp_add(gmp_mul($num, 58), $p);
        }

        $bin = '';
        while (gmp_cmp($num, 0) > 0) {
            $byte = gmp_intval(gmp_mod($num, 256));
            $num = gmp_div_q($num, 256);
            $bin = chr($byte) . $bin;
        }

        // restore leading zeros
        $i = 0;
        while ($i < $len && $input[$i] === '1') {
            $bin = "\x00" . $bin;
            $i++;
        }

        return $bin;
    }

    private function tronBase58ToHex41(string $base58): string
    {
        $bin = $this->base58decode(trim($base58));
        if (strlen($bin) < 5) {
            throw new \RuntimeException("Invalid TRON address (too short).");
        }

        $payload  = substr($bin, 0, -4);
        $checksum = substr($bin, -4);

        $check = substr(hash('sha256', hash('sha256', $payload, true), true), 0, 4);
        if (!hash_equals($check, $checksum)) {
            throw new \RuntimeException("Invalid TRON address checksum.");
        }

        $hex = bin2hex($payload);
        if (strlen($hex) !== 42 || strpos($hex, '41') !== 0) {
            throw new \RuntimeException("Invalid TRON address payload (expected 0x41 prefix).");
        }

        return $hex;
    }

    private function abiEncodeTrc20Transfer(string $toBase58, int $amountSun): string
    {
        $toHex41 = $this->tronBase58ToHex41($toBase58); // 41 + 20 bytes
        $toHex20 = substr($toHex41, 2);                 // remove '41'

        $toPadded   = str_pad($toHex20, 64, '0', STR_PAD_LEFT);
        $amtHex     = dechex($amountSun);
        $amtPadded  = str_pad($amtHex, 64, '0', STR_PAD_LEFT);

        return $toPadded . $amtPadded;
    }

    /**
     * TRON signature expects v = recId (0/1) as last byte.
     */
    private function signTransaction(array $tx, string $privateKeyHex): array
    {
        if (empty($tx['raw_data_hex'])) {
            throw new \RuntimeException('Missing raw_data_hex in transaction');
        }

        $privateKeyHex = $this->normalizePrivateKey($privateKeyHex);

        $rawHex  = $tx['raw_data_hex'];
        $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]);

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

        $recId = (int)$sig->recoveryParam; // 0 or 1
        if ($recId !== 0 && $recId !== 1) {
            throw new \RuntimeException("Unexpected recoveryParam={$recId}");
        }

        $vHex = str_pad(dechex($recId), 2, '0', STR_PAD_LEFT);

        $tx['signature'] = [$rHex . $sHex . $vHex];
        return $tx;
    }

    // ---------------- Broadcasting ----------------

    private function sendTrx(string $fromAddress, string $privateKeyHex, string $toAddress, int $amountSun): ?string
    {
        try {
            $payload = [
                'to_address'    => $toAddress,
                'owner_address' => $fromAddress,
                'amount'        => $amountSun,
                'visible'       => true,
            ];

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

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

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

            $signedTx = $this->signTransaction($tx, $privateKeyHex);

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

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

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

            echo "    ! TRX broadcast success but no txid\n";
            return null;

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

    private function sendUsdt(string $fromAddress, string $privateKeyHex, string $toAddress, int $amountSun): ?string
    {
        try {
            $parameter = $this->abiEncodeTrc20Transfer($toAddress, $amountSun);

            $triggerPayload = [
                'owner_address'     => $fromAddress,
                'contract_address'  => $this->usdtContract,
                'function_selector' => 'transfer(address,uint256)',
                'parameter'         => $parameter,
                'fee_limit'         => self::USDT_FEE_LIMIT_SUN,
                'call_value'        => 0,
                'visible'           => true,
            ];

            $trig = $this->httpPostJson('/wallet/triggersmartcontract', $triggerPayload);

            if (empty($trig['result']) || empty($trig['result']['result'])) {
                $msg = $trig['message'] ?? $trig['Error'] ?? 'trigger_failed';
                echo "    ! USDT triggersmartcontract error: {$msg}\n";
                return null;
            }

            if (empty($trig['transaction']) || empty($trig['transaction']['raw_data_hex'])) {
                echo "    ! USDT triggersmartcontract missing transaction/raw_data_hex\n";
                return null;
            }

            $tx = $trig['transaction'];

            $signedTx = $this->signTransaction($tx, $privateKeyHex);

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

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

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

            echo "    ! USDT broadcast success but no txid\n";
            return null;

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

    // ---------------- Main run ----------------

    public function run(): void
    {
        if (!$this->primaryTrx || !$this->primaryUsdt) {
            echo "Missing primary TRX/USDT-TRC20 rows in cwallet table.\n";
            echo "Ensure cwallet.coin='TRX' and cwallet.coin='USDT-TRC20' exist.\n";
            return;
        }

        $res = $this->fetchWalletKeys();

        echo "TRON Sweeper starting...\n";
        echo "Primary TRX wallet      : {$this->primaryTrx['wallet_add']} (cwallet_id={$this->primaryTrx['cwallet_id']})\n";
        echo "Primary USDT-TRC20 wallet: {$this->primaryUsdt['wallet_add']} (cwallet_id={$this->primaryUsdt['cwallet_id']})\n\n";

        while ($row = $res->fetch_assoc()) {
            $keyId   = (int)$row['key_id'];
            $userId  = (int)$row['user_id'];
            $address = trim((string)$row['wallet_add']);
            $privKey = trim((string)$row['private_key']);

            if ($address === '' || $privKey === '') continue;

            $fromWalletId = $this->findUserWalletIdByAddress($userId, $address);

            echo "→ Checking key_id={$keyId} user_id={$userId} addr={$address}\n";

            try {
                $privKey = $this->normalizePrivateKey($privKey);
            } catch (\Throwable $e) {
                echo "  ! bad private key: {$e->getMessage()}\n";
                $this->logSweep('TRX', $userId, $fromWalletId, $address, (int)$this->primaryTrx['cwallet_id'], $this->primaryTrx['wallet_add'], 0.0, 0, null, 'failed', 'bad_private_key');
                continue;
            }

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

            $onTrx   = (float)$bal['trx'];
            $onUsdt  = (float)$bal['usdt'];
            $trxSun  = (int)$bal['trx_sun'];
            $usdtSun = (int)$bal['usdt_sun'];

            echo "  On-chain: TRX={$onTrx}  USDT={$onUsdt}\n";

            // Update cached balances in wallet_keys
            try {
                $this->updateWalletKeyBalances($keyId, $onTrx, $onUsdt);
            } catch (\Throwable $e) {
                echo "  ! Failed to update wallet_keys balances: " . $e->getMessage() . "\n";
            }

            // ========= USDT FIRST =========
            $usdtAttempted = false;
            $usdtSuccess   = false;

            if ($onUsdt > self::MIN_USDT_SWEEP && $usdtSun > 0) {
                $usdtAttempted = true;

                echo "  USDT sweep (first): {$onUsdt} -> {$this->primaryUsdt['wallet_add']}\n";

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

                $usdtSuccess = (bool)$usdtTxid;

                $this->logSweep(
                    'USDT-TRC20',
                    $userId,
                    $fromWalletId,
                    $address,
                    (int)$this->primaryUsdt['cwallet_id'],
                    $this->primaryUsdt['wallet_add'],
                    $onUsdt,
                    $usdtSun,
                    $usdtTxid,
                    $usdtSuccess ? 'broadcast' : 'failed',
                    $usdtSuccess ? null : 'broadcast_failed'
                );

                echo $usdtTxid ? "  ✓ USDT txid={$usdtTxid}\n" : "  ! USDT broadcast failed\n";
            }

            // ========= TRX NEXT (always based on TRX threshold) =========
            if ($onTrx > self::MIN_TRX_SWEEP && $trxSun > 0) {

                // If USDT was eligible (>= threshold) but failed, keep more TRX for retry
                $trxBuffer = self::TRX_BUFFER_DEFAULT;

                if ($usdtAttempted && !$usdtSuccess) {
                    $trxBuffer = self::TRX_BUFFER_RETRY;
                    echo "  Note: USDT sweep failed; keeping higher TRX buffer ({$trxBuffer} TRX) for retries.\n";
                }

                $sweepTrx = max(0.0, $onTrx - $trxBuffer);

                if ($sweepTrx > 0) {
                    $sweepSun = (int)floor($sweepTrx * 1_000_000);

                    echo "  TRX sweep (next): {$sweepTrx} -> {$this->primaryTrx['wallet_add']}\n";

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

                    $this->logSweep(
                        'TRX',
                        $userId,
                        $fromWalletId,
                        $address,
                        (int)$this->primaryTrx['cwallet_id'],
                        $this->primaryTrx['wallet_add'],
                        $sweepTrx,
                        $sweepSun,
                        $trxTxid,
                        $trxTxid ? 'broadcast' : 'failed',
                        $trxTxid ? null : 'broadcast_failed'
                    );

                    echo $trxTxid ? "  ✓ TRX txid={$trxTxid}\n" : "  ! TRX broadcast failed\n";
                }
            }

            echo "\n";
        }

        $res->free();
        echo "TRON Sweeper finished.\n";
    }
}

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


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