commit c9dfdd03862120a82fcb185e218f8fe2ce24abce Author: viraladmin <00purple@gmail.com> Date: Sat Jul 25 17:48:29 2026 -0600 Initial Contractless faucet release diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..df3b18a --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy this file to the private project root above public/. +# For project/public/faucet, use project/faucet.env. +FAUCET_ADDRESS=0000000000000000000000000000000000000000.cltc +CONTRACTLESS_WALLET_PATH=/private/path/to/faucet.wallet +CONTRACTLESS_WALLET_KEY=replace-with-wallet-decryption-key +CONTRACTLESS_RPC_HOST=127.0.0.1 +CONTRACTLESS_RPC_PORT=50050 +CONTRACTLESS_RPC_TIMEOUT=10 +# Leave blank when Composer is installed in this faucet or its web-project root. +FAUCET_COMPOSER_AUTOLOAD= +FAUCET_CLAIMS_FILE=/private/path/outside/webroot/claims.json +FAUCET_LOCK_FILE=/private/path/outside/webroot/claims.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdcec8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Never commit live faucet secrets or state. +.env +.env.* +!.env.example +faucet.env +storage/claims.json +storage/claims.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf49eab --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# Contractless Faucet + +This faucet uses PHP and JSON storage. It connects directly to a Contractless +node through the authenticated RPC protocol, creates and signs transfers +locally, and submits them without invoking Contractless CLI programs. + +## Requirements + +- 64-bit PHP 8.1 or newer +- Composer +- The Contractless PHP Skein and Falcon modules +- `contractless/contractless-php-rpc` +- Access to a compatible Contractless RPC node +- A dedicated, registered faucet wallet +- Read access to that wallet file +- A writable private directory for the JSON claim ledger + +Install the native modules first: + +```text +https://contractless.dev/contractless/Contractless-PHP-Modules +``` + +Install the RPC package in the Composer project containing the faucet: + +```bash +composer require contractless/contractless-php-rpc:^0.2.0 -W +``` + +The web-server account needs permission to read Composer's `vendor` directory +and faucet wallet, and write the configured claim-ledger directory. It does +not need access to a node runtime, balance sheet, or Contractless CLI binary. + +The faucet configuration, wallet file, wallet decryption key, and claim ledger +must never be downloadable through the web server. + +## Configuration + +For an installation at: + +```text +/var/www/project/public/faucet +``` + +copy the included template to the private project root: + +```bash +cp /var/www/project/public/faucet/.env.example /var/www/project/faucet.env +sudo chown root:www-data /var/www/project/faucet.env +sudo chmod 640 /var/www/project/faucet.env +``` + +Then edit `/var/www/project/faucet.env` and replace every example value with +the correct faucet settings. + +The faucet automatically loads `faucet.env` from the project root two +directories above the faucet: + +```text +/var/www/project/faucet.env +``` + +No Apache configuration, PHP-FPM pool changes, shell exports, or service +restarts are required. Reloading the faucet page uses the saved values. + +The optional `FAUCET_ENV_FILE` process variable can point to a different file, +but normal installations do not need it. + +`FAUCET_ADDRESS` determines the active network: + +- An address ending in `.cltc` configures testnet and displays `CLTC`. +- An address ending in `.clc` configures mainnet and displays `CLC`. + +The faucet address must be the registered canonical 40-character short +address. Faucet claims also require canonical short addresses; vanity aliases +are not accepted by the claim form. + +Point the faucet at its dedicated wallet and provide that wallet's decryption +key: + +```text +CONTRACTLESS_WALLET_PATH=/var/www/project/faucet.wallet +CONTRACTLESS_WALLET_KEY=wallet-decryption-key +``` + +The RPC library reads the public key from the wallet, extracts the encrypted +private key from its image, verifies the image payload HMAC, decrypts the +private key, proves that the Falcon keypair matches, and verifies that the +wallet's short address is derived from that public key. + +`FAUCET_ADDRESS` must match the canonical `short_address` saved in this wallet. +Keep the wallet file and decryption key outside the public document root, but +inside a path PHP is permitted to read. For the example layout, the public +document root is `/var/www/project/public`, so `/var/www/project/faucet.wallet` +is private from HTTP while remaining available to PHP. Never expose the wallet +or key through HTML, JavaScript, logs, error responses, or source control. + +Before enabling the faucet, verify the wallet through the installed RPC +package: + +```bash +cd /path/to/Contractless-PHP-RPC +php examples/load_wallet.php /private/path/to/faucet.wallet 'wallet decryption key' +``` + +Configure the node used for RPC: + +```text +CONTRACTLESS_RPC_HOST=127.0.0.1 +CONTRACTLESS_RPC_PORT=50050 +CONTRACTLESS_RPC_TIMEOUT=10 +``` + +Use port `50050` for the default testnet RPC or `50055` for the default mainnet +RPC unless the selected node uses a different configured port. + +The faucet searches for Composer's autoloader in its own directory, its parent +directory, and the web-project root two directories above it. Set +`FAUCET_COMPOSER_AUTOLOAD` to the absolute `vendor/autoload.php` path only when +Composer is installed elsewhere. + +For production, set `FAUCET_CLAIMS_FILE` and `FAUCET_LOCK_FILE` to paths +outside the public web root. The included `storage` directory is suitable for +local setup and includes an Apache access-denial rule, but server-level private +storage is preferable. + +## Claim Rules + +Each successful request sends 25 base testnet or mainnet coins and pays a +0.25-coin transfer fee from the faucet wallet. + +Before reserving or broadcasting a claim, the faucet: + +- Retrieves its confirmed base-coin balance through RPC +- Refuses the request when the faucet cannot cover the claim and fee +- Confirms that the receiving canonical wallet address is registered +- Creates and signs the transfer locally +- Submits the exact signed transaction through RPC + +The faucet records a successful claim only after the node returns +`successful_broadcast: true`. + +A claim is rejected when either the canonical wallet address or normalized +client IP has claimed during the preceding 24 hours. IPv4-mapped IPv6 addresses +are treated as IPv4. Native IPv6 clients are grouped by `/64`. + +Claims are reserved under an exclusive file lock before broadcasting. This +prevents simultaneous requests from passing the same address or IP check. +Failed broadcasts release their reservation so the user may try again. + +The faucet trusts `REMOTE_ADDR` and intentionally ignores forwarded-IP headers. +When deploying behind a reverse proxy, configure the proxy or web server to +replace `REMOTE_ADDR` only from explicitly trusted proxy addresses. + +## JSON Ledger + +The claim ledger is keyed directly by canonical wallet address and normalized +IP: + +```json +{ + "version": 1, + "last_request_timestamp_ms": 0, + "total_claimed_atomic": 2500000000, + "total_claims": 1, + "addresses": { + "abcd000000000000000000000000000000001234.cltc": { + "claimed_timestamp": 1785000000, + "status": "completed", + "address": "abcd000000000000000000000000000000001234.cltc", + "ip": "203.0.113.10", + "txid": "transaction hash" + } + }, + "ips": { + "203.0.113.10": { + "claimed_timestamp": 1785000000, + "status": "completed", + "address": "abcd000000000000000000000000000000001234.cltc", + "ip": "203.0.113.10", + "txid": "transaction hash" + } + } +} +``` + +## Security + +Run the faucet under a dedicated operating-system account with access only to +its private configuration and claim ledger. Do not run PHP or the faucet as +root. Serve the site over HTTPS, keep directory listing disabled, and keep +`faucet.env` outside the public web root. diff --git a/api.php b/api.php new file mode 100644 index 0000000..ebda0eb --- /dev/null +++ b/api.php @@ -0,0 +1,222 @@ + $ledger); + $balance = null; + if (configuration_ready()) { + try { + $balance = faucet_balance_atomic(); + } catch (Throwable $error) { + error_log('[contractless_faucet] RPC status failed: ' . $error->getMessage()); + } + } + json_response([ + 'success' => true, + 'online' => configuration_ready() && $balance !== null, + 'network' => $network['network'], + 'symbol' => $network['symbol'], + 'faucet_address' => $config['faucet_address'], + 'claim_amount' => atomic_to_decimal($config['claim_amount_atomic']), + 'cooldown_seconds' => $config['cooldown_seconds'], + 'faucet_balance' => $balance === null ? null : atomic_to_decimal($balance), + 'total_claimed' => atomic_to_decimal((int) $ledger['total_claimed_atomic']), + 'total_addresses' => count($ledger['addresses']), + ]); + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Allow: GET, POST'); + json_response(['success' => false, 'error' => 'Method not allowed.'], 405); + } + if (!configuration_ready()) { + json_response([ + 'success' => false, + 'error' => 'The faucet is not configured or is temporarily offline.', + ], 503); + } + + $rawBody = file_get_contents('php://input'); + if ($rawBody === false || strlen($rawBody) > 4096) { + json_response(['success' => false, 'error' => 'Invalid request.'], 400); + } + $body = json_decode($rawBody, true); + if (!is_array($body)) { + json_response(['success' => false, 'error' => 'Request body must be JSON.'], 400); + } + + $requestedAddress = normalize_requested_address( + (string) ($body['address'] ?? ''), + $network['suffix'] + ); + if ($requestedAddress === null) { + json_response([ + 'success' => false, + 'error' => "Enter a valid registered {$network['network']} wallet address.", + ], 422); + } + + // REMOTE_ADDR is authoritative. Forwarded headers are intentionally ignored. + $clientIp = normalized_client_ip((string) ($_SERVER['REMOTE_ADDR'] ?? '')); + if ($clientIp === null) { + json_response(['success' => false, 'error' => 'Could not identify the requesting IP.'], 400); + } + + $now = time(); + $nowMs = (int) floor(microtime(true) * 1000); + $precheck = with_ledger_lock(true, function (array &$ledger) use ( + $requestedAddress, + $clientIp, + $now, + $nowMs, + $config + ): array { + $lastRequest = (int) $ledger['last_request_timestamp_ms']; + if ($nowMs - $lastRequest < $config['minimum_request_interval_ms']) { + return ['allowed' => false, 'status' => 429, 'error' => 'The faucet is busy. Try again.']; + } + $ledger['last_request_timestamp_ms'] = $nowMs; + + $addressClaim = active_claim( + $ledger['addresses'], + $requestedAddress, + $now, + $config['cooldown_seconds'] + ); + $ipClaim = active_claim($ledger['ips'], $clientIp, $now, $config['cooldown_seconds']); + $claim = $addressClaim ?? $ipClaim; + if ($claim !== null) { + return [ + 'allowed' => false, + 'status' => 429, + 'error' => 'This wallet address or IP has already claimed recently.', + 'retry_after_seconds' => claim_remaining_seconds( + $claim, + $now, + $config['cooldown_seconds'] + ), + ]; + } + return ['allowed' => true]; + }); + if (!$precheck['allowed']) { + json_response(['success' => false] + $precheck, $precheck['status']); + } + + $balance = faucet_balance_atomic(); + $requiredBalance = $config['claim_amount_atomic'] + $config['transfer_fee_atomic']; + if ($balance === null || $balance < $requiredBalance) { + json_response([ + 'success' => false, + 'error' => 'The faucet is temporarily low on funds. Please try again later.', + ], 503); + } + + try { + $transaction = create_faucet_transfer($requestedAddress); + } catch (InvalidArgumentException $error) { + json_response(['success' => false, 'error' => $error->getMessage()], 422); + } + $canonicalAddress = strtolower(trim((string) $transaction['receiver'])); + $requestId = bin2hex(random_bytes(16)); + + $reservation = with_ledger_lock(true, function (array &$ledger) use ( + $canonicalAddress, + $clientIp, + $now, + $requestId, + $config + ): array { + $addressClaim = active_claim( + $ledger['addresses'], + $canonicalAddress, + $now, + $config['cooldown_seconds'] + ); + $ipClaim = active_claim($ledger['ips'], $clientIp, $now, $config['cooldown_seconds']); + $claim = $addressClaim ?? $ipClaim; + if ($claim !== null) { + return [ + 'allowed' => false, + 'retry_after_seconds' => claim_remaining_seconds( + $claim, + $now, + $config['cooldown_seconds'] + ), + ]; + } + + $entry = [ + 'claimed_timestamp' => $now, + 'status' => 'pending', + 'request_id' => $requestId, + 'address' => $canonicalAddress, + 'ip' => $clientIp, + ]; + $ledger['addresses'][$canonicalAddress] = $entry; + $ledger['ips'][$clientIp] = $entry; + return ['allowed' => true]; + }); + + if (!$reservation['allowed']) { + json_response([ + 'success' => false, + 'error' => 'This wallet address or IP has already claimed recently.', + 'retry_after_seconds' => $reservation['retry_after_seconds'], + ], 429); + } + + try { + $txid = broadcast_faucet_transfer($transaction); + with_ledger_lock(true, function (array &$ledger) use ( + $canonicalAddress, + $clientIp, + $requestId, + $txid, + $config + ): void { + foreach ([['addresses', $canonicalAddress], ['ips', $clientIp]] as [$group, $key]) { + if (($ledger[$group][$key]['request_id'] ?? '') === $requestId) { + $ledger[$group][$key]['status'] = 'completed'; + $ledger[$group][$key]['txid'] = $txid; + unset($ledger[$group][$key]['request_id']); + } + } + $ledger['total_claimed_atomic'] += $config['claim_amount_atomic']; + $ledger['total_claims']++; + }); + } catch (Throwable $error) { + with_ledger_lock(true, function (array &$ledger) use ( + $canonicalAddress, + $clientIp, + $requestId + ): void { + foreach ([['addresses', $canonicalAddress], ['ips', $clientIp]] as [$group, $key]) { + if (($ledger[$group][$key]['request_id'] ?? '') === $requestId) { + unset($ledger[$group][$key]); + } + } + }); + throw $error; + } + + json_response([ + 'success' => true, + 'txid' => $txid, + 'amount' => atomic_to_decimal($config['claim_amount_atomic']), + 'symbol' => $network['symbol'], + 'message' => ucfirst($network['network']) . ' coins were broadcast successfully.', + ]); +} catch (Throwable $error) { + error_log('[contractless_faucet] ' . $error->getMessage()); + json_response([ + 'success' => false, + 'error' => 'The faucet could not complete this request. Please try again later.', + ], 500); +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..b8c6a0a --- /dev/null +++ b/config.php @@ -0,0 +1,68 @@ + $line) { + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + if (!str_contains($line, '=')) { + throw new RuntimeException( + 'Invalid faucet configuration at line ' . ($lineNumber + 1) + ); + } + + [$name, $value] = explode('=', $line, 2); + $name = trim($name); + $value = trim($value); + if (preg_match('/^[A-Z][A-Z0-9_]*$/', $name) !== 1) { + throw new RuntimeException( + 'Invalid faucet variable name at line ' . ($lineNumber + 1) + ); + } + + if (strlen($value) >= 2) { + $first = $value[0]; + $last = $value[strlen($value) - 1]; + if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) { + $value = substr($value, 1, -1); + } + } + + // Real process variables override values from faucet.env. + if (getenv($name) !== false) { + continue; + } + putenv("$name=$value"); + $_ENV[$name] = $value; + } +} + +// Every configured filesystem path should be absolute in production. +return [ + 'faucet_address' => getenv('FAUCET_ADDRESS') ?: '', + 'wallet_path' => getenv('CONTRACTLESS_WALLET_PATH') ?: '', + 'wallet_key' => getenv('CONTRACTLESS_WALLET_KEY') ?: '', + 'rpc_host' => getenv('CONTRACTLESS_RPC_HOST') ?: '127.0.0.1', + 'rpc_port' => (int) (getenv('CONTRACTLESS_RPC_PORT') ?: 50050), + 'rpc_timeout_seconds' => (float) (getenv('CONTRACTLESS_RPC_TIMEOUT') ?: 10), + 'composer_autoload' => getenv('FAUCET_COMPOSER_AUTOLOAD') ?: '', + 'claims_file' => getenv('FAUCET_CLAIMS_FILE') ?: __DIR__ . '/storage/claims.json', + 'lock_file' => getenv('FAUCET_LOCK_FILE') ?: __DIR__ . '/storage/claims.lock', + 'claim_amount_atomic' => 2_500_000_000, + 'transfer_fee_atomic' => 25_000_000, + 'cooldown_seconds' => 86_400, + 'minimum_request_interval_ms' => 250, +]; diff --git a/contractless-logo.png b/contractless-logo.png new file mode 100644 index 0000000..b50c764 Binary files /dev/null and b/contractless-logo.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..27206f2 --- /dev/null +++ b/index.html @@ -0,0 +1,139 @@ + + + + + + + Contractless Testnet Faucet + + + + + + +
+ + +
+
+ + +
+

