Initial Contractless faucet release
This commit is contained in:
commit
c9dfdd0386
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Never commit live faucet secrets or state.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
faucet.env
|
||||
storage/claims.json
|
||||
storage/claims.lock
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -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">→</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>↗</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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1 @@
|
|||
Require all denied
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue