ConnectedGoodsService.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.backend.api.BackendService;
import com.bloxbean.cardano.client.backend.api.DefaultUtxoSupplier;
import com.bloxbean.cardano.client.backend.api.UtxoService;
import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService;
import com.bloxbean.cardano.client.backend.koios.Constants;
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService;
import com.bloxbean.cardano.client.common.model.Network;
import com.bloxbean.cardano.client.exception.CborSerializationException;
import com.bloxbean.cardano.client.plutus.spec.ConstrPlutusData;
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder;
import com.bloxbean.cardano.client.quicktx.ScriptTx;
import com.bloxbean.cardano.client.quicktx.Tx;
import com.bloxbean.cardano.client.transaction.spec.Asset;
import com.bloxbean.cardano.client.transaction.spec.Transaction;
import com.bloxbean.cardano.client.util.HexUtil;
import com.bloxbean.cardano.yaci.store.common.domain.AddressUtxo;
import io.uverify.backend.enums.CardanoNetwork;
import io.uverify.backend.extension.ExtensionManager;
import io.uverify.backend.extension.UVerifyServiceExtension;
import io.uverify.backend.extension.dto.Item;
import io.uverify.backend.extension.dto.MintConnectedGoodsResponse;
import io.uverify.backend.extension.dto.SocialHub;
import io.uverify.backend.extension.entity.ConnectedGoodEntity;
import io.uverify.backend.extension.entity.ConnectedGoodUpdateEntity;
import io.uverify.backend.extension.entity.SocialHubEntity;
import io.uverify.backend.extension.repository.ConnectedGoodUpdateRepository;
import io.uverify.backend.extension.repository.ConnectedGoodsRepository;
import io.uverify.backend.extension.repository.SocialHubRepository;
import io.uverify.backend.extension.validators.ConnectedGoodsDatum;
import io.uverify.backend.extension.validators.ConnectedGoodsDatumItem;
import io.uverify.backend.extension.validators.SocialHubDatum;
import io.uverify.backend.extension.validators.SocialHubRedeemer;
import io.uverify.backend.extension.validators.converter.ConnectedGoodsDatumConverter;
import io.uverify.backend.extension.validators.converter.ConnectedGoodsDatumItemConverter;
import io.uverify.backend.extension.validators.converter.SocialHubDatumConverter;
import io.uverify.backend.extension.validators.converter.SocialHubRedeemerConverter;
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.http.HttpStatus;
import org.springframework.stereotype.Service;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.*;

import static io.uverify.backend.extension.utils.ConnectedGoodUtils.*;
import static io.uverify.backend.util.CardanoUtils.fromCardanoNetwork;

@Service
@Slf4j
@ConditionalOnProperty(value = "extensions.connected-goods.enabled", havingValue = "true")
public class ConnectedGoodsService implements UVerifyServiceExtension {

    private static final int CTR_IV_LENGTH = 16;
    private static final int KEY_LENGTH = 256;
    private static final int ITERATION_COUNT = 65536;
    @Autowired
    private final ConnectedGoodsRepository connectedGoodsRepository;
    @Autowired
    private final ConnectedGoodUpdateRepository connectedGoodUpdateRepository;
    @Autowired
    private final SocialHubRepository socialHubRepository;
    private final Network network;
    private final String salt;
    private BackendService backendService;

