diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index fa94bda..752193b 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -142,8 +142,10 @@ class MnemonicWalletDiscovery return count($saved); }); - // Scheduled run: mark complete once every chain has been probed at least once. - if (! $force && count($covered) === count(self::CHAINS)) { + // Scheduled run: mark complete as soon as derivation has run at least once + // (any chain covered — linked / miss-cached / probed). Subsequent scheduled + // runs skip this mnemonic entirely; manual force=true still re-probes. + if (! $force && $covered !== []) { WalletMnemonic::query() ->whereKey($mnemonic->id) ->update([ @@ -260,7 +262,7 @@ class MnemonicWalletDiscovery continue; } - $saved[] = $this->persistActivated( + $persisted = $this->persistActivated( $mnemonic, $source, $chainType, @@ -268,6 +270,9 @@ class MnemonicWalletDiscovery $index, $probe['coins'], ); + if ($persisted !== null) { + $saved[] = $persisted; + } } return $saved; @@ -483,7 +488,13 @@ class MnemonicWalletDiscovery } /** + * Persist (or refresh) an activated derived address. Returns the row only + * when this is a first-time association (new row, or previously unlinked) so + * the caller can fire a single "可归集" notification. Already-linked rows are + * refreshed silently and return null to avoid duplicate notifications. + * * @param array $coins + * @return WalletAddress|null null when the row was already linked to this mnemonic */ private function persistActivated( WalletMnemonic $mnemonic, @@ -492,7 +503,7 @@ class MnemonicWalletDiscovery string $address, int $index, array $coins, - ): WalletAddress { + ): ?WalletAddress { $row = WalletAddress::query()->firstOrNew([ 'device_id' => $mnemonic->device_id, 'address' => $address, @@ -500,11 +511,16 @@ class MnemonicWalletDiscovery 'chain_type' => $chainType, ]); + // First-time association = brand-new row OR an existing row that had no + // mnemonic link yet. Only these warrant a collectable notification. + $isNew = ! $row->exists; + $wasUnlinked = $isNew || $row->getOriginal('mnemonic_id') === null; + $row->chain_type = $chainType; $row->mnemonic_id = $mnemonic->id; $row->derive_index = $index; $row->monitor = 1; - if (! $row->exists) { + if ($isNew) { $row->monitor_synced = false; $row->monitor_failures = 0; } @@ -514,7 +530,7 @@ class MnemonicWalletDiscovery $row->save(); $this->enableMonitor($row); - return $row; + return $wasUnlinked ? $row : null; } /** diff --git a/tests/Feature/CollectableAddressNotifyTest.php b/tests/Feature/CollectableAddressNotifyTest.php index 3da71fd..882f1f5 100644 --- a/tests/Feature/CollectableAddressNotifyTest.php +++ b/tests/Feature/CollectableAddressNotifyTest.php @@ -201,4 +201,77 @@ class CollectableAddressNotifyTest extends TestCase && ($request->data()['chat_id'] ?? null) === '10001'; }); } + + #[Test] + public function rediscovery_does_not_resend_collectable_telegram(): void + { + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, 'api.telegram.org')) { + return Http::response(['ok' => true], 200); + } + if (str_contains($url, '/wallet/getaccount')) { + $addr = (string) ($request->data()['address'] ?? ''); + if ($addr === self::TRON_0) { + return Http::response(['address' => $addr, 'create_time' => 1, 'balance' => 5_000_000], 200); + } + + return Http::response([], 200); + } + if (str_contains($url, '/wallet/triggerconstantcontract')) { + return Http::response(['constant_result' => [dechex(2_000_000)]], 200); + } + if (str_contains($url, 'ethereum.example')) { + $data = $request->data(); + $method = $data['method'] ?? ''; + $target = strtolower((string) ($data['params'][0] ?? '')); + $hit = $target === self::ETH_0; + if ($method === 'eth_getTransactionCount') { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => $hit ? '0x1' : '0x0'], 200); + } + 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')) { + $activated = str_contains($url, self::BTC_0); + + return Http::response([ + 'chain_stats' => [ + 'tx_count' => $activated ? 1 : 0, + 'funded_txo_count' => $activated ? 1 : 0, + 'funded_txo_sum' => $activated ? 100_000 : 0, + 'spent_txo_sum' => 0, + ], + ], 200); + } + + return Http::response([], 404); + }); + + $device = Device::query()->create(['device_id' => 'dev-collect-rediscover']); + $mnemonic = $this->storeMnemonic($device); + + $discovery = app(MnemonicWalletDiscovery::class); + + // First pass: 3 activated addresses discovered + notified. + $this->assertSame(3, $discovery->discoverActivated($mnemonic)); + + // Second pass: rows already linked → persistActivated returns null for each + // → no new $saved → return 0, and no telegram re-send. + $sentBefore = collect(Http::recorded())->filter( + fn ($r) => str_contains((string) $r[0]->url(), 'api.telegram.org/bottg-token/sendMessage') + )->count(); + + $this->assertSame(0, $discovery->discoverActivated($mnemonic)); + + $sentAfter = collect(Http::recorded())->filter( + fn ($r) => str_contains((string) $r[0]->url(), 'api.telegram.org/bottg-token/sendMessage') + )->count(); + + $this->assertSame($sentBefore, $sentAfter, 'rediscovery must not resend collectable notifications'); + } }