Initial Contractless faucet release

This commit is contained in:
viraladmin 2026-07-25 17:48:29 -06:00
commit c9dfdd0386
11 changed files with 1954 additions and 0 deletions

12
.env.example Normal file
View File

@ -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

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Never commit live faucet secrets or state.
.env
.env.*
!.env.example
faucet.env
storage/claims.json
storage/claims.lock

192
README.md Normal file
View File

@ -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.

222
api.php Normal file
View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib.php';
try {
$config = faucet_config();
$network = network_details($config['faucet_address']);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$ledger = with_ledger_lock(false, static fn(array $ledger): array => $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);
}

68
config.php Normal file
View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* Keep faucet.env in the private web-project root, above the public directory.
* For /var/www/project/public/faucet, the default file is:
* /var/www/project/faucet.env
*/
$envFile = getenv('FAUCET_ENV_FILE') ?: dirname(__DIR__, 2) . '/faucet.env';
if (is_file($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
throw new RuntimeException("Could not read faucet configuration: $envFile");
}
foreach ($lines as $lineNumber => $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,
];

BIN
contractless-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

139
index.html Normal file
View File

@ -0,0 +1,139 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Request Contractless testnet currency from the public faucet.">
<title>Contractless Testnet Faucet</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="site-shell">
<header class="site-header">
<a class="brand" href="#" aria-label="Contractless faucet home">
<img src="contractless-logo.png" alt="">
<span>CONTRACTLESS</span>
<small id="brand-network">TESTNET FAUCET</small>
</a>
<button class="menu-button" type="button" aria-expanded="false" aria-controls="site-nav">
<span></span><span></span>
<span class="sr-only">Toggle navigation</span>
</button>
<nav id="site-nav" class="site-nav" aria-label="Project links">
<a href="https://contractless.community/contractless_whitepaper.pdf">Whitepaper</a>
<a href="https://contractless.community/">Community</a>
<a href="https://contractless.dev/contractless/Contractless">Node source</a>
<a href="https://contractless.dev/contractless/Contractless/releases">Node binaries</a>
<a href="https://contractless.dev/contractless/Contractless-GUI-Wallet">Wallet source</a>
<a href="https://contractless.dev/contractless/Contractless-Faucet">Faucet source</a>
<a class="download-wallet" href="https://contractless.dev/contractless/Contractless-GUI-Wallet/releases">Download wallet</a>
</nav>
</header>
<main>
<section class="faucet-stage">
<div class="signal-field" aria-hidden="true">
<div class="signal signal-a"></div>
<div class="signal signal-b"></div>
<div class="signal signal-c"></div>
<div class="signal-core">
<img src="contractless-logo.png" alt="">
</div>
<span class="coordinate coordinate-a">40.7128 / -74.0060</span>
<span id="network-coordinate" class="coordinate coordinate-b">TESTNET / 25 CLTC</span>
</div>
<div class="faucet-copy">
<p class="eyebrow"><span></span> Public <b id="eyebrow-network">testnet</b> utility</p>
<h1>Put the network<br>in your wallet.</h1>
<p class="intro">
Request <span id="intro-amount">25 CLTC</span> <span id="intro-network">testnet</span>
coins for transactions, storage, swaps,
loans, NFTs, and everything else worth testing.
</p>
</div>
<div class="request-panel">
<div class="panel-index" aria-hidden="true">01</div>
<div class="panel-heading">
<p>Faucet request</p>
<div id="faucet-status" class="status"><i></i> Checking</div>
</div>
<form id="faucet-form">
<label for="wallet-address">Registered wallet address</label>
<div class="address-control">
<input
id="wallet-address"
name="wallet-address"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="0000000000000000000000000000000000000000.cltc"
aria-describedby="address-note"
>
<span id="input-symbol" class="input-marker" aria-hidden="true">CLTC</span>
</div>
<p id="address-note" class="field-note">The wallet must already be registered on testnet.</p>
<button type="submit" class="request-button">
<span id="request-label">Request 25 CLTC</span>
<b aria-hidden="true">&rarr;</b>
</button>
</form>
<div id="form-message" class="form-message" role="status" aria-live="polite"></div>
<div class="request-rules">
<div><strong id="rule-amount">25</strong><span id="rule-symbol">CLTC per request</span></div>
<div><strong>24H</strong><span>between claims</span></div>
<div><strong>0</strong><span>real-world value</span></div>
</div>
</div>
</section>
<section class="faucet-support" aria-label="Support the faucet">
<div>
<span>Keep the faucet alive</span>
<strong id="tip-label">Send CLTC tips to:</strong>
</div>
<code id="faucet-address">0000000000000000000000000000000000000000.cltc</code>
<button id="copy-address" type="button">Copy address</button>
</section>
<section class="network-strip" aria-label="Faucet statistics">
<p><span>Total coins in faucet address</span><strong id="faucet-balance">-- CLTC</strong></p>
<p><span>Total coins claimed</span><strong id="total-claimed">0.00000000 CLTC</strong></p>
<p><span>Total addresses claimed</span><strong id="total-addresses">0</strong></p>
</section>
<section class="lower-band">
<div>
<p class="section-number">02 / USE IT</p>
<h2>Test freely.<br>Break things carefully.</h2>
</div>
<p>
<span id="lower-network">Testnet</span> coins are made for experimentation. Create transactions,
explore the wallet, run a node, and help expose problems before
Contractless reaches mainnet.
</p>
<a href="https://contractless.dev/contractless/Contractless/releases">
Run a testnet node <span>&nearr;</span>
</a>
</section>
</main>
<footer>
<span>CONTRACTLESS / FAIR PROOF OF WORK</span>
<span id="footer-network">TESTNET COINS HAVE NO MONETARY VALUE</span>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>

331
lib.php Normal file
View File

@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Transaction\TransferBuilder;
use Contractless\Rpc\Transport\StreamTransport;
use Contractless\Rpc\Wallet\Credentials;
function faucet_config(): array
{
static $config;
if ($config === null) {
$config = require __DIR__ . '/config.php';
}
return $config;
}
function network_details(string $faucetAddress): array
{
$address = strtolower(trim($faucetAddress));
if (str_ends_with($address, '.clc')) {
return ['network' => 'mainnet', 'symbol' => 'CLC', 'suffix' => 'clc'];
}
return ['network' => 'testnet', 'symbol' => 'CLTC', 'suffix' => 'cltc'];
}
function default_ledger(): array
{
return [
'version' => 1,
'last_request_timestamp_ms' => 0,
'total_claimed_atomic' => 0,
'total_claims' => 0,
'addresses' => new stdClass(),
'ips' => new stdClass(),
];
}
function normalize_ledger(array $ledger): array
{
$defaults = default_ledger();
$ledger = array_replace($defaults, $ledger);
$ledger['addresses'] = is_array($ledger['addresses']) ? $ledger['addresses'] : [];
$ledger['ips'] = is_array($ledger['ips']) ? $ledger['ips'] : [];
return $ledger;
}
function read_ledger_unlocked(string $path): array
{
if (!is_file($path)) {
return normalize_ledger([]);
}
$contents = file_get_contents($path);
if ($contents === false || trim($contents) === '') {
return normalize_ledger([]);
}
$decoded = json_decode($contents, true);
if (!is_array($decoded)) {
throw new RuntimeException('The faucet claim ledger is not valid JSON.');
}
return normalize_ledger($decoded);
}
function write_ledger_unlocked(string $path, array $ledger): void
{
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create the faucet storage directory.');
}
$json = json_encode($ledger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Could not encode the faucet claim ledger.');
}
$temporary = $path . '.tmp.' . bin2hex(random_bytes(6));
if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false) {
throw new RuntimeException('Could not write the faucet claim ledger.');
}
if (!rename($temporary, $path)) {
@unlink($temporary);
throw new RuntimeException('Could not replace the faucet claim ledger.');
}
@chmod($path, 0640);
}
function with_ledger_lock(bool $write, callable $callback): mixed
{
$config = faucet_config();
$directory = dirname($config['lock_file']);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create the faucet lock directory.');
}
$lock = fopen($config['lock_file'], 'c+');
if ($lock === false) {
throw new RuntimeException('Could not open the faucet claim lock.');
}
try {
if (!flock($lock, $write ? LOCK_EX : LOCK_SH)) {
throw new RuntimeException('Could not lock the faucet claim ledger.');
}
$ledger = read_ledger_unlocked($config['claims_file']);
$result = $callback($ledger);
if ($write) {
write_ledger_unlocked($config['claims_file'], $ledger);
}
flock($lock, LOCK_UN);
return $result;
} finally {
fclose($lock);
}
}
function normalized_client_ip(string $remoteAddress): ?string
{
$ip = trim($remoteAddress);
if (str_starts_with(strtolower($ip), '::ffff:')) {
$mapped = substr($ip, 7);
if (filter_var($mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $mapped;
}
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $ip;
}
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return null;
}
$packed = inet_pton($ip);
if ($packed === false) {
return null;
}
// One IPv6 /64 is one faucet identity.
return substr(bin2hex($packed), 0, 16) . '::/64';
}
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;
}

178
script.js Normal file
View File

@ -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();

1
storage/.htaccess Normal file
View File

@ -0,0 +1 @@
Require all denied

804
styles.css Normal file
View File

@ -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;
}
}