TokenizableCertificateService.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.extension.service;

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.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.api.util.AssetUtil;
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.common.model.Network;
import com.bloxbean.cardano.client.exception.CborSerializationException;
import com.bloxbean.cardano.client.plutus.spec.*;
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder;
import com.bloxbean.cardano.client.quicktx.ScriptTx;
import com.bloxbean.cardano.client.transaction.spec.Asset;
import com.bloxbean.cardano.client.transaction.spec.Transaction;
import com.bloxbean.cardano.client.util.HexUtil;
import io.uverify.backend.entity.BootstrapDatumEntity;
import io.uverify.backend.entity.StateDatumEntity;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.enums.UVerifyScriptPurpose;
import io.uverify.backend.extension.dto.tokenizable.*;
import io.uverify.backend.extension.enums.ExtensionTransactionType;
import io.uverify.backend.extension.validators.tokenizable.TokenizableConfig;
import io.uverify.backend.extension.validators.tokenizable.TokenizableDatum;
import io.uverify.backend.model.ProxyRedeemer;
import io.uverify.backend.model.StateDatum;
import io.uverify.backend.model.StateRedeemer;
import io.uverify.backend.model.UVerifyCertificate;
import io.uverify.backend.model.converter.ProxyRedeemerConverter;
import io.uverify.backend.service.BootstrapDatumService;
import io.uverify.backend.service.CardanoBlockchainService;
import io.uverify.backend.service.LibraryService;
import io.uverify.backend.service.StateDatumService;
import io.uverify.backend.util.CardanoUtils;
import io.uverify.backend.util.ValidatorHelper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;

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

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

/**
 * Builds unsigned Cardano transactions for the tokenizable-certificate contract.
 *
 * <h3>Supported operations</h3>
 * <ul>
 *   <li><b>Init</b> – creates the HEAD node and sets the list configuration.</li>
 *   <li><b>Insert</b> – submits a UVerify certificate and mints a node NFT in the
 *       sorted linked list in a single atomic transaction.</li>
 *   <li><b>Redeem (Claim)</b> – mints the owner NFT for an available node.</li>
 *   <li><b>Status</b> – queries the on-chain state of a node by key.</li>
 * </ul>
 *
 * <h3>EUTXO constraint</h3>
 * The MINT validator for Insert reads the HEAD UTxO from {@code reference_inputs}.
 * The SPEND validator for Insert consumes the predecessor node from {@code inputs}.
 * Cardano's ledger requires these sets to be disjoint, so when HEAD would be the
 * predecessor (empty list, or new key smaller than all existing keys) the Insert
 * cannot be executed in a single transaction.  The service returns a descriptive
 * error in that case.
 */
@Slf4j
@Service
@ConditionalOnProperty(value = "extensions.tokenizable-certificate.enabled", havingValue = "true")
public class TokenizableCertificateService {

    /**
     * Hex-encoded token-name prefix for all nodes in a list ("TCN").
     */
    private static final String NODE_PREFIX_HEX = "54434e";

    /**
     * CIP-68 label-222 prefix (user token).
     */
    private static final String CIP68_USER_PREFIX_HEX = "000de140";

    /**
     * CIP-68 label-100 prefix (reference token).
     */
    private static final String CIP68_REF_PREFIX_HEX = "000643b0";

    private final Network network;
    private final CardanoNetwork cardanoNetwork;
    private BackendService backendService;

    @Autowired
    private ValidatorHelper validatorHelper;
    @Autowired
    private LibraryService libraryService;
    @Autowired
    private StateDatumService stateDatumService;
    @Autowired
    private BootstrapDatumService bootstrapDatumService;
    @Autowired
    private CardanoBlockchainService cardanoBlockchainService;

    @Autowired
    public TokenizableCertificateService(
            @Value("${cardano.network}") String network,
            @Value("${cardano.backend.service.type}") String cardanoBackendServiceType,
            @Value("${cardano.backend.blockfrost.baseUrl}") String blockfrostBaseUrl,
            @Value("${cardano.backend.blockfrost.projectId}") String blockfrostProjectId) {

        this.cardanoNetwork = CardanoNetwork.valueOf(network);
        this.network = fromCardanoNetwork(this.cardanoNetwork);

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

    public String buildTransaction(TokenizableBuildRequest req) throws ApiException, CborSerializationException {
        if (req.getType() == ExtensionTransactionType.REDEEM) {
            BuildRedeemRequest redeem = new BuildRedeemRequest();
            redeem.setOwnerAddress(req.getSenderAddress());
            redeem.setKey(req.getKey());
            redeem.setInitUtxoTxHash(req.getInitUtxoTxHash());
            redeem.setInitUtxoOutputIndex(req.getInitUtxoOutputIndex());
            return buildRedeemTransaction(redeem);
        }

        PlutusScript script = getTokenizableCertificateContract(req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
        String policyId = validatorToScriptHash(script);
        String scriptAddress = scriptAddress(script);
        String headUnit = policyId + NODE_PREFIX_HEX;
        boolean headExists = getCurrentUtxoByUnit(scriptAddress, headUnit, backendService).isPresent();

        if (!headExists) {
            TokenizableConfig config = req.getConfig();
            if (config == null) {
                throw new IllegalArgumentException("config is required for the first issuance (Init path). " +
                        "Provide deployer payment key hash and optionally allowedInserters / cip68ScriptAddress.");
            }
            config.setUverifyValidatorHash(validatorToScriptHash(validatorHelper.getParameterizedUVerifyStateContract()));
            BuildInitRequest init = new BuildInitRequest();
            init.setDeployerAddress(req.getSenderAddress());
            init.setInitUtxoTxHash(req.getInitUtxoTxHash());
            init.setInitUtxoOutputIndex(req.getInitUtxoOutputIndex());
            init.setConfig(config);
            init.setKey(req.getKey());
            init.setOwnerPubKeyHash(req.getOwnerPubKeyHash());
            init.setAssetName(req.getAssetName());
            init.setBootstrapTokenName(req.getBootstrapTokenName());
            return buildInitTransaction(init);
        } else {
            BuildInsertRequest insert = new BuildInsertRequest();
            insert.setInserterAddress(req.getSenderAddress());
            insert.setKey(req.getKey());
            insert.setOwnerPubKeyHash(req.getOwnerPubKeyHash());
            insert.setAssetName(req.getAssetName());
            insert.setInitUtxoTxHash(req.getInitUtxoTxHash());
            insert.setInitUtxoOutputIndex(req.getInitUtxoOutputIndex());
            insert.setBootstrapTokenName(req.getBootstrapTokenName());
            return buildInsertTransaction(insert);
        }
    }

    public String buildInitTransaction(BuildInitRequest req) throws ApiException, CborSerializationException {
        PlutusScript tokenizableScript = getTokenizableCertificateContract(req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
        String tokenizableAddress = scriptAddress(tokenizableScript);

        Result<List<Utxo>> utxoResult = backendService.getUtxoService().getUtxos(req.getDeployerAddress(), 100, 1);
        if (!utxoResult.isSuccessful() || utxoResult.getValue() == null) {
            throw new IllegalStateException("Could not retrieve UTxOs for deployer address");
        }
        Optional<Utxo> optInitUtxo = utxoResult.getValue().stream()
                .filter(u -> u.getTxHash().equals(req.getInitUtxoTxHash())
                        && u.getOutputIndex() == req.getInitUtxoOutputIndex())
                .findFirst();
        if (optInitUtxo.isEmpty()) {
            throw new IllegalArgumentException("Init UTxO " + req.getInitUtxoTxHash() + "#"
                    + req.getInitUtxoOutputIndex() + " not found in deployer address");
        }
        Utxo initUtxo = optInitUtxo.get();

        Address deployerAddress = new Address(req.getDeployerAddress());
        byte[] deployerCredential = deployerAddress.getPaymentCredentialHash()
                .orElseThrow(() -> new IllegalArgumentException("Invalid deployer address"));

        PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
        PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();

        UVerifyCertificate cert = UVerifyCertificate.builder()
                .hash(req.getKey())
                .algorithm("sha3_256")
                .issuer(HexUtil.encodeHexString(deployerCredential))
                .extra("{\"uverify_template_id\":\"tokenizableCertificate\"}")
                .build();

        Asset headToken = Asset.builder()
                .name("0x" + NODE_PREFIX_HEX)
                .value(BigInteger.ONE)
                .build();

        String nodeTokenName = NODE_PREFIX_HEX + req.getKey();
        Asset nodeToken = Asset.builder()
                .name("0x" + nodeTokenName)
                .value(BigInteger.ONE)
                .build();

        PlutusData initMintRedeemer = ConstrPlutusData.of(0,
                BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));

        TokenizableDatum.Head headDatum = TokenizableDatum.Head.builder()
                .next(req.getKey())
                .config(req.getConfig())
                .build();

        TokenizableDatum.Node nodeDatum = TokenizableDatum.Node.builder()
                .key(req.getKey())
                .next(null)
                .owner(req.getOwnerPubKeyHash())
                .assetName(req.getAssetName())
                .redeemed(false)
                .build();

        ScriptTx tokenizableMintTx = cardanoBlockchainService.buildUVerifyCertificateScriptTx(
                req.getDeployerAddress(), List.of(cert));

        tokenizableMintTx = tokenizableMintTx
                .mintAsset(tokenizableScript, List.of(headToken, nodeToken), initMintRedeemer)
                .payToContract(tokenizableAddress, Amount.asset(AssetUtil.getUnit(tokenizableScript.getPolicyId(), headToken), 1L), headDatum.toPlutusData())
                .payToContract(tokenizableAddress, Amount.asset(AssetUtil.getUnit(tokenizableScript.getPolicyId(), nodeToken), 1L), nodeDatum.toPlutusData());

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        Transaction unsignedTx = new QuickTxBuilder(backendService)
                .compose(tokenizableMintTx)
                .feePayer(req.getDeployerAddress())
                .collateralPayer(req.getDeployerAddress())
                .mergeOutputs(false)
                .withRequiredSigners(deployerAddress)
                .withReferenceScripts(stateContract, proxyContract)
                .validFrom(currentSlot - 10)
                .validTo(currentSlot + 600)
                .build();

        // Check if the init utxo is already part of the transaction
        // because of the UVerify certificate
        if (unsignedTx.getBody().getInputs().stream().anyMatch(utxo ->
                utxo.getTransactionId().equals(initUtxo.getTxHash())
                        && utxo.getIndex() == initUtxo.getOutputIndex())) {
            return unsignedTx.serializeToHex();
        }

        tokenizableMintTx = tokenizableMintTx.collectFrom(initUtxo);

        unsignedTx = new QuickTxBuilder(backendService)
                .compose(tokenizableMintTx)
                .feePayer(req.getDeployerAddress())
                .collateralPayer(req.getDeployerAddress())
                .mergeOutputs(false)
                .withRequiredSigners(deployerAddress)
                .withReferenceScripts(stateContract, proxyContract)
                .validFrom(currentSlot - 10)
                .validTo(currentSlot + 600)
                .build();

        return unsignedTx.serializeToHex();
    }

    public String buildInsertTransaction(BuildInsertRequest req) throws ApiException, CborSerializationException {
        PlutusScript tokenizableScript = getTokenizableCertificateContract(
                req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
        String policyId = validatorToScriptHash(tokenizableScript);
        String tokenizableAddress = scriptAddress(tokenizableScript);

        // ── 1. Locate HEAD and predecessor ───────────────────────────────────
        Utxo headUtxo = fetchUtxoByToken(tokenizableAddress, policyId, NODE_PREFIX_HEX);
        TokenizableDatum.Head headDatum = (TokenizableDatum.Head) TokenizableDatum.fromInlineDatum(headUtxo.getInlineDatum());

        PredecessorResult pred = findPredecessor(tokenizableAddress, policyId, req.getKey(), headDatum);
        if (pred.isHeadPredecessor()) {
            String bootstrapTokenForFork = req.getBootstrapTokenName() != null ? req.getBootstrapTokenName() : "";
            Optional<StateDatumEntity> existingState = resolveStateDatumOptional(req.getInserterAddress(), bootstrapTokenForFork);
            if (existingState.isPresent()) {
                throw new IllegalStateException(
                        "Cannot insert key '" + req.getKey() + "': the HEAD node would be the predecessor. "
                                + "This is impossible due to the Cardano EUTXO constraint.");
            }
            return buildForkAndOrphanInsertTransaction(req, tokenizableScript, tokenizableAddress, headUtxo);
        }

        PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
        PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();

        byte[] inserterCredential = new Address(req.getInserterAddress()).getPaymentCredentialHash()
                .orElseThrow(() -> new IllegalArgumentException("Invalid inserter address"));

        UVerifyCertificate cert = UVerifyCertificate.builder()
                .hash(req.getKey())
                .algorithm("sha3_256")
                .issuer(HexUtil.encodeHexString(inserterCredential))
                .extra("{\"uverify_template_id\":\"tokenizableCertificate\"}")
                .build();

        // ── 3. Tokenizable insert components ─────────────────────────────────
        String nodeTokenName = NODE_PREFIX_HEX + req.getKey();
        Asset nodeToken = Asset.builder()
                .name("0x" + nodeTokenName)
                .value(BigInteger.ONE)
                .build();

        TokenizableDatum.Node newNodeDatum = TokenizableDatum.Node.builder()
                .key(req.getKey())
                .next(pred.getSuccessorKey())    // what the predecessor's current next was
                .owner(req.getOwnerPubKeyHash())
                .assetName(req.getAssetName())
                .redeemed(false)
                .build();

        PlutusData insertSpendRedeemer = ConstrPlutusData.of(1,
                BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));
        PlutusData insertMintRedeemer = ConstrPlutusData.of(1,
                BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));

        // Compute updated predecessor datum (update its `next` to point to the new key)
        PlutusData updatedPredecessorDatum = pred.buildUpdatedPredecessorDatum(req.getKey());


        ScriptTx tokenizableInsertTx = cardanoBlockchainService.buildUVerifyCertificateScriptTx(
                        req.getInserterAddress(), List.of(cert))
                .readFrom(headUtxo)
                .collectFrom(pred.getUtxo(), insertSpendRedeemer)
                .payToContract(tokenizableAddress, pred.getUtxo().getAmount(), updatedPredecessorDatum)
                .mintAsset(tokenizableScript, List.of(nodeToken), insertMintRedeemer,
                        tokenizableAddress, newNodeDatum.toPlutusData());

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        Address inserterAddress = new Address(req.getInserterAddress());
        Transaction unsignedTx = new QuickTxBuilder(backendService)
                .compose(tokenizableInsertTx)
                .feePayer(req.getInserterAddress())
                .collateralPayer(req.getInserterAddress())
                .mergeOutputs(false)
                .withRequiredSigners(inserterAddress)
                .withReferenceScripts(stateContract, proxyContract)
                .validFrom(currentSlot - 10)
                .validTo(currentSlot + 600)
                .build();

        return unsignedTx.serializeToHex();
    }

    /**
     * Builds an unsigned Redeem (claim) transaction.
     * The owner must sign the returned transaction.
     */
    public String buildRedeemTransaction(BuildRedeemRequest req) throws ApiException, CborSerializationException {
        PlutusScript script = getTokenizableCertificateContract(
                req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
        String policyId = validatorToScriptHash(script);
        String scriptAddress = scriptAddress(script);

        // Find the node UTxO
        String nodeTokenName = NODE_PREFIX_HEX + req.getKey();
        Utxo nodeUtxo = fetchUtxoByToken(scriptAddress, policyId, nodeTokenName);
        TokenizableDatum.Node nodeDatum = (TokenizableDatum.Node) TokenizableDatum.fromInlineDatum(nodeUtxo.getInlineDatum());

        if (nodeDatum.isRedeemed()) {
            throw new IllegalStateException("Node '" + req.getKey() + "' has already been claimed.");
        }

        // HEAD for reference (MINT validator needs it)
        Utxo headUtxo = fetchUtxoByToken(scriptAddress, policyId, NODE_PREFIX_HEX);
        TokenizableDatum.Head headDatum = (TokenizableDatum.Head) TokenizableDatum.fromInlineDatum(headUtxo.getInlineDatum());
        TokenizableConfig config = headDatum.getConfig();

        // Redeemed node output datum (status → Redeemed)
        TokenizableDatum.Node redeemedNodeDatum = TokenizableDatum.Node.builder()
                .key(nodeDatum.getKey())
                .next(nodeDatum.getNext())
                .owner(nodeDatum.getOwner())
                .assetName(nodeDatum.getAssetName())
                .redeemed(true)
                .build();

        Address ownerAddress = new Address(req.getOwnerAddress());

        PlutusData redeemSpendRedeemer = ConstrPlutusData.of(2,
                BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));
        PlutusData redeemMintRedeemer = ConstrPlutusData.of(2,
                BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));

        ScriptTx tx = new ScriptTx()
                .readFrom(headUtxo)
                .collectFrom(nodeUtxo, redeemSpendRedeemer)
                .payToContract(scriptAddress, nodeUtxo.getAmount(), redeemedNodeDatum.toPlutusData());

        if (config.getCip68ScriptAddress() == null) {
            // Plain (non-CIP-68) token — mint 1 and send to owner
            Asset userToken = Asset.builder()
                    .name("0x" + nodeDatum.getAssetName())
                    .value(BigInteger.ONE)
                    .build();
            tx.mintAsset(script, List.of(userToken), redeemMintRedeemer,
                    req.getOwnerAddress(), PlutusData.unit());
        } else {
            // CIP-68: mint user token + reference token
            String userTn = CIP68_USER_PREFIX_HEX + nodeDatum.getAssetName();
            String refTn = CIP68_REF_PREFIX_HEX + nodeDatum.getAssetName();
            Asset userToken = Asset.builder().name("0x" + userTn).value(BigInteger.ONE).build();
            Asset refToken = Asset.builder().name("0x" + refTn).value(BigInteger.ONE).build();

            String cip68ScriptAddress = AddressProvider.getEntAddress(
                    PlutusV3Script.builder().cborHex("").build(), // placeholder — actual address resolved by hash
                    network).toBech32();
            // Resolve CIP-68 reference script address from the stored script hash
            cip68ScriptAddress = resolveScriptAddress(config.getCip68ScriptAddress());

            tx.mintAsset(script, List.of(userToken, refToken), redeemMintRedeemer,
                            req.getOwnerAddress(), PlutusData.unit())
                    .payToContract(cip68ScriptAddress,
                            List.of(Amount.asset(policyId + refTn, 1)),
                            PlutusData.unit());
        }

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        Transaction unsignedTx = new QuickTxBuilder(backendService)
                .compose(tx)
                .feePayer(req.getOwnerAddress())
                .collateralPayer(req.getOwnerAddress())
                .withRequiredSigners(ownerAddress)
                .validFrom(currentSlot - 10)
                .validTo(currentSlot + 600)
                .build();

        return unsignedTx.serializeToHex();
    }

    /**
     * Returns the on-chain status of a node identified by its key.
     */
    public CertificateStatusResponse getCertificateStatus(
            String key, String initUtxoTxHash, int initUtxoOutputIndex) throws ApiException {

        PlutusScript script = getTokenizableCertificateContract(initUtxoTxHash, initUtxoOutputIndex);
        String policyId = validatorToScriptHash(script);
        String scriptAddress = scriptAddress(script);

        String nodeTokenName = NODE_PREFIX_HEX + key;
        Optional<Utxo> optUtxo = getCurrentUtxoByUnit(scriptAddress, policyId + nodeTokenName, backendService);

        if (optUtxo.isEmpty()) {
            return CertificateStatusResponse.builder().key(key).exists(false).claimed(false).build();
        }

        TokenizableDatum datum = TokenizableDatum.fromInlineDatum(optUtxo.get().getInlineDatum());
        if (datum instanceof TokenizableDatum.Node node) {
            return CertificateStatusResponse.builder()
                    .key(key)
                    .exists(true)
                    .claimed(node.isRedeemed())
                    .ownerPubKeyHash(node.getOwner())
                    .assetName(node.getAssetName())
                    .next(node.getNext())
                    .build();
        }
        return CertificateStatusResponse.builder().key(key).exists(false).claimed(false).build();
    }

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

