LibraryService.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.address.Address;
import com.bloxbean.cardano.client.address.AddressProvider;
import com.bloxbean.cardano.client.api.exception.ApiException;
import com.bloxbean.cardano.client.api.model.Amount;
import com.bloxbean.cardano.client.api.model.Result;
import com.bloxbean.cardano.client.api.model.Utxo;
import com.bloxbean.cardano.client.backend.api.BackendService;
import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService;
import com.bloxbean.cardano.client.backend.koios.Constants;
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService;
import com.bloxbean.cardano.client.common.model.Network;
import com.bloxbean.cardano.client.exception.CborSerializationException;
import com.bloxbean.cardano.client.plutus.blueprint.PlutusBlueprintUtil;
import com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion;
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
import com.bloxbean.cardano.client.plutus.spec.PlutusScript;
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder;
import com.bloxbean.cardano.client.quicktx.ScriptTx;
import com.bloxbean.cardano.client.quicktx.Tx;
import com.bloxbean.cardano.client.transaction.spec.Transaction;
import com.bloxbean.cardano.client.util.HexUtil;
import io.uverify.backend.dto.BuildStatus;
import io.uverify.backend.dto.BuildTransactionResponse;
import io.uverify.backend.dto.LibraryDeploymentResponse;
import io.uverify.backend.dto.LibraryEntry;
import io.uverify.backend.entity.LibraryEntity;
import io.uverify.backend.enums.BuildStatusCode;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.enums.TransactionType;
import io.uverify.backend.model.ProxyDatum;
import io.uverify.backend.repository.LibraryRepository;
import io.uverify.backend.util.ValidatorHelper;
import io.uverify.backend.util.ValidatorUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Hex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import static io.uverify.backend.util.ValidatorUtils.*;

@Service
@Slf4j
public class LibraryService {

    @Autowired
    private final ValidatorHelper validatorHelper;
    private final Address serviceUserAddress;
    private final String libraryContractAddress;
    private final PlutusScript libraryContract;
    @Autowired
    private final LibraryRepository libraryRepository;
    private final Network network;
    private Utxo proxyLibraryUtxo;
    private Utxo stateLibraryUtxo;
    private BackendService backendService;

    @Autowired
    public LibraryService(@Value("${cardano.service.user.address}") String serviceUserAddress,
                          @Value("${cardano.backend.service.type}") String cardanoBackendServiceType,
                          @Value("${cardano.backend.blockfrost.baseUrl}") String blockfrostBaseUrl,
                          @Value("${cardano.backend.blockfrost.projectId}") String blockfrostProjectId,
                          @Value("${cardano.network}") String network,
                          ValidatorHelper validatorHelper,
                          LibraryRepository libraryRepository
    ) {
        this.serviceUserAddress = new Address(serviceUserAddress);
        this.validatorHelper = validatorHelper;
        this.libraryRepository = libraryRepository;
        this.network = CardanoNetwork.valueOf(network).toCardaoNetwork();

        if (cardanoBackendServiceType.equals("blockfrost")) {
            if (blockfrostProjectId == null || blockfrostProjectId.isEmpty()) {
                throw new IllegalArgumentException("Blockfrost projectId is required when using Blockfrost backend service");
            }

            this.backendService = new BFBackendService(
                    blockfrostBaseUrl,
                    blockfrostProjectId);
        } else if (cardanoBackendServiceType.equals("koios")) {
            this.backendService = new KoiosBackendService(Constants.KOIOS_PREPROD_URL);
        }

        Optional<byte[]> optionalUserPaymentCredential = this.serviceUserAddress.getPaymentCredentialHash();

        if (optionalUserPaymentCredential.isEmpty()) {
            throw new IllegalArgumentException("Invalid Cardano payment address");
        }

        this.libraryContract = getLibraryContract(optionalUserPaymentCredential.get());
        this.libraryContractAddress = AddressProvider.getEntAddress(libraryContract, this.network).toBech32();
        reloadLibraryCache();
    }

    public void setBackendService(BackendService backendService) {
        this.backendService = backendService;
    }

    public Utxo getProxyLibraryUtxo() {
        if (proxyLibraryUtxo == null) {
            reloadLibraryCache();
        }
        return proxyLibraryUtxo;
    }

    public Utxo getStateLibraryUtxo() {
        if (stateLibraryUtxo == null) {
            reloadLibraryCache();
        }
        return stateLibraryUtxo;
    }

    public Transaction deployUVerifyContracts() {
        Optional<byte[]> optionalUserPaymentCredential = serviceUserAddress.getPaymentCredentialHash();

        if (optionalUserPaymentCredential.isEmpty()) {
            throw new IllegalArgumentException("Invalid Cardano payment address");
        }

        String proxyTransactionHash = validatorHelper.getProxyTransactionHash();
        Integer proxyOutputIndex = validatorHelper.getProxyOutputIndex();

        PlutusScript libraryContract = getLibraryContract(optionalUserPaymentCredential.get());
        String libraryContractAddress = AddressProvider.getEntAddress(libraryContract, network).toBech32();

        PlutusScript uverifyProxyContract = getUverifyProxyContract(proxyTransactionHash, proxyOutputIndex);
        PlutusScript uverifyStateContract = getUVerifyStateContract(proxyTransactionHash, proxyOutputIndex);

        if (libraryRepository.count() > 0) {
            throw new IllegalStateException("Library contracts have already been deployed. Multiple deployments are not allowed.");
        }

        Tx tx = new Tx()
                .from(serviceUserAddress.getAddress())
                .payToContract(libraryContractAddress, Amount.ada(1L), PlutusData.unit(), uverifyProxyContract)
                .payToContract(libraryContractAddress, Amount.ada(1L), PlutusData.unit(), uverifyStateContract)
                .registerStakeAddress(AddressProvider.getRewardAddress(uverifyStateContract, network).toBech32());

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        return quickTxBuilder.compose(tx)
                .feePayer(serviceUserAddress.getAddress())
                .withRequiredSigners(serviceUserAddress)
                .mergeOutputs(false)
                .build();
    }

    public BuildTransactionResponse buildDeployTransaction() {
        return buildDeployTransaction("");
    }

