Contractless-PHP-API/public/index.php

280 lines
10 KiB
PHP
Raw Permalink Normal View History

2026-07-27 21:21:26 +00:00
<?php
declare(strict_types=1);
use Contractless\Api\ApplicationFactory;
use Contractless\Api\Http\ExceptionResponder;
use Contractless\Api\Http\JsonResponse;
use Contractless\Api\Http\HttpException;
use Contractless\Api\Http\Request;
use Contractless\Api\Http\RequestContext;
use Contractless\Api\Http\Router;
use Contractless\Api\Rpc\RpcReplyDecoder;
use Contractless\Api\Rpc\RequestCredentials;
2026-07-30 15:55:32 +00:00
use Contractless\Api\Nft\NftMediaService;
use Contractless\Api\Routes\NftMediaRoutes;
2026-07-27 21:21:26 +00:00
use Contractless\Api\Routes\RemainingRoutes;
use Contractless\Api\Security\SecurityMiddleware;
use Contractless\Api\Security\TransactionPolicy;
use Contractless\Rpc\Crypto\NativeCrypto;
$projectRoot = dirname(__DIR__);
$autoload = $projectRoot . '/vendor/autoload.php';
if (!is_file($autoload)) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo '{"success":false,"error":"API dependencies are not installed."}';
exit;
}
require $autoload;
RequestContext::initialize();
try {
$config = ApplicationFactory::configuration($projectRoot);
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$path = is_string($path) ? '/' . trim($path, '/') : '/';
if ($path === '/') {
$path = '/';
}
// General HTTP security runs before wallet authentication or RPC creation.
(new SecurityMiddleware($config))->handle($method, $path);
if ($method === 'GET' && $path === '/api/v1/live') {
JsonResponse::success(['alive' => true]);
}
$requestCredentials = RequestCredentials::fromHeaders(new NativeCrypto());
$application = ApplicationFactory::createFromConfig(
$config,
$requestCredentials->handshakeProof,
);
$router = new Router();
$router->get('/api/v1', static function () use ($application): never {
JsonResponse::success([
'name' => 'Contractless PHP API',
'version' => 'v1',
'rpc_endpoints' => count($application->transport->endpointIds()),
]);
});
$router->get('/api/v1/health', static function () use ($application): never {
$height = RpcReplyDecoder::unsigned32(
$application->client->blockHeight(),
'block height',
);
JsonResponse::success(['online' => true, 'height' => $height]);
});
$router->get('/api/v1/ready', static function () use ($application): never {
$height = RpcReplyDecoder::unsigned32(
$application->client->blockHeight(),
'block height',
);
JsonResponse::success([
'ready' => true,
'height' => $height,
'rpc_endpoints' => $application->endpointHealth->status(
$application->transport->endpointIds(),
),
]);
});
$router->get('/api/v1/network', static function () use ($application): never {
JsonResponse::success(
RpcReplyDecoder::networkInfo($application->client->networkInfo()),
);
});
$router->get('/api/v1/height', static function () use ($application): never {
JsonResponse::success([
'height' => RpcReplyDecoder::unsigned32(
$application->client->blockHeight(),
'block height',
),
]);
});
$router->get('/api/v1/time', static function () use ($application): never {
JsonResponse::success([
'timestamp' => RpcReplyDecoder::unsigned32(
$application->client->nodeTime(),
'node time',
),
]);
});
$router->get('/api/v1/addresses/validate', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'valid' => RpcReplyDecoder::validStatus(
$application->client->validateAddress($address),
),
]);
});
$router->get('/api/v1/addresses/vanity/resolve', static function () use ($application): never {
$address = strtolower(Request::queryString('address', 25));
if (preg_match('/^([a-z]{1,20})\.(clc|cltc)$/', $address, $matches) !== 1) {
throw new HttpException(422, 'Enter a valid Contractless vanity address.');
}
$fixedWidthAddress = str_pad($matches[1], 20, ' ', STR_PAD_LEFT)
. '.'
. $matches[2];
JsonResponse::success([
'vanity_address' => $address,
'owner_address' => RpcReplyDecoder::optionalText(
$application->client->vanityOwner($fixedWidthAddress),
),
]);
});
$router->get('/api/v1/addresses/registration', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'registered' => RpcReplyDecoder::registrationStatus(
$application->client->walletRegistrationStatus($address),
),
]);
});
2026-07-29 18:05:54 +00:00
$router->post(
'/api/v1/addresses/register',
static function () use ($application, $requestCredentials): never {
$body = Request::json();
$address = Request::canonicalAddress(
Request::bodyString($body, 'address', 45),
);
$publicKeyHex = strtolower(
Request::bodyString($body, 'public_key', 1_794),
);
$signature = Request::signature(
Request::bodyString($body, 'signature', 1_332),
);
if (
strlen($publicKeyHex) !== 1_794
|| !ctype_xdigit($publicKeyHex)
) {
throw new HttpException(
422,
'The public key must contain 1,794 hexadecimal characters.',
);
}
if (!hash_equals($requestCredentials->address, $address)) {
throw new HttpException(
403,
'A wallet may only submit its own registration.',
);
}
$publicKey = hex2bin($publicKeyHex);
if (
$publicKey === false
|| !hash_equals(
$requestCredentials->handshakeProof->publicKey,
$publicKey,
)
) {
throw new HttpException(
403,
'The registration public key does not match the authenticated wallet.',
);
}
JsonResponse::success([
'address' => $address,
'registered' => RpcReplyDecoder::registrationStatus(
$application->client->registerWallet(
$address,
$publicKey,
$signature,
),
),
]);
},
);
2026-07-27 21:21:26 +00:00
$router->get('/api/v1/balances/base', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
$coin = strtoupper(Request::queryString('coin', 15));
$expectedCoin = str_ends_with($address, '.cltc') ? 'CLTC' : 'CLC';
if ($coin !== $expectedCoin) {
throw new HttpException(
422,
"Base coin must be $expectedCoin for this address.",
);
}
JsonResponse::success([
'address' => $address,
'coin' => $coin,
'balance' => RpcReplyDecoder::balance(
$application->client->coinBalance($coin, $address),
),
]);
});
$router->get('/api/v1/balances', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
JsonResponse::success([
'address' => $address,
'balances' => RpcReplyDecoder::balances(
$application->client->totalBalance($address),
),
]);
});
$router->get('/api/v1/transactions/lookup', static function () use ($application): never {
$txid = Request::hash(Request::queryString('txid', 64), 'txid');
JsonResponse::success([
'txid' => $txid,
] + RpcReplyDecoder::transaction(
$application->client->transactionById($txid),
));
});
$router->get('/api/v1/addresses/history', static function () use ($application): never {
$address = Request::canonicalAddress(Request::queryString('address', 45));
$skip = Request::queryInt('skip', 0, 0, 4_294_967_295);
$limit = Request::queryInt('limit', 100, 1, 1_000);
JsonResponse::success([
'address' => $address,
'skip' => $skip,
'limit' => $limit,
'transactions' => RpcReplyDecoder::addressHistory(
$application->client->addressHistory($address, $skip, $limit),
),
]);
});
$router->post('/api/v1/messages/verify', static function () use ($application): never {
$body = Request::json();
// Message bytes must remain exactly as signed, including surrounding whitespace.
$message = Request::bodyString($body, 'message', 65_535, trim: false);
$address = Request::canonicalAddress(
Request::bodyString($body, 'address', 45),
);
$signature = Request::signature(
Request::bodyString($body, 'signature', 1_332),
);
JsonResponse::success([
'valid' => RpcReplyDecoder::validStatus(
$application->client->validateMessage($message, $address, $signature),
),
]);
});
$router->post('/api/v1/transactions/broadcast', static function () use ($application): never {
$body = Request::json();
$transaction = Request::transactionHex(
Request::bodyString($body, 'transaction_hex', 1_900_000),
);
$transactionType = TransactionPolicy::validate($transaction);
JsonResponse::success(
['transaction_type' => $transactionType] + RpcReplyDecoder::broadcast(
$application->client->submitTransaction($transaction),
),
);
});
RemainingRoutes::register($router, $application);
2026-07-30 15:55:32 +00:00
NftMediaRoutes::register(
$router,
$application,
new NftMediaService($config->nftMedia),
);
2026-07-27 21:21:26 +00:00
$router->dispatch($method, $path);
} catch (Throwable $error) {
ExceptionResponder::respond($error);
}