    // ── Private helpers ───────────────────────────────────────────────────────

    private String scriptAddress(PlutusScript script) {
        if (cardanoNetwork == CardanoNetwork.MAINNET) {
            return AddressProvider.getEntAddress(script, com.bloxbean.cardano.client.common.model.Networks.mainnet()).toBech32();
        }
        return AddressProvider.getEntAddress(script, com.bloxbean.cardano.client.common.model.Networks.preprod()).toBech32();
    }

    private String resolveScriptAddress(String scriptHash) {
        // Build a minimal enterprise address from the script hash
        byte[] hashBytes = HexUtil.decodeHexString(scriptHash);
        com.bloxbean.cardano.client.address.Credential cred =
                com.bloxbean.cardano.client.address.Credential.fromScript(hashBytes);
        if (cardanoNetwork == CardanoNetwork.MAINNET) {
            return AddressProvider.getEntAddress(cred, com.bloxbean.cardano.client.common.model.Networks.mainnet()).toBech32();
        }
        return AddressProvider.getEntAddress(cred, com.bloxbean.cardano.client.common.model.Networks.preprod()).toBech32();
    }

    /**
     * Fetches the unique UTxO at {@code scriptAddress} that holds exactly 1 of the
     * token with unit {@code policyId + tokenName}.
     */
    private Utxo fetchUtxoByToken(String scriptAddress, String policyId, String tokenNameHex) throws ApiException {
        String unit = policyId + tokenNameHex;
        Optional<Utxo> opt = getCurrentUtxoByUnit(scriptAddress, unit, backendService);
        if (opt.isEmpty()) {
            throw new IllegalStateException("UTxO with unit " + unit + " not found at " + scriptAddress);
        }
        return opt.get();
    }

    /**
     * Scans all UTxOs at the tokenizable script address and finds the node that
     * should immediately precede the new {@code key} in the sorted linked list.
     * <p>
     * Returns {@link PredecessorResult#headPredecessor()} when HEAD is the only
     * valid predecessor — this case cannot be handled in a single transaction.
     */
    private PredecessorResult findPredecessor(
            String scriptAddress, String policyId, String key,
            TokenizableDatum.Head headDatum) throws ApiException {

        // If the list is empty, HEAD is the predecessor (EUTXO conflict).
        if (headDatum.getNext() == null) {
            return PredecessorResult.headPredecessor();
        }

        // If key < first node's key, HEAD is the predecessor (EUTXO conflict).
        if (key.compareTo(headDatum.getNext()) < 0) {
            return PredecessorResult.headPredecessor();
        }

        // Load all node UTxOs and build an in-memory map key → (Utxo, NodeDatum).
        List<Utxo> allUtxos = getAllUtxosAtAddress(scriptAddress);

        java.util.Map<String, Utxo> utxoByKey = new java.util.HashMap<>();
        java.util.Map<String, TokenizableDatum.Node> datumByKey = new java.util.HashMap<>();
        for (Utxo u : allUtxos) {
            if (u.getInlineDatum() == null) continue;
            try {
                TokenizableDatum d = TokenizableDatum.fromInlineDatum(u.getInlineDatum());
                if (d instanceof TokenizableDatum.Node node) {
                    utxoByKey.put(node.getKey(), u);
                    datumByKey.put(node.getKey(), node);
                }
            } catch (Exception ignored) {
                // Skip UTxOs with unrecognized datums
            }
        }

        // Walk the linked list from the first node to find the insertion point.
        String currentKey = headDatum.getNext();
        while (currentKey != null) {
            TokenizableDatum.Node currentNode = datumByKey.get(currentKey);
            if (currentNode == null) {
                throw new IllegalStateException("Linked list is inconsistent: node '" + currentKey + "' missing from UTxO set.");
            }
            String nextKey = currentNode.getNext();
            // Insert between currentNode and nextKey if key > currentKey AND (nextKey==null OR key < nextKey)
            if (key.compareTo(currentKey) > 0 && (nextKey == null || key.compareTo(nextKey) < 0)) {
                return PredecessorResult.nodePredecessor(utxoByKey.get(currentKey), currentNode, nextKey);
            }
            if (key.equals(currentKey)) {
                throw new IllegalArgumentException("Key '" + key + "' already exists in the list.");
            }
            currentKey = nextKey;
        }

        // Should not happen if comparisons above are correct, but be safe.
        return PredecessorResult.headPredecessor();
    }

