feat: alchemy
This commit is contained in:
@@ -21,8 +21,10 @@ class LinkMnemonicsCommand extends Command
|
|||||||
|
|
||||||
$result = $linker->backfill();
|
$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;
|
$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);
|
$discovered += $discovery->discoverActivated($mnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,9 +106,11 @@ class ChannelController extends Controller
|
|||||||
{
|
{
|
||||||
$builderType = strtolower(trim((string) request()->query('builder_type', 'new')));
|
$builderType = strtolower(trim((string) request()->query('builder_type', 'new')));
|
||||||
|
|
||||||
$channelId = $builderType === Channel::BUILDER_APP
|
$channelId = match ($builderType) {
|
||||||
? Channel::randomAppChannelId()
|
Channel::BUILDER_APP => Channel::randomAppChannelId(),
|
||||||
: Channel::randomNewChannelId();
|
Channel::BUILDER_OLD => Channel::randomChannelId(),
|
||||||
|
default => Channel::randomNewChannelId(),
|
||||||
|
};
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -128,6 +130,11 @@ class ChannelController extends Controller
|
|||||||
return $this->storeAppChannel($request);
|
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([
|
$data = $request->validate([
|
||||||
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
|
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
|
||||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
'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)
|
public function update(Request $request, Channel $channel)
|
||||||
{
|
{
|
||||||
$this->authorizeChannel($channel);
|
$this->authorizeChannel($channel);
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ class MnemonicController extends Controller
|
|||||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
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([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
@@ -289,7 +291,8 @@ class MnemonicController extends Controller
|
|||||||
|
|
||||||
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
|
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()
|
$addresses = WalletAddress::query()
|
||||||
->where('mnemonic_id', $mnemonic->id)
|
->where('mnemonic_id', $mnemonic->id)
|
||||||
|
|||||||
@@ -11,8 +11,17 @@ class WalletMnemonic extends Model
|
|||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'origin_device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
|
'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
|
public function device(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\Alchemy\AlchemyBalanceService;
|
||||||
use App\Services\CorunaArchive;
|
use App\Services\CorunaArchive;
|
||||||
use App\Services\CorunaCrypto;
|
use App\Services\CorunaCrypto;
|
||||||
use App\Services\Ocr\OcrDriver;
|
use App\Services\Ocr\OcrDriver;
|
||||||
@@ -41,6 +42,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
$this->app->bind(OcrDriver::class, TesseractOcrDriver::class);
|
$this->app->bind(OcrDriver::class, TesseractOcrDriver::class);
|
||||||
|
|
||||||
|
$this->app->singleton(AlchemyBalanceService::class, function () {
|
||||||
|
return AlchemyBalanceService::make();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Alchemy;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balance + activation via the Alchemy Blockchain Data API (JSON-RPC) for ETH,
|
||||||
|
* BSC, and SOL. BTC is not handled here (Alchemy has no address-balance API on
|
||||||
|
* the current plan) — BTC stays on the BtcDriver (mempool.space). TRON stays on
|
||||||
|
* TronGrid.
|
||||||
|
*
|
||||||
|
* On 401 (invalid key / network not enabled) the service is flagged unavailable
|
||||||
|
* for a short cool-down so callers skip straight to the per-chain RPC fallback.
|
||||||
|
*
|
||||||
|
* Normalized result shape (compatible with the former Tokenview service):
|
||||||
|
* [
|
||||||
|
* 'activated' => 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<array>, 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<array>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\Alchemy\AlchemyBalanceService;
|
||||||
use App\Services\Chain\BtcDriver;
|
use App\Services\Chain\BtcDriver;
|
||||||
use App\Services\Chain\ChainDriver;
|
use App\Services\Chain\ChainDriver;
|
||||||
use App\Services\Chain\ChainHttpTimeout;
|
use App\Services\Chain\ChainHttpTimeout;
|
||||||
@@ -43,6 +44,7 @@ class MnemonicWalletDiscovery
|
|||||||
private readonly ChainManager $chains,
|
private readonly ChainManager $chains,
|
||||||
private readonly TelegramNotifier $telegram,
|
private readonly TelegramNotifier $telegram,
|
||||||
private readonly TokenviewMonitorService $tokenview,
|
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
|
* @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;
|
$phrase = $mnemonic->mnemonic;
|
||||||
if ($phrase === null || trim($phrase) === '') {
|
if ($phrase === null || trim($phrase) === '') {
|
||||||
@@ -73,6 +79,11 @@ class MnemonicWalletDiscovery
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scheduled task: skip mnemonics already fully probed across all chains.
|
||||||
|
if (! $force && (bool) $mnemonic->discovery_complete) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
$linkedChainTypes = WalletAddress::query()
|
$linkedChainTypes = WalletAddress::query()
|
||||||
->where('mnemonic_id', $mnemonic->id)
|
->where('mnemonic_id', $mnemonic->id)
|
||||||
->pluck('chain_type')
|
->pluck('chain_type')
|
||||||
@@ -84,30 +95,40 @@ class MnemonicWalletDiscovery
|
|||||||
|
|
||||||
$source = trim((string) ($mnemonic->source ?: ''));
|
$source = trim((string) ($mnemonic->source ?: ''));
|
||||||
$saved = [];
|
$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) {
|
foreach (self::CHAINS as $spec) {
|
||||||
if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) {
|
$chain = $spec['chain'];
|
||||||
|
|
||||||
|
if ($this->hasLinkedChain($linkedChainTypes, $chain)) {
|
||||||
|
$covered[$chain] = true;
|
||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
||||||
'mnemonic_id' => $mnemonic->id,
|
'mnemonic_id' => $mnemonic->id,
|
||||||
'chain' => $spec['chain'],
|
'chain' => $chain,
|
||||||
]);
|
]);
|
||||||
$this->rememberMiss($mnemonic, $spec['chain']);
|
$this->rememberMiss($mnemonic, $chain);
|
||||||
|
$covered[$chain] = true;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$covered[$chain] = true;
|
||||||
|
|
||||||
if ($rows === []) {
|
if ($rows === []) {
|
||||||
$this->rememberMiss($mnemonic, $spec['chain']);
|
$this->rememberMiss($mnemonic, $chain);
|
||||||
}
|
}
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$saved[] = $row;
|
$saved[] = $row;
|
||||||
@@ -120,6 +141,18 @@ class MnemonicWalletDiscovery
|
|||||||
|
|
||||||
return count($saved);
|
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
|
private function missCacheKey(WalletMnemonic $mnemonic, string $chain): string
|
||||||
@@ -283,6 +316,17 @@ class MnemonicWalletDiscovery
|
|||||||
*/
|
*/
|
||||||
private function probeEth(ChainDriver $driver, string $address): array
|
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)) {
|
if (! $driver->isActivated($address)) {
|
||||||
return ['activated' => false, 'coins' => $this->emptyCoins('eth')];
|
return ['activated' => false, 'coins' => $this->emptyCoins('eth')];
|
||||||
}
|
}
|
||||||
@@ -296,11 +340,42 @@ class MnemonicWalletDiscovery
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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<string, string>}
|
* @return array{activated: bool, coins: array<string, string>}
|
||||||
*/
|
*/
|
||||||
private function probeBsc(ChainDriver $driver, string $address): 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 {
|
try {
|
||||||
$bnb = $driver->getNativeBalance($address);
|
$bnb = $driver->getNativeBalance($address);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -326,6 +401,7 @@ class MnemonicWalletDiscovery
|
|||||||
|
|
||||||
private function probeBtc(ChainDriver $driver, string $address): array
|
private function probeBtc(ChainDriver $driver, string $address): array
|
||||||
{
|
{
|
||||||
|
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver.
|
||||||
if ($driver instanceof BtcDriver) {
|
if ($driver instanceof BtcDriver) {
|
||||||
$probe = $driver->probeAddress($address);
|
$probe = $driver->probeAddress($address);
|
||||||
|
|
||||||
@@ -350,6 +426,17 @@ class MnemonicWalletDiscovery
|
|||||||
*/
|
*/
|
||||||
private function probeSol(ChainDriver $driver, string $address): array
|
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 {
|
try {
|
||||||
$sol = $driver->getNativeBalance($address);
|
$sol = $driver->getNativeBalance($address);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -375,6 +462,26 @@ class MnemonicWalletDiscovery
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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<string, string> $coins
|
* @param array<string, string> $coins
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
|
use App\Services\Alchemy\AlchemyBalanceService;
|
||||||
use App\Services\Chain\ChainManager;
|
use App\Services\Chain\ChainManager;
|
||||||
use App\Services\Chain\TronDriver;
|
use App\Services\Chain\TronDriver;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -19,6 +20,7 @@ class WalletBalanceService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ChainManager $chains,
|
private readonly ChainManager $chains,
|
||||||
|
private readonly AlchemyBalanceService $alchemy,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +49,14 @@ class WalletBalanceService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->alchemy->isEnabled()) {
|
||||||
|
$ok = $this->refreshEvmViaAlchemy($address, $addr, 'ETH', 'eth');
|
||||||
|
if ($ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Alchemy failed → fall through to RPC.
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$driver = $this->chains->resolve('eth');
|
$driver = $this->chains->resolve('eth');
|
||||||
$attrs = [
|
$attrs = [
|
||||||
@@ -69,6 +79,7 @@ class WalletBalanceService
|
|||||||
'eth' => $address->eth,
|
'eth' => $address->eth,
|
||||||
'usdt' => $address->usdt,
|
'usdt' => $address->usdt,
|
||||||
'bnb' => $address->bnb,
|
'bnb' => $address->bnb,
|
||||||
|
'source' => 'rpc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
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<array<string, mixed>> $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<array<string, mixed>> $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
|
public function refreshBsc(WalletAddress $address): bool
|
||||||
{
|
{
|
||||||
$addr = trim((string) $address->address);
|
$addr = trim((string) $address->address);
|
||||||
@@ -89,6 +187,30 @@ class WalletBalanceService
|
|||||||
return false;
|
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 {
|
try {
|
||||||
$coins = $this->bscCoins($addr);
|
$coins = $this->bscCoins($addr);
|
||||||
if ($coins === null) {
|
if ($coins === null) {
|
||||||
@@ -104,6 +226,7 @@ class WalletBalanceService
|
|||||||
'address' => $addr,
|
'address' => $addr,
|
||||||
'bnb' => $address->bnb,
|
'bnb' => $address->bnb,
|
||||||
'usdt' => $address->usdt,
|
'usdt' => $address->usdt,
|
||||||
|
'source' => 'rpc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -227,6 +350,7 @@ class WalletBalanceService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver (mempool.space).
|
||||||
try {
|
try {
|
||||||
$driver = $this->chains->resolve('btc');
|
$driver = $this->chains->resolve('btc');
|
||||||
$this->persistCoins($address, [
|
$this->persistCoins($address, [
|
||||||
@@ -237,6 +361,7 @@ class WalletBalanceService
|
|||||||
'wallet_address_id' => $address->id,
|
'wallet_address_id' => $address->id,
|
||||||
'address' => $addr,
|
'address' => $addr,
|
||||||
'btc' => $address->btc,
|
'btc' => $address->btc,
|
||||||
|
'source' => 'rpc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -257,6 +382,30 @@ class WalletBalanceService
|
|||||||
return false;
|
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 {
|
try {
|
||||||
$driver = $this->chains->resolve('sol');
|
$driver = $this->chains->resolve('sol');
|
||||||
$attrs = [
|
$attrs = [
|
||||||
@@ -273,6 +422,7 @@ class WalletBalanceService
|
|||||||
'address' => $addr,
|
'address' => $addr,
|
||||||
'sol' => $address->sol,
|
'sol' => $address->sol,
|
||||||
'usdt' => $address->usdt,
|
'usdt' => $address->usdt,
|
||||||
|
'source' => 'rpc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -444,4 +594,5 @@ class WalletBalanceService
|
|||||||
|
|
||||||
return $human === '' ? '0' : $human;
|
return $human === '' ? '0' : $human;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,25 @@ return [
|
|||||||
'api_key' => env('TOKENVIEW_API_KEY', ''),
|
'api_key' => env('TOKENVIEW_API_KEY', ''),
|
||||||
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
|
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
|
||||||
'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'),
|
'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' => [
|
'tron' => [
|
||||||
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
|
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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_mnemonics', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
<!-- Keep outbound integrations off unless a test opts in via config()/Http::fake. -->
|
<!-- Keep outbound integrations off unless a test opts in via config()/Http::fake. -->
|
||||||
<env name="TOKENVIEW_API_KEY" value=""/>
|
<env name="TOKENVIEW_API_KEY" value=""/>
|
||||||
<env name="TOKENVIEW_SIGN_KEY" value=""/>
|
<env name="TOKENVIEW_SIGN_KEY" value=""/>
|
||||||
|
<env name="TOKENVIEW_DATA_API_KEY" value=""/>
|
||||||
|
<env name="ALCHEMY_API_KEY" value=""/>
|
||||||
<env name="TELEGRAM_BOT_TOKEN" value=""/>
|
<env name="TELEGRAM_BOT_TOKEN" value=""/>
|
||||||
<env name="TELEGRAM_OWNER_CHAT_ID" value=""/>
|
<env name="TELEGRAM_OWNER_CHAT_ID" value=""/>
|
||||||
<env name="TRON_API_KEY" value=""/>
|
<env name="TRON_API_KEY" value=""/>
|
||||||
|
|||||||
@@ -202,20 +202,26 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
var versionBlock = (isAdmin && creating)
|
var versionBlock = (isAdmin && creating)
|
||||||
? '<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">' +
|
||||||
'<select name="builder_type" id="LAY-ch-builder-type" lay-filter="LAY-ch-builder-type">' +
|
'<select name="builder_type" id="LAY-ch-builder-type" lay-filter="LAY-ch-builder-type">' +
|
||||||
'<option value="new"' + (!isApp ? ' selected' : '') + '>新版(生成静态资源)</option>' +
|
'<option value="new"' + (!isApp && values.builder_type !== 'old' ? ' selected' : '') + '>新版(生成静态资源)</option>' +
|
||||||
|
'<option value="old"' + (values.builder_type === 'old' ? ' selected' : '') + '>旧版(生成静态资源 / web 目录)</option>' +
|
||||||
'<option value="app"' + (isApp ? ' selected' : '') + '>App(仅记录,UUID 渠道 ID)</option>' +
|
'<option value="app"' + (isApp ? ' selected' : '') + '>App(仅记录,UUID 渠道 ID)</option>' +
|
||||||
'</select></div></div>'
|
'</select></div></div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 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
|
var channelBlock = creating
|
||||||
? (isApp
|
? (isApp
|
||||||
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
||||||
'<input name="channel_id" class="layui-input" readonly id="LAY-ch-id-input" value="' + (values.channel_id || '') + '" placeholder="自动生成 UUID">' +
|
'<input name="channel_id" class="layui-input" readonly id="LAY-ch-id-input" value="' + (values.channel_id || '') + '" placeholder="自动生成 UUID">' +
|
||||||
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
|
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
|
||||||
|
: (isOld
|
||||||
|
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
||||||
|
'<input name="channel_id" class="layui-input" readonly id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="自动生成 32 位 hex">' +
|
||||||
|
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
|
||||||
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
||||||
'<input name="channel_id" class="layui-input" maxlength="6" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="例如 A.B.C1 或 3.1.07">' +
|
'<input name="channel_id" class="layui-input" maxlength="6" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="例如 A.B.C1 或 3.1.07">' +
|
||||||
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>')
|
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'))
|
||||||
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
|
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
|
||||||
|
|
||||||
// App-specific fields: app_name + bundle_id
|
// App-specific fields: app_name + bundle_id
|
||||||
@@ -246,7 +252,7 @@ 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>' +
|
||||||
(creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新版将调用 builder 生成静态资源;App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') +
|
(creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新版/旧版均调用 builder 生成静态资源(新版→channel/ 目录,旧版→web/ 目录);App 仅创建数据库记录。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') +
|
||||||
'</form>',
|
'</form>',
|
||||||
success: function () {
|
success: function () {
|
||||||
form.render();
|
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);
|
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) {
|
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-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');
|
var idInput = $('#LAY-ch-id-input');
|
||||||
if (isAppNow) {
|
if (isAppNow) {
|
||||||
idInput.attr('readonly', true).attr('maxlength', '').attr('placeholder', '自动生成 UUID');
|
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);
|
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 {
|
} else {
|
||||||
idInput.removeAttr('readonly').attr('maxlength', '6').attr('placeholder', '例如 A.B.C1 或 3.1.07').val('');
|
idInput.removeAttr('readonly').attr('maxlength', '6').attr('placeholder', '例如 A.B.C1 或 3.1.07').val('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
html += '<div class="mn-wallet-body"><table class="mn-wallet-table"><thead><tr>' +
|
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>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><th>SOL</th>' +
|
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th><th>SOL</th>' +
|
||||||
|
'<th>操作</th>' +
|
||||||
'</tr></thead><tbody>';
|
'</tr></thead><tbody>';
|
||||||
rows.forEach(function (r) {
|
rows.forEach(function (r) {
|
||||||
var inactive = r.active === false;
|
var inactive = r.active === false;
|
||||||
@@ -193,6 +194,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
|
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
|
||||||
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
|
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
|
||||||
'<td class="coin-cell">' + coinVal(r.sol) + '</td>' +
|
'<td class="coin-cell">' + coinVal(r.sol) + '</td>' +
|
||||||
|
'<td>—</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
});
|
});
|
||||||
html += '</tbody></table></div></div>';
|
html += '</tbody></table></div></div>';
|
||||||
|
|||||||
@@ -367,8 +367,11 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[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']);
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
@@ -377,9 +380,40 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
'builder_type' => Channel::BUILDER_OLD,
|
'builder_type' => Channel::BUILDER_OLD,
|
||||||
'user_id' => 0,
|
'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');
|
->assertSessionHasErrors('channel_id');
|
||||||
|
|
||||||
$this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]);
|
$this->assertDatabaseMissing('channels', ['channel_id' => self::NEW_CHANNEL_ID]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user