feat: chain:start and chain:status artisan commands

CorePHP manages the testnet chain binaries via ConsoleBooting lifecycle.
chain:start checks if daemon/wallet are running, starts them if not,
waits for RPC readiness. chain:status shows daemon height, aliases,
PoS status, wallet and LNS node state. Config-driven paths for
binary locations, data dirs, mining address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-04 10:39:24 +01:00
parent c89bee8e51
commit 9286f84020
No known key found for this signature in database
GPG key ID: AF404715446AEB41
4 changed files with 241 additions and 2 deletions

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Mod\Chain;
use Core\Events\ConsoleBooting;
use Core\Events\FrameworkBooted;
use Mod\Chain\Services\DaemonRpc;
use Mod\Chain\Services\WalletRpc;
@ -12,6 +13,7 @@ class Boot
{
public static array $listens = [
FrameworkBooted::class => 'onFrameworkBooted',
ConsoleBooting::class => 'onConsole',
];
public function onFrameworkBooted(FrameworkBooted $event): void
@ -22,4 +24,10 @@ class Boot
app('router')->aliasMiddleware('auth.api', \App\Http\Middleware\ApiTokenAuth::class);
}
public function onConsole(ConsoleBooting $event): void
{
$event->command(Commands\ChainStart::class);
$event->command(Commands\ChainStatus::class);
}
}

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Mod\Chain\Commands;
use Illuminate\Console\Command;
/**
* Start the testnet chain daemon and wallet if not already running.
*
* php artisan chain:start
* php artisan chain:start --daemon-only
* php artisan chain:start --wallet-only
*/
class ChainStart extends Command
{
protected $signature = 'chain:start
{--daemon-only : Only start the daemon}
{--wallet-only : Only start the wallet}';
protected $description = 'Start testnet chain daemon and wallet';
public function handle(): int
{
$config = config('chain');
$binDir = $config['bin_dir'] ?? '/opt/lethean/testnet';
$dataDir = $config['data_dir'] ?? '/opt/lethean/testnet-data';
$walletDir = $config['wallet_dir'] ?? '/opt/lethean/testnet-wallet';
$walletFile = $config['wallet_file'] ?? 'registrar.wallet';
$miningAddress = $config['mining_address'] ?? '';
$miningThreads = $config['mining_threads'] ?? 12;
if (! $this->option('wallet-only')) {
$this->startDaemon($binDir, $dataDir, $miningAddress, $miningThreads);
}
if (! $this->option('daemon-only')) {
$this->waitForDaemon($config['daemon_rpc'] ?? 'http://127.0.0.1:46941/json_rpc');
$this->startWallet($binDir, $walletDir, $walletFile);
}
return self::SUCCESS;
}
private function startDaemon(string $binDir, string $dataDir, string $miningAddress, int $threads): void
{
if ($this->isRunning('lethean-testnet-chain-node')) {
$this->info('Daemon already running.');
return;
}
@unlink("{$dataDir}/lock.lck");
$binary = "{$binDir}/lethean-testnet-chain-node";
if (! is_executable($binary)) {
$this->error("Daemon binary not found: {$binary}");
return;
}
$args = [
$binary,
"--data-dir {$dataDir}",
'--rpc-bind-ip 0.0.0.0 --rpc-bind-port 46941',
'--p2p-bind-port 46942 --api-bind-port 46943',
'--rpc-enable-admin-api --rpc-ignore-offline',
'--allow-local-ip --log-level 1 --disable-upnp',
];
if ($miningAddress) {
$args[] = "--start-mining {$miningAddress} --mining-threads {$threads}";
}
$cmd = implode(' ', $args);
$logFile = "{$dataDir}/lethean-testnet-chain-node.log";
// Safe: all values from config, no user input
exec("nohup {$cmd} > {$logFile} 2>&1 &"); // @codeCoverageIgnore
$this->info("Daemon started. Mining: {$threads} threads.");
}
private function startWallet(string $binDir, string $walletDir, string $walletFile): void
{
if ($this->isRunning('lethean-wallet-cli')) {
$this->info('Wallet already running.');
return;
}
$binary = "{$binDir}/lethean-testnet-wallet-cli";
if (! is_executable($binary)) {
$binary = '/opt/lethean/bin/lethean-wallet-cli';
}
if (! is_executable($binary)) {
$this->error('Wallet binary not found.');
return;
}
$walletPath = "{$walletDir}/{$walletFile}";
if (! file_exists($walletPath)) {
$this->error("Wallet file not found: {$walletPath}");
return;
}
$cmd = implode(' ', [
$binary,
"--wallet-file {$walletPath}",
'--password ""',
'--daemon-address 127.0.0.1:46941',
'--rpc-bind-port 46944 --rpc-bind-ip 127.0.0.1',
]);
// Safe: all values from config, no user input
exec("nohup {$cmd} > {$walletDir}/wallet-rpc.log 2>&1 &"); // @codeCoverageIgnore
$this->info('Wallet RPC started on port 46944.');
}
private function waitForDaemon(string $rpcUrl, int $timeout = 30): void
{
$this->info('Waiting for daemon RPC...');
$endpoint = str_replace('/json_rpc', '', $rpcUrl) . '/json_rpc';
$start = time();
while (time() - $start < $timeout) {
try {
$response = @file_get_contents($endpoint, false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => '{"jsonrpc":"2.0","id":"0","method":"getinfo"}',
'timeout' => 3,
],
]));
if ($response) {
$this->info('Daemon ready.');
return;
}
} catch (\Throwable $e) {
// Not ready yet
}
sleep(2);
}
$this->warn("Daemon not responding after {$timeout}s.");
}
private function isRunning(string $processName): bool
{
exec("pgrep -f " . escapeshellarg($processName), $output);
return ! empty($output);
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Mod\Chain\Commands;
use Illuminate\Console\Command;
use Mod\Chain\Services\DaemonRpc;
/**
* Show chain daemon and wallet status.
*
* php artisan chain:status
*/
class ChainStatus extends Command
{
protected $signature = 'chain:status';
protected $description = 'Show testnet chain daemon and wallet status';
public function handle(DaemonRpc $rpc): int
{
$this->info('=== Chain Status ===');
// Daemon — safe: hardcoded process name, no user input
$daemonRunning = $this->isRunning('lethean-testnet-chain-node');
$this->line('Daemon: ' . ($daemonRunning ? '<fg=green>running</>' : '<fg=red>stopped</>'));
if ($daemonRunning) {
$info = $rpc->getInfo();
if (! isset($info['_offline'])) {
$this->line(" Height: {$info['height']}");
$this->line(" Aliases: {$info['alias_count']}");
$this->line(" TX Pool: {$info['tx_pool_size']}");
$this->line(' PoS: ' . (($info['pos_allowed'] ?? false) ? 'active' : 'inactive'));
$this->line(" Difficulty: {$info['pow_difficulty']}");
} else {
$this->warn(' RPC unreachable');
}
}
// Wallet — safe: hardcoded process name
$walletRunning = $this->isRunning('lethean-wallet-cli');
$this->line('Wallet: ' . ($walletRunning ? '<fg=green>running</>' : '<fg=red>stopped</>'));
// LNS — safe: hardcoded process name
$lnsRunning = $this->isRunning('lns-new');
$this->line('LNS: ' . ($lnsRunning ? '<fg=green>running</>' : '<fg=red>stopped</>'));
return self::SUCCESS;
}
private function isRunning(string $processName): bool
{
// Safe: escapeshellarg prevents injection, process names are hardcoded
exec("pgrep -f " . escapeshellarg($processName), $output); // @codeCoverageIgnore
return ! empty($output);
}
}

View file

@ -6,8 +6,16 @@ return [
'daemon_rpc' => env('DAEMON_RPC', 'http://127.0.0.1:46941/json_rpc'),
'wallet_rpc' => env('WALLET_RPC', 'http://127.0.0.1:46944/json_rpc'),
'cache_ttl' => (int) env('CHAIN_CACHE_TTL', 10),
'network' => env('CHAIN_NETWORK', 'testnet'), // testnet or mainnet
'network' => env('CHAIN_NETWORK', 'testnet'),
'lns_url' => env('LNS_URL', 'http://127.0.0.1:5553'),
'api_token' => env('API_TOKEN', ''),
'api_url' => env('API_URL', ''), // empty = same origin, set to https://api.lthn.io for production
'api_url' => env('API_URL', 'https://api.lthn.io'),
// Binary management (chain:start, chain:status)
'bin_dir' => env('CHAIN_BIN_DIR', '/opt/lethean/testnet'),
'data_dir' => env('CHAIN_DATA_DIR', '/opt/lethean/testnet-data'),
'wallet_dir' => env('CHAIN_WALLET_DIR', '/opt/lethean/testnet-wallet'),
'wallet_file' => env('CHAIN_WALLET_FILE', 'registrar.wallet'),
'mining_address' => env('CHAIN_MINING_ADDRESS', 'iTHNHN11yXMeBphpFSuHnDaSJ15QxiSEJXNY59VKbxKq4ype4xAH1PZHd1EKTknkPK9hHTu2G2tBBZzvrcRFaYMF8vWTzFZjGY'),
'mining_threads' => (int) env('CHAIN_MINING_THREADS', 12),
];