Skip to content

Commit 973e34d

Browse files
authored
Enhance UtxoSelectionStrategy for HDWallet (#487)
* 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
1 parent 53b1614 commit 973e34d

File tree

36 files changed

+710
-650
lines changed

36 files changed

+710
-650
lines changed

address/src/main/java/com/bloxbean/cardano/client/address/Address.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.bloxbean.cardano.client.common.model.Network;
44
import com.bloxbean.cardano.client.crypto.Bech32;
5+
import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath;
56
import com.bloxbean.cardano.client.exception.AddressRuntimeException;
67

78
import java.util.Optional;
@@ -19,6 +20,9 @@ public class Address {
1920
private AddressType addressType;
2021
private Network network;
2122

23+
//Optional
24+
private DerivationPath derivationPath;
25+
2226
/**
2327
* Create Address from a byte array
2428
* @param prefix Address prefix
@@ -49,6 +53,11 @@ public Address(String address) {
4953
this.network = readNetworkType(this.bytes);
5054
}
5155

56+
public Address(String address, DerivationPath derivationPath) {
57+
this(address);
58+
this.derivationPath = derivationPath;
59+
}
60+
5261
/**
5362
* Create Address from a byte array
5463
* @param addressBytes
@@ -188,4 +197,9 @@ public Optional<String> getBech32VerificationKeyHash() {
188197
return getPaymentCredentialHash()
189198
.map(paymentCred -> Bech32.encode(paymentCred, ADDR_VKH_PREFIX));
190199
}
200+
201+
public Optional<DerivationPath> getDerivationPath() {
202+
return Optional.ofNullable(derivationPath);
203+
}
204+
191205
}

backend-modules/blockfrost/src/main/java/com/bloxbean/cardano/client/backend/blockfrost/service/BFUtxoService.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.bloxbean.cardano.client.backend.api.TransactionService;
88
import com.bloxbean.cardano.client.backend.api.UtxoService;
99
import com.bloxbean.cardano.client.backend.blockfrost.service.http.AddressesApi;
10+
import com.bloxbean.cardano.client.backend.model.AddressTransactionContent;
1011
import retrofit2.Call;
1112
import retrofit2.Response;
1213

@@ -62,4 +63,19 @@ public Result<List<Utxo>> getUtxos(String address, String unit, int count, int p
6263
public Result<Utxo> getTxOutput(String txHash, int outputIndex) throws ApiException {
6364
return transactionService.getTransactionOutput(txHash, outputIndex);
6465
}
66+
67+
@Override
68+
public boolean isUsedAddress(String address) throws ApiException {
69+
Call<List<AddressTransactionContent>> call = addressApi.getTransactions(getProjectId(), address, 1, 1, OrderEnum.asc.toString(), null, null);
70+
try {
71+
Response<List<AddressTransactionContent>> response = call.execute();
72+
var txList = processResponse(response);
73+
if (txList.isSuccessful() && txList.getValue().size() > 0)
74+
return true;
75+
else
76+
return false;
77+
} catch (IOException e) {
78+
throw new ApiException("Error checking transaction history for address : " + address, e);
79+
}
80+
}
6581
}

backend/src/main/java/com/bloxbean/cardano/client/backend/api/DefaultUtxoSupplier.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.bloxbean.cardano.client.backend.api;
22

3+
import com.bloxbean.cardano.client.address.Address;
34
import com.bloxbean.cardano.client.api.common.OrderEnum;
45
import com.bloxbean.cardano.client.api.exception.ApiException;
56
import com.bloxbean.cardano.client.api.model.Utxo;
@@ -36,4 +37,13 @@ public Optional<Utxo> getTxOutput(String txHash, int outputIndex) {
3637
throw new ApiRuntimeException(e);
3738
}
3839
}
40+
41+
@Override
42+
public boolean isUsedAddress(Address address) {
43+
try {
44+
return utxoService.isUsedAddress(address.toBech32());
45+
} catch (ApiException e) {
46+
throw new ApiRuntimeException(e);
47+
}
48+
}
3949
}

backend/src/main/java/com/bloxbean/cardano/client/backend/api/UtxoService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,15 @@ public interface UtxoService {
6161
* @throws ApiException If any error occurs
6262
*/
6363
Result<Utxo> getTxOutput(String txHash, int outputIndex) throws ApiException;
64+
65+
/**
66+
* Checks if the provided address has been used in any transactions.
67+
*
68+
* @param address The address to be checked.
69+
* @return true if the address has been used, otherwise false.
70+
* @throws ApiException If any error occurs during the operation.
71+
*/
72+
default boolean isUsedAddress(String address) throws ApiException {
73+
throw new UnsupportedOperationException();
74+
}
6475
}

coinselection/src/main/java/com/bloxbean/cardano/client/coinselection/UtxoSelectionStrategy.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
package com.bloxbean.cardano.client.coinselection;
22

3+
import com.bloxbean.cardano.client.address.Address;
4+
import com.bloxbean.cardano.client.api.AddressIterator;
5+
import com.bloxbean.cardano.client.api.common.AddressIterators;
36
import com.bloxbean.cardano.client.api.exception.ApiException;
47
import com.bloxbean.cardano.client.api.model.Amount;
58
import com.bloxbean.cardano.client.api.model.Utxo;
69
import com.bloxbean.cardano.client.coinselection.config.CoinselectionConfig;
710
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
811

912
import java.math.BigInteger;
10-
import java.util.ArrayList;
11-
import java.util.Collections;
12-
import java.util.List;
13-
import java.util.Set;
13+
import java.util.*;
1414

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

99-
Set<Utxo> select(String address, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit);
99+
default Set<Utxo> select(String address, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
100+
return select(AddressIterators.of(new Address(address)), outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
101+
}
102+
103+
default List<Utxo> selectUtxos(AddressIterator addrIter, String unit, BigInteger amount, Set<Utxo> utxosToExclude) throws ApiException {
104+
Set<Utxo> selected = select(addrIter, List.of(new Amount(unit, amount)), null, null, utxosToExclude, CoinselectionConfig.INSTANCE.getCoinSelectionLimit());
105+
return selected != null ? new ArrayList<>(selected) : Collections.emptyList();
106+
}
107+
108+
Set<Utxo> select(AddressIterator addressIterator, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit);
100109

101110
UtxoSelectionStrategy fallback();
102111

coinselection/src/main/java/com/bloxbean/cardano/client/coinselection/impl/DefaultUtxoSelectionStrategyImpl.java

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.bloxbean.cardano.client.coinselection.impl;
22

3+
import com.bloxbean.cardano.client.address.Address;
4+
import com.bloxbean.cardano.client.api.AddressIterator;
35
import com.bloxbean.cardano.client.api.UtxoSupplier;
46
import com.bloxbean.cardano.client.api.common.OrderEnum;
57
import com.bloxbean.cardano.client.api.exception.ApiRuntimeException;
68
import com.bloxbean.cardano.client.api.exception.InsufficientBalanceException;
79
import com.bloxbean.cardano.client.api.model.Amount;
810
import com.bloxbean.cardano.client.api.model.Utxo;
11+
import com.bloxbean.cardano.client.api.model.WalletUtxo;
912
import com.bloxbean.cardano.client.coinselection.UtxoSelectionStrategy;
1013
import com.bloxbean.cardano.client.coinselection.exception.InputsLimitExceededException;
1114
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
@@ -35,11 +38,14 @@ public DefaultUtxoSelectionStrategyImpl(UtxoSupplier utxoSupplier, boolean ignor
3538
}
3639

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

46+
//reset addrIter to handle reuse of same iterator from caller
47+
if (addrIter != null) addrIter.reset();
48+
4349
//TODO -- Should we throw error if both datumHash and inlineDatum are set ??
4450

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

58-
int page = 0;
64+
5965
final int nrOfItems = 100;
6066

61-
while(!remaining.isEmpty()){
62-
var fetchResult = utxoSupplier.getPage(sender, nrOfItems, page, OrderEnum.asc);
67+
String firstAddr = null;
68+
while(addrIter.hasNext()) {
69+
int page = 0;
70+
Address senderAddr = addrIter.next();
71+
String sender = senderAddr.toBech32();
6372

64-
var fetched = fetchResult != null
65-
? fetchResult.stream()
66-
.sorted(sortByMostMatchingAssets(outputAmounts))
67-
.collect(Collectors.toList())
68-
: Collections.<Utxo>emptyList();
73+
if (firstAddr == null)
74+
firstAddr = sender; //TODO -- Just a workaround for log
6975

70-
page += 1;
71-
for(Utxo utxo : fetched) {
72-
if(!accept(utxo)){
73-
continue;
74-
}
75-
if(utxosToExclude != null && utxosToExclude.contains(utxo)){
76-
continue;
77-
}
78-
if(utxo.getDataHash() != null && !utxo.getDataHash().isEmpty() && ignoreUtxosWithDatumHash){
79-
continue;
80-
}
81-
if(datumHash != null && !datumHash.isEmpty() && !datumHash.equals(utxo.getDataHash())){
82-
continue;
83-
}
84-
//TODO - add tests for this scenario
85-
if(inlineDatum != null && !inlineDatum.serializeToHex().equals(utxo.getInlineDatum())) {
86-
continue;
87-
}
88-
if(selectedUtxos.contains(utxo)){
89-
continue;
90-
}
91-
List<Amount> utxoAmounts = utxo.getAmount();
92-
93-
boolean utxoSelected = false;
94-
for(Amount amount : utxoAmounts) {
95-
var remainingAmount = remaining.get(amount.getUnit());
96-
if(remainingAmount != null && BigInteger.ZERO.compareTo(remainingAmount) < 0){
97-
utxoSelected = true;
98-
var newRemaining = remainingAmount.subtract(amount.getQuantity());
99-
if(BigInteger.ZERO.compareTo(newRemaining) < 0){
100-
remaining.put(amount.getUnit(), newRemaining);
101-
}else{
102-
remaining.remove(amount.getUnit());
76+
while (!remaining.isEmpty()) {
77+
var fetchResult = utxoSupplier.getPage(sender, nrOfItems, page, OrderEnum.asc);
78+
79+
var fetched = fetchResult != null
80+
? fetchResult.stream()
81+
.sorted(sortByMostMatchingAssets(outputAmounts))
82+
.collect(Collectors.toList())
83+
: Collections.<Utxo>emptyList();
84+
85+
page += 1;
86+
for (Utxo utxo : fetched) {
87+
if (!accept(utxo)) {
88+
continue;
89+
}
90+
if (utxosToExclude != null && utxosToExclude.contains(utxo)) {
91+
continue;
92+
}
93+
if (utxo.getDataHash() != null && !utxo.getDataHash().isEmpty() && ignoreUtxosWithDatumHash) {
94+
continue;
95+
}
96+
if (datumHash != null && !datumHash.isEmpty() && !datumHash.equals(utxo.getDataHash())) {
97+
continue;
98+
}
99+
//TODO - add tests for this scenario
100+
if (inlineDatum != null && !inlineDatum.serializeToHex().equals(utxo.getInlineDatum())) {
101+
continue;
102+
}
103+
if (selectedUtxos.contains(utxo)) {
104+
continue;
105+
}
106+
List<Amount> utxoAmounts = utxo.getAmount();
107+
108+
boolean utxoSelected = false;
109+
for (Amount amount : utxoAmounts) {
110+
var remainingAmount = remaining.get(amount.getUnit());
111+
if (remainingAmount != null && BigInteger.ZERO.compareTo(remainingAmount) < 0) {
112+
utxoSelected = true;
113+
var newRemaining = remainingAmount.subtract(amount.getQuantity());
114+
if (BigInteger.ZERO.compareTo(newRemaining) < 0) {
115+
remaining.put(amount.getUnit(), newRemaining);
116+
} else {
117+
remaining.remove(amount.getUnit());
118+
}
103119
}
104120
}
105-
}
106121

107-
if(utxoSelected){
108-
selectedUtxos.add(utxo);
109-
if(!remaining.isEmpty() && selectedUtxos.size() > maxUtxoSelectionLimit){
110-
throw new InputsLimitExceededException("Selection limit of " + maxUtxoSelectionLimit + " utxos reached with " + remaining + " remaining");
122+
if (utxoSelected) {
123+
var walletUtxo = WalletUtxo.from(utxo);
124+
walletUtxo.setDerivationPath(senderAddr.getDerivationPath().isPresent()? senderAddr.getDerivationPath().get(): null);
125+
126+
selectedUtxos.add(walletUtxo);
127+
if (!remaining.isEmpty() && selectedUtxos.size() > maxUtxoSelectionLimit) {
128+
throw new InputsLimitExceededException("Selection limit of " + maxUtxoSelectionLimit + " utxos reached with " + remaining + " remaining");
129+
}
111130
}
112131
}
113-
}
114-
if(fetched.isEmpty()){
115-
break;
132+
if (fetched.isEmpty()) {
133+
break;
134+
}
116135
}
117136
}
118137
if(!remaining.isEmpty()){
119-
throw new InsufficientBalanceException("Not enough funds for [" + remaining + "], address: " + sender);
138+
throw new InsufficientBalanceException("Not enough funds for [" + remaining + "], address: " + firstAddr);
120139
}
121140
return selectedUtxos;
122141
}catch(InputsLimitExceededException e){
123142
var fallback = fallback();
124143
if(fallback != null){
125-
return fallback.select(sender, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
144+
return fallback.select(addrIter, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
126145
}
127146
throw new ApiRuntimeException("Input limit exceeded and no fallback provided", e);
128147
}

coinselection/src/main/java/com/bloxbean/cardano/client/coinselection/impl/ExcludeUtxoSelectionStrategy.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
package com.bloxbean.cardano.client.coinselection.impl;
22

3+
import com.bloxbean.cardano.client.api.AddressIterator;
34
import com.bloxbean.cardano.client.api.model.Amount;
45
import com.bloxbean.cardano.client.api.model.Utxo;
56
import com.bloxbean.cardano.client.coinselection.UtxoSelectionStrategy;
67
import com.bloxbean.cardano.client.plutus.spec.PlutusData;
78
import com.bloxbean.cardano.client.transaction.spec.TransactionInput;
89

9-
import java.util.Collections;
10-
import java.util.HashSet;
11-
import java.util.List;
12-
import java.util.Set;
10+
import java.util.*;
1311
import java.util.stream.Collectors;
1412

1513
/**
@@ -34,7 +32,7 @@ public ExcludeUtxoSelectionStrategy(UtxoSelectionStrategy utxoSelectionStrategy,
3432
}
3533

3634
@Override
37-
public Set<Utxo> select(String address, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude,
35+
public Set<Utxo> select(AddressIterator addrIter, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude,
3836
int maxUtxoSelectionLimit) {
3937
Set<Utxo> finalUtxoToExclude;
4038
if (utxosToExclude != null) {
@@ -43,7 +41,7 @@ public Set<Utxo> select(String address, List<Amount> outputAmounts, String datum
4341
} else {
4442
finalUtxoToExclude = this.excludeList;
4543
}
46-
return utxoSelectionStrategy.select(address, outputAmounts, datumHash, inlineDatum, finalUtxoToExclude, maxUtxoSelectionLimit);
44+
return utxoSelectionStrategy.select(addrIter, outputAmounts, datumHash, inlineDatum, finalUtxoToExclude, maxUtxoSelectionLimit);
4745
}
4846

4947
@Override

coinselection/src/main/java/com/bloxbean/cardano/client/coinselection/impl/LargestFirstUtxoSelectionStrategy.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.bloxbean.cardano.client.coinselection.impl;
22

3+
import com.bloxbean.cardano.client.address.Address;
4+
import com.bloxbean.cardano.client.api.AddressIterator;
35
import com.bloxbean.cardano.client.api.exception.ApiRuntimeException;
46
import com.bloxbean.cardano.client.api.exception.InsufficientBalanceException;
57
import com.bloxbean.cardano.client.api.model.Amount;
@@ -33,10 +35,15 @@ public LargestFirstUtxoSelectionStrategy(UtxoSupplier utxoSupplier, boolean igno
3335
}
3436

3537
@Override
36-
public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
38+
public Set<Utxo> select(AddressIterator addrIter, List<Amount> outputAmounts, String datumHash, PlutusData inlineDatum, Set<Utxo> utxosToExclude, int maxUtxoSelectionLimit) {
3739
if(outputAmounts == null || outputAmounts.isEmpty()){
3840
return Collections.emptySet();
3941
}
42+
43+
//Reset the iterator incase it's reused
44+
if (addrIter != null)
45+
addrIter.reset();
46+
4047
try{
4148
Set<Utxo> selectedUtxos = new HashSet<>();
4249

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

52-
var fetchResult = this.utxoSupplier.getAll(sender);
59+
List<Utxo> fetchResult = new ArrayList<>();
60+
while (addrIter.hasNext()) {
61+
var utxos = this.utxoSupplier.getAll(addrIter.next().toBech32());
62+
fetchResult.addAll(utxos);
63+
}
5364

5465
var allUtxos = fetchResult.stream()
5566
.sorted(sortLargestFirst(outputAmounts))
@@ -104,7 +115,7 @@ public Set<Utxo> select(String sender, List<Amount> outputAmounts, String datumH
104115
}catch(InputsLimitExceededException e){
105116
var fallback = fallback();
106117
if(fallback != null){
107-
return fallback.select(sender, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
118+
return fallback.select(addrIter, outputAmounts, datumHash, inlineDatum, utxosToExclude, maxUtxoSelectionLimit);
108119
}
109120
throw new ApiRuntimeException("Input limit exceeded and no fallback provided", e);
110121
}

0 commit comments

Comments
 (0)