'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'; } function normalize_requested_address(string $address, string $suffix): ?string { $address = strtolower(trim($address)); $escapedSuffix = preg_quote($suffix, '/'); $canonical = "[a-f0-9]{40}\\.$escapedSuffix"; return preg_match("/^$canonical$/", $address) === 1 ? $address : 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; }