280 lines
10 KiB
PHP
280 lines
10 KiB
PHP
<?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;
|
|
use Contractless\Api\Nft\NftMediaService;
|
|
use Contractless\Api\Routes\NftMediaRoutes;
|
|
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),
|
|
),
|
|
]);
|
|
});
|
|
$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,
|
|
),
|
|
),
|
|
]);
|
|
},
|
|
);
|
|
$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);
|
|
NftMediaRoutes::register(
|
|
$router,
|
|
$application,
|
|
new NftMediaService($config->nftMedia),
|
|
);
|
|
$router->dispatch($method, $path);
|
|
} catch (Throwable $error) {
|
|
ExceptionResponder::respond($error);
|
|
}
|