Public testnet utility

+

Put the network
in your wallet.

+

+ Request 25 CLTC testnet + coins for transactions, storage, swaps, + loans, NFTs, and everything else worth testing. +

+
+ +
+ +
+

Faucet request

+
Checking
+
+ +
+ +
+ + +
+

The wallet must already be registered on testnet.

+ + +
+ +
+ +
+
25CLTC per request
+
24Hbetween claims
+
0real-world value
+
+
+
+ +
+
+ Keep the faucet alive + Send CLTC tips to: +
+ 0000000000000000000000000000000000000000.cltc + +
+ +
+

Total coins in faucet address-- CLTC

+

Total coins claimed0.00000000 CLTC

+

Total addresses claimed0

+
+ +
+
+

02 / USE IT

+

Test freely.
Break things carefully.

+
+

+ Testnet coins are made for experimentation. Create transactions, + explore the wallet, run a node, and help expose problems before + Contractless reaches mainnet. +

+ + Run a testnet node + +
+
+ + +
+ + + + diff --git a/lib.php b/lib.php new file mode 100644 index 0000000..323918e --- /dev/null +++ b/lib.php @@ -0,0 +1,331 @@ + '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; +} diff --git a/script.js b/script.js new file mode 100644 index 0000000..c5e26d0 --- /dev/null +++ b/script.js @@ -0,0 +1,178 @@ +const menuButton = document.querySelector(".menu-button"); +const navigation = document.querySelector(".site-nav"); +const form = document.querySelector("#faucet-form"); +const address = document.querySelector("#wallet-address"); +const message = document.querySelector("#form-message"); +const copyAddress = document.querySelector("#copy-address"); +const faucetAddress = document.querySelector("#faucet-address"); +const requestButton = form.querySelector(".request-button"); +let faucetState = { + network: "testnet", + symbol: "CLTC", + claimAmount: "25.00000000", + online: false, +}; + +menuButton.addEventListener("click", () => { + const open = navigation.classList.toggle("open"); + menuButton.setAttribute("aria-expanded", String(open)); +}); + +function displayAmount(value) { + const numeric = Number(value); + return Number.isFinite(numeric) + ? numeric.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 8, + }) + : value; +} + +function waitDescription(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.ceil((seconds % 3600) / 60); + return hours > 0 + ? `${hours} hour${hours === 1 ? "" : "s"} and ${minutes} minute${minutes === 1 ? "" : "s"}` + : `${minutes} minute${minutes === 1 ? "" : "s"}`; +} + +function applyStatus(status) { + const network = status.network || "testnet"; + const symbol = status.symbol || "CLTC"; + const claimAmount = status.claim_amount || "25.00000000"; + const displayClaim = displayAmount(claimAmount); + const networkTitle = network.charAt(0).toUpperCase() + network.slice(1); + + faucetState = { + network, + symbol, + claimAmount, + online: Boolean(status.online), + }; + + document.querySelector("#brand-network").textContent = `${network.toUpperCase()} FAUCET`; + document.querySelector("#network-coordinate").textContent = + `${network.toUpperCase()} / ${displayClaim} ${symbol}`; + document.querySelector("#eyebrow-network").textContent = network; + document.querySelector("#intro-amount").textContent = `${displayClaim} ${symbol}`; + document.querySelector("#intro-network").textContent = network; + document.querySelector("#input-symbol").textContent = symbol; + document.querySelector("#address-note").textContent = + `The wallet must already be registered on ${network}.`; + document.querySelector("#request-label").textContent = + `Request ${displayClaim} ${symbol}`; + document.querySelector("#rule-amount").textContent = displayClaim; + document.querySelector("#rule-symbol").textContent = `${symbol} per request`; + document.querySelector("#tip-label").textContent = `Send ${symbol} tips to:`; + document.querySelector("#lower-network").textContent = networkTitle; + document.querySelector("#footer-network").textContent = + `${network.toUpperCase()} COINS HAVE NO MONETARY VALUE`; + + faucetAddress.textContent = + status.faucet_address || `Faucet ${network} address is not configured`; + document.querySelector("#faucet-balance").textContent = + status.faucet_balance === null + ? `-- ${symbol}` + : `${displayAmount(status.faucet_balance)} ${symbol}`; + document.querySelector("#total-claimed").textContent = + `${displayAmount(status.total_claimed || "0")} ${symbol}`; + document.querySelector("#total-addresses").textContent = + Number(status.total_addresses || 0).toLocaleString(); + + const faucetStatus = document.querySelector("#faucet-status"); + faucetStatus.lastChild.textContent = status.online ? " Online" : " Offline"; + faucetStatus.classList.toggle("offline", !status.online); + requestButton.disabled = !status.online; +} + +async function refreshStatus() { + try { + const response = await fetch("api.php", { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + const status = await response.json(); + if (!response.ok || !status.success) { + throw new Error(status.error || "Status request failed."); + } + applyStatus(status); + } catch { + applyStatus({ + network: faucetState.network, + symbol: faucetState.symbol, + claim_amount: faucetState.claimAmount, + online: false, + faucet_address: "", + faucet_balance: null, + total_claimed: "0", + total_addresses: 0, + }); + } +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + const value = address.value.trim(); + const suffix = faucetState.network === "mainnet" ? "clc" : "cltc"; + const escapedSuffix = suffix.replace(".", "\\."); + const valid = new RegExp( + `^[a-f0-9]{40}\\.${escapedSuffix}$`, + "i", + ).test(value); + + message.classList.add("visible"); + message.classList.remove("success"); + if (!valid) { + message.textContent = + `Enter a valid registered Contractless wallet address ending in .${suffix}.`; + return; + } + + requestButton.disabled = true; + message.textContent = "Creating and broadcasting your faucet transaction..."; + + try { + const response = await fetch("api.php", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ address: value }), + }); + const result = await response.json(); + if (!response.ok || !result.success) { + const retry = result.retry_after_seconds + ? ` Try again in ${waitDescription(result.retry_after_seconds)}.` + : ""; + throw new Error((result.error || "The faucet request failed.") + retry); + } + + message.classList.add("success"); + message.textContent = + `${displayAmount(result.amount)} ${result.symbol} was broadcast. ` + + `Transaction: ${result.txid}`; + address.value = ""; + await refreshStatus(); + } catch (error) { + message.textContent = + error instanceof Error ? error.message : "The faucet request failed."; + } finally { + requestButton.disabled = !faucetState.online; + } +}); + +copyAddress.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(faucetAddress.textContent.trim()); + copyAddress.textContent = "Copied"; + } catch { + copyAddress.textContent = "Copy failed"; + } + + setTimeout(() => { + copyAddress.textContent = "Copy address"; + }, 1800); +}); + +refreshStatus(); diff --git a/storage/.htaccess b/storage/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/storage/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..bbd2bc4 --- /dev/null +++ b/styles.css @@ -0,0 +1,804 @@ +:root { + --ink: #111315; + --paper: #f3f5ef; + --acid: #d9ff3f; + --cyan: #55dce7; + --blue: #245f9d; + --red: #ff5b52; + --line: rgba(17, 19, 21, 0.2); + --mono: "DM Mono", monospace; + --sans: "Manrope", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--ink); + color: var(--ink); + font-family: var(--sans); +} + +body { + margin: 0; + min-width: 320px; + background: + linear-gradient(rgba(17, 19, 21, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(17, 19, 21, 0.045) 1px, transparent 1px), + var(--paper); + background-size: 32px 32px; +} + +a { + color: inherit; +} + +button, +input { + font: inherit; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.site-shell { + min-height: 100vh; + overflow: hidden; +} + +.site-header { + min-height: 76px; + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--ink); + background: rgba(243, 245, 239, 0.94); +} + +.brand { + width: 268px; + flex: 0 0 268px; + display: grid; + grid-template-columns: 38px 1fr; + grid-template-rows: 1fr 1fr; + column-gap: 11px; + align-items: end; + padding: 13px 22px; + color: white; + background: var(--blue); + text-decoration: none; +} + +.brand img { + width: 36px; + height: 36px; + grid-row: 1 / 3; + align-self: center; +} + +.brand span { + font-size: 14px; + font-weight: 800; + align-self: end; +} + +.brand small { + font-family: var(--mono); + font-size: 9px; + align-self: start; +} + +.site-nav { + display: flex; + align-items: stretch; + flex: 1; +} + +.site-nav a { + min-width: 0; + flex: 1; + display: grid; + place-items: center; + padding: 10px 9px; + border-right: 1px solid var(--line); + font-family: var(--mono); + font-size: 10px; + line-height: 1.25; + text-align: center; + text-decoration: none; + text-transform: uppercase; + transition: background-color 160ms ease, color 160ms ease; +} + +.site-nav a:hover, +.site-nav a:focus-visible { + color: var(--paper); + background: var(--ink); + outline: 0; +} + +.site-nav .download-wallet { + border-top: 0; + border-bottom: 0; + border-left: 1px; + border-right: 0; + border-color: var(--ink); + border-style: solid; + color: var(--ink); + background: var(--acid); + font-weight: 700; + letter-spacing: 1px; +} + +.site-nav .download-wallet:hover, +.site-nav .download-wallet:focus-visible { + color: white; + background: var(--blue); +} + +.menu-button { + display: none; +} + +.faucet-stage { + min-height: 670px; + display: grid; + grid-template-columns: minmax(340px, 0.95fr) minmax(400px, 1.05fr); + grid-template-rows: 1fr auto; + position: relative; + padding: clamp(42px, 7vw, 104px) clamp(24px, 6vw, 96px) 62px; + border-bottom: 1px solid var(--ink); +} + +.faucet-stage::before { + content: ""; + position: absolute; + left: 52%; + top: 0; + bottom: 0; + width: 1px; + background: var(--line); +} + +.faucet-copy { + position: relative; + z-index: 2; + align-self: center; + max-width: 660px; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 30px; + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; +} + +.eyebrow span { + width: 36px; + height: 5px; + background: var(--red); +} + +h1 { + max-width: 720px; + margin: 0; + font-size: clamp(54px, 6.6vw, 104px); + line-height: 0.92; + letter-spacing: 0; + text-transform: uppercase; +} + +.intro { + max-width: 550px; + margin: 30px 0 0; + font-size: 17px; + line-height: 1.65; +} + +.signal-field { + position: absolute; + z-index: 1; + top: 48px; + right: max(24px, 5vw); + width: min(43vw, 620px); + aspect-ratio: 1; + opacity: 0.72; + pointer-events: none; +} + +.signal { + position: absolute; + inset: 0; + border: 1px solid var(--line); + border-radius: 50%; +} + +.signal-a { + inset: 8%; + border-color: var(--cyan); + border-width: 8px 1px 1px; + animation: orbit 18s linear infinite; +} + +.signal-b { + inset: 22%; + border: 1px dashed var(--ink); + animation: orbit 26s linear infinite reverse; +} + +.signal-c { + inset: 34%; + border-color: var(--red); + border-width: 1px 5px 1px 1px; + animation: orbit 12s linear infinite; +} + +.signal-core { + position: absolute; + inset: 42%; + display: grid; + place-items: center; + background: var(--blue); + transform: rotate(45deg); +} + +.signal-core img { + width: 58%; + transform: rotate(-45deg); +} + +.coordinate { + position: absolute; + font-family: var(--mono); + font-size: 9px; +} + +.coordinate-a { + top: 13%; + left: 13%; +} + +.coordinate-b { + right: 1%; + bottom: 18%; +} + +@keyframes orbit { + to { transform: rotate(360deg); } +} + +.request-panel { + position: relative; + z-index: 3; + grid-column: 2; + grid-row: 1 / 3; + align-self: end; + justify-self: end; + width: min(100%, 570px); + margin-left: 60px; + border: 1px solid var(--ink); + background: var(--paper); +} + +.panel-index { + position: absolute; + top: -1px; + left: -46px; + width: 45px; + height: 45px; + display: grid; + place-items: center; + color: var(--paper); + background: var(--ink); + font-family: var(--mono); + font-size: 11px; +} + +.panel-heading { + height: 62px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 22px; + border-bottom: 1px solid var(--ink); +} + +.panel-heading p { + margin: 0; + font-weight: 800; + text-transform: uppercase; +} + +.status { + display: flex; + align-items: center; + gap: 7px; + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; +} + +.status i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #34a853; + animation: pulse 1.8s ease-in-out infinite; +} + +.status.offline i { + background: var(--red); +} + +@keyframes pulse { + 50% { opacity: 0.35; } +} + +form { + padding: 28px 22px 22px; +} + +label { + display: block; + margin-bottom: 10px; + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; +} + +.address-control { + height: 58px; + display: grid; + grid-template-columns: 1fr auto; + border: 1px solid var(--ink); + background: white; +} + +.address-control:focus-within { + outline: 3px solid var(--cyan); + outline-offset: 2px; +} + +input { + width: 100%; + min-width: 0; + padding: 0 15px; + border: 0; + outline: 0; + background: transparent; + font-family: var(--mono); + font-size: 12px; +} + +.input-marker { + display: grid; + place-items: center; + padding: 0 14px; + border-left: 1px solid var(--ink); + font-family: var(--mono); + font-size: 10px; +} + +.field-note { + margin: 9px 0 22px; + color: #4a4c49; + font-size: 12px; +} + +.request-button { + width: 100%; + min-height: 62px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 20px; + border: 1px solid var(--ink); + color: var(--ink); + background: var(--acid); + cursor: pointer; + font-weight: 800; + text-transform: uppercase; + transition: background-color 150ms ease; +} + +.request-button b { + width: 46px; + height: 46px; + display: grid; + place-items: center; + color: white; + background: var(--ink); + font-size: 22px; + font-weight: 400; +} + +.request-button:hover, +.request-button:focus-visible { + background: var(--cyan); + outline: 0; +} + +.request-button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.form-message { + display: none; + margin: 0 22px 22px; + padding: 12px 14px; + border-left: 5px solid var(--red); + background: rgba(255, 91, 82, 0.1); + font-family: var(--mono); + font-size: 11px; + line-height: 1.5; +} + +.form-message.visible { + display: block; +} + +.form-message.success { + border-left-color: #278943; + background: rgba(39, 137, 67, 0.1); + overflow-wrap: anywhere; +} + +.request-rules { + display: grid; + grid-template-columns: repeat(3, 1fr); + border-top: 1px solid var(--ink); +} + +.request-rules div { + min-width: 0; + padding: 18px 14px; + border-right: 1px solid var(--line); +} + +.request-rules div:last-child { + border-right: 0; +} + +.request-rules strong, +.request-rules span { + display: block; +} + +.request-rules strong { + margin-bottom: 3px; + font-size: 22px; +} + +.request-rules span { + font-family: var(--mono); + font-size: 8px; + line-height: 1.4; + text-transform: uppercase; +} + +.faucet-support { + min-height: 86px; + display: grid; + grid-template-columns: auto minmax(280px, 1fr) auto; + gap: 30px; + align-items: center; + padding: 16px clamp(24px, 6vw, 96px); + border-bottom: 1px solid var(--ink); + background: var(--acid); +} + +.faucet-support div span, +.faucet-support div strong { + display: block; +} + +.faucet-support div span { + margin-bottom: 4px; + font-family: var(--mono); + font-size: 9px; + text-transform: uppercase; +} + +.faucet-support div strong { + font-size: 17px; + text-transform: uppercase; +} + +.faucet-support code { + min-width: 0; + overflow: hidden; + font-family: var(--mono); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.faucet-support button { + min-height: 42px; + padding: 0 18px; + border: 1px solid var(--ink); + color: white; + background: var(--ink); + cursor: pointer; + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; +} + +.faucet-support button:hover, +.faucet-support button:focus-visible { + color: var(--ink); + background: var(--cyan); + outline: 0; +} + +.network-strip { + min-height: 78px; + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + color: white; + background: var(--ink); +} + +.network-strip p { + margin: 0; + padding: 14px clamp(20px, 4vw, 64px); + border-right: 1px solid rgba(255, 255, 255, 0.16); +} + +.network-strip span, +.network-strip strong { + display: block; +} + +.network-strip span { + margin-bottom: 5px; + color: var(--acid); + font-family: var(--mono); + font-size: 9px; + line-height: 1.5; + text-transform: uppercase; +} + +.network-strip strong { + font-size: 16px; + letter-spacing: 0; +} + +.lower-band { + min-height: 280px; + display: grid; + grid-template-columns: 1.25fr 1fr 0.7fr; + gap: 50px; + align-items: center; + padding: 54px clamp(24px, 6vw, 96px); + border-bottom: 1px solid var(--ink); + background: var(--cyan); +} + +.section-number { + margin: 0 0 12px; + font-family: var(--mono); + font-size: 10px; +} + +h2 { + margin: 0; + font-size: clamp(34px, 4vw, 60px); + line-height: 0.98; + letter-spacing: 0; + text-transform: uppercase; +} + +.lower-band > p { + margin: 0; + font-size: 15px; + line-height: 1.65; +} + +.lower-band > a { + justify-self: end; + padding-bottom: 8px; + border-bottom: 2px solid var(--ink); + font-weight: 800; + text-decoration: none; +} + +.lower-band > a span { + margin-left: 12px; +} + +footer { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 clamp(24px, 6vw, 96px); + color: white; + background: var(--blue); + font-family: var(--mono); + font-size: 9px; +} + +@media (max-width: 1080px) { + .site-header { + flex-wrap: wrap; + } + + .brand { + width: 240px; + flex-basis: 240px; + } + + .site-nav { + order: 3; + width: 100%; + flex-basis: 100%; + border-top: 1px solid var(--ink); + } + + .site-nav a { + min-height: 48px; + } + + .faucet-stage { + grid-template-columns: 1fr 1fr; + } + + .signal-field { + opacity: 0.42; + } +} + +@media (max-width: 760px) { + .site-header { + min-height: 68px; + align-items: stretch; + justify-content: space-between; + } + + .brand { + width: 234px; + flex-basis: 234px; + padding: 10px 16px; + } + + .menu-button { + width: 68px; + display: grid; + place-content: center; + gap: 7px; + border: 0; + border-left: 1px solid var(--ink); + background: transparent; + } + + .menu-button span:not(.sr-only) { + width: 25px; + height: 2px; + display: block; + background: var(--ink); + } + + .site-nav { + display: none; + grid-template-columns: 1fr 1fr; + } + + .site-nav.open { + display: grid; + } + + .site-nav a { + border-bottom: 1px solid var(--line); + } + + .faucet-stage { + min-height: 0; + display: block; + padding: 64px 20px 48px; + } + + .faucet-stage::before { + display: none; + } + + h1 { + font-size: clamp(49px, 15vw, 72px); + } + + .intro { + font-size: 15px; + } + + .signal-field { + top: 12px; + right: -35%; + width: 100vw; + opacity: 0.22; + } + + .request-panel { + width: 100%; + margin: 72px 0 0; + } + + .panel-index { + left: 0; + top: -46px; + } + + .network-strip { + grid-template-columns: 1fr; + } + + .network-strip p { + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.16); + } + + .network-strip p:last-child { + border-bottom: 0; + } + + .faucet-support { + grid-template-columns: 1fr auto; + gap: 14px; + } + + .faucet-support code { + grid-column: 1 / 3; + grid-row: 2; + padding-top: 12px; + border-top: 1px solid rgba(17, 19, 21, 0.25); + } + + .faucet-support button { + grid-column: 2; + grid-row: 1; + } + + .lower-band { + grid-template-columns: 1fr; + gap: 28px; + padding: 48px 20px; + } + + .lower-band > a { + justify-self: start; + } + + footer { + min-height: 78px; + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: 5px; + padding: 20px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +}