CardanoBlockchainService.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.address.AddressProvider;
import com.bloxbean.cardano.client.address.Credential;
import com.bloxbean.cardano.client.api.UtxoSupplier;
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.api.DefaultUtxoSupplier;
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.backend.model.TxContentUtxo;
import com.bloxbean.cardano.client.backend.model.TxContentUtxoOutputs;
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.transaction.TransactionSigner;
import com.bloxbean.cardano.client.transaction.spec.Asset;
import com.bloxbean.cardano.client.transaction.spec.Transaction;
import com.bloxbean.cardano.client.util.HexUtil;
import com.bloxbean.cardano.yaci.store.common.domain.AddressUtxo;
import io.uverify.backend.entity.BootstrapDatumEntity;
import io.uverify.backend.entity.FeeReceiverEntity;
import io.uverify.backend.entity.StateDatumEntity;
import io.uverify.backend.entity.UVerifyCertificateEntity;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.model.BootstrapDatum;
import io.uverify.backend.model.StateDatum;
import io.uverify.backend.model.UVerifyCertificate;
import io.uverify.backend.util.CardanoUtils;
import io.uverify.backend.util.ValidatorUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;

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

@Service
@Slf4j
public class CardanoBlockchainService {
    @Autowired
    private final BootstrapDatumService bootstrapDatumService;
    @Autowired
    private final StateDatumService stateDatumService;
    @Autowired
    private final UVerifyCertificateService uVerifyCertificateService;
    private final CardanoNetwork network;
    private final Address serviceUserAddress;
    private BackendService backendService;

