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.crypto.Blake2bUtil;
import com.bloxbean.cardano.client.exception.CborSerializationException;
import com.bloxbean.cardano.client.plutus.blueprint.PlutusBlueprintUtil;
import com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion;
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
import com.bloxbean.cardano.client.plutus.spec.PlutusScript;
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder;
import com.bloxbean.cardano.client.quicktx.ScriptTx;
import com.bloxbean.cardano.client.quicktx.Tx;
import com.bloxbean.cardano.client.transaction.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.core.model.Redeemer;
import com.bloxbean.cardano.yaci.core.model.RedeemerTag;
import com.bloxbean.cardano.yaci.core.model.TransactionOutput;
import com.bloxbean.cardano.yaci.store.common.domain.AddressUtxo;
import com.bloxbean.cardano.yaci.store.events.EventMetadata;
import com.bloxbean.cardano.yaci.store.events.TransactionEvent;
import io.uverify.backend.dto.BuildStatus;
import io.uverify.backend.dto.ProxyInitResponse;
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.BuildStatusCode;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.enums.UVerifyScriptPurpose;
import io.uverify.backend.model.*;
import io.uverify.backend.model.converter.ProxyRedeemerConverter;
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.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.*;
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;
@Autowired
private final ValidatorHelper validatorHelper;
private final CardanoNetwork network;
private final Address serviceUserAddress;
@Autowired
private final LibraryService libraryService;
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,
ValidatorHelper validatorHelper,
BootstrapDatumService bootstrapDatumService, StateDatumService stateDatumService,
LibraryService libraryService
) {
this.bootstrapDatumService = bootstrapDatumService;
this.stateDatumService = stateDatumService;
this.uVerifyCertificateService = uVerifyCertificateService;
this.network = CardanoNetwork.valueOf(network);
this.serviceUserAddress = new Address(serviceUserAddress);
this.validatorHelper = validatorHelper;
this.libraryService = libraryService;
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 boolean isTransactionOnChain(String txHash) {
try {
Result<TxContentUtxo> result = backendService.getTransactionService().getTransactionUtxos(txHash);
return result.isSuccessful();
} catch (Exception e) {
log.debug("isTransactionOnChain check failed for {}: {}", txHash, e.getMessage());
return false;
}
}
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, 2);
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();
if (stateDatum.getVersion() == 1) {
throw new IllegalArgumentException("No applicable state datum found for user account");
} else {
return updateStateDatum(address, stateDatum, uVerifyCertificates);
}
}
public ScriptTx buildUVerifyCertificateScriptTx(String address, List<UVerifyCertificate> uVerifyCertificates) throws ApiException, CborSerializationException {
List<StateDatumEntity> stateDatumEntities = stateDatumService.findByOwner(address, 2);
if (stateDatumEntities.isEmpty()) {
log.debug("No state datum found for address " + address + ". Start forking a new state datum.");
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 buildForkProxyStateDatumScriptTx(address, uVerifyCertificates, optionalBootstrapDatum.get().getTokenName());
} 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 buildUpdateStateDatumScriptTx(address, stateDatumEntity, uVerifyCertificates);
}
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 buildForkProxyStateDatumScriptTx(address, uVerifyCertificates, bootstrapDatum.get().getTokenName());
} else {
log.debug("Updating state datum with current conditions.");
return buildUpdateStateDatumScriptTx(address, stateDatumEntity, uVerifyCertificates);
}
} else {
log.debug("No fee required for updating state datum");
return buildUpdateStateDatumScriptTx(address, stateDatumEntity, uVerifyCertificates);
}
}
}
public Transaction persistUVerifyCertificates(String address, List<UVerifyCertificate> uVerifyCertificates) throws ApiException, CborSerializationException {
ScriptTx scriptTx = buildUVerifyCertificateScriptTx(address, uVerifyCertificates);
Address userAddress = new Address(address);
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
long currentSlot = CardanoUtils.getLatestSlot(backendService);
return new QuickTxBuilder(backendService)
.compose(scriptTx)
.validFrom(currentSlot - 10)
.validTo(currentSlot + 600)
.collateralPayer(address)
.feePayer(address)
.withRequiredSigners(userAddress)
.withReferenceScripts(stateContract, proxyContract)
.build();
}
public ProxyInitResponse initProxyContract() throws ApiException, CborSerializationException {
ProxyInitResponse proxyInitResponse = new ProxyInitResponse();
proxyInitResponse.setStatus(BuildStatus.builder()
.code(BuildStatusCode.ERROR)
.build());
QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
String serviceAddress = this.serviceUserAddress.getAddress();
PlutusScript stateContract;
String existingProxyTxHash = validatorHelper.getProxyTransactionHash();
if (existingProxyTxHash.equals("")) {
Result<List<Utxo>> result = this.backendService.getUtxoService().getUtxos(serviceAddress, 100, 1);
List<Utxo> utxos = result.getValue();
Utxo utxo = utxos.get(0);
PlutusScript proxyContract = ValidatorUtils.getUverifyProxyContract(utxo);
String proxyScriptHash = ValidatorUtils.validatorToScriptHash(proxyContract);
stateContract = ValidatorUtils.getUVerifyStateContract(proxyScriptHash, ValidatorUtils.getProxyStateTokenName(utxo.getTxHash(), utxo.getOutputIndex()));
String stateScriptHash = ValidatorUtils.validatorToScriptHash(stateContract);
Optional<byte[]> paymentCredentialHash = this.serviceUserAddress.getPaymentCredentialHash();
if (paymentCredentialHash.isEmpty()) {
throw new IllegalArgumentException("Invalid service user address");
}
ProxyDatum proxyDatum = ProxyDatum.builder()
.ScriptOwner(Hex.encodeHexString(paymentCredentialHash.get()))
.ScriptPointer(stateScriptHash)
.build();
String tokenName = ValidatorUtils.getProxyStateTokenName(utxo.getTxHash(), utxo.getOutputIndex());
Asset authToken = Asset.builder()
.name("0x" + tokenName)
.value(BigInteger.valueOf(1))
.build();
String proxyScriptAddress = AddressProvider.getEntAddress(proxyContract, network.toCardaoNetwork()).toBech32();
PlutusData initProxyRedeemer = new ProxyRedeemerConverter().toPlutusData(ProxyRedeemer.ADMIN_ACTION);
String proxyUnit = proxyContract.getPolicyId() + tokenName;
ScriptTx tx = new ScriptTx()
.collectFrom(List.of(utxo))
.mintAsset(proxyContract, authToken, initProxyRedeemer)
.payToContract(proxyScriptAddress, List.of(Amount.asset(proxyUnit, 1)), proxyDatum.toPlutusData())
.withChangeAddress(serviceAddress);
Transaction transaction = quickTxBuilder.compose(tx)
.feePayer(serviceAddress)
.withRequiredSigners(serviceUserAddress)
.build();
proxyInitResponse.setUnsignedProxyTransaction(transaction.serializeToHex());
proxyInitResponse.setProxyOutputIndex(utxo.getOutputIndex());
proxyInitResponse.setProxyTxHash(utxo.getTxHash());
proxyInitResponse.setStatus(BuildStatus.builder()
.code(BuildStatusCode.SUCCESS)
.build());
} else {
log.info("It seems that there was a previous proxy deployment. Reusing the existing proxy contract.");
proxyInitResponse.setStatus(BuildStatus.builder()
.code(BuildStatusCode.ERROR)
.message("Existing proxy contract found with transaction hash " + existingProxyTxHash + ". Please use this transaction hash to build the transaction.")
.build());
}
return proxyInitResponse;
}
public ScriptTx buildUpdateStateDatumScriptTx(String address, StateDatumEntity stateDatum, List<UVerifyCertificate> uVerifyCertificates) throws ApiException {
PlutusScript uverifyProxyContract = validatorHelper.getParameterizedProxyContract();
String proxyScriptHash = validatorToScriptHash(uverifyProxyContract);
PlutusScript uverifyStateContract = validatorHelper.getParameterizedUVerifyStateContract();
String unit = proxyScriptHash + stateDatum.getId();
String proxyScriptAddress = AddressProvider.getEntAddress(uverifyProxyContract, fromCardanoNetwork(network)).toBech32();
Optional<Utxo> optionalUtxo = ValidatorUtils.getCurrentUtxoByUnit(proxyScriptAddress, unit, backendService);
if (optionalUtxo.isEmpty()) {
throw new IllegalArgumentException("State token not found in current UTxO set");
}
Utxo utxo = optionalUtxo.get();
StateDatum nextStateDatum = StateDatum.fromPreviousStateDatum(utxo.getInlineDatum());
nextStateDatum.setCertificates(uVerifyCertificates);
Utxo proxyStateUtxo;
try {
proxyStateUtxo = validatorHelper.resolveProxyStateUtxo(backendService);
} catch (Exception exception) {
log.error("Unable to fetch proxy state utxo: " + exception.getMessage());
return null;
}
StateRedeemer redeemer = StateRedeemer.builder()
.purpose(UVerifyScriptPurpose.UPDATE_STATE)
.certificates(uVerifyCertificates)
.build();
PlutusData spendProxyRedeemer = new ProxyRedeemerConverter().toPlutusData(ProxyRedeemer.USER_ACTION);
String stateScriptRewardAddress = AddressProvider.getRewardAddress(uverifyStateContract, fromCardanoNetwork(network)).toBech32();
Utxo stateLibraryUtxo = libraryService.getStateLibraryUtxo();
Utxo proxyLibraryUtxo = libraryService.getProxyLibraryUtxo();
ScriptTx updateStateTokenTx = new ScriptTx()
.readFrom(proxyStateUtxo, stateLibraryUtxo, proxyLibraryUtxo)
.collectFrom(utxo, spendProxyRedeemer)
.payToContract(proxyScriptAddress, utxo.getAmount(), nextStateDatum.toPlutusData())
.withdraw(stateScriptRewardAddress, BigInteger.valueOf(0), redeemer.toPlutusData());
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)));
}
}
return updateStateTokenTx;
}
public Transaction updateStateDatum(String address, StateDatumEntity stateDatum, List<UVerifyCertificate> uVerifyCertificates) throws ApiException {
Address userAddress = new Address(address);
PlutusScript uverifyStateContract = validatorHelper.getParameterizedUVerifyStateContract();
PlutusScript uverifyProxyContract = validatorHelper.getParameterizedProxyContract();
ScriptTx updateStateTokenTx = buildUpdateStateDatumScriptTx(address, stateDatum, uVerifyCertificates);
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)
.withReferenceScripts(uverifyStateContract, uverifyProxyContract)
.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 processUVerifyProxyTx(StateRedeemer stateRedeemer, String txHash, String blockHash, long blockNumber,
long blockTime, long slot, String inlineDatum) {
if (stateRedeemer.getPurpose().equals(UVerifyScriptPurpose.MINT_STATE)) {
StateDatum stateDatum = StateDatum.fromUtxoDatum(inlineDatum);
stateDatum.setCertificates(stateRedeemer.getCertificates());
Optional<StateDatumEntity> optionalStateDatumEntity = stateDatumService.findById(stateDatum.getId());
final StateDatumEntity stateDatumEntity;
if (optionalStateDatumEntity.isEmpty()) {
BootstrapDatumEntity bootstrapDatumEntity = bootstrapDatumService.getBootstrapDatum(stateDatum.getBootstrapDatumName(), 2)
.orElseThrow(() -> new IllegalArgumentException("Bootstrap datum not found"));
stateDatumEntity = StateDatumEntity.fromStateDatum(stateDatum, txHash, bootstrapDatumEntity, slot);
stateDatumService.updateStateDatum(stateDatumEntity, slot);
} else {
stateDatumEntity = optionalStateDatumEntity.get();
stateDatumEntity.setTransactionId(txHash);
stateDatumEntity.setCountdown(stateDatum.getCountdown());
stateDatumService.updateStateDatum(stateDatumEntity, slot);
}
List<UVerifyCertificate> uVerifyCertificates = stateDatum.getCertificates();
List<UVerifyCertificateEntity> uVerifyCertificatesEntities = new ArrayList<>();
for (UVerifyCertificate uVerifyCertificate : uVerifyCertificates) {
UVerifyCertificateEntity uVerifyCertificateEntity = UVerifyCertificateEntity.fromUVerifyCertificate(uVerifyCertificate);
uVerifyCertificateEntity.setSlot(slot);
uVerifyCertificateEntity.setStateDatum(stateDatumEntity);
uVerifyCertificateEntity.setTransactionId(txHash);
uVerifyCertificateEntity.setBlockHash(blockHash);
uVerifyCertificateEntity.setBlockNumber(blockNumber);
uVerifyCertificateEntity.setCreationTime(new Date(blockTime * 1000));
uVerifyCertificatesEntities.add(uVerifyCertificateEntity);
}
uVerifyCertificateService.saveAllCertificates(uVerifyCertificatesEntities);
} else if (stateRedeemer.getPurpose().equals(UVerifyScriptPurpose.MINT_BOOTSTRAP)) {
BootstrapDatumEntity bootstrapDatumEntity = BootstrapDatumEntity.fromInlineDatum(inlineDatum, txHash, slot, network);
bootstrapDatumService.save(bootstrapDatumEntity);
} else if (stateRedeemer.getPurpose().equals(UVerifyScriptPurpose.BURN_BOOTSTRAP)) {
BootstrapDatumEntity bootstrapDatumEntity = BootstrapDatumEntity.fromInlineDatum(inlineDatum, txHash, slot, network);
bootstrapDatumService.markAsInvalid(bootstrapDatumEntity.getTokenName(), slot);
} else if (stateRedeemer.getPurpose().equals(UVerifyScriptPurpose.UPDATE_STATE)) {
StateDatum stateDatum = StateDatum.fromUtxoDatum(inlineDatum);
stateDatum.setCertificates(stateRedeemer.getCertificates());
Optional<StateDatumEntity> optionalStateDatumEntity = stateDatumService.findById(stateDatum.getId());
if (optionalStateDatumEntity.isEmpty()) {
log.error("State datum not found for id " + stateDatum.getId() + " in transaction " + txHash);
return;
}
final StateDatumEntity stateDatumEntity = optionalStateDatumEntity.get();
stateDatumEntity.setTransactionId(txHash);
stateDatumEntity.setCountdown(stateDatum.getCountdown());
stateDatumService.updateStateDatum(stateDatumEntity, slot);
List<UVerifyCertificate> uVerifyCertificates = stateDatum.getCertificates();
List<UVerifyCertificateEntity> uVerifyCertificatesEntities = new ArrayList<>();
for (UVerifyCertificate uVerifyCertificate : uVerifyCertificates) {
UVerifyCertificateEntity uVerifyCertificateEntity = UVerifyCertificateEntity.fromUVerifyCertificate(uVerifyCertificate);
uVerifyCertificateEntity.setSlot(slot);
uVerifyCertificateEntity.setStateDatum(stateDatumEntity);
uVerifyCertificateEntity.setTransactionId(txHash);
uVerifyCertificateEntity.setBlockHash(blockHash);
uVerifyCertificateEntity.setBlockNumber(blockNumber);
uVerifyCertificateEntity.setCreationTime(new Date(blockTime * 1000));
uVerifyCertificatesEntities.add(uVerifyCertificateEntity);
}
uVerifyCertificateService.saveAllCertificates(uVerifyCertificatesEntities);
}
}
public List<AddressUtxo> processAddressUtxos(List<AddressUtxo> addressUtxos) {
List<AddressUtxo> processedUtxos = new ArrayList<>();
for (AddressUtxo addressUtxo : addressUtxos) {
if (includesBootstrapToken(addressUtxo, network)) {
if (isMintingTransaction(addressUtxo, ValidatorUtils.getMintOrBurnAuthTokenHash(network))) {
BootstrapDatumEntity bootstrapDatumEntity = BootstrapDatumEntity.fromAddressUtxo(addressUtxo, network);
bootstrapDatumService.save(bootstrapDatumEntity);
processedUtxos.add(addressUtxo);
} else if (isBurningTransaction(addressUtxo, ValidatorUtils.getMintOrBurnAuthTokenHash(network))) {
bootstrapDatumService.markAsInvalid(getBootstrapTokenName(addressUtxo, network), addressUtxo.getSlot());
processedUtxos.add(addressUtxo);
} else {
throw new IllegalArgumentException("Invalid bootstrap token transaction amount!");
}
} else if (includesStateToken(addressUtxo, network)) {
StateDatum stateDatum = StateDatum.fromLegacyUtxoDatum(addressUtxo.getInlineDatum());
Optional<StateDatumEntity> optionalStateDatumEntity = stateDatumService.findByAddressUtxo(addressUtxo);
if (isBurningTransaction(addressUtxo, ValidatorUtils.getMintStateTokenHash(network))) {
stateDatumService.invalidateStateDatum(stateDatum.getId(), addressUtxo.getSlot());
processedUtxos.add(addressUtxo);
} else {
final StateDatumEntity stateDatumEntity;
if (optionalStateDatumEntity.isEmpty()) {
BootstrapDatumEntity bootstrapDatumEntity = bootstrapDatumService.getBootstrapDatum(stateDatum.getBootstrapDatumName(), 1)
.orElseThrow(() -> new IllegalArgumentException("Bootstrap datum not found"));
stateDatumEntity = StateDatumEntity.fromAddressUtxo(addressUtxo, bootstrapDatumEntity);
stateDatumService.updateStateDatum(stateDatumEntity, addressUtxo.getSlot());
} else {
stateDatumEntity = optionalStateDatumEntity.get();
stateDatumEntity.setTransactionId(addressUtxo.getTxHash());
stateDatumEntity.setCountdown(stateDatum.getCountdown());
stateDatumService.updateStateDatum(stateDatumEntity, addressUtxo.getSlot());
}
List<UVerifyCertificate> uVerifyCertificates = stateDatum.getCertificates();
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.setBlockHash(addressUtxo.getBlockHash());
uVerifyCertificateEntity.setBlockNumber(addressUtxo.getBlockNumber());
uVerifyCertificateEntity.setCreationTime(new Date(addressUtxo.getBlockTime() * 1000));
uVerifyCertificatesEntities.add(uVerifyCertificateEntity);
}
uVerifyCertificateService.saveAllCertificates(uVerifyCertificatesEntities);
processedUtxos.add(addressUtxo);
}
}
}
return processedUtxos;
}
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);
}
public Transaction mintProxyBootstrapDatum(BootstrapDatum bootstrapDatum) {
if (bootstrapDatumService.bootstrapDatumAlreadyExists(bootstrapDatum.getTokenName(), 1)) {
throw new IllegalArgumentException("Bootstrap datum with name " + bootstrapDatum.getTokenName() + " already exists");
}
PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
Asset authorizationToken = Asset.builder()
.name(bootstrapDatum.getTokenName())
.value(BigInteger.ONE)
.build();
String stateScriptRewardAddress = validatorHelper.getStateContractAddress();
String proxyScriptAddress = validatorHelper.getProxyContractAddress();
StateRedeemer redeemer = StateRedeemer.builder()
.purpose(UVerifyScriptPurpose.MINT_BOOTSTRAP)
.certificates(Collections.emptyList())
.build();
PlutusData mintProxyRedeemer = new ProxyRedeemerConverter().toPlutusData(ProxyRedeemer.USER_ACTION);
Utxo proxyStateUtxo;
try {
proxyStateUtxo = validatorHelper.resolveProxyStateUtxo(backendService);
} catch (Exception exception) {
log.error("Unable to fetch proxy state utxo: " + exception.getMessage());
return null;
}
Utxo stateLibraryUtxo = libraryService.getStateLibraryUtxo();
ScriptTx scriptTx = new ScriptTx()
.readFrom(proxyStateUtxo, stateLibraryUtxo)
.withdraw(stateScriptRewardAddress, BigInteger.valueOf(0), redeemer.toPlutusData())
.mintAsset(proxyContract, List.of(authorizationToken),
mintProxyRedeemer, proxyScriptAddress,
bootstrapDatum.toPlutusData());
return quickTxBuilder.compose(scriptTx)
.withReferenceScripts(stateContract)
.feePayer(serviceUserAddress.getAddress())
.withRequiredSigners(serviceUserAddress)
.build();
}
public ScriptTx buildForkProxyStateDatumScriptTx(String address, List<UVerifyCertificate> uVerifyCertificates, String bootstrapTokenName) throws ApiException, CborSerializationException {
Optional<BootstrapDatumEntity> optionalBootstrapDatumEntity = bootstrapDatumService.getBootstrapDatum(bootstrapTokenName, 2);
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());
PlutusScript proxyContract = validatorHelper.getParameterizedProxyContract();
String unit = proxyContract.getPolicyId() + 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.setCertificateDataHash(uVerifyCertificates);
stateDatum.setCountdown(stateDatum.getCountdown() - 1);
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)));
Asset userStateToken = Asset.builder()
.name("0x" + stateDatum.getId())
.value(BigInteger.ONE)
.build();
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
String stateScriptRewardAddress = AddressProvider.getRewardAddress(stateContract, fromCardanoNetwork(network)).toBech32();
String proxyScriptAddress = AddressProvider.getEntAddress(proxyContract, fromCardanoNetwork(network)).toBech32();
StateRedeemer redeemer = StateRedeemer.builder()
.purpose(UVerifyScriptPurpose.MINT_STATE)
.certificates(uVerifyCertificates)
.build();
PlutusData mintProxyRedeemer = new ProxyRedeemerConverter().toPlutusData(ProxyRedeemer.USER_ACTION);
Utxo proxyStateUtxo;
try {
proxyStateUtxo = validatorHelper.resolveProxyStateUtxo(backendService);
} catch (Exception exception) {
log.error("Unable to fetch proxy state utxo: " + exception.getMessage());
return null;
}
Utxo stateLibraryUtxo = libraryService.getStateLibraryUtxo();
ScriptTx scriptTransaction = new ScriptTx()
.readFrom(utxo, proxyStateUtxo, stateLibraryUtxo)
.collectFrom(userUtxo)
.withdraw(stateScriptRewardAddress, BigInteger.valueOf(0), redeemer.toPlutusData())
.mintAsset(proxyContract, List.of(userStateToken),
mintProxyRedeemer, proxyScriptAddress,
stateDatum.toPlutusData());
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)));
}
}
return scriptTransaction;
}
public Transaction forkProxyStateDatum(String address, List<UVerifyCertificate> uVerifyCertificates, String bootstrapTokenName) throws ApiException, CborSerializationException {
ScriptTx scriptTransaction = buildForkProxyStateDatumScriptTx(address, uVerifyCertificates, bootstrapTokenName);
Address userAddress = new Address(address);
PlutusScript stateContract = validatorHelper.getParameterizedUVerifyStateContract();
long currentSlot = CardanoUtils.getLatestSlot(backendService);
long validFrom = currentSlot - 10;
long transactionTtl = currentSlot + 600; // 10 minutes
return new QuickTxBuilder(backendService)
.compose(scriptTransaction)
.validFrom(validFrom)
.validTo(transactionTtl)
.withReferenceScripts(stateContract)
.collateralPayer(address)
.feePayer(address)
.withRequiredSigners(userAddress)
.build();
}
private Optional<StateRedeemer> findWithdrawalRedeemer(Map<String, BigInteger> withdrawals, List<Redeemer> redeemers, Address address) {
if (withdrawals == null || withdrawals.size() == 0)
return Optional.empty();
ArrayList<String> rewardAddresses = new ArrayList<>(withdrawals.keySet().stream().map(String::toLowerCase).toList());
Collections.sort(rewardAddresses);
int redeemerIndex = rewardAddresses.indexOf(HexUtil.encodeHexString(address.getBytes()).toLowerCase());
Optional<Redeemer> optionalRedeemer = redeemers.stream().filter(redeemer -> redeemer.getIndex() == redeemerIndex).findFirst();
if (optionalRedeemer.isEmpty()) {
return Optional.empty();
}
Redeemer redeemer = optionalRedeemer.get();
try {
PlutusData deserialize = PlutusData.deserialize(HexUtil.decodeHexString(redeemer.getData().getCbor()));
return Optional.of(StateRedeemer.fromPlutusData(deserialize));
} catch (Exception e) {
log.error("Error deserializing StateRedeemer from redeemer data cbor: {}", redeemer.getData().getCbor(), e);
return Optional.empty();
}
}
private boolean signedByAddress(com.bloxbean.cardano.yaci.helper.model.Transaction transaction, String address) {
return transaction.getWitnesses().getVkeyWitnesses().stream()
.anyMatch(vkeyWitness -> {
byte[] vkeyHash = Blake2bUtil.blake2bHash224(HexUtil.decodeHexString(vkeyWitness.getKey()));
Optional<byte[]> paymentCredentialHash = new Address(address).getPaymentCredentialHash();
return paymentCredentialHash.isPresent() && Arrays.equals(vkeyHash, paymentCredentialHash.get());
});
}
public void processTransactionEvent(TransactionEvent transactionEvent) {
EventMetadata metadata = transactionEvent.getMetadata();
if (metadata.isParallelMode()) {
return;
}
for (com.bloxbean.cardano.yaci.helper.model.Transaction transaction : transactionEvent.getTransactions()) {
if (transaction.isInvalid())
continue;
final String libraryContractAddress = libraryService.getLibraryAddress();
boolean hasLibraryInteraction = transaction.getBody().getOutputs() != null && transaction.getBody().getOutputs().stream().anyMatch(utxo -> utxo.getAddress().equals(libraryContractAddress));
if (!hasLibraryInteraction && (transaction.getWitnesses().getRedeemers() == null || transaction.getWitnesses().getRedeemers().size() == 0))
continue;
final String proxyTxHash = validatorHelper.getProxyTransactionHash();
final Integer proxyOutputIndex = validatorHelper.getProxyOutputIndex();
final PlutusScript uverifyProxyContract = getUverifyProxyContract(proxyTxHash, proxyOutputIndex);
final String uverifyProxyScriptHash = ValidatorUtils.validatorToScriptHash(uverifyProxyContract);
String hexStateContractAddress = HexUtil.encodeHexString(new Address(validatorHelper.getStateContractAddress()).getBytes());
List<com.bloxbean.cardano.yaci.core.model.Amount> mints = transaction.getBody().getMint();
Optional<com.bloxbean.cardano.yaci.core.model.Amount> maybeMint = mints.stream().filter(amount -> amount.getPolicyId().equals(uverifyProxyScriptHash)).findFirst();
boolean hasStateContractInteraction = transaction.getBody().getWithdrawals() != null && transaction.getBody().getWithdrawals().containsKey(hexStateContractAddress);
Optional<byte[]> optionalUserPaymentCredential = serviceUserAddress.getPaymentCredentialHash();
if (optionalUserPaymentCredential.isEmpty()) {
throw new IllegalArgumentException("Invalid Cardano payment address");
}
if (maybeMint.isPresent()) {
com.bloxbean.cardano.yaci.core.model.Amount mint = maybeMint.get();
List<String> distinctPolicies = transaction.getBody().getMint().stream()
.map(com.bloxbean.cardano.yaci.core.model.Amount::getPolicyId)
.distinct().toList();
int redeemerIndex = distinctPolicies.indexOf(mint.getPolicyId());
Optional<Redeemer> optionalRedeemer = transaction.getWitnesses().getRedeemers().stream().filter(redeemer -> redeemer.getTag().equals(RedeemerTag.Mint) && redeemer.getIndex() == redeemerIndex).findFirst();
if (optionalRedeemer.isEmpty()) {
log.warn("No redeemer found for minting UVerify Proxy Token in tx: {}", transaction.getTxHash());
continue;
}
Redeemer redeemer = optionalRedeemer.get();
ProxyRedeemer proxyRedeemer = new ProxyRedeemerConverter().deserialize(redeemer.getData().getCbor());
Map<String, BigInteger> withdrawals = transaction.getBody().getWithdrawals();
List<Redeemer> rewardRedeemers = transaction.getWitnesses().getRedeemers().stream().filter(potentialRedeemer -> potentialRedeemer.getTag().equals(RedeemerTag.Reward)).toList();
String stateContractAddress = validatorHelper.getStateContractAddress();
Optional<StateRedeemer> stateRedeemer = findWithdrawalRedeemer(withdrawals, rewardRedeemers, new Address(stateContractAddress));
if (stateRedeemer.isEmpty()) {
log.warn("No StateRedeemer found for withdrawal in tx: {}", transaction.getTxHash());
continue;
}
if (proxyRedeemer.equals(ProxyRedeemer.USER_ACTION)) {
Optional<TransactionOutput> optionalUtxo = transaction.getBody().getOutputs().stream().filter(utxo -> utxo.getAmounts().stream()
.anyMatch(amount -> amount.getPolicyId() != null &&
amount.getPolicyId().equals(uverifyProxyScriptHash))).findFirst();
if (optionalUtxo.isEmpty()) {
log.warn("No UTXO found with UVerify Proxy Token in tx: {}", transaction.getTxHash());
continue;
}
TransactionOutput utxo = optionalUtxo.get();
processUVerifyProxyTx(stateRedeemer.get(), transaction.getTxHash(),
metadata.getBlockHash(), transaction.getBlockNumber(), metadata.getBlockTime(), metadata.getSlot(), utxo.getInlineDatum());
}
} else if (hasStateContractInteraction) {
final String proxyContractAddress = validatorHelper.getProxyContractAddress();
Optional<TransactionOutput> maybeTransactionOutput = transaction.getBody().getOutputs().stream().filter(txOutput -> txOutput.getAddress().equals(proxyContractAddress)
&& txOutput.getAmounts().stream().anyMatch(amount -> amount.getPolicyId() != null && amount.getPolicyId().equals(uverifyProxyScriptHash))).findFirst();
if (maybeTransactionOutput.isEmpty()) {
log.warn("No output found with UVerify Proxy Token in tx: {}", transaction.getTxHash());
continue;
}
Map<String, BigInteger> withdrawals = transaction.getBody().getWithdrawals();
List<Redeemer> rewardRedeemers = transaction.getWitnesses().getRedeemers().stream().filter(potentialRedeemer -> potentialRedeemer.getTag().equals(RedeemerTag.Reward)).toList();
String stateContractAddress = validatorHelper.getStateContractAddress();
Optional<StateRedeemer> stateRedeemer = findWithdrawalRedeemer(withdrawals, rewardRedeemers, new Address(stateContractAddress));
if (stateRedeemer.isEmpty()) {
log.warn("No StateRedeemer found for withdrawal in tx: {}", transaction.getTxHash());
continue;
}
TransactionOutput transactionOutput = maybeTransactionOutput.get();
processUVerifyProxyTx(stateRedeemer.get(), transaction.getTxHash(),
metadata.getBlockHash(), transaction.getBlockNumber(), metadata.getBlockTime(), metadata.getSlot(), transactionOutput.getInlineDatum());
}
if (hasLibraryInteraction) {
boolean signedByServiceUser = signedByAddress(transaction, serviceUserAddress.getAddress());
if (signedByServiceUser) {
ArrayList<com.bloxbean.cardano.yaci.helper.model.Utxo> utxos = new ArrayList<>(transaction.getUtxos().stream()
.filter(utxo -> utxo.getAddress().equals(libraryContractAddress)).toList());
if (utxos.size() > 0) {
libraryService.deployToLibrary(utxos, transaction.getSlot());
}
}
}
}
}
/**
* Build, sign, and submit a simple ADA transfer from the given sender account to the
* recipient address, creating {@code utxoCount} separate outputs each carrying
* {@code lovelacePerUtxo} lovelace.
*
* @param senderAccount The account whose UTXOs fund the transaction.
* @param recipientAddress Target Cardano address (bech32).
* @param utxoCount Number of separate outputs to create.
* @param lovelacePerUtxo Amount of lovelace per output.
* @return Cardano transaction hash on success.
*/
public Result<String> sendAda(Account senderAccount, String recipientAddress,
int utxoCount, BigInteger lovelacePerUtxo)
throws CborSerializationException, ApiException {
Tx tx = new Tx();
for (int i = 0; i < utxoCount; i++) {
tx.payToAddress(recipientAddress, Amount.lovelace(lovelacePerUtxo));
}
tx.from(senderAccount.baseAddress());
Transaction unsignedTx = new QuickTxBuilder(backendService)
.compose(tx)
.feePayer(senderAccount.baseAddress())
.mergeOutputs(false)
.build();
Transaction signedTx = TransactionSigner.INSTANCE.sign(unsignedTx, senderAccount.hdKeyPair());
return submitTransaction(signedTx);
}
}