Skip to content

Commit

Permalink
Enhance UtxoSelectionStrategy for HDWallet (#487)
Browse files Browse the repository at this point in the history
* New method to take AddressSupplier as parameter

* refactor: HD wallet utxo supplier enhancement initial changes

* Add derivation path to address and handle wallet in input builder

* #487 Refactor method names for address usage checks. Added support for indexToScan in HDWalletAddressIterator

* Revert to original version and only add isUsedAddress method

* Fix equal method to consider sub-classes

* #487 Use AddressIterator instead of Iterator and other refactoring

* Remove system out
  • Loading branch information
satran004 authored Feb 15, 2025
1 parent 53b1614 commit 973e34d
Show file tree
Hide file tree
Showing 36 changed files with 710 additions and 650 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.bloxbean.cardano.client.common.model.Network;
import com.bloxbean.cardano.client.crypto.Bech32;
import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath;
import com.bloxbean.cardano.client.exception.AddressRuntimeException;

import java.util.Optional;
Expand All @@ -19,6 +20,9 @@ public class Address {
private AddressType addressType;
private Network network;

//Optional
private DerivationPath derivationPath;

/**
* Create Address from a byte array
* @param prefix Address prefix
Expand Down Expand Up @@ -49,6 +53,11 @@ public Address(String address) {
this.network = readNetworkType(this.bytes);
}

public Address(String address, DerivationPath derivationPath) {
this(address);
this.derivationPath = derivationPath;
}

/**
* Create Address from a byte array
* @param addressBytes
Expand Down Expand Up @@ -188,4 +197,9 @@ public Optional<String> getBech32VerificationKeyHash() {
return getPaymentCredentialHash()
.map(paymentCred -> Bech32.encode(paymentCred, ADDR_VKH_PREFIX));
}

public Optional<DerivationPath> getDerivationPath() {
return Optional.ofNullable(derivationPath);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.bloxbean.cardano.client.backend.api.TransactionService;
import com.bloxbean.cardano.client.backend.api.UtxoService;
import com.bloxbean.cardano.client.backend.blockfrost.service.http.AddressesApi;
import com.bloxbean.cardano.client.backend.model.AddressTransactionContent;
import retrofit2.Call;
import retrofit2.Response;

Expand Down Expand Up @@ -62,4 +63,19 @@ public Result<List<Utxo>> getUtxos(String address, String unit, int count, int p
public Result<Utxo> getTxOutput(String txHash, int outputIndex) throws ApiException {
return transactionService.getTransactionOutput(txHash, outputIndex);
}

@Override
public boolean isUsedAddress(String address) throws ApiException {
Call<List<AddressTransactionContent>> call = addressApi.getTransactions(getProjectId(), address, 1, 1, OrderEnum.asc.toString(), null, null);
try {
Response<List<AddressTransactionContent>> response = call.execute();
var txList = processResponse(response);
if (txList.isSuccessful() && txList.getValue().size() > 0)
return true;
else
return false;
} catch (IOException e) {
throw new ApiException("Error checking transaction history for address : " + address, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.bloxbean.cardano.client.backend.api;

import com.bloxbean.cardano.client.address.Address;
import com.bloxbean.cardano.client.api.common.OrderEnum;
import com.bloxbean.cardano.client.api.exception.ApiException;
import com.bloxbean.cardano.client.api.model.Utxo;
Expand Down Expand Up @@ -36,4 +37,13 @@ public Optional<Utxo> getTxOutput(String txHash, int outputIndex) {
throw new ApiRuntimeException(e);
}
}

@Override
public boolean isUsedAddress(Address address) {
try {
return utxoService.isUsedAddress(address.toBech32());
} catch (ApiException e) {
throw new ApiRuntimeException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,15 @@ public interface UtxoService {
* @throws ApiException If any error occurs
*/
Result<Utxo> getTxOutput(String txHash, int outputIndex) throws ApiException;

/**
* Checks if the provided address has been used in any transactions.
*
* @param address The address to be checked.
* @return true if the address has been used, otherwise false.
* @throws ApiException If any error occurs during the operation.
*/
default boolean isUsedAddress(String address) throws ApiException {
throw new UnsupportedOperationException();
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.bloxbean.cardano.client.coinselection;

import com.bloxbean.cardano.client.address.Address;
import com.bloxbean.cardano.client.api.AddressIterator;
import com.bloxbean.cardano.client.api.common.AddressIterators;
import com.bloxbean.cardano.client.api.exception.ApiException;
import com.bloxbean.cardano.client.api.model.Amount;
import com.bloxbean.cardano.client.api.model.Utxo;
import com.bloxbean.cardano.client.coinselection.config.CoinselectionConfig;
import com.bloxbean.cardano.client.plutus.spec.PlutusData;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.*;

/**
* Implement this interface to provide custom UtxoSelection Strategy
Expand Down Expand Up @@ -96,7 +96,16 @@ default Set<Utxo> select(String address, Amount outputAmount, String datumHash,
return select(address, Collections.singletonList(outputAmount), datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
}

Set<Utxo> select(String address, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit);
default Set<Utxo> select(String address, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
return select(AddressIterators.of(new Address(address)), outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
}

default List<Utxo> selectUtxos(AddressIterator addrIter, String unit, BigInteger amount, Set<Utxo> utxosToExclude) throws ApiException {
Set<Utxo> selected = select(addrIter, List.of(new Amount(unit, amount)), null, null, utxosToExclude, CoinselectionConfig.INSTANCE.getCoinSelectionLimit());
return selected != null ? new ArrayList<>(selected) : Collections.emptyList();
}

Set<Utxo> select(AddressIterator addressIterator, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit);

UtxoSelectionStrategy fallback();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.bloxbean.cardano.client.coinselection.impl;

import com.bloxbean.cardano.client.address.Address;
import com.bloxbean.cardano.client.api.AddressIterator;
import com.bloxbean.cardano.client.api.UtxoSupplier;
import com.bloxbean.cardano.client.api.common.OrderEnum;
import com.bloxbean.cardano.client.api.exception.ApiRuntimeException;
import com.bloxbean.cardano.client.api.exception.InsufficientBalanceException;
import com.bloxbean.cardano.client.api.model.Amount;
import com.bloxbean.cardano.client.api.model.Utxo;
import com.bloxbean.cardano.client.api.model.WalletUtxo;
import com.bloxbean.cardano.client.coinselection.UtxoSelectionStrategy;
import com.bloxbean.cardano.client.coinselection.exception.InputsLimitExceededException;
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
Expand Down Expand Up @@ -35,11 +38,14 @@ public DefaultUtxoSelectionStrategyImpl(UtxoSupplier utxoSupplier, boolean ignor
}

@Override
public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
public Set<Utxo> select(AddressIterator addrIter, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
if(outputAmounts == null || outputAmounts.isEmpty()){
return Collections.emptySet();
}

//reset addrIter to handle reuse of same iterator from caller
if (addrIter != null) addrIter.reset();

//TODO -- Should we throw error if both datumHash and inlineDatum are set ??

try{
Expand All @@ -55,74 +61,87 @@ public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumH
.filter(entry -> BigInteger.ZERO.compareTo(entry.getValue()) < 0)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

int page = 0;

final int nrOfItems = 100;

while(!remaining.isEmpty()){
var fetchResult = utxoSupplier.getPage(sender, nrOfItems, page, OrderEnum.asc);
String firstAddr = null;
while(addrIter.hasNext()) {
int page = 0;
Address senderAddr = addrIter.next();
String sender = senderAddr.toBech32();

var fetched = fetchResult != null
? fetchResult.stream()
.sorted(sortByMostMatchingAssets(outputAmounts))
.collect(Collectors.toList())
: Collections.<Utxo>emptyList();
if (firstAddr == null)
firstAddr = sender; //TODO -- Just a workaround for log

page += 1;
for(Utxo utxo : fetched) {
if(!accept(utxo)){
continue;
}
if(utxosToExclude != null && utxosToExclude.contains(utxo)){
continue;
}
if(utxo.getDataHash() != null && !utxo.getDataHash().isEmpty() && ignoreUtxosWithDatumHash){
continue;
}
if(datumHash != null && !datumHash.isEmpty() && !datumHash.equals(utxo.getDataHash())){
continue;
}
//TODO - add tests for this scenario
if(inlineDatum != null && !inlineDatum.serializeToHex().equals(utxo.getInlineDatum())) {
continue;
}
if(selectedUtxos.contains(utxo)){
continue;
}
List<Amount> utxoAmounts = utxo.getAmount();

boolean utxoSelected = false;
for(Amount amount : utxoAmounts) {
var remainingAmount = remaining.get(amount.getUnit());
if(remainingAmount != null && BigInteger.ZERO.compareTo(remainingAmount) < 0){
utxoSelected = true;
var newRemaining = remainingAmount.subtract(amount.getQuantity());
if(BigInteger.ZERO.compareTo(newRemaining) < 0){
remaining.put(amount.getUnit(), newRemaining);
}else{
remaining.remove(amount.getUnit());
while (!remaining.isEmpty()) {
var fetchResult = utxoSupplier.getPage(sender, nrOfItems, page, OrderEnum.asc);

var fetched = fetchResult != null
? fetchResult.stream()
.sorted(sortByMostMatchingAssets(outputAmounts))
.collect(Collectors.toList())
: Collections.<Utxo>emptyList();

page += 1;
for (Utxo utxo : fetched) {
if (!accept(utxo)) {
continue;
}
if (utxosToExclude != null && utxosToExclude.contains(utxo)) {
continue;
}
if (utxo.getDataHash() != null && !utxo.getDataHash().isEmpty() && ignoreUtxosWithDatumHash) {
continue;
}
if (datumHash != null && !datumHash.isEmpty() && !datumHash.equals(utxo.getDataHash())) {
continue;
}
//TODO - add tests for this scenario
if (inlineDatum != null && !inlineDatum.serializeToHex().equals(utxo.getInlineDatum())) {
continue;
}
if (selectedUtxos.contains(utxo)) {
continue;
}
List<Amount> utxoAmounts = utxo.getAmount();

boolean utxoSelected = false;
for (Amount amount : utxoAmounts) {
var remainingAmount = remaining.get(amount.getUnit());
if (remainingAmount != null && BigInteger.ZERO.compareTo(remainingAmount) < 0) {
utxoSelected = true;
var newRemaining = remainingAmount.subtract(amount.getQuantity());
if (BigInteger.ZERO.compareTo(newRemaining) < 0) {
remaining.put(amount.getUnit(), newRemaining);
} else {
remaining.remove(amount.getUnit());
}
}
}
}

if(utxoSelected){
selectedUtxos.add(utxo);
if(!remaining.isEmpty() && selectedUtxos.size() > maxUtxoSelectionLimit){
throw new InputsLimitExceededException("Selection limit of " + maxUtxoSelectionLimit + " utxos reached with " + remaining + " remaining");
if (utxoSelected) {
var walletUtxo = WalletUtxo.from(utxo);
walletUtxo.setDerivationPath(senderAddr.getDerivationPath().isPresent()? senderAddr.getDerivationPath().get(): null);

selectedUtxos.add(walletUtxo);
if (!remaining.isEmpty() && selectedUtxos.size() > maxUtxoSelectionLimit) {
throw new InputsLimitExceededException("Selection limit of " + maxUtxoSelectionLimit + " utxos reached with " + remaining + " remaining");
}
}
}
}
if(fetched.isEmpty()){
break;
if (fetched.isEmpty()) {
break;
}
}
}
if(!remaining.isEmpty()){
throw new InsufficientBalanceException("Not enough funds for [" + remaining + "], address: " + sender);
throw new InsufficientBalanceException("Not enough funds for [" + remaining + "], address: " + firstAddr);
}
return selectedUtxos;
}catch(InputsLimitExceededException e){
var fallback = fallback();
if(fallback != null){
return fallback.select(sender, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
return fallback.select(addrIter, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
}
throw new ApiRuntimeException("Input limit exceeded and no fallback provided", e);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
package com.bloxbean.cardano.client.coinselection.impl;

import com.bloxbean.cardano.client.api.AddressIterator;
import com.bloxbean.cardano.client.api.model.Amount;
import com.bloxbean.cardano.client.api.model.Utxo;
import com.bloxbean.cardano.client.coinselection.UtxoSelectionStrategy;
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
import com.bloxbean.cardano.client.transaction.spec.TransactionInput;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;

/**
Expand All @@ -34,7 +32,7 @@ public ExcludeUtxoSelectionStrategy(UtxoSelectionStrategy utxoSelectionStrategy,
}

@Override
public Set<Utxo> select(String address, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude,
public Set<Utxo> select(AddressIterator addrIter, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude,
int maxUtxoSelectionLimit) {
Set<Utxo> finalUtxoToExclude;
if (utxosToExclude != null) {
Expand All @@ -43,7 +41,7 @@ public Set<Utxo> select(String address, List<Amount> outputAmounts, String datum
} else {
finalUtxoToExclude = this.excludeList;
}
return utxoSelectionStrategy.select(address, outputAmounts, datumHash, inlineDatum, finalUtxoToExclude, maxUtxoSelectionLimit);
return utxoSelectionStrategy.select(addrIter, outputAmounts, datumHash, inlineDatum, finalUtxoToExclude, maxUtxoSelectionLimit);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.bloxbean.cardano.client.coinselection.impl;

import com.bloxbean.cardano.client.address.Address;
import com.bloxbean.cardano.client.api.AddressIterator;
import com.bloxbean.cardano.client.api.exception.ApiRuntimeException;
import com.bloxbean.cardano.client.api.exception.InsufficientBalanceException;
import com.bloxbean.cardano.client.api.model.Amount;
Expand Down Expand Up @@ -33,10 +35,15 @@ public LargestFirstUtxoSelectionStrategy(UtxoSupplier utxoSupplier, boolean igno
}

@Override
public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
public Set<Utxo> select(AddressIterator addrIter, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
if(outputAmounts == null || outputAmounts.isEmpty()){
return Collections.emptySet();
}

//Reset the iterator incase it's reused
if (addrIter != null)
addrIter.reset();

try{
Set<Utxo> selectedUtxos = new HashSet<>();

Expand All @@ -49,7 +56,11 @@ public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumH
.filter(entry -> BigInteger.ZERO.compareTo(entry.getValue()) < 0)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

var fetchResult = this.utxoSupplier.getAll(sender);
List<Utxo> fetchResult = new ArrayList<>();
while (addrIter.hasNext()) {
var utxos = this.utxoSupplier.getAll(addrIter.next().toBech32());
fetchResult.addAll(utxos);
}

var allUtxos = fetchResult.stream()
.sorted(sortLargestFirst(outputAmounts))
Expand Down Expand Up @@ -104,7 +115,7 @@ public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumH
}catch(InputsLimitExceededException e){
var fallback = fallback();
if(fallback != null){
return fallback.select(sender, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
return fallback.select(addrIter, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
}
throw new ApiRuntimeException("Input limit exceeded and no fallback provided", e);
}
Expand Down
Loading

0 comments on commit 973e34d

Please sign in to comment.