Contractless-Faucet/lib.php

379 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Transaction\TransferBuilder;
use Contractless\Rpc\Transport\StreamTransport;
use Contractless\Rpc\Wallet\Credentials;
function faucet_config(): array
{
static $config;
if ($config === null) {
$config = require __DIR__ . '/config.php';
}
return $config;
}
function network_details(string $faucetAddress): array
{
$address = strtolower(trim($faucetAddress));
if (str_ends_with($address, '.clc')) {
return ['network' => 'mainnet', 'symbol' => 'CLC', 'suffix' => 'clc'];
}
return ['network' => 'testnet', 'symbol' => 'CLTC', 'suffix' => 'cltc'];
}
function default_ledger(): array
{
return [
'version' => 1,
'last_request_timestamp_ms' => 0,
'total_claimed_atomic' => 0,
'total_claims' => 0,
'addresses' => new stdClass(),
'ips' => new stdClass(),
];
}
function normalize_ledger(array $ledger): array
{
$defaults = default_ledger();
$ledger = array_replace($defaults, $ledger);
$ledger['addresses'] = is_array($ledger['addresses']) ? $ledger['addresses'] : [];
$ledger['ips'] = is_array($ledger['ips']) ? $ledger['ips'] : [];
return $ledger;
}
function read_ledger_unlocked(string $path): array
{
if (!is_file($path)) {
return normalize_ledger([]);
}
$contents = file_get_contents($path);
if ($contents === false || trim($contents) === '') {
return normalize_ledger([]);
}
$decoded = json_decode($contents, true);
if (!is_array($decoded)) {
throw new RuntimeException('The faucet claim ledger is not valid JSON.');
}
return normalize_ledger($decoded);
}
function write_ledger_unlocked(string $path, array $ledger): void
{
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create the faucet storage directory.');
}
$json = json_encode($ledger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Could not encode the faucet claim ledger.');
}
$temporary = $path . '.tmp.' . bin2hex(random_bytes(6));
if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false) {
throw new RuntimeException('Could not write the faucet claim ledger.');
}
if (!rename($temporary, $path)) {
@unlink($temporary);
throw new RuntimeException('Could not replace the faucet claim ledger.');
}
@chmod($path, 0640);
}
function with_ledger_lock(bool $write, callable $callback): mixed
{
$config = faucet_config();
$directory = dirname($config['lock_file']);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create the faucet lock directory.');
}
$lock = fopen($config['lock_file'], 'c+');
if ($lock === false) {
throw new RuntimeException('Could not open the faucet claim lock.');
}
try {
if (!flock($lock, $write ? LOCK_EX : LOCK_SH)) {
throw new RuntimeException('Could not lock the faucet claim ledger.');
}
$ledger = read_ledger_unlocked($config['claims_file']);
$result = $callback($ledger);
if ($write) {
write_ledger_unlocked($config['claims_file'], $ledger);
}
flock($lock, LOCK_UN);
return $result;
} finally {
fclose($lock);
}
}
function normalized_client_ip(string $remoteAddress): ?string
{
$ip = trim($remoteAddress);
if (str_starts_with(strtolower($ip), '::ffff:')) {
$mapped = substr($ip, 7);
if (filter_var($mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $mapped;
}
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $ip;
}
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return null;
}
$packed = inet_pton($ip);
if ($packed === false) {
return null;
}
// One IPv6 /64 is one faucet identity.
return substr(bin2hex($packed), 0, 16) . '::/64';
}
/**
* Normalize a claim input without contacting the node.
*
* Canonical addresses are returned as-is. Vanity names are left-padded to
* the fixed 20-byte payload required by the vanity-owner RPC command.
*/
function normalize_requested_address(string $address, string $suffix): ?array
{
$address = strtolower(trim($address));
$escapedSuffix = preg_quote($suffix, '/');
$canonical = "[a-f0-9]{40}\\.$escapedSuffix";
if (preg_match("/^$canonical$/", $address) === 1) {
return ['type' => 'canonical', 'address' => $address];
}
$separator = strrpos($address, '.');
if ($separator === false) {
return null;
}
$payload = substr($address, 0, $separator);
$network = substr($address, $separator + 1);
if (
$network !== $suffix
|| preg_match('/^[a-z]{1,20}$/', $payload) !== 1
) {
return null;
}
return [
'type' => 'vanity',
'address' => str_pad($payload, 20, ' ', STR_PAD_LEFT) . '.' . $suffix,
];
}
/**
* Resolve either an ordinary short address or registered vanity address to
* the canonical short address used by transfers and faucet cooldown records.
*/
function resolve_requested_address(array $requested, string $suffix): ?string
{
if ($requested['type'] === 'canonical') {
return $requested['address'];
}
$owner = strtolower(trim(faucet_rpc()['client']->vanityOwner($requested['address'])));
if ($owner === '' || str_starts_with($owner, 'error:')) {
return null;
}
$escapedSuffix = preg_quote($suffix, '/');
return preg_match("/^[a-f0-9]{40}\\.$escapedSuffix$/", $owner) === 1
? $owner
: null;
}
function atomic_to_decimal(int $atomic): string
{
$whole = intdiv($atomic, 100_000_000);
$fraction = $atomic % 100_000_000;
return sprintf('%d.%08d', $whole, $fraction);
}
function composer_autoload_path(array $config): ?string
{
$candidates = [];
if ($config['composer_autoload'] !== '') {
$candidates[] = $config['composer_autoload'];
}
$candidates[] = __DIR__ . '/vendor/autoload.php';
$candidates[] = dirname(__DIR__) . '/vendor/autoload.php';
$candidates[] = dirname(__DIR__, 2) . '/vendor/autoload.php';
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
/**
* @return array{client: Client, crypto: NativeCrypto, credentials: Credentials}
*/
function faucet_rpc(): array
{
static $rpc;
if ($rpc !== null) {
return $rpc;
}
$config = faucet_config();
$autoload = composer_autoload_path($config);
if ($autoload === null) {
throw new RuntimeException('Composer autoload.php could not be found.');
}
require_once $autoload;
$credentials = Credentials::fromWalletFile(
$config['wallet_path'],
$config['wallet_key'],
);
$crypto = new NativeCrypto();
$client = new Client(
new StreamTransport(
$config['rpc_host'],
$config['rpc_port'],
$config['rpc_timeout_seconds'],
),
$crypto,
$credentials,
);
$rpc = [
'client' => $client,
'crypto' => $crypto,
'credentials' => $credentials,
];
return $rpc;
}
function faucet_balance_atomic(): ?int
{
$config = faucet_config();
$network = network_details($config['faucet_address']);
$reply = faucet_rpc()['client']->coinBalance(
$network['symbol'],
$config['faucet_address'],
);
if (str_starts_with($reply, 'error:')) {
return null;
}
if (strlen($reply) !== 8) {
throw new RuntimeException('The RPC returned an invalid faucet balance.');
}
$decoded = unpack('Pbalance', $reply);
if (!isset($decoded['balance']) || !is_int($decoded['balance'])) {
throw new RuntimeException('The faucet balance could not be decoded.');
}
return $decoded['balance'];
}
function claim_remaining_seconds(array $entry, int $now, int $cooldown): int
{
$claimed = (int) ($entry['claimed_timestamp'] ?? 0);
return max(0, ($claimed + $cooldown) - $now);
}
function active_claim(array $entries, string $key, int $now, int $cooldown): ?array
{
if (!isset($entries[$key]) || !is_array($entries[$key])) {
return null;
}
return claim_remaining_seconds($entries[$key], $now, $cooldown) > 0
? $entries[$key]
: null;
}
function create_faucet_transfer(string $receiver): array
{
$config = faucet_config();
$network = network_details($config['faucet_address']);
$rpc = faucet_rpc();
if ($rpc['client']->walletRegistrationStatus($receiver) !== '1') {
throw new InvalidArgumentException('The receiving wallet is not registered.');
}
$transaction = (new TransferBuilder($rpc['crypto'], $rpc['credentials']))->create(
sender: $config['faucet_address'],
receiver: $receiver,
coin: $network['symbol'],
value: $config['claim_amount_atomic'],
fee: $config['transfer_fee_atomic'],
);
return [
'transaction' => $transaction,
'hash' => bin2hex($rpc['crypto']->skein256($transaction->unsignedJson)),
'receiver' => $receiver,
];
}
function broadcast_faucet_transfer(array $transaction): string
{
$reply = trim(
faucet_rpc()['client']->submitSignedTransaction($transaction['transaction'])
);
if ($reply !== 'successful_broadcast: true') {
throw new RuntimeException('The network rejected the faucet transaction: ' . $reply);
}
return (string) $transaction['hash'];
}
function configuration_ready(): bool
{
$config = faucet_config();
$savedAddress = saved_wallet_address($config['wallet_path']);
return $config['faucet_address'] !== ''
&& preg_match('/^[a-f0-9]{40}\.(?:clc|cltc)$/', $config['faucet_address']) === 1
&& $savedAddress === $config['faucet_address']
&& $config['wallet_key'] !== ''
&& trim($config['rpc_host']) !== ''
&& preg_match('/\s/', $config['rpc_host']) !== 1
&& $config['rpc_port'] >= 1
&& $config['rpc_port'] <= 65535
&& $config['rpc_timeout_seconds'] > 0
&& composer_autoload_path($config) !== null;
}
function saved_wallet_address(string $walletPath): ?string
{
if (!is_file($walletPath) || !is_readable($walletPath)) {
return null;
}
$contents = file_get_contents($walletPath);
if ($contents === false) {
return null;
}
$wallet = json_decode($contents, true);
if (!is_array($wallet) || !is_string($wallet['short_address'] ?? null)) {
return null;
}
return strtolower(trim($wallet['short_address']));
}
function json_response(array $payload, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
exit;
}