    @Autowired
    public ConnectedGoodsService(
            @Value("${cardano.backend.service.type}") String cardanoBackendServiceType,
            @Value("${cardano.backend.blockfrost.baseUrl}") String blockfrostBaseUrl,
            @Value("${cardano.backend.blockfrost.projectId}") String blockfrostProjectId,
            @Value("${extensions.connected-goods.encryption.salt}") String salt,
            @Value("${cardano.network}") String network,
            ConnectedGoodsRepository connectedGoodsRepository, SocialHubRepository socialHubRepository,
            @Autowired ExtensionManager extensionManager, ConnectedGoodUpdateRepository connectedGoodUpdateRepository) {
        this.connectedGoodsRepository = connectedGoodsRepository;
        this.connectedGoodUpdateRepository = connectedGoodUpdateRepository;
        this.socialHubRepository = socialHubRepository;
        this.salt = salt;
        this.network = fromCardanoNetwork(CardanoNetwork.valueOf(network));
        extensionManager.registerExtension(this);

        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 void setBackendService(BackendService backendService) {
        this.backendService = backendService;
    }

    public SocialHubEntity getSocialHubByBatchIdAndMintHash(String batchId, String mintHash) {
        Optional<SocialHubEntity> socialHubEntity = socialHubRepository.findByBatchIdAndMintHash(batchId, mintHash);
        return socialHubEntity.orElse(null);
    }

    public SocialHubEntity getSocialHubByBatchIdsAndItemId(String batchIds, String itemId) {
        Optional<SocialHubEntity> socialHubEntity = socialHubRepository.findByBatchIdsAndMintHash(Arrays.stream(batchIds.split(",")).toList(), applySHA3_256(itemId));
        return socialHubEntity.orElse(null);
    }

    public void processAddressUtxos(List<AddressUtxo> addressUtxos) {
        for (AddressUtxo addressUtxo : addressUtxos) {
            try {
                if (includesConnectedGoodsToken(addressUtxo)) {
                    ConnectedGoodsDatum connectedGoodsDatum = new ConnectedGoodsDatumConverter().deserialize(addressUtxo.getInlineDatum());
                    String transactionId = addressUtxo.getTxHash();
                    long slot = addressUtxo.getSlot();

                    String batchId = HexUtil.encodeHexString(connectedGoodsDatum.getId());
                    Optional<ConnectedGoodEntity> entry = connectedGoodsRepository.findById(batchId);

                    ConnectedGoodEntity connectedGoodEntity;
                    ConnectedGoodUpdateEntity connectedGoodUpdateEntity = new ConnectedGoodUpdateEntity();
                    connectedGoodUpdateEntity.setSlot(slot);
                    connectedGoodUpdateEntity.setTransactionId(transactionId);
                    connectedGoodUpdateEntity.setOutputIndex(addressUtxo.getOutputIndex());
                    if (entry.isEmpty()) {
                        connectedGoodEntity = new ConnectedGoodEntity();
                        connectedGoodEntity.setId(batchId);
                        connectedGoodEntity.setCreationSlot(slot);

                        List<SocialHubEntity> socialHubEntities = new ArrayList<>();
                        for (ConnectedGoodsDatumItem item : connectedGoodsDatum.getItems()) {
                            SocialHubEntity socialHubEntity = new SocialHubEntity();
                            socialHubEntity.setPassword(HexUtil.encodeHexString(item.getPassword()));
                            socialHubEntity.setAssetId(item.getTokenName());
                            socialHubEntity.setTransactionId(transactionId);
                            socialHubEntity.setOutputIndex(addressUtxo.getOutputIndex());
                            socialHubEntity.setCreationSlot(slot);
                            socialHubEntities.add(socialHubEntity);
                        }

                        connectedGoodEntity.setUpdates(List.of(connectedGoodUpdateEntity));
                        connectedGoodEntity.setSocialHubEntities(socialHubEntities);
                    } else {
                        connectedGoodEntity = entry.get();
                        connectedGoodEntity.getUpdates().add(connectedGoodUpdateEntity);

                    }
                    connectedGoodsRepository.save(connectedGoodEntity);
                }

                if (includesSocialHubToken(addressUtxo)) {
                    SocialHubDatum socialHubDatum = new SocialHubDatumConverter().deserialize(addressUtxo.getInlineDatum());
                    String transactionId = addressUtxo.getTxHash();

                    String batchId = HexUtil.encodeHexString(socialHubDatum.getBatchId());
                    String tokenName = getSocialHubTokenName(addressUtxo);

                    if (tokenName != null) {
                        Optional<SocialHubEntity> optionalSocialHub = socialHubRepository.findByBatchIdAndAssetId(batchId, tokenName);

                        if (optionalSocialHub.isPresent()) {
                            SocialHubEntity socialHubEntity = SocialHubEntity.fromSocialHubDatum(socialHubDatum);

                            socialHubEntity.setTransactionId(transactionId);
                            socialHubEntity.setOutputIndex(addressUtxo.getOutputIndex());
                            socialHubEntity.setCreationSlot(addressUtxo.getSlot());

                            SocialHubEntity previousSocialHubEntity = optionalSocialHub.get();
                            socialHubEntity.setPassword(previousSocialHubEntity.getPassword());
                            socialHubEntity.setAssetId(previousSocialHubEntity.getAssetId());
                            socialHubEntity.setConnectedGood(previousSocialHubEntity.getConnectedGood());
                            socialHubRepository.save(socialHubEntity);
                        } else {
                            log.warn("SocialHub not found for batchId: {} and tokenName: {}", batchId, tokenName);
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public void handleRollbackToSlot(long slot) {
        connectedGoodsRepository.deleteAllAfterSlot(slot);
        connectedGoodUpdateRepository.deleteAllAfterSlot(slot);
        socialHubRepository.deleteAllAfterSlot(slot);
    }

    private SecretKey generateKey(String key) throws Exception {
        byte[] saltBytes = Base64.getDecoder().decode(salt);
        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        KeySpec spec = new PBEKeySpec(key.toCharArray(), saltBytes, ITERATION_COUNT, KEY_LENGTH);
        SecretKey tmp = factory.generateSecret(spec);
        return new SecretKeySpec(tmp.getEncoded(), "AES");
    }

    private Optional<byte[]> encrypt(Optional<byte[]> plaintext, String key) throws Exception {
        if (plaintext.isPresent()) {
            return Optional.of(encrypt(plaintext.get(), key));
        } else {
            return Optional.empty();
        }
    }

    public byte[] encrypt(byte[] plaintext, String key) throws Exception {
        byte[] iv = new byte[CTR_IV_LENGTH];
        SecureRandom random = SecureRandom.getInstanceStrong();
        random.nextBytes(iv);

        SecretKey secretKey = generateKey(key);

        Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));

        byte[] ciphertext = cipher.doFinal(plaintext);

        byte[] encrypted = new byte[iv.length + ciphertext.length];
        System.arraycopy(iv, 0, encrypted, 0, iv.length);
        System.arraycopy(ciphertext, 0, encrypted, iv.length, ciphertext.length);

        return encrypted;
    }

    private String decrypt(byte[] encryptedText, String key) throws Exception {
        if (encryptedText != null && encryptedText.length > CTR_IV_LENGTH) {
            return decryptString(encryptedText, key);
        } else {
            return null;
        }
    }

    private String decryptString(byte[] encryptedText, String key) throws Exception {
        byte[] iv = Arrays.copyOfRange(encryptedText, 0, CTR_IV_LENGTH);
        byte[] ciphertext = Arrays.copyOfRange(encryptedText, CTR_IV_LENGTH, encryptedText.length);

        SecretKey secretKey = generateKey(key);

        Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));

        byte[] decrypted = cipher.doFinal(ciphertext);
        return new String(decrypted, StandardCharsets.UTF_8);
    }

    private SocialHubDatum encryptSocialHub(SocialHubDatum plainSocialHub, String password) throws Exception {
        SocialHubDatum encryptedSocialHub = new SocialHubDatum();
        encryptedSocialHub.setOwner(plainSocialHub.getOwner());
        encryptedSocialHub.setBatchId(plainSocialHub.getBatchId());
        encryptedSocialHub.setName(encrypt(plainSocialHub.getName(), password));
        encryptedSocialHub.setSubtitle(encrypt(plainSocialHub.getSubtitle(), password));
        encryptedSocialHub.setX(encrypt(plainSocialHub.getX(), password));
        encryptedSocialHub.setTelegram(encrypt(plainSocialHub.getTelegram(), password));
        encryptedSocialHub.setDiscord(encrypt(plainSocialHub.getDiscord(), password));
        encryptedSocialHub.setYoutube(encrypt(plainSocialHub.getYoutube(), password));
        encryptedSocialHub.setWebsite(encrypt(plainSocialHub.getWebsite(), password));
        encryptedSocialHub.setEmail(encrypt(plainSocialHub.getEmail(), password));
        encryptedSocialHub.setAdahandle(encrypt(plainSocialHub.getAdahandle(), password));
        encryptedSocialHub.setReddit(encrypt(plainSocialHub.getReddit(), password));
        encryptedSocialHub.setInstagram(encrypt(plainSocialHub.getInstagram(), password));
        encryptedSocialHub.setGithub(encrypt(plainSocialHub.getGithub(), password));
        encryptedSocialHub.setLinkedin(encrypt(plainSocialHub.getLinkedin(), password));
        encryptedSocialHub.setPicture(encrypt(plainSocialHub.getPicture(), password));
        return encryptedSocialHub;
    }

    public SocialHub decryptSocialHub(SocialHub encryptedSocialHub, String password) throws Exception {
        SocialHub decryptedSocialHub = new SocialHub();
        decryptedSocialHub.setOwner(encryptedSocialHub.getOwner());
        decryptedSocialHub.setItemName(encryptedSocialHub.getItemName());
        decryptedSocialHub.setName(decrypt(encryptedSocialHub.asBinaryName(), password));
        decryptedSocialHub.setSubtitle(decrypt(encryptedSocialHub.asBinarySubtitle(), password));
        decryptedSocialHub.setX(decrypt(encryptedSocialHub.asBinaryX(), password));
        decryptedSocialHub.setTelegram(decrypt(encryptedSocialHub.asBinaryTelegram(), password));
        decryptedSocialHub.setDiscord(decrypt(encryptedSocialHub.asBinaryDiscord(), password));
        decryptedSocialHub.setYoutube(decrypt(encryptedSocialHub.asBinaryYoutube(), password));
        decryptedSocialHub.setWebsite(decrypt(encryptedSocialHub.asBinaryWebsite(), password));
        decryptedSocialHub.setEmail(decrypt(encryptedSocialHub.asBinaryEmail(), password));
        decryptedSocialHub.setAdaHandle(decrypt(encryptedSocialHub.asBinaryAdahandle(), password));
        decryptedSocialHub.setReddit(decrypt(encryptedSocialHub.asBinaryReddit(), password));
        decryptedSocialHub.setInstagram(decrypt(encryptedSocialHub.asBinaryInstagram(), password));
        decryptedSocialHub.setGithub(decrypt(encryptedSocialHub.asBinaryGithub(), password));
        decryptedSocialHub.setLinkedin(decrypt(encryptedSocialHub.asBinaryLinkedin(), password));
        decryptedSocialHub.setPicture(decrypt(encryptedSocialHub.asBinaryPicture(), password));
        return decryptedSocialHub;
    }

    public Transaction claim(String assetName, String password, String transactionId, int outputIndex, SocialHubDatum socialHubDatum, String userAddress) throws ApiException {
        Result<Utxo> output = backendService.getUtxoService().getTxOutput(transactionId, outputIndex);
        Utxo utxo = output.getValue();

        ConnectedGoodsDatum inputDatum = new ConnectedGoodsDatumConverter().deserialize(utxo.getInlineDatum());
        inputDatum.setItems(inputDatum.getItems().stream().filter(item -> !item.getTokenName().equals(assetName)).toList());
        ConstrPlutusData datum = new ConnectedGoodsDatumConverter().toPlutusData(inputDatum);

        String scriptAddress = AddressProvider.getEntAddress(connectedGoodsScript, network).toBech32();
        ConnectedGoodsDatumItem connectedGood = new ConnectedGoodsDatumItem(assetName, password.getBytes());
        ConstrPlutusData redeemer = new ConnectedGoodsDatumItemConverter().toPlutusData(connectedGood);

        Address address = new Address(userAddress);
        byte[] ownerCredential = address.getPaymentCredentialHash().orElseThrow();

        socialHubDatum.setOwner(ownerCredential);
        socialHubDatum.setBatchId(inputDatum.getId());

        String socialHubScriptAddress = AddressProvider.getEntAddress(socialHubScript, network).toBech32();
        Asset asset = Asset.builder()
                .name(connectedGood.getTokenName())
                .value(BigInteger.ONE)
                .build();

        SocialHubDatum encryptedSocialHub;
        try {
            encryptedSocialHub = encryptSocialHub(socialHubDatum, password);
        } catch (Exception exception) {
            log.error(exception.getMessage(), exception);
            throw new ApiException("Failed to encrypt social hub data", exception);
        }

        ScriptTx transaction = new ScriptTx()
                .mintAsset(socialHubScript, List.of(asset),
                        PlutusData.unit(),
                        socialHubScriptAddress, new SocialHubDatumConverter().toPlutusData(encryptedSocialHub))
                .attachSpendingValidator(connectedGoodsScript)
                .collectFrom(utxo, redeemer)
                .payToContract(scriptAddress, utxo.getAmount(), datum);

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        return quickTxBuilder.compose(transaction)
                .collateralPayer(userAddress)
                .feePayer(userAddress)
                .withRequiredSigners(address)
                .build();
    }

    public MintConnectedGoodsResponse mint(String tokenName, List<Item> items, String userAddress) throws CborSerializationException {
        Map<String, String> itemMap = new HashMap<>();
        for (Item item : items) {
            itemMap.put(item.getAssetName(), item.getPassword());
        }
        return mint(tokenName, itemMap, userAddress);
    }

    public MintConnectedGoodsResponse mint(String tokenName, Map<String, String> items, String userAddress) throws CborSerializationException {
        List<ConnectedGoodsDatumItem> connectedGoods = new ArrayList<>();
        items.forEach((assetName, password) -> connectedGoods.add(new ConnectedGoodsDatumItem(assetName, HexUtil.decodeHexString(applySHA3_256(password)))));

        UtxoService utxoService = backendService.getUtxoService();
        DefaultUtxoSupplier utxoSupplier = new DefaultUtxoSupplier(utxoService);

        List<Utxo> utxos = utxoSupplier.getAll(userAddress);
        if (utxos.isEmpty()) {
            throw new IllegalStateException("No UTxOs found for facilitator account");
        }

        // TODO: find UTXO with at least 2 ADA but also the smallest UTXO
        Optional<Utxo> optionalUtxo = utxos.stream().filter(utxo -> {
            Amount lovelace = utxo.getAmount().stream()
                    .filter(amount -> amount.getUnit().equals("lovelace")).findFirst().orElse(null);

            if (lovelace == null) {
                return false;
            }

            return lovelace.getQuantity().compareTo(BigInteger.valueOf(3_000_000)) > 0;
        }).findFirst();

        if (optionalUtxo.isEmpty()) {
            throw new IllegalStateException("No UTxOs found with at least 3 ADA");
        }

        Utxo utxo = optionalUtxo.get();
        String batchId = applySHA3_256(HexUtil.decodeHexString(utxo.getTxHash() + HexUtil.encodeHexString(String.valueOf(utxo.getOutputIndex()).getBytes())));
        ConnectedGoodsDatum connectedGoodsDatum = new ConnectedGoodsDatum(
                HexUtil.decodeHexString(batchId), connectedGoods);
        ConstrPlutusData datum = new ConnectedGoodsDatumConverter().toPlutusData(connectedGoodsDatum);

        String scriptAddress = AddressProvider.getEntAddress(connectedGoodsScript, network).toBech32();
        Asset asset = Asset.builder()
                .name(tokenName)
                .value(BigInteger.ONE)
                .build();

        ScriptTx mintTransaction = new ScriptTx()
                .mintAsset(connectedGoodsScript, asset, PlutusData.unit());

        Tx sendTransaction = new Tx()
                .from(userAddress)
                .collectFrom(List.of(utxo))
                .payToContract(scriptAddress, List.of(Amount.ada(1.0), Amount.asset(
                                connectedGoodsScript.getPolicyId(), asset.getName(), BigInteger.ONE)),
                        datum);

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        Transaction unsignedTransaction = quickTxBuilder.compose(mintTransaction, sendTransaction)
                .collateralPayer(userAddress)
                .feePayer(userAddress)
                .withRequiredSigners(new Address(userAddress))
                .build();

        String unsignedTransactionHex = unsignedTransaction.serializeToHex();
        MintConnectedGoodsResponse response = new MintConnectedGoodsResponse();
        response.setUnsignedTransaction(unsignedTransactionHex);
        response.setBatchId(batchId);
        response.setStatus(HttpStatus.OK);
        return response;
    }

    private Transaction modify(SocialHubDatum socialHubDatum, String transactionId, int outputIndex,
                               String userAddress, SocialHubRedeemer socialHubRedeemer, String password) throws ApiException {
        Result<Utxo> output = backendService.getUtxoService().getTxOutput(transactionId, outputIndex);
        Utxo utxo = output.getValue();

        SocialHubDatum encryptedSocialHubDatum;
        try {
            encryptedSocialHubDatum = encryptSocialHub(socialHubDatum, password);
        } catch (Exception exception) {
            log.error(exception.getMessage(), exception);
            throw new ApiException("Failed to encrypt social hub data", exception);
        }

        ConstrPlutusData datum = new SocialHubDatumConverter().toPlutusData(encryptedSocialHubDatum);
        ConstrPlutusData redeemer = new SocialHubRedeemerConverter().toPlutusData(socialHubRedeemer);

        String socialHubScriptAddress = AddressProvider.getEntAddress(socialHubScript, network).toBech32();
        ScriptTx transaction = new ScriptTx()
                .attachSpendingValidator(socialHubScript)
                .collectFrom(utxo, redeemer)
                .payToContract(socialHubScriptAddress, utxo.getAmount(), datum);

        QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService);
        Address address = new Address(userAddress);

        return quickTxBuilder.compose(transaction)
                .collateralPayer(userAddress)
                .feePayer(userAddress)
                .withRequiredSigners(address)
                .build();
    }

    public Transaction update(SocialHubDatum socialHubDatum, String transactionId, int outputIndex, String userAddress, String password) throws ApiException {
        return modify(socialHubDatum, transactionId, outputIndex, userAddress, SocialHubRedeemer.UPDATE, password);
    }

    public Transaction transfer(SocialHubDatum socialHubDatum, String transactionId, int outputIndex, String userAddress, String password) throws ApiException {
        return modify(socialHubDatum, transactionId, outputIndex, userAddress, SocialHubRedeemer.TRANSFER, password);
    }

    public ConnectedGoodUpdateEntity getLatestUpdateByConnectedGoodId(String id) {
        return connectedGoodUpdateRepository.getLatestUpdateByConnectedGoodId(id);
    }
}