FractionizedCertificateService.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.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.blockfrost.service.BFBackendService;
import com.bloxbean.cardano.client.backend.koios.Constants;
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService;
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.StateDatumEntity;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.extension.dto.fractionized.*;
import io.uverify.backend.extension.enums.ExtensionTransactionType;
import io.uverify.backend.extension.validators.fractionized.FractionizedConfig;
import io.uverify.backend.extension.validators.fractionized.FractionizedDatum;
import io.uverify.backend.model.UVerifyCertificate;
import io.uverify.backend.service.CardanoBlockchainService;
import io.uverify.backend.service.StateDatumService;
import io.uverify.backend.util.CardanoUtils;
import io.uverify.backend.util.ValidatorHelper;
import io.uverify.backend.util.ValidatorUtils;
import lombok.extern.slf4j.Slf4j;
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.util.*;
import static io.uverify.backend.util.ValidatorUtils.getCurrentUtxoByUnit;
import static io.uverify.backend.util.ValidatorUtils.validatorToScriptHash;
@Slf4j
@Service
@ConditionalOnProperty(value = "extensions.fractionized-certificate.enabled", havingValue = "true")
public class FractionizedCertificateService {
private static final String NODE_PREFIX_HEX = "46524e";
private final CardanoNetwork cardanoNetwork;
private BackendService backendService;
@Autowired
private ValidatorHelper validatorHelper;
@Autowired
private StateDatumService stateDatumService;
@Autowired
private CardanoBlockchainService cardanoBlockchainService;
@Autowired
public FractionizedCertificateService(
@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);
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(FractionizedBuildRequest req) throws ApiException, CborSerializationException {
if (req.getType() == ExtensionTransactionType.REDEEM) {
BuildClaimRequest claim = new BuildClaimRequest();
claim.setClaimerAddress(req.getSenderAddress());
claim.setKey(req.getKey());
claim.setAmount(req.getAmount());
claim.setInitUtxoTxHash(req.getInitUtxoTxHash());
claim.setInitUtxoOutputIndex(req.getInitUtxoOutputIndex());
return buildClaimTransaction(claim);
}
// CREATE — decide Init vs Insert by checking whether HEAD already exists
PlutusScript script = getFractionizedCertificateContract(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) {
BuildInitRequest init = new BuildInitRequest();
FractionizedConfig 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()));
init.setDeployerAddress(req.getSenderAddress());
init.setInitUtxoTxHash(req.getInitUtxoTxHash());
init.setInitUtxoOutputIndex(req.getInitUtxoOutputIndex());
init.setConfig(config);
init.setKey(req.getKey());
init.setTotalAmount(req.getTotalAmount());
init.setClaimants(req.getClaimants());
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.setTotalAmount(req.getTotalAmount());
insert.setClaimants(req.getClaimants());
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 fractionizedScript = getFractionizedCertificateContract(req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
String fractionizedAddress = scriptAddress(fractionizedScript);
// ── 1. Find the init UTxO in the deployer's wallet ───────────────────
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"));
UVerifyCertificate cert = UVerifyCertificate.builder()
.hash(req.getKey())
.algorithm("sha3_256")
.issuer(HexUtil.encodeHexString(deployerCredential))
.extra("{\"uverify_template_id\":\"fractionizedCertificate\"}")
.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())));
FractionizedDatum.FHead headDatum = FractionizedDatum.FHead.builder()
.next(Optional.of(HexUtil.decodeHexString(req.getKey()))) // HEAD points to first node
.config(req.getConfig())
.build();
List<byte[]> initClaimantBytes = req.getClaimants() != null
? req.getClaimants().stream().map(HexUtil::decodeHexString).toList()
: List.of();
FractionizedDatum.FNode nodeDatum = FractionizedDatum.FNode.builder()
.key(HexUtil.decodeHexString(req.getKey()))
.next(Optional.empty())
.totalAmount(req.getTotalAmount())
.remainingAmount(req.getTotalAmount())
.claimants(initClaimantBytes)
.assetName(HexUtil.decodeHexString(req.getAssetName()))
.exhausted(false)
.build();
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
ScriptTx fractionizedMintTx = cardanoBlockchainService.buildUVerifyCertificateScriptTx(
req.getDeployerAddress(), List.of(cert));
fractionizedMintTx = fractionizedMintTx
.mintAsset(fractionizedScript, List.of(headToken, nodeToken), initMintRedeemer)
.payToContract(fractionizedAddress, Amount.asset(AssetUtil.getUnit(fractionizedScript.getPolicyId(), headToken), 1L), headDatum.toPlutusData())
.payToContract(fractionizedAddress, Amount.asset(AssetUtil.getUnit(fractionizedScript.getPolicyId(), nodeToken), 1L), nodeDatum.toPlutusData());
long currentSlot = CardanoUtils.getLatestSlot(backendService);
Transaction unsignedTx = new QuickTxBuilder(backendService)
.compose(fractionizedMintTx)
.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();
}
fractionizedMintTx = fractionizedMintTx.collectFrom(initUtxo);
unsignedTx = new QuickTxBuilder(backendService)
.compose(fractionizedMintTx)
.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 fractionizedScript = getFractionizedCertificateContract(
req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
String policyId = validatorToScriptHash(fractionizedScript);
String fractionizedAddress = scriptAddress(fractionizedScript);
Utxo headUtxo = fetchUtxoByToken(fractionizedAddress, policyId, NODE_PREFIX_HEX);
FractionizedDatum.FHead headDatum = (FractionizedDatum.FHead) FractionizedDatum.fromInlineDatum(headUtxo.getInlineDatum());
PredecessorResult pred = findPredecessor(fractionizedAddress, 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, fractionizedScript, fractionizedAddress, headUtxo);
}
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\":\"fractionizedCertificate\"}")
.build();
String nodeTokenName = NODE_PREFIX_HEX + req.getKey();
Asset nodeToken = Asset.builder()
.name("0x" + nodeTokenName)
.value(BigInteger.ONE)
.build();
List<byte[]> insertClaimantBytes = req.getClaimants() != null
? req.getClaimants().stream().map(HexUtil::decodeHexString).toList()
: List.of();
String insertSuccessorKey = pred.getSuccessorKey();
FractionizedDatum.FNode newNodeDatum = FractionizedDatum.FNode.builder()
.key(HexUtil.decodeHexString(req.getKey()))
.next(insertSuccessorKey != null ? Optional.of(HexUtil.decodeHexString(insertSuccessorKey)) : Optional.empty())
.totalAmount(req.getTotalAmount())
.remainingAmount(req.getTotalAmount())
.claimants(insertClaimantBytes)
.assetName(HexUtil.decodeHexString(req.getAssetName()))
.exhausted(false)
.build();
PlutusData insertSpendRedeemer = ConstrPlutusData.of(1,
BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));
PlutusData insertMintRedeemer = ConstrPlutusData.of(1,
BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())));
PlutusData updatedPredecessorDatum = pred.buildUpdatedPredecessorDatum(req.getKey());
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
ScriptTx fractionizedMintTx = cardanoBlockchainService.buildUVerifyCertificateScriptTx(
req.getInserterAddress(), List.of(cert))
.readFrom(headUtxo)
.collectFrom(pred.getUtxo(), insertSpendRedeemer)
.payToContract(fractionizedAddress, pred.getUtxo().getAmount(), updatedPredecessorDatum)
.mintAsset(fractionizedScript, List.of(nodeToken), insertMintRedeemer,
fractionizedAddress, newNodeDatum.toPlutusData());
long currentSlot = CardanoUtils.getLatestSlot(backendService);
Address inserterAddress = new Address(req.getInserterAddress());
Transaction unsignedTx = new QuickTxBuilder(backendService)
.compose(fractionizedMintTx)
.feePayer(req.getInserterAddress())
.collateralPayer(req.getInserterAddress())
.mergeOutputs(false)
.withRequiredSigners(inserterAddress)
.withReferenceScripts(stateContract, proxyContract)
.validFrom(currentSlot - 10)
.validTo(currentSlot + 600)
.build();
return unsignedTx.serializeToHex();
}
public String buildClaimTransaction(BuildClaimRequest req) throws ApiException, CborSerializationException {
PlutusScript script = getFractionizedCertificateContract(
req.getInitUtxoTxHash(), req.getInitUtxoOutputIndex());
String policyId = validatorToScriptHash(script);
String scriptAddress = scriptAddress(script);
String nodeTokenName = NODE_PREFIX_HEX + req.getKey();
Utxo nodeUtxo = fetchUtxoByToken(scriptAddress, policyId, nodeTokenName);
FractionizedDatum.FNode nodeDatum = (FractionizedDatum.FNode) FractionizedDatum.fromInlineDatum(nodeUtxo.getInlineDatum());
if (nodeDatum.isExhausted()) {
throw new IllegalStateException("Node '" + req.getKey() + "' has been exhausted — no tokens remaining.");
}
if (req.getAmount() <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero.");
}
if (req.getAmount() > nodeDatum.getRemainingAmount()) {
throw new IllegalArgumentException("Requested amount " + req.getAmount()
+ " exceeds remaining amount " + nodeDatum.getRemainingAmount() + ".");
}
Address claimerAddr = new Address(req.getClaimerAddress());
String claimerPubKeyHash = HexUtil.encodeHexString(claimerAddr.getPaymentCredentialHash().orElseThrow(
() -> new IllegalArgumentException("Could not extract payment key hash from claimer address")));
Utxo headUtxo = fetchUtxoByToken(scriptAddress, policyId, NODE_PREFIX_HEX);
FractionizedDatum.FNode updatedNodeDatum = nodeDatum.withRemainingAmount(
nodeDatum.getRemainingAmount() - req.getAmount());
PlutusData claimSpendRedeemer = ConstrPlutusData.of(2,
BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())),
BytesPlutusData.of(HexUtil.decodeHexString(claimerPubKeyHash)),
BigIntPlutusData.of(BigInteger.valueOf(req.getAmount())));
PlutusData claimMintRedeemer = ConstrPlutusData.of(2,
BytesPlutusData.of(HexUtil.decodeHexString(req.getKey())),
BytesPlutusData.of(HexUtil.decodeHexString(claimerPubKeyHash)),
BigIntPlutusData.of(BigInteger.valueOf(req.getAmount())));
Asset fungibleToken = Asset.builder()
.name("0x" + HexUtil.encodeHexString(nodeDatum.getAssetName()))
.value(BigInteger.valueOf(req.getAmount()))
.build();
ScriptTx tx = new ScriptTx()
.readFrom(headUtxo)
.collectFrom(nodeUtxo, claimSpendRedeemer)
.payToContract(scriptAddress, nodeUtxo.getAmount(), updatedNodeDatum.toPlutusData())
.mintAsset(script, List.of(fungibleToken), claimMintRedeemer,
req.getClaimerAddress(), PlutusData.unit());
long currentSlot = CardanoUtils.getLatestSlot(backendService);
Transaction unsignedTx = new QuickTxBuilder(backendService)
.compose(tx)
.feePayer(req.getClaimerAddress())
.collateralPayer(req.getClaimerAddress())
.withRequiredSigners(claimerAddr)
.validFrom(currentSlot - 10)
.validTo(currentSlot + 600)
.build();
return unsignedTx.serializeToHex();
}
public FractionizedStatusResponse getCertificateStatus(
String key, String initUtxoTxHash, int initUtxoOutputIndex) throws ApiException {
PlutusScript script = getFractionizedCertificateContract(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 FractionizedStatusResponse.builder().key(key).exists(false).build();
}
FractionizedDatum datum = FractionizedDatum.fromInlineDatum(optUtxo.get().getInlineDatum());
if (datum instanceof FractionizedDatum.FNode node) {
return FractionizedStatusResponse.builder()
.key(key)
.exists(true)
.totalAmount(node.getTotalAmount())
.remainingAmount(node.getRemainingAmount())
.exhausted(node.isExhausted())
.claimants(node.getClaimants().stream().map(HexUtil::encodeHexString).toList())
.assetName(HexUtil.encodeHexString(node.getAssetName()))
.next(node.getNext().map(HexUtil::encodeHexString).orElse(null))
.build();
}
return FractionizedStatusResponse.builder().key(key).exists(false).build();
}
public void setBackendService(BackendService backendService) {
this.backendService = backendService;
}
// ── Private helpers ───────────────────────────────────────────────────────
private PlutusScript getFractionizedCertificateContract(String txHash, int outputIndex) {
return ValidatorUtils.getFractionizedCertificateContract(txHash, outputIndex);
}
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 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();
}
private PredecessorResult findPredecessor(
String scriptAddress, String policyId, String key,
FractionizedDatum.FHead headDatum) throws ApiException {
Optional<byte[]> headNextBytes = headDatum.getNext();
if (headNextBytes.isEmpty()) {
return PredecessorResult.headPredecessor();
}
String headNextHex = HexUtil.encodeHexString(headNextBytes.get());
if (key.compareTo(headNextHex) < 0) {
return PredecessorResult.headPredecessor();
}
List<Utxo> allUtxos = getAllUtxosAtAddress(scriptAddress);
Map<String, Utxo> utxoByKey = new HashMap<>();
Map<String, FractionizedDatum.FNode> datumByKey = new HashMap<>();
for (Utxo u : allUtxos) {
if (u.getInlineDatum() == null) continue;
try {
FractionizedDatum d = FractionizedDatum.fromInlineDatum(u.getInlineDatum());
if (d instanceof FractionizedDatum.FNode node) {
String nodeKeyHex = HexUtil.encodeHexString(node.getKey());
utxoByKey.put(nodeKeyHex, u);
datumByKey.put(nodeKeyHex, node);
}
} catch (Exception ignored) {
}
}
String currentKey = headNextHex;
while (currentKey != null) {
FractionizedDatum.FNode currentNode = datumByKey.get(currentKey);
if (currentNode == null) {
throw new IllegalStateException("Linked list is inconsistent: node '" + currentKey + "' missing.");
}
String nextKey = currentNode.getNext().map(HexUtil::encodeHexString).orElse(null);
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;
}
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 fractionizedScript, String fractionizedAddress,
Utxo headUtxo) throws ApiException, CborSerializationException {
Address userAddress = new Address(req.getInserterAddress());
byte[] userCredential = userAddress.getPaymentCredentialHash()
.orElseThrow(() -> new IllegalArgumentException("Invalid Cardano payment address"));
UVerifyCertificate cert = UVerifyCertificate.builder()
.hash(req.getKey())
.algorithm("sha3_256")
.issuer(HexUtil.encodeHexString(userCredential))
.extra("{\"uverify_template_id\":\"fractionizedCertificate\"}")
.build();
// 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();
List<byte[]> forkClaimantBytes = req.getClaimants() != null
? req.getClaimants().stream().map(HexUtil::decodeHexString).toList()
: List.of();
FractionizedDatum.FNode newNodeDatum = FractionizedDatum.FNode.builder()
.key(HexUtil.decodeHexString(req.getKey()))
.next(Optional.empty())
.totalAmount(req.getTotalAmount())
.remainingAmount(req.getTotalAmount())
.claimants(forkClaimantBytes)
.assetName(HexUtil.decodeHexString(req.getAssetName()))
.exhausted(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();
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
ScriptTx uverifyCertificateTx = cardanoBlockchainService.buildUVerifyCertificateScriptTx(
req.getInserterAddress(), List.of(cert));
ScriptTx fractionizedMintTx = new ScriptTx()
.readFrom(headUtxo)
.mintAsset(fractionizedScript, List.of(nodeToken), insertMintRedeemer,
fractionizedAddress, newNodeDatum.toPlutusData())
.mintAsset(fractionizedScript, List.of(certToken), insertMintRedeemer);
long currentSlot = CardanoUtils.getLatestSlot(backendService);
Transaction unsignedTx = new QuickTxBuilder(backendService)
.compose(fractionizedMintTx, uverifyCertificateTx)
.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);
}
}
// ── PredecessorResult ─────────────────────────────────────────────────────
private static class PredecessorResult {
private final boolean headPredecessor;
private final Utxo utxo;
private final FractionizedDatum.FNode node;
private final String successorKey;
private PredecessorResult(boolean headPredecessor, Utxo utxo,
FractionizedDatum.FNode 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, FractionizedDatum.FNode 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(HexUtil.decodeHexString(newKey)).toPlutusData();
}
}
}