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:24:27 +01:00
use Mod\Names\Actions ;
2026-04-04 12:06:00 +01:00
use Mod\Names\Models ;
2026-04-04 11:21:58 +01:00
use Mod\Names\Models\NameClaim ;
2026-04-04 11:49:19 +01:00
use Mod\Names\Resources ;
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
{
2026-04-04 11:24:27 +01:00
return response () -> json ( Actions\CheckAvailability :: run ( $name ));
2026-04-03 16:13:55 +01:00
}
/**
* 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 );
}
2026-04-04 11:49:19 +01:00
$alias [ 'name' ] = $name ;
return ( new Resources\NameResource ( $alias )) -> response ();
2026-04-03 16:13:55 +01:00
}
/**
* 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
{
2026-04-04 12:12:07 +01:00
$ticket = Actions\UpdateDnsRecords :: run ( $name , $request -> input ( 'records' , []));
2026-04-04 02:06:38 +01:00
return response () -> json ([
2026-04-04 12:12:07 +01:00
'name' => $ticket -> name ,
2026-04-04 12:06:00 +01:00
'ticket' => $ticket -> ticket_id ,
2026-04-04 12:12:07 +01:00
'tx_id' => $ticket -> tx_id ,
'status' => $ticket -> status ,
], $ticket -> status === 'queued' ? 202 : 200 );
2026-04-04 02:06:38 +01:00
}
/**
* GET / v1 / names / ticket / { id }
*
* Check the status of a DNS change ticket .
*/
public function ticket ( string $id ) : JsonResponse
{
2026-04-04 12:06:00 +01:00
$ticket = Models\DnsTicket :: where ( 'ticket_id' , $id ) -> first ();
2026-04-04 02:06:38 +01:00
if ( ! $ticket ) {
return response () -> json ([ 'error' => 'Ticket not found' ], 404 );
}
2026-04-04 12:06:00 +01:00
// If pending, check if the tx has confirmed on chain
if ( $ticket -> status === 'pending' && $ticket -> tx_id ) {
$alias = $this -> rpc -> getAliasByName ( $ticket -> name );
2026-04-04 02:06:38 +01:00
if ( $alias && str_contains ( $alias [ 'comment' ] ? ? '' , 'dns=' )) {
2026-04-04 12:06:00 +01:00
$ticket -> confirm ();
Cache :: forget ( " dns_edit_lock: { $ticket -> name } " );
2026-04-04 02:06:38 +01:00
}
}
return response () -> json ([
2026-04-04 12:06:00 +01:00
'ticket' => $ticket -> ticket_id ,
'name' => $ticket -> name ,
'status' => $ticket -> status ,
'tx_id' => $ticket -> tx_id ,
'created_at' => $ticket -> created_at ? -> toIso8601String (),
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
{
2026-04-04 11:24:27 +01:00
$claim = Actions\SubmitClaim :: run ( $request -> only ([ 'name' , 'email' ]));
2026-04-04 08:30:23 +01:00
2026-04-04 11:49:19 +01:00
return ( new Resources\ClaimResource ( $claim ))
-> additional ([ 'message' => " Your claim has been submitted. We will notify you at { $claim -> email } when approved. " ])
-> response ()
-> setStatusCode ( 201 );
2026-04-04 08:30:23 +01:00
}
/**
* 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-04 12:07:38 +01:00
/**
* POST / v1 / names / claims / { id } / approve
*/
public function approveClaim ( string $id ) : JsonResponse
{
$claim = NameClaim :: where ( 'claim_id' , $id ) -> first ();
if ( ! $claim ) {
return response () -> json ([ 'error' => 'Claim not found' ], 404 );
}
if ( $claim -> status !== 'pending' ) {
return response () -> json ([ 'error' => " Claim already { $claim -> status } " ], 409 );
}
$claim -> approve ();
Models\NameActivity :: log ( $claim -> name , 'claim_approved' , [
'claim_id' => $claim -> claim_id ,
'email' => $claim -> email ,
]);
return response () -> json ([
'claim_id' => $claim -> claim_id ,
'name' => $claim -> name ,
'status' => 'approved' ,
]);
}
/**
* POST / v1 / names / claims / { id } / reject
*/
public function rejectClaim ( string $id ) : JsonResponse
{
$claim = NameClaim :: where ( 'claim_id' , $id ) -> first ();
if ( ! $claim ) {
return response () -> json ([ 'error' => 'Claim not found' ], 404 );
}
if ( $claim -> status !== 'pending' ) {
return response () -> json ([ 'error' => " Claim already { $claim -> status } " ], 409 );
}
$claim -> reject ();
Models\NameActivity :: log ( $claim -> name , 'claim_rejected' , [
'claim_id' => $claim -> claim_id ,
]);
return response () -> json ([
'claim_id' => $claim -> claim_id ,
'name' => $claim -> name ,
'status' => 'rejected' ,
]);
}
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
}
}