FaucetService.java
/*
* UVerify Backend
* Copyright (C) 2025 Fabian Bormann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package io.uverify.backend.service;
import com.bloxbean.cardano.client.account.Account;
import com.bloxbean.cardano.client.api.exception.ApiException;
import com.bloxbean.cardano.client.api.model.Result;
import com.bloxbean.cardano.client.crypto.api.impl.EdDSASigningProvider;
import com.bloxbean.cardano.client.exception.CborSerializationException;
import com.bloxbean.cardano.client.util.HexUtil;
import io.uverify.backend.dto.FaucetChallengeRequest;
import io.uverify.backend.dto.FaucetChallengeResponse;
import io.uverify.backend.dto.FaucetClaimRequest;
import io.uverify.backend.dto.FaucetClaimResponse;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.util.CardanoUtils;
import lombok.extern.slf4j.Slf4j;
import org.cardanofoundation.cip30.AddressFormat;
import org.cardanofoundation.cip30.CIP30Verifier;
import org.cardanofoundation.cip30.Cip30VerificationResult;
import org.cardanofoundation.cip30.MessageFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import static io.uverify.backend.util.CardanoUtils.fromCardanoNetwork;
@Slf4j
@Service
@ConditionalOnProperty(name = "faucet.enabled", havingValue = "true")
public class FaucetService {
private static final String ACTION_NAME = "FAUCET_REQUEST";
private final Account faucetAccount;
private final int utxoCount;
private final BigInteger lovelacePerUtxo;
private final long cooldownMs;
/**
* Payment-credential-hex → timestamp of last successful claim.
*/
private final Map<String, Long> cooldownMap = new ConcurrentHashMap<>();
@Autowired
private CardanoBlockchainService cardanoBlockchainService;
@Autowired
public FaucetService(
@Value("${faucet.mnemonic:}") String faucetMnemonic,
@Value("${faucet.utxo-count:3}") int utxoCount,
@Value("${faucet.utxo-amount-lovelace:10000000}") long utxoAmountLovelace,
@Value("${faucet.cooldown-ms:120000}") long cooldownMs,
@Value("${cardano.network}") String network) {
CardanoNetwork cardanoNetwork = CardanoNetwork.valueOf(network);
this.utxoCount = utxoCount;
this.lovelacePerUtxo = BigInteger.valueOf(utxoAmountLovelace);
this.cooldownMs = cooldownMs;
if (faucetMnemonic.isEmpty()) {
log.warn("Faucet mnemonic is not set. Generating a temporary faucet account. " +
"This account will have no funds — set FAUCET_MNEMONIC to a pre-funded testnet wallet.");
this.faucetAccount = new Account(fromCardanoNetwork(cardanoNetwork));
} else {
this.faucetAccount = Account.createFromMnemonic(fromCardanoNetwork(cardanoNetwork), faucetMnemonic);
}
log.info("Faucet enabled. Faucet address: {}", this.faucetAccount.baseAddress());
}
public FaucetChallengeResponse requestChallenge(FaucetChallengeRequest request) {
String address = request.getAddress();
if (isInCooldown(address)) {
return FaucetChallengeResponse.builder()
.status(HttpStatus.TOO_MANY_REQUESTS)
.error("This address has recently received funds. Please wait before requesting again.")
.build();
}
long timestamp = System.currentTimeMillis();
String message = buildMessage(address, timestamp);
EdDSASigningProvider edDSASigningProvider = new EdDSASigningProvider();
String signature = HexUtil.encodeHexString(
edDSASigningProvider.signExtended(message.getBytes(StandardCharsets.UTF_8), faucetAccount.privateKeyBytes()));
return FaucetChallengeResponse.builder()
.address(address)
.message(message)
.signature(signature)
.timestamp(timestamp)
.status(HttpStatus.OK)
.build();
}
public FaucetClaimResponse claimFunds(FaucetClaimRequest request) {
if (!signaturesAreValid(request)) {
return FaucetClaimResponse.builder()
.status(HttpStatus.BAD_REQUEST)
.error("The provided signatures are not valid or the request is outdated (older than 10 minutes).")
.build();
}
if (!hasValidTimeframe(request.getTimestamp())) {
return FaucetClaimResponse.builder()
.status(HttpStatus.BAD_REQUEST)
.error("The request is outdated. Please request a new challenge.")
.build();
}
if (isInCooldown(request.getAddress())) {
return FaucetClaimResponse.builder()
.status(HttpStatus.TOO_MANY_REQUESTS)
.error("This address has recently received funds. Please wait before requesting again.")
.build();
}
// Record cooldown before submitting to prevent race conditions
String credentialHex = HexUtil.encodeHexString(CardanoUtils.extractCredentialFromAddress(request.getAddress()));
cooldownMap.put(credentialHex, System.currentTimeMillis());
try {
Result<String> result = cardanoBlockchainService.sendAda(
faucetAccount, request.getAddress(), utxoCount, lovelacePerUtxo);
if (!result.isSuccessful()) {
// Remove cooldown entry so the user can retry
cooldownMap.remove(credentialHex);
log.error("Faucet transaction failed: {}", result.getResponse());
return FaucetClaimResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.error("Failed to submit faucet transaction: " + result.getResponse())
.build();
}
log.info("Faucet sent {} UTXOs of {} lovelace to {} (tx: {})",
utxoCount, lovelacePerUtxo, request.getAddress(), result.getValue());
return FaucetClaimResponse.builder()
.txHash(result.getValue())
.status(HttpStatus.OK)
.build();
} catch (CborSerializationException | ApiException e) {
cooldownMap.remove(credentialHex);
log.error("Faucet transaction error for address {}", request.getAddress(), e);
return FaucetClaimResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.error("Failed to submit faucet transaction.")
.build();
}
}
private boolean isInCooldown(String address) {
String credentialHex;
try {
credentialHex = HexUtil.encodeHexString(CardanoUtils.extractCredentialFromAddress(address));
} catch (IllegalArgumentException e) {
return false;
}
Long lastClaim = cooldownMap.get(credentialHex);
if (lastClaim == null) {
return false;
}
if (System.currentTimeMillis() - lastClaim >= cooldownMs) {
cooldownMap.remove(credentialHex);
return false;
}
return true;
}
private boolean signaturesAreValid(FaucetClaimRequest request) {
String expectedMessage = buildMessage(request.getAddress(), request.getTimestamp());
if (!expectedMessage.equals(request.getMessage())) {
return false;
}
EdDSASigningProvider edDSASigningProvider = new EdDSASigningProvider();
boolean backendSignatureValid = edDSASigningProvider.verify(
HexUtil.decodeHexString(request.getSignature()),
request.getMessage().getBytes(StandardCharsets.UTF_8),
faucetAccount.publicKeyBytes());
if (!backendSignatureValid) {
return false;
}
CIP30Verifier cip30Verifier = new CIP30Verifier(request.getUserSignature(), request.getUserPublicKey());
Cip30VerificationResult result = cip30Verifier.verify();
Optional<String> optionalUserAddress = result.getAddress(AddressFormat.TEXT);
if (optionalUserAddress.isEmpty()) {
return false;
}
return result.isValid()
&& expectedMessage.equals(result.getMessage(MessageFormat.TEXT))
&& request.getAddress().equals(optionalUserAddress.get());
}
private boolean hasValidTimeframe(long timestamp) {
return Math.abs(System.currentTimeMillis() - timestamp) <= 600_000;
}
private String buildMessage(String address, long timestamp) {
return "[" + ACTION_NAME + "@" + timestamp + "] Please sign this message with your private key to verify, " +
"that you are the owner of the address " + address + ".";
}
}