isValidName($name)) { return response()->json([ 'available' => false, 'reason' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.', ]); } $alias = $this->rpc->getAliasByName($name); $reserved = Cache::has("name_lock:{$name}"); return response()->json([ 'name' => $name, 'available' => $alias === null && ! $reserved, 'reserved' => $reserved, 'fqdn' => "{$name}.lthn", ]); } /** * GET /v1/names/lookup/charon */ public function lookup(string $name): JsonResponse { $alias = $this->rpc->getAliasByName(strtolower(trim($name))); if (! $alias) { return response()->json(['error' => 'Name not registered'], 404); } return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'address' => $alias['address'] ?? '', 'comment' => $alias['comment'] ?? '', 'registered' => true, ]); } /** * GET /v1/names/search?q=gate */ public function search(Request $request): JsonResponse { $query = strtolower(trim((string) $request->get('q'))); $result = $this->rpc->getAllAliases(); $aliases = $result['aliases'] ?? []; $matches = array_filter($aliases, function ($alias) use ($query) { return str_contains($alias['alias'] ?? '', $query) || str_contains($alias['comment'] ?? '', $query); }); return response()->json([ 'query' => $query, 'results' => array_values($matches), 'count' => count($matches), ]); } /** * GET /v1/names/directory */ public function directory(): JsonResponse { $result = $this->rpc->getAllAliases(); $aliases = $result['aliases'] ?? []; // Group by type from comment metadata $grouped = ['gateway' => [], 'service' => [], 'exit' => [], 'reserved' => [], 'user' => []]; foreach ($aliases as $alias) { $comment = $alias['comment'] ?? ''; if (str_contains($comment, 'type=gateway')) { $grouped['gateway'][] = $alias; } elseif (str_contains($comment, 'type=exit')) { $grouped['exit'][] = $alias; } elseif (str_contains($comment, 'type=service')) { $grouped['service'][] = $alias; } elseif (str_contains($comment, 'type=reserved')) { $grouped['reserved'][] = $alias; } else { $grouped['user'][] = $alias; } } return response()->json([ 'total' => count($aliases), 'directory' => $grouped, ]); } /** * POST /v1/names/register {"name": "mysite", "address": "iTHN..."} */ public function register(Request $request): JsonResponse { $name = strtolower(trim((string) $request->input('name'))); $address = trim((string) $request->input('address')); $comment = trim((string) $request->input('comment', 'v=lthn1;type=user')); if (! $this->isValidName($name)) { return response()->json([ 'error' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.', ], 422); } if (empty($address) || ! str_starts_with($address, 'iTHN')) { return response()->json([ 'error' => 'Invalid Lethean address.', ], 422); } // Pre-flight: check wallet has funds $balance = $this->wallet->getBalance(); $unlocked = ($balance['unlocked_balance'] ?? 0) / 1e12; if ($unlocked < 0.01) { return response()->json([ 'error' => 'Registrar wallet has insufficient funds. Please try again later.', 'name' => $name, ], 503); } // Check availability on chain + reservation lock $existing = $this->rpc->getAliasByName($name); if ($existing !== null) { return response()->json([ 'error' => 'Name already registered.', 'name' => $name, ], 409); } // Atomic reservation — prevent race condition $lockKey = "name_lock:{$name}"; if (Cache::has($lockKey)) { return response()->json([ 'error' => 'This name is being registered by another customer. Please try a different name.', 'name' => $name, ], 409); } Cache::put($lockKey, true, 600); // 10 minute reservation // Register via wallet RPC $result = $this->wallet->registerAlias($name, $address, $comment); if (isset($result['code']) || isset($result['error'])) { $message = $result['message'] ?? ($result['error'] ?? 'Unknown error'); $code = 502; if (str_contains($message, 'NOT_ENOUGH_MONEY')) { $message = 'Registrar wallet has insufficient funds. Please try again later.'; $code = 503; } // Release lock on permanent failure so name can be retried Cache::forget($lockKey); return response()->json([ 'error' => $message, 'name' => $name, ], $code); } if (isset($result['tx_id'])) { return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'address' => $address, 'tx_id' => $result['tx_id'], 'status' => 'pending', ], 201); } return response()->json([ 'error' => 'Unexpected response from chain', 'details' => $result, ], 500); } /** * GET /v1/names/records/charon * * Reads DNS records from the LNS sidechain. */ public function records(string $name): JsonResponse { $name = strtolower(trim($name)); $lnsUrl = config('chain.lns_url', 'http://127.0.0.1:5553'); $records = []; foreach (['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'] as $type) { $response = @file_get_contents("{$lnsUrl}/resolve?name={$name}&type={$type}"); if ($response) { $data = json_decode($response, true); if (! empty($data[$type])) { foreach ($data[$type] as $value) { $records[] = [ 'type' => $type, 'host' => '@', 'value' => $value, 'ttl' => 3600, ]; } } } } return response()->json([ 'name' => $name, 'fqdn' => "{$name}.lthn", 'records' => $records, ]); } /** * POST /v1/names/records/charon {"records": [{"type":"A","host":"@","value":"1.2.3.4"}]} * * Updates DNS records by calling update_alias on the wallet RPC. * Encodes records into the alias comment field for LNS to parse. */ public function updateRecords(Request $request, string $name): JsonResponse { $name = strtolower(trim($name)); $records = $request->input('records', []); // Look up the current alias to get the address $alias = $this->rpc->getAliasByName($name); if (! $alias) { return response()->json(['error' => 'Name not registered'], 404); } $address = $alias['address'] ?? ''; // Build the comment with DNS records encoded for LNS // Format: v=lthn1;type=user;dns=TYPE:HOST:VALUE,TYPE:HOST:VALUE $dnsEntries = []; foreach ($records as $record) { $type = $record['type'] ?? 'A'; $host = $record['host'] ?? '@'; $value = $record['value'] ?? ''; if ($value) { $dnsEntries[] = "{$type}:{$host}:{$value}"; } } $comment = 'v=lthn1;type=user'; if ($dnsEntries) { $comment .= ';dns=' . implode(',', $dnsEntries); } // Try to update the alias on chain via wallet RPC $result = $this->wallet->updateAlias($name, $address, $comment); $ticketId = substr(md5($name . time()), 0, 12); // Track ticket ID for background retry $ticketIds = Cache::get('dns_ticket_ids', []); $ticketIds[] = $ticketId; Cache::put('dns_ticket_ids', array_unique($ticketIds), 86400); if (isset($result['tx_id'])) { // Immediate success — tx submitted Cache::put("dns_ticket:{$ticketId}", [ 'name' => $name, 'status' => 'pending', 'tx_id' => $result['tx_id'], 'records' => $records, 'created_at' => now()->toIso8601String(), ], 3600); return response()->json([ 'name' => $name, 'ticket' => $ticketId, 'tx_id' => $result['tx_id'], 'status' => 'pending', 'message' => 'DNS update submitted. Awaiting chain confirmation.', ]); } // Chain busy — queue for retry Cache::put("dns_ticket:{$ticketId}", [ 'name' => $name, 'status' => 'queued', 'records' => $records, 'comment' => $comment, 'address' => $address, 'error' => $result['message'] ?? ($result['error'] ?? 'Chain busy'), 'created_at' => now()->toIso8601String(), ], 3600); return response()->json([ 'name' => $name, 'ticket' => $ticketId, 'status' => 'queued', 'message' => 'DNS update queued. Will be processed when chain is ready.', ], 202); } /** * GET /v1/names/ticket/{id} * * Check the status of a DNS change ticket. */ public function ticket(string $id): JsonResponse { $ticket = Cache::get("dns_ticket:{$id}"); if (! $ticket) { return response()->json(['error' => 'Ticket not found'], 404); } // If pending, check if the tx has confirmed if ($ticket['status'] === 'pending' && ! empty($ticket['tx_id'])) { $alias = $this->rpc->getAliasByName($ticket['name']); if ($alias && str_contains($alias['comment'] ?? '', 'dns=')) { $ticket['status'] = 'confirmed'; Cache::put("dns_ticket:{$id}", $ticket, 3600); } } return response()->json([ 'ticket' => $id, 'name' => $ticket['name'], 'status' => $ticket['status'], 'tx_id' => $ticket['tx_id'] ?? null, 'created_at' => $ticket['created_at'] ?? null, ]); } /** * GET /v1/names/health * * Registrar health — wallet balance, chain status, readiness. */ public function health(): JsonResponse { $balance = $this->wallet->getBalance(); $info = $this->rpc->getInfo(); $walletOffline = isset($balance['error']); $daemonOffline = isset($info['_offline']); $daemonStale = isset($info['_stale']); $unlocked = ($balance['unlocked_balance'] ?? 0) / 1e12; $fee = 0.01; $registrationsRemaining = (int) floor($unlocked / $fee); $lowFunds = $registrationsRemaining < 10; $criticalFunds = $registrationsRemaining < 2; $status = 'healthy'; if ($daemonOffline || $walletOffline) { $status = 'offline'; } elseif ($criticalFunds) { $status = 'critical'; } elseif ($lowFunds) { $status = 'low_funds'; } elseif ($daemonStale) { $status = 'degraded'; } $httpCode = match ($status) { 'offline' => 503, 'critical' => 503, 'degraded' => 200, default => 200, }; return response()->json([ 'status' => $status, 'registrar' => [ 'balance' => round($unlocked, 4), 'registrations_remaining' => $registrationsRemaining, 'low_funds' => $lowFunds, 'wallet_online' => ! $walletOffline, ], 'chain' => [ 'height' => $info['height'] ?? 0, 'aliases' => $info['alias_count'] ?? 0, 'pool_size' => $info['tx_pool_size'] ?? 0, 'synced' => ($info['daemon_network_state'] ?? 0) == 2, 'daemon_online' => ! $daemonOffline, 'stale' => $daemonStale, ], ], $httpCode); } private function isValidName(string $name): bool { return (bool) preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $name); } }