Contractless-Faucet/api.php

242 lines
8.3 KiB
PHP

<?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);
}
$requestedInput = normalize_requested_address(
(string) ($body['address'] ?? ''),
$network['suffix']
);
if ($requestedInput === null) {
json_response([
'success' => false,
'error' => "Enter a valid {$network['network']} wallet or vanity 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);
$rateLimit = with_ledger_lock(true, function (array &$ledger) use (
$nowMs,
$config
): bool {
$lastRequest = (int) $ledger['last_request_timestamp_ms'];
if ($nowMs - $lastRequest < $config['minimum_request_interval_ms']) {
return false;
}
$ledger['last_request_timestamp_ms'] = $nowMs;
return true;
});
if (!$rateLimit) {
json_response([
'success' => false,
'error' => 'The faucet is busy. Try again.',
], 429);
}
$requestedAddress = resolve_requested_address($requestedInput, $network['suffix']);
if ($requestedAddress === null) {
json_response([
'success' => false,
'error' => "The {$network['network']} wallet or vanity address is not registered.",
], 422);
}
$precheck = with_ledger_lock(true, function (array &$ledger) use (
$requestedAddress,
$clientIp,
$now,
$config
): array {
$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);
}