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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
40.7128 / -74.0060
+
TESTNET / 25 CLTC
+
+
+
+
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.
+
+
+
+
+
01
+
+
Faucet request
+
Checking
+
+
+
+
+
+
+
+
25 CLTC per request
+
24H between claims
+
0 real-world value
+
+
+
+
+
+
+ Keep the faucet alive
+ Send CLTC tips to:
+
+ 0000000000000000000000000000000000000000.cltc
+ Copy address
+
+
+
+ Total coins in faucet address -- CLTC
+ Total coins claimed 0.00000000 CLTC
+ Total addresses claimed 0
+
+
+
+
+
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 ↗
+
+
+
+
+
+ CONTRACTLESS / FAIR PROOF OF WORK
+
+
+
+
+
+
+
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;
+ }
+}