    private List<Utxo> getAllUtxosAtAddress(String address) throws ApiException {
        List<Utxo> result = new ArrayList<>();
        int page = 1;
        while (true) {
            Result<List<Utxo>> r = backendService.getUtxoService().getUtxos(address, 100, page);
            if (!r.isSuccessful() || r.getValue() == null || r.getValue().isEmpty()) break;
            result.addAll(r.getValue());
            if (r.getValue().size() < 100) break;
            page++;
        }
        return result;
    }

    private String buildForkAndOrphanInsertTransaction(BuildInsertRequest req,
                                                       PlutusScript tokenizableScript, String tokenizableAddress,
                                                       Utxo headUtxo) throws ApiException, CborSerializationException {
        String bootstrapToken = req.getBootstrapTokenName() != null ? req.getBootstrapTokenName() : "";
        Optional<BootstrapDatumEntity> optEntity = bootstrapDatumService.getBootstrapDatum(bootstrapToken, 2);
        if (optEntity.isEmpty()) {
            throw new IllegalArgumentException("Bootstrap datum '" + bootstrapToken + "' not found");
        }
        BootstrapDatumEntity bootstrapDatumEntity = optEntity.get();

        Address userAddress = new Address(req.getInserterAddress());
        byte[] userCredential = userAddress.getPaymentCredentialHash()
                .orElseThrow(() -> new IllegalArgumentException("Invalid Cardano payment address"));

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

        // Resolve bootstrap UTxO (read-only reference input)
        String bootstrapTokenUnit = proxyContract.getPolicyId()
                + HexUtil.encodeHexString(bootstrapToken.getBytes());
        Result<TxContentUtxo> txResult = backendService.getTransactionService()
                .getTransactionUtxos(bootstrapDatumEntity.getTransactionId());
        TxContentUtxoOutputs bootstrapOutput = txResult.getValue().getOutputs().stream()
                .filter(o -> o.getAmount().stream().anyMatch(a -> a.getUnit().equals(bootstrapTokenUnit)))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Bootstrap UTxO not found for token: " + bootstrapToken));
        Utxo bootstrapUtxo = bootstrapOutput.toUtxos(bootstrapDatumEntity.getTransactionId());

        // Pick a user wallet UTxO to consume (drives state ID derivation)
        List<Utxo> userUtxos = new DefaultUtxoSupplier(backendService.getUtxoService()).getAll(req.getInserterAddress());
        if (userUtxos.isEmpty()) {
            throw new IllegalArgumentException("No UTxOs found for inserter address");
        }
        Utxo userUtxo = userUtxos.get(0);

        // Derive state ID: sha256(txHash || LE_uint16(outputIndex))
        String indexHex = HexUtil.encodeHexString(ByteBuffer.allocate(2)
                .order(ByteOrder.LITTLE_ENDIAN)
                .putShort((short) userUtxo.getOutputIndex())
                .array());
        String stateId = DigestUtils.sha256Hex(HexUtil.decodeHexString(userUtxo.getTxHash() + indexHex));

        UVerifyCertificate cert = UVerifyCertificate.builder()
                .hash(req.getKey())
                .algorithm("sha3_256")
                .issuer(HexUtil.encodeHexString(userCredential))
                .extra("{\"uverify_template_id\":\"tokenizableCertificate\"}")
                .build();

        StateDatum stateDatum = StateDatum.fromBootstrapDatum(bootstrapUtxo.getInlineDatum(), userCredential);
        stateDatum.setCertificateDataHash(List.of(cert));
        stateDatum.setCountdown(stateDatum.getCountdown() - 1);
        stateDatum.setId(stateId);

        Asset stateToken = Asset.builder()
                .name("0x" + stateId)
                .value(BigInteger.ONE)
                .build();

        StateRedeemer mintStateRedeemer = StateRedeemer.builder()
                .purpose(UVerifyScriptPurpose.MINT_STATE)
                .certificates(List.of(cert))
                .build();
        PlutusData mintProxyRedeemer = new ProxyRedeemerConverter().toPlutusData(ProxyRedeemer.USER_ACTION);

        Utxo proxyStateRef = validatorHelper.resolveProxyStateUtxo(backendService);
        Utxo stateLibraryUtxo = libraryService.getStateLibraryUtxo();
        Utxo proxyLibraryUtxo = libraryService.getProxyLibraryUtxo();

        // Orphan node: HEAD stays in reference_inputs only, no predecessor consumed
        String nodeTokenName = NODE_PREFIX_HEX + req.getKey();
        Asset nodeToken = Asset.builder()
                .name("0x" + nodeTokenName)
                .value(BigInteger.ONE)
                .build();

        TokenizableDatum.Node newNodeDatum = TokenizableDatum.Node.builder()
                .key(req.getKey())
                .next(null)
                .owner(req.getOwnerPubKeyHash())
                .assetName(req.getAssetName())
                .redeemed(false)
                .build();

        PlutusData insertMintRedeemer = ConstrPlutusData.of(1,
                BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));

