UserStateService.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.address.Address;
import com.bloxbean.cardano.client.api.exception.ApiException;
import com.bloxbean.cardano.client.crypto.api.impl.EdDSASigningProvider;
import com.bloxbean.cardano.client.exception.CborSerializationException;
import com.bloxbean.cardano.client.transaction.spec.Transaction;
import com.bloxbean.cardano.client.util.HexUtil;
import io.uverify.backend.dto.*;
import io.uverify.backend.entity.BootstrapDatumEntity;
import io.uverify.backend.entity.StateDatumEntity;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.enums.UserAction;
import io.uverify.backend.repository.BootstrapDatumRepository;
import io.uverify.backend.repository.StateDatumRepository;
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.http.HttpStatus;
import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;

import static io.uverify.backend.util.CardanoUtils.fromCardanoNetwork;

@Slf4j
@Service
public class UserStateService {

    private final Account facilitator;
    private final CardanoNetwork network;

    @Autowired
    StateDatumRepository stateDatumRepository;

    @Autowired
    BootstrapDatumRepository bootstrapDatumRepository;

    @Autowired
    CardanoBlockchainService cardanoBlockchainService;

    @Autowired
    public UserStateService(@Value("${cardano.facilitator.user.mnemonic:}") String facilitatorAccountMnemonic,
                            @Value("${cardano.network}") String network) {
        this.network = CardanoNetwork.valueOf(network);
        if (facilitatorAccountMnemonic.isEmpty()) {
            log.warn("Facilitator account mnemonic is empty. Generating a temporary account. " +
                    "This is not recommended for production use and may result in an odd user experience, " +
                    "if this UVerify backend service restarts while requests are still pending.");
            this.facilitator = new Account(fromCardanoNetwork(this.network));
        } else {
            this.facilitator = new Account(fromCardanoNetwork(this.network), facilitatorAccountMnemonic);
        }
    }

    private UserActionResponse buildUserActionResponse(String address, UserAction action) {
        return buildUserActionResponse(address, action, null);
    }

    private UserActionResponse buildUserActionResponse(String address, UserAction action, String stateId) {
        long timestamp = System.currentTimeMillis();
        String actionName = action.name();
        if (stateId != null) {
            actionName += ":" + stateId;
        }

        String message = "[" + actionName + "@" + timestamp + "] Please sign this message with your private key to verify, " +
                "that you are the owner of the address " + address + ".";
        EdDSASigningProvider edDSASigningProvider = new EdDSASigningProvider();
        String signature = HexUtil.encodeHexString(
                edDSASigningProvider.signExtended(message.getBytes(StandardCharsets.UTF_8), facilitator.privateKeyBytes()));
        return UserActionResponse.builder()
                .address(address)
                .action(action)
                .signature(signature)
                .timestamp(timestamp)
                .message(message)
                .status(HttpStatus.OK)
                .build();
    }


    public UserActionResponse requestUserInfo(String address) {
        return buildUserActionResponse(address, UserAction.USER_INFO);
    }

    public UserActionResponse requestInvalidateState(String address, String stateId) {
        return buildUserActionResponse(address, UserAction.INVALIDATE_STATE, stateId);
    }

    public UserActionResponse requestOptOut(String address) {
        return buildUserActionResponse(address, UserAction.OPT_OUT);
    }

    private boolean signaturesAreValid(ExecuteUserActionRequest actionRequest) {
        EdDSASigningProvider edDSASigningProvider = new EdDSASigningProvider();

        String actionName = actionRequest.getAction().name();
        if (actionRequest.getStateId() != null) {
            actionName += ":" + actionRequest.getStateId();
        }

        String message = "[" + actionName + "@" + actionRequest.getTimestamp() + "] Please sign this message with your private key to verify, " +
                "that you are the owner of the address " + actionRequest.getAddress() + ".";

        if (!message.equals(actionRequest.getMessage())) {
            return false;
        }

        CIP30Verifier cip30Verifier = new CIP30Verifier(actionRequest.getUserSignature(), actionRequest.getUserPublicKey());
        Cip30VerificationResult cip30VerificationResult = cip30Verifier.verify();
        Optional<String> optionalUserAddress = cip30VerificationResult.getAddress(AddressFormat.TEXT);

        if (optionalUserAddress.isEmpty()) {
            return false;
        }
        // TODO: Check if timestamp is in validity range

        return edDSASigningProvider.verify(HexUtil.decodeHexString(actionRequest.getSignature()),
                actionRequest.getMessage().getBytes(), facilitator.publicKeyBytes()) &&
                cip30VerificationResult.isValid() && message.equals(cip30VerificationResult.getMessage(MessageFormat.TEXT))
                && actionRequest.getAddress().equals(optionalUserAddress.get());
    }