    public BuildTransactionResponse buildDeployTransaction(String compiledCode) {
        try {
            Transaction transaction;
            if (compiledCode != null && !compiledCode.isEmpty()) {
                transaction = upgradeProxy(compiledCode);
            } else {
                transaction = deployUVerifyContracts();
            }
            return BuildTransactionResponse.builder()
                    .unsignedTransaction(transaction.serializeToHex())
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.SUCCESS)
                            .build())
                    .type(TransactionType.DEPLOY)
                    .build();
        } catch (Exception exception) {
            return BuildTransactionResponse.builder()
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.ERROR)
                            .message(exception.getMessage())
                            .build())
                    .type(TransactionType.DEPLOY)
                    .build();
        }
    }

    private Transaction upgradeProxy(String compiledCode) {
        PlutusScript script = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(compiledCode, PlutusVersion.v3);

        Optional<byte[]> optionalUserPaymentCredential = serviceUserAddress.getPaymentCredentialHash();

        if (optionalUserPaymentCredential.isEmpty()) {
            throw new IllegalArgumentException("Invalid Cardano payment address");
        }

        Utxo proxyStateUtxo;
        try {
            proxyStateUtxo = validatorHelper.resolveProxyStateUtxo(backendService);
        } catch (ApiException e) {
            throw new RuntimeException(e);
        }

        PlutusScript libraryContract = getLibraryContract(optionalUserPaymentCredential.get());
        String libraryContractAddress = AddressProvider.getEntAddress(libraryContract, network).toBech32();

        Tx tx = new Tx()
                .from(serviceUserAddress.getAddress())
                .registerStakeAddress(AddressProvider.getRewardAddress(script, network).toBech32())
                .payToAddress(serviceUserAddress.getAddress(), Amount.ada(1L));

        PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
        String proxyScriptAddress = AddressProvider.getEntAddress(proxyContract, network).toBech32();

        ProxyDatum proxyDatum = ProxyDatum.builder()
                .ScriptOwner(Hex.encodeHexString(optionalUserPaymentCredential.get()))
                .ScriptPointer(validatorToScriptHash(script))
                .build();

        ScriptTx scriptTx = new ScriptTx()
                .readFrom(proxyLibraryUtxo)
                .payToContract(libraryContractAddress, Amount.ada(1L), PlutusData.unit(), script)
                .collectFrom(proxyStateUtxo, PlutusData.unit())
                .payToContract(proxyScriptAddress, proxyStateUtxo.getAmount(), proxyDatum.toPlutusData());

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        return quickTxBuilder.compose(tx, scriptTx)
                .feePayer(serviceUserAddress.getAddress())
                .withRequiredSigners(serviceUserAddress)
                .withReferenceScripts(proxyContract)
                .build();
    }

    private void reloadLibraryCache() {
        Optional<LibraryEntity> proxyEntry = libraryRepository.findById(0L);
        Optional<LibraryEntity> stateEntry = libraryRepository.getLatestScript();

        if (proxyEntry.isPresent() && stateEntry.isPresent()) {
            try {
                this.proxyLibraryUtxo = resolveUtxo(proxyEntry.get().getTransactionId(), proxyEntry.get().getOutputIndex());
                this.stateLibraryUtxo = resolveUtxo(stateEntry.get().getTransactionId(), stateEntry.get().getOutputIndex());
            } catch (Exception e) {
                log.error("Failed to reload library cache: {}", e.getMessage());
            }
        } else {
            log.error("Failed to reload library cache: Missing library entries in the database.");
        }
    }

    private Utxo resolveUtxo(String transactionId, Integer outputIndex) {
        try {
            Result<Utxo> utxoResult = backendService.getUtxoService().getTxOutput(transactionId, outputIndex);
            if (utxoResult.isSuccessful()) {
                return utxoResult.getValue();
            } else {
                log.error("Failed to fetch UTXO: {}", utxoResult.getResponse());
                return null;
            }
        } catch (ApiException e) {
            log.error("Failed to fetch UTXO: {}", e.getMessage());
            return null;
        }
    }

    private List<LibraryEntry> getLibraryEntries() {
        List<Utxo> utxos = new ArrayList<>();
        try {
            Result<List<Utxo>> utxoResult = backendService.getUtxoService().getUtxos(libraryContractAddress, 100, 1);
            if (utxoResult.isSuccessful() && utxoResult.getValue() != null) {
                utxos = utxoResult.getValue();
            }
        } catch (ApiException e) {
            log.error("Error fetching UTXOs for library contract address {}: {}", libraryContractAddress, e.getMessage());
        }

        String proxyScriptHash = ValidatorUtils.validatorToScriptHash(validatorHelper.getParameterizedProxyContract());
        String stateScriptHash = ValidatorUtils.validatorToScriptHash(validatorHelper.getParameterizedUVerifyStateContract());

        ArrayList<LibraryEntry> entries = new ArrayList<>();
        for (Utxo utxo : utxos) {
            boolean isActiveContract = utxo.getReferenceScriptHash() != null && (utxo.getReferenceScriptHash().equals(proxyScriptHash) || utxo.getReferenceScriptHash().equals(stateScriptHash));
            entries.add(LibraryEntry.builder()
                    .transactionHash(utxo.getTxHash())
                    .outputIndex(utxo.getOutputIndex())
                    .isActiveContract(isActiveContract)
                    .build());
        }
        return entries;
    }

    public LibraryDeploymentResponse getDeployments() {
        List<LibraryEntry> entries = getLibraryEntries();

        return LibraryDeploymentResponse.builder()
                .address(libraryContractAddress)
                .entries(entries)
                .build();
    }

    public BuildTransactionResponse undeployUnusedContracts() {
        List<LibraryEntry> entries = getLibraryEntries();

        if (entries.isEmpty()) {
            return BuildTransactionResponse.builder()
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.ERROR)
                            .message(
                                    "No contracts found in library"
                            ).build())
                    .build();
        }

        List<LibraryEntry> entriesToUndeploy = entries.stream()
                .filter(entry -> !entry.getIsActiveContract())
                .toList();

        if (entriesToUndeploy.isEmpty()) {
            return BuildTransactionResponse.builder()
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.SUCCESS)
                            .message(
                                    "No unused contracts to undeploy"
                            ).build())
                    .build();
        } else {
            ArrayList<Utxo> utxosToUndeploy = new ArrayList<>();
            for (LibraryEntry entry : entriesToUndeploy) {
                try {
                    Result<Utxo> utxoResult = backendService.getUtxoService().getTxOutput(entry.getTransactionHash(), entry.getOutputIndex());
                    if (utxoResult.isSuccessful() && utxoResult.getValue() != null) {
                        utxosToUndeploy.add(utxoResult.getValue());
                    } else {
                        log.error("Failed to fetch UTXO for transaction hash {} and output index {}", entry.getTransactionHash(), entry.getOutputIndex());
                    }
                } catch (ApiException e) {
                    log.error("Error fetching UTXO for transaction hash {} and output index {}: {}", entry.getTransactionHash(), entry.getOutputIndex(), e.getMessage());
                }
            }
            ScriptTx tx = new ScriptTx()
                    .collectFrom(utxosToUndeploy, PlutusData.unit())
                    .attachSpendingValidator(libraryContract)
                    .payToAddress(serviceUserAddress.getAddress(),
                            utxosToUndeploy.stream()
                                    .map(Utxo::getAmount)
                                    .flatMap(Collection::stream)
                                    .collect(Collectors.toList()));

            QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);

            Transaction unsignedTx = quickTxBuilder.compose(tx)
                    .feePayer(serviceUserAddress.getAddress())
                    .withRequiredSigners(serviceUserAddress)
                    .build();

            String serializedTx = "";
            try {
                serializedTx = HexUtil.encodeHexString(unsignedTx.serialize());
            } catch (CborSerializationException e) {
                log.error("Error serializing transaction: {}", e.getMessage());
            }

            if (serializedTx.equals("")) {
                return BuildTransactionResponse.builder()
                        .status(BuildStatus.builder()
                                .code(BuildStatusCode.ERROR)
                                .message(
                                        "Failed to serialize transaction"
                                ).build())
                        .build();
            }

            return BuildTransactionResponse.builder()
                    .unsignedTransaction(serializedTx)
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.SUCCESS)
                            .build()
                    ).build();
        }
    }

    public BuildTransactionResponse undeployContract(String transactionHash, Integer outputIndex) {
        List<LibraryEntry> entries = getLibraryEntries();
        LibraryEntry libraryEntry = entries.stream().filter(entry -> entry.getTransactionHash().equals(transactionHash) && entry.getOutputIndex().equals(outputIndex)).findFirst().orElse(null);
        if (libraryEntry == null || libraryEntry.getIsActiveContract()) {
            return BuildTransactionResponse.builder()
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.ERROR)
                            .message(
                                    "The library entry is either not found or is an active contract and cannot be undeployed"
                            ).build())
                    .build();
        }

        Utxo utxoToUndeploy = null;
        try {
            Result<Utxo> utxoResult = backendService.getUtxoService().getTxOutput(transactionHash, outputIndex);
            if (utxoResult.isSuccessful() && utxoResult.getValue() != null) {
                utxoToUndeploy = utxoResult.getValue();
            } else {
                log.error("Failed to fetch UTXO for transaction hash {} and output index {}", transactionHash, outputIndex);
            }
        } catch (ApiException e) {
            log.error("Error fetching UTXO for transaction hash {} and output index {}: {}", transactionHash, outputIndex, e.getMessage());
        }

        if (utxoToUndeploy == null) {
            return BuildTransactionResponse.builder()
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.ERROR)
                            .message(
                                    "Failed to fetch UTXO for the library entry to undeploy"
                            ).build())
                    .build();
        }

        ScriptTx tx = new ScriptTx()
                .collectFrom(utxoToUndeploy, PlutusData.unit())
                .attachSpendingValidator(libraryContract)
                .payToAddress(serviceUserAddress.getAddress(),
                        utxoToUndeploy.getAmount());

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        Transaction unsignedTx = quickTxBuilder.compose(tx)
                .feePayer(serviceUserAddress.getAddress())
                .withRequiredSigners(serviceUserAddress)
                .build();

        String serializedTx = "";
        try {
            serializedTx = HexUtil.encodeHexString(unsignedTx.serialize());
        } catch (CborSerializationException e) {
            log.error("Error serializing transaction: {}", e.getMessage());
        }

        if (serializedTx.equals("")) {
            return BuildTransactionResponse.builder()
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.ERROR)
                            .message(
                                    "Failed to serialize transaction"
                            ).build())
                    .build();
        } else {
            return BuildTransactionResponse.builder()
                    .unsignedTransaction(serializedTx)
                    .status(BuildStatus.builder()
                            .code(BuildStatusCode.SUCCESS)
                            .build()
                    ).build();
        }
    }

    private boolean hasProxyScriptRef(com.bloxbean.cardano.yaci.helper.model.Utxo utxo) {
        String compiledCode = utxo.getScriptRef();
        PlutusScript script = PlutusScript.deserializeScriptRef(HexUtil.decodeHexString(compiledCode));

        try {
            return validatorHelper.getParameterizedProxyContract().getPolicyId().equals(script.getPolicyId());
        } catch (Exception e) {
            log.error("Failed to get policy id from scripts: {}", e.getMessage());
            return false;
        }
    }

    private boolean isFirstDeployment() {
        return libraryRepository.count() == 0;
    }

    private void deployUtxoScript(com.bloxbean.cardano.yaci.helper.model.Utxo utxo, Long slot) {
        try {
            String compiledCode = utxo.getScriptRef();
            PlutusScript script = PlutusScript.deserializeScriptRef(HexUtil.decodeHexString(compiledCode));
            LibraryEntity libraryEntity = LibraryEntity.builder()
                    .slot(slot)
                    .transactionId(utxo.getTxHash())
                    .outputIndex(utxo.getIndex())
                    .compiledCode(script.getCborHex())
                    .hash(script.getPolicyId()).build();
            libraryRepository.save(libraryEntity);
        } catch (Exception e) {
            log.error("Potential script deployment skipped. Failed to deserialize inline datum for library UTXO: {}", e.getMessage());
        }
    }

    private void deployAllUtxoScripts(List<com.bloxbean.cardano.yaci.helper.model.Utxo> utxos, Long slot) {
        for (com.bloxbean.cardano.yaci.helper.model.Utxo utxo : utxos) {
            deployUtxoScript(utxo, slot);
        }
    }

    public void deployToLibrary(ArrayList<com.bloxbean.cardano.yaci.helper.model.Utxo> utxos, Long slot) {
        if (isFirstDeployment()) {
            boolean proxyScriptInUtxos = utxos.stream().anyMatch(this::hasProxyScriptRef);
            if (!proxyScriptInUtxos) {
                log.error("No proxy script found in the provided UTXOs for the first deployment. Deployment skipped.");
            } else {
                Optional<com.bloxbean.cardano.yaci.helper.model.Utxo> proxyUtxo = utxos.stream().filter(this::hasProxyScriptRef).findFirst();
                proxyUtxo.ifPresent(utxos::remove);

                if (proxyUtxo.isPresent()) {
                    deployUtxoScript(proxyUtxo.get(), slot);
                } else {
                    log.error("Failed to find the proxy script UTXO for the first deployment. Deployment skipped.");
                    return;
                }

                deployAllUtxoScripts(utxos, slot);
            }
        } else {
            deployAllUtxoScripts(utxos, slot);
        }
    }

    public String getLibraryAddress() {
        return libraryContractAddress;
    }
}