        // Proxy cert token: proves a UVerify cert is present in this tx
        Asset certToken = Asset.builder()
                .name("0x" + req.getKey())
                .value(BigInteger.ONE)
                .build();

        ScriptTx tx = new ScriptTx()
                .readFrom(bootstrapUtxo, proxyStateRef, stateLibraryUtxo, proxyLibraryUtxo, headUtxo)
                .collectFrom(userUtxo)
                .withdraw(stateRewardAddress, BigInteger.ZERO, mintStateRedeemer.toPlutusData())
                .mintAsset(proxyContract, List.of(stateToken), mintProxyRedeemer,
                        proxyScriptAddress, stateDatum.toPlutusData())
                .mintAsset(tokenizableScript, List.of(nodeToken), insertMintRedeemer,
                        tokenizableAddress, newNodeDatum.toPlutusData())
                .mintAsset(tokenizableScript, List.of(certToken), insertMintRedeemer);

        if (bootstrapDatumEntity.getFee() > 0) {
            long feePerReceiver = bootstrapDatumEntity.getFee() / stateDatum.getFeeReceivers().size();
            for (byte[] paymentCredential : stateDatum.getFeeReceivers()) {
                Credential cred = Credential.fromKey(paymentCredential);
                String receiverAddress = AddressProvider.getEntAddress(cred, network).toBech32();
                tx.payToAddress(receiverAddress, Amount.lovelace(BigInteger.valueOf(feePerReceiver)));
            }
        }

