2026-04-03 16:13:55 +01:00
< ? php
declare ( strict_types = 1 );
namespace Mod\Names\Controllers ;
use Illuminate\Http\JsonResponse ;
use Illuminate\Http\Request ;
use Illuminate\Routing\Controller ;
2026-04-04 02:06:38 +01:00
use Illuminate\Support\Facades\Cache ;
2026-04-04 08:08:18 +01:00
use Illuminate\Support\Facades\Http ;
2026-04-03 16:13:55 +01:00
use Mod\Chain\Services\DaemonRpc ;
2026-04-03 23:04:27 +01:00
use Mod\Chain\Services\WalletRpc ;
2026-04-04 11:21:58 +01:00
use Mod\Names\Models\NameClaim ;
2026-04-03 16:13:55 +01:00
/**
* . lthn TLD registrar API .
*
* GET / v1 / names / available / { name } — check if name is available
* GET / v1 / names / lookup / { name } — look up a registered name
* GET / v1 / names / search ? q = { query } — search names
2026-04-03 23:04:27 +01:00
* POST / v1 / names / register — request name registration
* GET / v1 / names / directory — list all names grouped by type
2026-04-03 16:13:55 +01:00
*/
class NamesController extends Controller
{
public function __construct (
private readonly DaemonRpc $rpc ,
2026-04-03 23:04:27 +01:00
private readonly WalletRpc $wallet ,
2026-04-03 16:13:55 +01:00
) {}
/**
* GET / v1 / names / available / myname
*/
public function available ( string $name ) : JsonResponse
{
$name = strtolower ( trim ( $name ));
if ( ! $this -> isValidName ( $name )) {
return response () -> json ([
'available' => false ,
'reason' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.' ,
]);
}
$alias = $this -> rpc -> getAliasByName ( $name );
2026-04-04 02:55:57 +01:00
$reserved = Cache :: has ( " name_lock: { $name } " );
2026-04-03 16:13:55 +01:00
return response () -> json ([
'name' => $name ,
2026-04-04 02:55:57 +01:00
'available' => $alias === null && ! $reserved ,
'reserved' => $reserved ,
2026-04-03 16:13:55 +01:00
'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 ,
]);
}
2026-04-03 23:04:27 +01:00
/**
* 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 );
}
2026-04-04 02:52:34 +01:00
// 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 );
}
2026-04-04 02:55:57 +01:00
// Check availability on chain + reservation lock
2026-04-03 23:04:27 +01:00
$existing = $this -> rpc -> getAliasByName ( $name );
if ( $existing !== null ) {
return response () -> json ([
'error' => 'Name already registered.' ,
'name' => $name ,
], 409 );
}
2026-04-04 02:55:57 +01:00
// Atomic reservation — prevent race condition
$lockKey = " name_lock: { $name } " ;
2026-04-04 08:08:18 +01:00
if ( ! Cache :: add ( $lockKey , true , 600 )) {
2026-04-04 02:55:57 +01:00
return response () -> json ([
'error' => 'This name is being registered by another customer. Please try a different name.' ,
'name' => $name ,
], 409 );
}
2026-04-03 23:04:27 +01:00
// 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 ;
}
2026-04-04 02:55:57 +01:00
// Release lock on permanent failure so name can be retried
Cache :: forget ( $lockKey );
2026-04-03 23:04:27 +01:00
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 );
}
2026-04-04 00:53:49 +01:00
/**
* 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 ) {
2026-04-04 08:08:18 +01:00
try {
$response = Http :: timeout ( 3 ) -> get ( " { $lnsUrl } /resolve " , [ 'name' => $name , 'type' => $type ]);
$data = $response -> successful () ? $response -> json () : null ;
} catch ( \Throwable ) {
$data = null ;
}
if ( $data ) {
2026-04-04 00:53:49 +01:00
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' , []);
2026-04-04 03:19:01 +01:00
// Per-name lock prevents concurrent edits from overwriting each other
$editLock = " dns_edit_lock: { $name } " ;
if ( Cache :: has ( $editLock )) {
return response () -> json ([
'error' => 'A DNS update for this name is already pending. Please wait for chain confirmation.' ,
'name' => $name ,
], 409 );
}
2026-04-04 00:53:49 +01:00
// 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
2026-04-04 03:17:09 +01:00
// Format: v=lthn1;type=user;dns=TYPE:HOST:VALUE|TYPE:HOST:VALUE
// Uses pipe separator (not comma) — commas can appear in TXT values
2026-04-04 00:53:49 +01:00
$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 ) {
2026-04-04 03:17:09 +01:00
$comment .= ';dns=' . implode ( '|' , $dnsEntries );
2026-04-04 00:53:49 +01:00
}
2026-04-04 02:06:38 +01:00
// Try to update the alias on chain via wallet RPC
2026-04-04 00:53:49 +01:00
$result = $this -> wallet -> updateAlias ( $name , $address , $comment );
2026-04-04 08:08:18 +01:00
$ticketId = bin2hex ( random_bytes ( 6 ));
2026-04-04 02:06:38 +01:00
2026-04-04 02:41:29 +01:00
// Track ticket ID for background retry
$ticketIds = Cache :: get ( 'dns_ticket_ids' , []);
$ticketIds [] = $ticketId ;
Cache :: put ( 'dns_ticket_ids' , array_unique ( $ticketIds ), 86400 );
2026-04-04 02:06:38 +01:00
if ( isset ( $result [ 'tx_id' ])) {
2026-04-04 03:19:01 +01:00
// Lock this name for edits until block confirms (5 min TTL)
Cache :: put ( $editLock , $ticketId , 300 );
2026-04-04 02:06:38 +01:00
Cache :: put ( " dns_ticket: { $ticketId } " , [
'name' => $name ,
'status' => 'pending' ,
'tx_id' => $result [ 'tx_id' ],
'records' => $records ,
'created_at' => now () -> toIso8601String (),
], 3600 );
2026-04-04 00:53:49 +01:00
return response () -> json ([
'name' => $name ,
2026-04-04 02:06:38 +01:00
'ticket' => $ticketId ,
'tx_id' => $result [ 'tx_id' ],
'status' => 'pending' ,
'message' => 'DNS update submitted. Awaiting chain confirmation.' ,
]);
2026-04-04 00:53:49 +01:00
}
2026-04-04 02:06:38 +01:00
// Chain busy — queue for retry
Cache :: put ( " dns_ticket: { $ticketId } " , [
2026-04-04 00:53:49 +01:00
'name' => $name ,
2026-04-04 02:06:38 +01:00
'status' => 'queued' ,
2026-04-04 00:53:49 +01:00
'records' => $records ,
2026-04-04 02:06:38 +01:00
'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 );
2026-04-04 03:19:01 +01:00
Cache :: forget ( " dns_edit_lock: { $ticket [ 'name' ] } " );
2026-04-04 02:06:38 +01:00
}
}
return response () -> json ([
'ticket' => $id ,
'name' => $ticket [ 'name' ],
'status' => $ticket [ 'status' ],
'tx_id' => $ticket [ 'tx_id' ] ? ? null ,
'created_at' => $ticket [ 'created_at' ] ? ? null ,
2026-04-04 00:53:49 +01:00
]);
}
2026-04-04 02:52:34 +01:00
/**
* GET / v1 / names / health
*
* Registrar health — wallet balance , chain status , readiness .
*/
public function health () : JsonResponse
{
$balance = $this -> wallet -> getBalance ();
$info = $this -> rpc -> getInfo ();
2026-04-04 03:06:23 +01:00
$walletOffline = isset ( $balance [ 'error' ]);
$daemonOffline = isset ( $info [ '_offline' ]);
$daemonStale = isset ( $info [ '_stale' ]);
2026-04-04 02:52:34 +01:00
$unlocked = ( $balance [ 'unlocked_balance' ] ? ? 0 ) / 1e12 ;
2026-04-04 03:06:23 +01:00
$fee = 0.01 ;
2026-04-04 02:52:34 +01:00
$registrationsRemaining = ( int ) floor ( $unlocked / $fee );
$lowFunds = $registrationsRemaining < 10 ;
$criticalFunds = $registrationsRemaining < 2 ;
$status = 'healthy' ;
2026-04-04 03:06:23 +01:00
if ( $daemonOffline || $walletOffline ) {
$status = 'offline' ;
} elseif ( $criticalFunds ) {
2026-04-04 02:52:34 +01:00
$status = 'critical' ;
} elseif ( $lowFunds ) {
$status = 'low_funds' ;
2026-04-04 03:06:23 +01:00
} elseif ( $daemonStale ) {
$status = 'degraded' ;
2026-04-04 02:52:34 +01:00
}
2026-04-04 03:06:23 +01:00
$httpCode = match ( $status ) {
'offline' => 503 ,
'critical' => 503 ,
'degraded' => 200 ,
default => 200 ,
};
2026-04-04 02:52:34 +01:00
return response () -> json ([
'status' => $status ,
'registrar' => [
'balance' => round ( $unlocked , 4 ),
'registrations_remaining' => $registrationsRemaining ,
'low_funds' => $lowFunds ,
2026-04-04 03:06:23 +01:00
'wallet_online' => ! $walletOffline ,
2026-04-04 02:52:34 +01:00
],
'chain' => [
'height' => $info [ 'height' ] ? ? 0 ,
'aliases' => $info [ 'alias_count' ] ? ? 0 ,
'pool_size' => $info [ 'tx_pool_size' ] ? ? 0 ,
'synced' => ( $info [ 'daemon_network_state' ] ? ? 0 ) == 2 ,
2026-04-04 03:06:23 +01:00
'daemon_online' => ! $daemonOffline ,
'stale' => $daemonStale ,
2026-04-04 02:52:34 +01:00
],
2026-04-04 03:06:23 +01:00
], $httpCode );
2026-04-04 02:52:34 +01:00
}
2026-04-04 06:28:58 +01:00
/**
* GET / v1 / names / sunrise / verify / { name }
*
* Generate a verification token for a sunrise claim .
* The brand adds this as a DNS TXT record to prove domain ownership .
*/
public function sunriseVerify ( string $name ) : JsonResponse
{
$name = strtolower ( trim ( $name ));
// Check name is reserved
$alias = $this -> rpc -> getAliasByName ( $name );
if ( ! $alias || ! str_contains ( $alias [ 'comment' ] ? ? '' , 'type=reserved' )) {
return response () -> json ([ 'error' => 'Name is not in the sunrise reservation list' ], 404 );
}
// Generate a deterministic verification token
$token = 'lthn-verify=' . substr ( hash ( 'sha256' , $name . config ( 'chain.api_token' , 'lthn' )), 0 , 32 );
return response () -> json ([
'name' => $name ,
'fqdn' => " { $name } .lthn " ,
'verification' => [
'method' => 'dns-txt' ,
2026-04-04 06:34:26 +01:00
'instruction' => " Add a TXT record to your domain's DNS to prove ownership " ,
2026-04-04 06:28:58 +01:00
'record_host' => " _lthn-verify. { $name } .com " ,
'record_type' => 'TXT' ,
'record_value' => $token ,
'example' => " _lthn-verify. { $name } .com. IN TXT \" { $token } \" " ,
'check_url' => " /v1/names/sunrise/check/ { $name } " ,
],
'alternative_domains' => [
" { $name } .com " ,
" { $name } .org " ,
" { $name } .net " ,
" { $name } .io " ,
" { $name } .co.uk " ,
],
2026-04-04 06:34:26 +01:00
'claim_process' => [
'step_1' => 'Add DNS TXT record to verify domain ownership' ,
'step_2' => 'Call check endpoint to confirm verification' ,
'step_3' => 'Purchase the name via https://order.lthn.ai' ,
'step_4' => 'Name transferred to your wallet with full DNS control' ,
],
'ownership_tiers' => [
'free' => 'Registry holds private key. Limited DNS records. No wallet transfer.' ,
'paid' => 'Your wallet, your key. Expanded DNS record limits. Full sovereignty.' ,
],
'purchase_url' => 'https://order.lthn.ai/order/' ,
2026-04-04 06:28:58 +01:00
]);
}
/**
* GET / v1 / names / sunrise / check / { name }
*
* Check if a sunrise verification TXT record has been added .
* Looks up _lthn - verify . { name } . com for the expected token .
*/
public function sunriseCheck ( string $name ) : JsonResponse
{
$name = strtolower ( trim ( $name ));
$expectedToken = 'lthn-verify=' . substr ( hash ( 'sha256' , $name . config ( 'chain.api_token' , 'lthn' )), 0 , 32 );
$verified = false ;
$checkedDomains = [];
foreach ([ '.com' , '.org' , '.net' , '.io' , '.co.uk' ] as $tld ) {
$host = " _lthn-verify. { $name } { $tld } " ;
$records = @ dns_get_record ( $host , DNS_TXT ) ? : [];
$found = false ;
foreach ( $records as $record ) {
if (( $record [ 'txt' ] ? ? '' ) === $expectedToken ) {
$found = true ;
$verified = true ;
break ;
}
}
$checkedDomains [] = [
'domain' => " { $name } { $tld } " ,
'host' => $host ,
'verified' => $found ,
];
}
return response () -> json ([
'name' => $name ,
'fqdn' => " { $name } .lthn " ,
'verified' => $verified ,
'expected_token' => $expectedToken ,
'checked_domains' => $checkedDomains ,
'status' => $verified ? 'verified' : 'pending' ,
'next_steps' => $verified
2026-04-04 06:34:26 +01:00
? 'Domain ownership verified. Complete your claim by purchasing at https://order.lthn.ai — your name will be transferred to your wallet with full DNS control.'
2026-04-04 06:28:58 +01:00
: 'TXT record not found. Add the record and allow DNS propagation (up to 48h).' ,
2026-04-04 06:34:26 +01:00
'purchase_url' => $verified ? 'https://order.lthn.ai/order/' : null ,
2026-04-04 06:28:58 +01:00
]);
}
2026-04-04 08:30:23 +01:00
/**
* POST / v1 / names / claim { " name " : " mysite " , " email " : " user@example.com " }
*
* Pre - register a name claim . Queued for manual approval during soft launch .
*/
public function claim ( Request $request ) : JsonResponse
{
$name = strtolower ( trim (( string ) $request -> input ( 'name' )));
$email = trim (( string ) $request -> input ( 'email' ));
if ( ! $this -> isValidName ( $name )) {
return response () -> json ([ 'error' => 'Invalid name. Use 6+ lowercase alphanumeric characters.' ], 422 );
}
if ( strlen ( $name ) < 6 ) {
return response () -> json ([ 'error' => 'Name must be at least 6 characters.' ], 422 );
}
if ( empty ( $email ) || ! filter_var ( $email , FILTER_VALIDATE_EMAIL )) {
return response () -> json ([ 'error' => 'Valid email required for claim notification.' ], 422 );
}
2026-04-04 11:21:58 +01:00
// Check not already registered on chain
2026-04-04 08:30:23 +01:00
$alias = $this -> rpc -> getAliasByName ( $name );
if ( $alias !== null ) {
return response () -> json ([ 'error' => 'Name already registered.' , 'name' => $name ], 409 );
}
2026-04-04 11:21:58 +01:00
// Check not already claimed in database
if ( NameClaim :: where ( 'name' , $name ) -> exists ()) {
return response () -> json ([ 'error' => 'Name already claimed. Awaiting approval.' , 'name' => $name ], 409 );
2026-04-04 08:30:23 +01:00
}
2026-04-04 11:21:58 +01:00
$claim = NameClaim :: create ([
2026-04-04 08:30:23 +01:00
'name' => $name ,
'email' => $email ,
2026-04-04 11:21:58 +01:00
]);
2026-04-04 08:30:23 +01:00
return response () -> json ([
2026-04-04 11:21:58 +01:00
'claim_id' => $claim -> claim_id ,
2026-04-04 08:30:23 +01:00
'name' => $name ,
'fqdn' => " { $name } .lthn " ,
'status' => 'pending' ,
'message' => 'Your claim has been submitted. We will notify you at ' . $email . ' when approved.' ,
], 201 );
}
/**
* GET / v1 / names / claims
*
* List all pending claims ( admin only ) .
*/
public function listClaims () : JsonResponse
{
2026-04-04 11:21:58 +01:00
$claims = NameClaim :: orderByDesc ( 'created_at' ) -> get ();
2026-04-04 08:30:23 +01:00
return response () -> json ([
'claims' => $claims ,
2026-04-04 11:21:58 +01:00
'total' => $claims -> count (),
'pending' => NameClaim :: pending () -> count (),
2026-04-04 08:30:23 +01:00
]);
}
2026-04-04 03:44:53 +01:00
/**
* Matches daemon ' s validate_alias_name : a - z , 0 - 9 , dash , dot . Max 255 chars .
* We additionally require at least 1 char ( daemon allows empty but we don ' t ) .
*/
2026-04-03 16:13:55 +01:00
private function isValidName ( string $name ) : bool
{
2026-04-04 03:44:53 +01:00
return ( bool ) preg_match ( '/^[a-z0-9][a-z0-9.\-]{0,254}$/' , $name );
2026-04-03 16:13:55 +01:00
}
}