    public ExecuteUserActionResponse executeUserOptOut(ExecuteUserActionRequest request) {
        if (signaturesAreValid(request)) {
            String userCredential = HexUtil.encodeHexString(CardanoUtils.extractCredentialFromAddress(request.getAddress()));
            List<StateDatumEntity> stateDatumEntities = stateDatumRepository.findByOwner(userCredential);
            Address userAddress = new Address(request.getAddress());
            ExecuteUserActionResponse response = ExecuteUserActionResponse.builder().status(HttpStatus.OK).build();
            try {
                Transaction unsignedTransaction = cardanoBlockchainService.invalidateStates(userAddress, stateDatumEntities.stream().map(StateDatumEntity::getTransactionId).toList());
                response.setUnsignedTransaction(unsignedTransaction.serializeToHex());
            } catch (ApiException | CborSerializationException exception) {
                log.error("Failed to invalidate states", exception);
                response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
                response.setError("Failed to invalidate states.");
            }
            return response;
        } else {
            return ExecuteUserActionResponse.builder().status(HttpStatus.BAD_REQUEST)
                    .error("The provided signatures are not valid.").build();
        }
    }

    public ExecuteUserActionResponse executeStateInvalidationRequest(ExecuteUserActionRequest actionRequest) {
        if (signaturesAreValid(actionRequest)) {
            Optional<StateDatumEntity> optionalStateDatum = stateDatumRepository.findById(actionRequest.getStateId());

            if (optionalStateDatum.isEmpty()) {
                return ExecuteUserActionResponse.builder()
                        .status(HttpStatus.BAD_REQUEST)
                        .error("State with the provided ID does not exist.")
                        .build();
            }

            StateDatumEntity stateDatumEntity = optionalStateDatum.get();
            Address userAddress = new Address(stateDatumEntity.getOwner());
            ExecuteUserActionResponse response = ExecuteUserActionResponse.builder().status(HttpStatus.OK).build();
            try {
                response.setUnsignedTransaction(cardanoBlockchainService.invalidateState(userAddress, stateDatumEntity.getTransactionId()).serializeToHex());
            } catch (ApiException exception) {
                log.error("Failed to invalidate state", exception);
                response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
                response.setError("Failed to invalidate state.");
            } catch (CborSerializationException exception) {
                log.error("Failed to serialize transaction", exception);
                response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
                response.setError("Failed to serialize transaction.");
            }
            return response;
        }
        return ExecuteUserActionResponse.builder().status(HttpStatus.BAD_REQUEST)
                .error("The provided signatures are not valid.").build();
    }

    public ExecuteUserActionResponse executeUserInfoRequest(ExecuteUserActionRequest actionRequest) {
        if (signaturesAreValid(actionRequest)) {
            String userCredential = HexUtil.encodeHexString(CardanoUtils.extractCredentialFromAddress(actionRequest.getAddress()));
            List<BootstrapDatumEntity> bootstrapDatumEntities = bootstrapDatumRepository.findAllWhitelisted();
            List<BootstrapDatumEntity> customBootstrapDatumEntities = bootstrapDatumRepository.findByAllowedCredential(userCredential);

            bootstrapDatumEntities.addAll(customBootstrapDatumEntities);

            List<StateDatumEntity> stateDatumEntities = stateDatumRepository.findByOwner(userCredential);

            return ExecuteUserActionResponse.builder()
                    .state(UserState.builder()
                            .bootstrapDatums(bootstrapDatumEntities.stream()
                                    .map(bootstrapDatumEntity ->
                                            BootstrapData.fromBootstrapDatumEntity(bootstrapDatumEntity, network))
                                    .toList())
                            .states(stateDatumEntities.stream().map(stateDatumEntity ->
                                            StateData.fromStateDatumEntity(stateDatumEntity, network))
                                    .toList())
                            .build())
                    .status(HttpStatus.OK)
                    .build();
        }
        return ExecuteUserActionResponse.builder().status(HttpStatus.BAD_REQUEST)
                .error("The provided signatures are not valid.").build();
    }
}