        long currentSlot = CardanoUtils.getLatestSlot(backendService);
        Transaction unsignedTx = new QuickTxBuilder(backendService)
                .compose(tx)
                .feePayer(req.getInserterAddress())
                .collateralPayer(req.getInserterAddress())
                .mergeOutputs(false)
                .withRequiredSigners(userAddress)
                .withReferenceScripts(stateContract, proxyContract)
                .validFrom(currentSlot - 10)
                .validTo(currentSlot + 600)
                .build();

        return unsignedTx.serializeToHex();
    }

    private Optional<StateDatumEntity> resolveStateDatumOptional(String address, String bootstrapTokenName) {
        if (bootstrapTokenName == null || bootstrapTokenName.isEmpty()) {
            List<StateDatumEntity> list = stateDatumService.findByOwner(address, 2);
            if (list.isEmpty()) return Optional.empty();
            return Optional.of(stateDatumService.selectCheapestStateDatum(list));
        } else {
            return stateDatumService.findByUserAndBootstrapToken(address, bootstrapTokenName);
        }
    }

    private StateDatumEntity resolveStateDatum(String address, String bootstrapTokenName) {
        Optional<StateDatumEntity> opt;
        if (bootstrapTokenName == null || bootstrapTokenName.isEmpty()) {
            List<StateDatumEntity> list = stateDatumService.findByOwner(address, 2);
            if (list.isEmpty()) throw new IllegalStateException("No UVerify state found for address " + address);
            opt = Optional.of(stateDatumService.selectCheapestStateDatum(list));
        } else {
            opt = stateDatumService.findByUserAndBootstrapToken(address, bootstrapTokenName);
            if (opt.isEmpty()) throw new IllegalStateException("No UVerify state found for given bootstrap token");
        }
        return opt.get();
    }

    private static class PredecessorResult {
        private final boolean headPredecessor;
        private final Utxo utxo;
        private final TokenizableDatum.Node node;
        private final String successorKey;

        private PredecessorResult(boolean headPredecessor, Utxo utxo,
                                  TokenizableDatum.Node node, String successorKey) {
            this.headPredecessor = headPredecessor;
            this.utxo = utxo;
            this.node = node;
            this.successorKey = successorKey;
        }

        static PredecessorResult headPredecessor() {
            return new PredecessorResult(true, null, null, null);
        }

        static PredecessorResult nodePredecessor(Utxo utxo, TokenizableDatum.Node node, String successorKey) {
            return new PredecessorResult(false, utxo, node, successorKey);
        }

        boolean isHeadPredecessor() {
            return headPredecessor;
        }

        Utxo getUtxo() {
            return utxo;
        }

        String getSuccessorKey() {
            return successorKey;
        }

        PlutusData buildUpdatedPredecessorDatum(String newKey) {
            return node.withNext(newKey).toPlutusData();
        }
    }
}