    @Autowired
    public CardanoBlockchainService(@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,
                                    UVerifyCertificateService uVerifyCertificateService,
                                    BootstrapDatumService bootstrapDatumService, StateDatumService stateDatumService
    ) {
        this.bootstrapDatumService = bootstrapDatumService;
        this.stateDatumService = stateDatumService;
        this.uVerifyCertificateService = uVerifyCertificateService;
        this.network = CardanoNetwork.valueOf(network);
        this.serviceUserAddress = new Address(serviceUserAddress);

        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);
        }
    }

    public Result<String> submitTransaction(Transaction transaction, Account signer) throws CborSerializationException, ApiException {
        Transaction signedTransaction = TransactionSigner.INSTANCE.sign(transaction, signer.hdKeyPair());
        return submitTransaction(signedTransaction);
    }

    public Result<String> submitTransaction(Transaction transaction) throws CborSerializationException, ApiException {
        return backendService.getTransactionService().submitTransaction(transaction.serialize());
    }

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

    public Transaction updateStateDatum(String address, List<UVerifyCertificate> uVerifyCertificates) throws ApiException {
        return updateStateDatum(address, uVerifyCertificates, "");
    }

    public Transaction updateStateDatum(String address, List<UVerifyCertificate> uVerifyCertificates, String bootstrapTokenName) throws ApiException {
        Optional<StateDatumEntity> stateDatumEntity = Optional.empty();
        if (bootstrapTokenName.isEmpty()) {
            List<StateDatumEntity> stateDatumEntities = stateDatumService.findByOwner(address);
            if (stateDatumEntities.size() == 1) {
                stateDatumEntity = Optional.of(stateDatumEntities.get(0));
            } else if (stateDatumEntities.size() > 1) {
                stateDatumEntity = Optional.of(stateDatumService.selectCheapestStateDatum(stateDatumEntities));
            }
        } else {
            stateDatumEntity = stateDatumService.findByUserAndBootstrapToken(address, bootstrapTokenName);
        }

        if (stateDatumEntity.isEmpty()) {
            throw new IllegalArgumentException("No applicable state datum found for user account");
        }

        StateDatumEntity stateDatum = stateDatumEntity.get();
        return updateStateDatum(address, stateDatum, uVerifyCertificates);
    }

    public Transaction updateStateDatum(String address, StateDatumEntity stateDatum, List<UVerifyCertificate> uVerifyCertificates) throws ApiException {
        Address userAddress = new Address(address);
        Optional<byte[]> optionalUserPaymentCredential = userAddress.getPaymentCredentialHash();

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

        byte[] userAccountCredential = optionalUserPaymentCredential.get();

        String unit = getMintStateTokenHash(network) + Hex.encodeHexString(userAccountCredential);

        Optional<Utxo> optionalUtxo = ValidatorUtils.getUtxoByTransactionAndUnit(stateDatum.getTransactionId(), unit, backendService);

        if (optionalUtxo.isEmpty()) {
            throw new IllegalArgumentException("State token not found in transaction outputs");
        }

        Utxo utxo = optionalUtxo.get();

        PlutusScript updateTestStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getUpdateStateTokenCode(network), PlutusVersion.v3);
        String updateTestStateScriptAddress = AddressProvider.getEntAddress(updateTestStateTokenScript, fromCardanoNetwork(network)).toBech32();

        StateDatum nextStateDatum = StateDatum.fromPreviousStateDatum(utxo.getInlineDatum());
        nextStateDatum.setUVerifyCertificates(uVerifyCertificates);
        ScriptTx updateStateTokenTx = new ScriptTx()
                .collectFrom(utxo, PlutusData.unit())
                .payToContract(updateTestStateScriptAddress, utxo.getAmount(), nextStateDatum.toPlutusData(network))
                .attachSpendingValidator(updateTestStateTokenScript);

        BootstrapDatumEntity bootstrapDatum = stateDatum.getBootstrapDatum();
        if (stateDatum.getCountdown() % bootstrapDatum.getFeeInterval() == 0) {
            long fee = bootstrapDatum.getFee() / bootstrapDatum.getFeeReceivers().size();
            for (FeeReceiverEntity feeReceiver : bootstrapDatum.getFeeReceivers()) {
                Credential credential = Credential.fromKey(feeReceiver.getCredential());
                Address feeReceiverAddress = AddressProvider.getEntAddress(credential, fromCardanoNetwork(network));
                updateStateTokenTx.payToAddress(feeReceiverAddress.getAddress(), Amount.lovelace(BigInteger.valueOf(fee)));
            }
        }

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        long validFrom = currentSlot - 10;
        long transactionTtl = currentSlot + 600; // 10 minutes

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        return quickTxBuilder.compose(updateStateTokenTx)
                .validFrom(validFrom)
                .validTo(transactionTtl)
                .collateralPayer(address)
                .feePayer(address)
                .withRequiredSigners(userAddress)
                .build();
    }

    public Transaction persistUVerifyCertificates(String address, List<UVerifyCertificate> uVerifyCertificate) throws ApiException {
        List<StateDatumEntity> stateDatumEntities = stateDatumService.findByOwner(address);
        if (stateDatumEntities.isEmpty()) {
            log.debug("No state datum found for address " + address + ". Start forking a new state datum.");
            return forkStateDatum(address, uVerifyCertificate);
        } else {
            StateDatumEntity stateDatumEntity = stateDatumService.selectCheapestStateDatum(stateDatumEntities);
            boolean needsToPayFee = stateDatumEntity.getCountdown() % stateDatumEntity.getBootstrapDatum().getFeeInterval() == 0;
            if (needsToPayFee) {
                log.debug("Fee required for updating state datum. Checking for better conditions.");
                Address userAddress = new Address(address);
                Optional<byte[]> optionalUserAccountCredential = userAddress.getPaymentCredentialHash();

                if (optionalUserAccountCredential.isEmpty()) {
                    throw new IllegalArgumentException("Invalid Cardano payment address");
                }
                Optional<BootstrapDatum> bootstrapDatum = bootstrapDatumService.selectCheapestBootstrapDatum(optionalUserAccountCredential.get());

                if (bootstrapDatum.isEmpty()) {
                    return updateStateDatum(address, stateDatumEntity, uVerifyCertificate);
                }

                double bootstrapFeeEveryHundredTransactions = (100.0 / bootstrapDatum.get().getFeeInterval()) * bootstrapDatum.get().getFee();
                double stateFeeEveryHundredTransactions = (100.0 / stateDatumEntity.getBootstrapDatum().getFeeInterval()) * stateDatumEntity.getBootstrapDatum().getFee();
                if (bootstrapFeeEveryHundredTransactions < stateFeeEveryHundredTransactions) {
                    log.debug("Forking state datum with better conditions.");
                    return forkStateDatum(address, uVerifyCertificate, bootstrapDatum.get().getTokenName());
                } else {
                    log.debug("Updating state datum with current conditions.");
                    return updateStateDatum(address, stateDatumEntity, uVerifyCertificate);
                }
            } else {
                log.debug("No fee required for updating state datum");
                return updateStateDatum(address, stateDatumEntity, uVerifyCertificate);
            }
        }
    }

    public Transaction forkStateDatum(String address, List<UVerifyCertificate> uVerifyCertificates) throws ApiException {
        Address userAddress = new Address(address);
        Optional<byte[]> optionalUserAccountCredential = userAddress.getPaymentCredentialHash();

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

        Optional<BootstrapDatum> optionalBootstrapDatum = bootstrapDatumService.selectCheapestBootstrapDatum(optionalUserAccountCredential.get());

        if (optionalBootstrapDatum.isEmpty()) {
            throw new IllegalArgumentException("No applicable bootstrap datum found for user account");
        }

        return forkStateDatum(address, uVerifyCertificates, optionalBootstrapDatum.get().getTokenName());
    }

    public Transaction forkStateDatum(String address, List<UVerifyCertificate> uVerifyCertificates, String bootstrapTokenName) throws ApiException {
        Optional<BootstrapDatumEntity> optionalBootstrapDatumEntity = bootstrapDatumService.getBootstrapDatum(bootstrapTokenName);

        if (optionalBootstrapDatumEntity.isEmpty()) {
            throw new IllegalArgumentException("Bootstrap datum with name " + bootstrapTokenName + " not found");
        }

        BootstrapDatumEntity bootstrapDatumEntity = optionalBootstrapDatumEntity.get();

        Address userAddress = new Address(address);
        Optional<byte[]> optionalUserAccountCredential = userAddress.getPaymentCredentialHash();

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

        byte[] userAccountCredential = optionalUserAccountCredential.get();
        Result<TxContentUtxo> transactionUtxosResult = backendService.getTransactionService().getTransactionUtxos(bootstrapDatumEntity.getTransactionId());

        String unit = getMintOrBurnAuthTokenHash(network) + HexUtil.encodeHexString(bootstrapTokenName.getBytes());
        Optional<TxContentUtxoOutputs> optionalTxContentUtxoOutput = transactionUtxosResult.getValue().getOutputs().stream().filter(utxo -> utxo.getAmount().stream().anyMatch(amount -> amount.getUnit().equals(unit))).findFirst();

        if (optionalTxContentUtxoOutput.isEmpty()) {
            throw new IllegalArgumentException("Bootstrap token or datum not found in transaction outputs");
        }

        TxContentUtxoOutputs txContentUtxoOutput = optionalTxContentUtxoOutput.get();
        Utxo utxo = txContentUtxoOutput.toUtxos(bootstrapDatumEntity.getTransactionId());

        StateDatum stateDatum = StateDatum.fromBootstrapDatum(utxo.getInlineDatum(), userAccountCredential);
        stateDatum.setUVerifyCertificates(uVerifyCertificates);
        stateDatum.setCountdown(stateDatum.getCountdown() - 1);

        PlutusScript mintStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getMintStateTokenCode(network), PlutusVersion.v3);
        Asset userStateToken = Asset.builder()
                .name("0x" + Hex.encodeHexString(userAccountCredential))
                .value(BigInteger.ONE)
                .build();

        PlutusScript updateTestStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getUpdateStateTokenCode(network), PlutusVersion.v3);
        String updateTestStateScriptAddress = AddressProvider.getEntAddress(updateTestStateTokenScript, fromCardanoNetwork(network)).toBech32();

        UtxoSupplier utxoSupplier = new DefaultUtxoSupplier(backendService.getUtxoService());
        List<Utxo> userUtxos = utxoSupplier.getAll(address);

        if (userUtxos.isEmpty()) {
            throw new IllegalArgumentException("No UTXOs found for user address");
        }

        Utxo userUtxo = userUtxos.get(0);
        String index = HexUtil.encodeHexString(ByteBuffer.allocate(2)
                .order(ByteOrder.LITTLE_ENDIAN)
                .putShort((short) userUtxo.getOutputIndex())
                .array());

        stateDatum.setId(DigestUtils.sha256Hex(HexUtil.decodeHexString(userUtxo.getTxHash() + index)));

        ScriptTx scriptTransaction = new ScriptTx()
                .readFrom(utxo)
                .collectFrom(userUtxo)
                .mintAsset(mintStateTokenScript, List.of(userStateToken), PlutusData.unit(), updateTestStateScriptAddress, stateDatum.toPlutusData(network));

        if (bootstrapDatumEntity.getFee() > 0) {
            long fee = bootstrapDatumEntity.getFee() / stateDatum.getFeeReceivers().size();
            for (byte[] paymentCredential : stateDatum.getFeeReceivers()) {
                Credential credential = Credential.fromKey(paymentCredential);
                Address feeReceiverAddress = AddressProvider.getEntAddress(credential, fromCardanoNetwork(network));
                scriptTransaction.payToAddress(feeReceiverAddress.getAddress(), Amount.lovelace(BigInteger.valueOf(fee)));
            }
        }

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        long validFrom = currentSlot - 10;
        long transactionTtl = currentSlot + 600; // 10 minutes

        return quickTxBuilder.compose(scriptTransaction)
                .validFrom(validFrom)
                .validTo(transactionTtl)
                .collateralPayer(address)
                .feePayer(address)
                .withRequiredSigners(userAddress)
                .build();
    }

    public Transaction invalidateBootstrapDatum(String bootstrapTokenName) throws ApiException {
        Optional<BootstrapDatumEntity> optionalBootstrapDatumEntity = bootstrapDatumService.getBootstrapDatum(bootstrapTokenName);

        if (optionalBootstrapDatumEntity.isEmpty()) {
            throw new IllegalArgumentException("Bootstrap datum with name " + bootstrapTokenName + " doesn't exists");
        }

        BootstrapDatumEntity bootstrapDatum = optionalBootstrapDatumEntity.get();
        Result<TxContentUtxo> transactionUtxosResult = backendService.getTransactionService().getTransactionUtxos(bootstrapDatum.getTransactionId());

        String unit = getMintOrBurnAuthTokenHash(network) + HexUtil.encodeHexString(bootstrapTokenName.getBytes());
        Optional<TxContentUtxoOutputs> optionalTxContentUtxoOutput = transactionUtxosResult.getValue().getOutputs().stream().filter(utxo -> utxo.getAmount().stream().anyMatch(amount -> amount.getUnit().equals(unit))).findFirst();

        if (optionalTxContentUtxoOutput.isEmpty()) {
            throw new IllegalArgumentException("Bootstrap token or datum not found in transaction outputs");
        }

        TxContentUtxoOutputs txContentUtxoOutput = optionalTxContentUtxoOutput.get();
        Utxo utxo = txContentUtxoOutput.toUtxos(bootstrapDatum.getTransactionId());

        PlutusScript authorizationScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getMintOrBurnAuthTokenCode(network), PlutusVersion.v3);
        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);

        Asset authorizationToken = Asset.builder()
                .name(bootstrapDatum.getTokenName())
                .value(BigInteger.valueOf(-1))
                .build();


        UtxoSupplier utxoSupplier = new DefaultUtxoSupplier(backendService.getUtxoService());
        List<Utxo> userUtxos = utxoSupplier.getAll(serviceUserAddress.getAddress());

        if (userUtxos.isEmpty()) {
            throw new IllegalArgumentException("No UTXOs found for user address");
        }

        Utxo userUtxo = userUtxos.get(0);

        ScriptTx scriptTx = new ScriptTx()
                .attachSpendingValidator(authorizationScript)
                .collectFrom(utxo, PlutusData.unit())
                .collectFrom(userUtxo)
                .mintAsset(authorizationScript, List.of(authorizationToken), PlutusData.unit());

        return quickTxBuilder.compose(scriptTx)
                .feePayer(serviceUserAddress.getAddress())
                .withRequiredSigners(serviceUserAddress)
                .build();
    }

    public Transaction initializeBootstrapDatum(BootstrapDatum bootstrapDatum) throws ApiException {
        if (bootstrapDatumService.bootstrapDatumAlreadyExists(bootstrapDatum.getTokenName())) {
            throw new IllegalArgumentException("Bootstrap datum with name " + bootstrapDatum.getTokenName() + " already exists");
        }

        PlutusScript authorizationScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getMintOrBurnAuthTokenCode(network), PlutusVersion.v3);
        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);

        Asset authorizationToken = Asset.builder()
                .name(bootstrapDatum.getTokenName())
                .value(BigInteger.ONE)
                .build();

        String scriptAddress = AddressProvider.getEntAddress(authorizationScript, fromCardanoNetwork(network)).toBech32();
        ScriptTx scriptTx = new ScriptTx()
                .mintAsset(authorizationScript, List.of(authorizationToken), PlutusData.unit(), scriptAddress, bootstrapDatum.toPlutusData(network));

        return quickTxBuilder.compose(scriptTx)
                .feePayer(serviceUserAddress.getAddress())
                .withRequiredSigners(serviceUserAddress)
                .build();
    }

    public Transaction invalidateState(Address userAddress, String transactionId) throws ApiException {
        Optional<byte[]> optionalUserPaymentCredential = userAddress.getPaymentCredentialHash();

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

        byte[] userAccountCredential = optionalUserPaymentCredential.get();
        String unit = getMintStateTokenHash(network) + Hex.encodeHexString(userAccountCredential);

        Optional<Utxo> optionalUtxo = ValidatorUtils.getUtxoByTransactionAndUnit(transactionId, unit, backendService);

        if (optionalUtxo.isEmpty()) {
            throw new IllegalArgumentException("State token not found in transaction outputs");
        }

        Utxo utxo = optionalUtxo.get();
        PlutusScript updateTestStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getUpdateStateTokenCode(network), PlutusVersion.v3);
        PlutusScript mintStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getMintStateTokenCode(network), PlutusVersion.v3);

        Asset userStateToken = Asset.builder()
                .name("0x" + Hex.encodeHexString(userAccountCredential))
                .value(BigInteger.valueOf(-1))
                .build();

        ScriptTx updateStateTokenTx = new ScriptTx()
                .collectFrom(utxo, PlutusData.unit())
                .attachSpendingValidator(updateTestStateTokenScript)
                .mintAsset(mintStateTokenScript, List.of(userStateToken), PlutusData.unit());

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        long validFrom = currentSlot - 10;
        long transactionTtl = currentSlot + 600; // 10 minutes

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        return quickTxBuilder.compose(updateStateTokenTx)
                .validFrom(validFrom)
                .validTo(transactionTtl)
                .feePayer(userAddress.getAddress())
                .withRequiredSigners(userAddress)
                .build();
    }

    public void processAddressUtxos(List<AddressUtxo> addressUtxos) {
        for (AddressUtxo addressUtxo : addressUtxos) {
            if (includesBootstrapToken(addressUtxo, network)) {
                if (isMintingTransaction(addressUtxo, ValidatorUtils.getMintOrBurnAuthTokenHash(network))) {
                    BootstrapDatumEntity bootstrapDatumEntity = BootstrapDatumEntity.fromAddressUtxo(addressUtxo, network);
                    bootstrapDatumService.save(bootstrapDatumEntity);
                } else if (isBurningTransaction(addressUtxo, ValidatorUtils.getMintOrBurnAuthTokenHash(network))) {
                    bootstrapDatumService.markAsInvalid(getBootstrapTokenName(addressUtxo, network), addressUtxo.getSlot());
                } else {
                    throw new IllegalArgumentException("Invalid bootstrap token transaction amount!");
                }
            } else if (includesStateToken(addressUtxo, network)) {
                StateDatum stateDatum = StateDatum.fromUtxoDatum(addressUtxo.getInlineDatum());
                Optional<StateDatumEntity> optionalStateDatumEntity = stateDatumService.findByAddressUtxo(addressUtxo);

                if (isBurningTransaction(addressUtxo, ValidatorUtils.getMintStateTokenHash(network))) {
                    stateDatumService.invalidateStateDatum(stateDatum.getId(), addressUtxo.getSlot());
                } else {
                    final StateDatumEntity stateDatumEntity;
                    if (optionalStateDatumEntity.isEmpty()) {
                        BootstrapDatumEntity bootstrapDatumEntity = bootstrapDatumService.getBootstrapDatum(stateDatum.getBootstrapDatumName())
                                .orElseThrow(() -> new IllegalArgumentException("Bootstrap datum not found"));
                        stateDatumEntity = StateDatumEntity.fromAddressUtxo(addressUtxo, bootstrapDatumEntity);
                        stateDatumService.save(stateDatumEntity);
                    } else {
                        stateDatumEntity = optionalStateDatumEntity.get();
                        stateDatumEntity.setTransactionId(addressUtxo.getTxHash());
                        stateDatumEntity.setCountdown(stateDatum.getCountdown());
                        stateDatumService.updateStateDatum(stateDatumEntity, addressUtxo.getSlot());
                    }

                    List<UVerifyCertificate> uVerifyCertificates = stateDatum.getUVerifyCertificates();
                    List<UVerifyCertificateEntity> uVerifyCertificatesEntities = new ArrayList<>();
                    for (UVerifyCertificate uVerifyCertificate : uVerifyCertificates) {
                        UVerifyCertificateEntity uVerifyCertificateEntity = UVerifyCertificateEntity.fromUVerifyCertificate(uVerifyCertificate);
                        uVerifyCertificateEntity.setSlot(addressUtxo.getSlot());
                        uVerifyCertificateEntity.setStateDatum(stateDatumEntity);
                        uVerifyCertificateEntity.setTransactionId(addressUtxo.getTxHash());
                        uVerifyCertificateEntity.setOutputIndex(addressUtxo.getOutputIndex());
                        uVerifyCertificateEntity.setBlockHash(addressUtxo.getBlockHash());
                        uVerifyCertificateEntity.setBlockNumber(addressUtxo.getBlockNumber());
                        uVerifyCertificateEntity.setCreationTime(new Date(addressUtxo.getBlockTime() * 1000));
                        uVerifyCertificatesEntities.add(uVerifyCertificateEntity);
                    }
                    uVerifyCertificateService.saveAllCertificates(uVerifyCertificatesEntities);
                }
            }
        }
    }

    public void handleRollbackToSlot(long slot) {
        bootstrapDatumService.undoInvalidationBeforeSlot(slot);
        uVerifyCertificateService.deleteAllCertificatesAfterSlot(slot);
        stateDatumService.undoInvalidationBeforeSlot(slot);
        stateDatumService.handleRollbackToSlot(slot);
        bootstrapDatumService.deleteAllAfterSlot(slot);
    }

    public Transaction invalidateStates(Address userAddress, List<String> transactionIds) throws ApiException {
        Optional<byte[]> optionalUserPaymentCredential = userAddress.getPaymentCredentialHash();

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

        byte[] userAccountCredential = optionalUserPaymentCredential.get();
        String unit = getMintStateTokenHash(network) + Hex.encodeHexString(userAccountCredential);

        PlutusScript updateTestStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getUpdateStateTokenCode(network), PlutusVersion.v3);
        PlutusScript mintStateTokenScript = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(getMintStateTokenCode(network), PlutusVersion.v3);
        Asset userStateToken = Asset.builder()
                .name("0x" + Hex.encodeHexString(userAccountCredential))
                .value(BigInteger.valueOf(-1))
                .build();

        List<ScriptTx> invalidateStateTransactions = new ArrayList<>();
        for (String transactionId : transactionIds) {
            Optional<Utxo> optionalUtxo = ValidatorUtils.getUtxoByTransactionAndUnit(transactionId, unit, backendService);
            if (optionalUtxo.isEmpty()) {
                throw new IllegalArgumentException("State token not found in transaction outputs");
            }
            Utxo utxo = optionalUtxo.get();

            invalidateStateTransactions.add(new ScriptTx()
                    .collectFrom(utxo, PlutusData.unit())
                    .attachSpendingValidator(updateTestStateTokenScript)
                    .mintAsset(mintStateTokenScript, List.of(userStateToken), PlutusData.unit()));
        }

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        long validFrom = currentSlot - 10;
        long transactionTtl = currentSlot + 600; // 10 minutes

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        return quickTxBuilder.compose(invalidateStateTransactions.toArray(new ScriptTx[0]))
                .validFrom(validFrom)
                .validTo(transactionTtl)
                .feePayer(userAddress.getAddress())
                .withRequiredSigners(userAddress)
                .build();
    }

    public Long getLatestSlot() throws ApiException {
        return CardanoUtils.getLatestSlot(backendService);
    }
}