379 lines
11 KiB
PHP
379 lines
11 KiB
PHP
<?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';
|
|
}
|
|
|
|
/**
|
|
* Normalize a claim input without contacting the node.
|
|
*
|
|
* Canonical addresses are returned as-is. Vanity names are left-padded to
|
|
* the fixed 20-byte payload required by the vanity-owner RPC command.
|
|
*/
|
|
function normalize_requested_address(string $address, string $suffix): ?array
|
|
{
|
|
$address = strtolower(trim($address));
|
|
$escapedSuffix = preg_quote($suffix, '/');
|
|
$canonical = "[a-f0-9]{40}\\.$escapedSuffix";
|
|
if (preg_match("/^$canonical$/", $address) === 1) {
|
|
return ['type' => 'canonical', 'address' => $address];
|
|
}
|
|
|
|
$separator = strrpos($address, '.');
|
|
if ($separator === false) {
|
|
return null;
|
|
}
|
|
$payload = substr($address, 0, $separator);
|
|
$network = substr($address, $separator + 1);
|
|
if (
|
|
$network !== $suffix
|
|
|| preg_match('/^[a-z]{1,20}$/', $payload) !== 1
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'type' => 'vanity',
|
|
'address' => str_pad($payload, 20, ' ', STR_PAD_LEFT) . '.' . $suffix,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolve either an ordinary short address or registered vanity address to
|
|
* the canonical short address used by transfers and faucet cooldown records.
|
|
*/
|
|
function resolve_requested_address(array $requested, string $suffix): ?string
|
|
{
|
|
if ($requested['type'] === 'canonical') {
|
|
return $requested['address'];
|
|
}
|
|
|
|
$owner = strtolower(trim(faucet_rpc()['client']->vanityOwner($requested['address'])));
|
|
if ($owner === '' || str_starts_with($owner, 'error:')) {
|
|
return null;
|
|
}
|
|
|
|
$escapedSuffix = preg_quote($suffix, '/');
|
|
return preg_match("/^[a-f0-9]{40}\\.$escapedSuffix$/", $owner) === 1
|
|
? $owner
|
|
: 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;
|
|
}
|