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";
}
}
Выполнить команду
Для локальной разработки. Не используйте в интернете!