diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index e5463e11cfb..36bb55add62 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -18,6 +18,7 @@ package bisq.cli; import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetVersionGrpc; import bisq.proto.grpc.GetVersionRequest; import bisq.proto.grpc.LockWalletRequest; @@ -58,6 +59,7 @@ public class CliMain { private enum Method { getversion, getbalance, + getfundingaddresses, lockwallet, unlockwallet, removewalletpassword, @@ -152,6 +154,12 @@ public static void run(String[] args) { out.println(btcBalance); return; } + case getfundingaddresses: { + var request = GetFundingAddressesRequest.newBuilder().build(); + var reply = walletService.getFundingAddresses(request); + out.println(reply.getFundingAddressesInfo()); + return; + } case lockwallet: { var request = LockWalletRequest.newBuilder().build(); walletService.lockWallet(request); @@ -201,7 +209,7 @@ public static void run(String[] args) { } default: { throw new RuntimeException(format("unhandled method '%s'", method)); - } + } } } catch (StatusRuntimeException ex) { // Remove the leading gRPC status code (e.g. "UNKNOWN: ") from the message @@ -222,6 +230,7 @@ private static void printHelp(OptionParser parser, PrintStream stream) { stream.format("%-19s%-30s%s%n", "------", "------", "------------"); stream.format("%-19s%-30s%s%n", "getversion", "", "Get server version"); stream.format("%-19s%-30s%s%n", "getbalance", "", "Get server wallet balance"); + stream.format("%-19s%-30s%s%n", "getfundingaddresses", "", "Get BTC funding addresses"); stream.format("%-19s%-30s%s%n", "lockwallet", "", "Remove wallet password from memory, locking the wallet"); stream.format("%-19s%-30s%s%n", "unlockwallet", "password timeout", "Store wallet password in memory for timeout seconds"); diff --git a/cli/test.sh b/cli/test.sh index 94aae7d25b6..be2e67bc46f 100755 --- a/cli/test.sh +++ b/cli/test.sh @@ -48,28 +48,115 @@ run ./bisq-cli --password="xyz" getversion [ "$status" -eq 0 ] echo "actual output: $output" >&2 - [ "$output" = "1.3.2" ] + [ "$output" = "1.3.4" ] } @test "test getversion" { run ./bisq-cli --password=xyz getversion [ "$status" -eq 0 ] echo "actual output: $output" >&2 - [ "$output" = "1.3.2" ] + [ "$output" = "1.3.4" ] } -@test "test getbalance (available & unlocked wallet with 0 btc balance)" { +@test "test setwalletpassword \"a b c\"" { + run ./bisq-cli --password=xyz setwalletpassword "a b c" + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "wallet encrypted" ] + sleep 1 +} + +@test "test unlockwallet without password & timeout args" { + run ./bisq-cli --password=xyz unlockwallet + [ "$status" -eq 1 ] + echo "actual output: $output" >&2 + [ "$output" = "Error: no password specified" ] +} + +@test "test unlockwallet without timeout arg" { + run ./bisq-cli --password=xyz unlockwallet "a b c" + [ "$status" -eq 1 ] + echo "actual output: $output" >&2 + [ "$output" = "Error: no unlock timeout specified" ] +} + + +@test "test unlockwallet \"a b c\" 8" { + run ./bisq-cli --password=xyz unlockwallet "a b c" 8 + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "wallet unlocked" ] +} + +@test "test getbalance while wallet unlocked for 8s" { run ./bisq-cli --password=xyz getbalance [ "$status" -eq 0 ] echo "actual output: $output" >&2 [ "$output" = "0.00000000" ] + sleep 8 +} + +@test "test unlockwallet \"a b c\" 6" { + run ./bisq-cli --password=xyz unlockwallet "a b c" 6 + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "wallet unlocked" ] +} + +@test "test lockwallet before unlockwallet timeout=6s expires" { + run ./bisq-cli --password=xyz lockwallet + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "wallet locked" ] +} + +@test "test setwalletpassword incorrect old pwd error" { + run ./bisq-cli --password=xyz setwalletpassword "z z z" "d e f" + [ "$status" -eq 1 ] + echo "actual output: $output" >&2 + [ "$output" = "Error: incorrect old password" ] +} + +@test "test setwalletpassword oldpwd newpwd" { + run ./bisq-cli --password=xyz setwalletpassword "a b c" "d e f" + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "wallet encrypted with new password" ] + sleep 1 +} + +@test "test getbalance wallet locked error" { + run ./bisq-cli --password=xyz getbalance + [ "$status" -eq 1 ] + echo "actual output: $output" >&2 + [ "$output" = "Error: wallet is locked" ] +} + +@test "test removewalletpassword" { + run ./bisq-cli --password=xyz removewalletpassword "d e f" + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "wallet decrypted" ] + sleep 1 +} + +@test "test getbalance when wallet available & unlocked with 0 btc balance" { + run ./bisq-cli --password=xyz getbalance + [ "$status" -eq 0 ] + echo "actual output: $output" >&2 + [ "$output" = "0.00000000" ] +} + +@test "test getfundingaddresses" { + run ./bisq-cli --password=xyz getfundingaddresses + [ "$status" -eq 0 ] } @test "test help displayed on stderr if no options or arguments" { run ./bisq-cli [ "$status" -eq 1 ] [ "${lines[0]}" = "Bisq RPC Client" ] - [ "${lines[1]}" = "Usage: bisq-cli [options] " ] + [ "${lines[1]}" = "Usage: bisq-cli [options] [params]" ] # TODO add asserts after help text is modified for new endpoints } @@ -77,6 +164,6 @@ run ./bisq-cli --help [ "$status" -eq 0 ] [ "${lines[0]}" = "Bisq RPC Client" ] - [ "${lines[1]}" = "Usage: bisq-cli [options] " ] + [ "${lines[1]}" = "Usage: bisq-cli [options] [params]" ] # TODO add asserts after help text is modified for new endpoints } diff --git a/core/src/main/java/bisq/core/grpc/CoreWalletService.java b/core/src/main/java/bisq/core/grpc/CoreWalletsService.java similarity index 55% rename from core/src/main/java/bisq/core/grpc/CoreWalletService.java rename to core/src/main/java/bisq/core/grpc/CoreWalletsService.java index ff9383c55d4..64d70e15c73 100644 --- a/core/src/main/java/bisq/core/grpc/CoreWalletService.java +++ b/core/src/main/java/bisq/core/grpc/CoreWalletsService.java @@ -1,28 +1,44 @@ package bisq.core.grpc; import bisq.core.btc.Balances; +import bisq.core.btc.model.AddressEntry; +import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.WalletsManager; +import bisq.common.util.Tuple3; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.crypto.KeyCrypterScrypt; import javax.inject.Inject; import org.spongycastle.crypto.params.KeyParameter; +import java.text.DecimalFormat; + +import java.math.BigDecimal; + +import java.util.List; +import java.util.Optional; import java.util.Timer; import java.util.TimerTask; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; +import static java.lang.String.format; import static java.util.concurrent.TimeUnit.SECONDS; @Slf4j -class CoreWalletService { +class CoreWalletsService { private final Balances balances; private final WalletsManager walletsManager; + private final BtcWalletService btcWalletService; @Nullable private TimerTask lockTask; @@ -30,10 +46,19 @@ class CoreWalletService { @Nullable private KeyParameter tempAesKey; + private final BigDecimal satoshiDivisor = new BigDecimal(100000000); + private final DecimalFormat btcFormat = new DecimalFormat("###,##0.00000000"); + @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") + private final Function formatSatoshis = (sats) -> + btcFormat.format(BigDecimal.valueOf(sats).divide(satoshiDivisor)); + @Inject - public CoreWalletService(Balances balances, WalletsManager walletsManager) { + public CoreWalletsService(Balances balances, + WalletsManager walletsManager, + BtcWalletService btcWalletService) { this.balances = balances; this.walletsManager = walletsManager; + this.btcWalletService = btcWalletService; } public long getAvailableBalance() { @@ -50,6 +75,68 @@ public long getAvailableBalance() { return balance.getValue(); } + public long getAddressBalance(String addressString) { + Address address = getAddressEntry(addressString).getAddress(); + return btcWalletService.getBalanceForAddress(address).value; + } + + public String getFundingAddresses() { + if (!walletsManager.areWalletsAvailable()) + throw new IllegalStateException("wallet is not yet available"); + + if (walletsManager.areWalletsEncrypted() && tempAesKey == null) + throw new IllegalStateException("wallet is locked"); + + // Create a new funding address if none exists. + if (btcWalletService.getAvailableAddressEntries().size() == 0) + btcWalletService.getFreshAddressEntry(); + + // Populate a list of Tuple3 + List> addrBalanceConfirms = + btcWalletService.getAvailableAddressEntries().stream() + .map(a -> new Tuple3<>(a.getAddressString(), + getAddressBalance(a.getAddressString()), + getNumConfirmationsForMostRecentTransaction(a.getAddressString()))) + .collect(Collectors.toList()); + + // Check to see if at least one of the existing addresses has a zero balance. + boolean hasZeroBalance = false; + for (Tuple3 abc : addrBalanceConfirms) { + if (abc.second == 0) { + hasZeroBalance = true; + break; + } + } + if (!hasZeroBalance) { + // None of the existing addresses have a zero balance, create a new address. + addrBalanceConfirms.add( + new Tuple3<>(btcWalletService.getFreshAddressEntry().getAddressString(), + 0L, + 0)); + } + + // Iterate the list of Tuple3 objects + // and build the formatted info string. + StringBuilder addressInfoBuilder = new StringBuilder(); + addrBalanceConfirms.forEach(a -> { + var btcBalance = formatSatoshis.apply(a.second); + var numConfirmations = getNumConfirmationsForMostRecentTransaction(a.first); + String addressInfo = "" + a.first + + " balance: " + format("%13s", btcBalance) + + ((a.second > 0) ? (" confirmations: " + format("%6d", numConfirmations)) : "") + + "\n"; + addressInfoBuilder.append(addressInfo); + }); + + return addressInfoBuilder.toString().trim(); + } + + public int getNumConfirmationsForMostRecentTransaction(String addressString) { + Address address = getAddressEntry(addressString).getAddress(); + TransactionConfidence confidence = btcWalletService.getConfidenceForAddress(address); + return confidence == null ? 0 : confidence.getDepthInBlocks(); + } + public void setWalletPassword(String password, String newPassword) { if (!walletsManager.areWalletsAvailable()) throw new IllegalStateException("wallet is not yet available"); @@ -156,4 +243,16 @@ private KeyCrypterScrypt getKeyCrypterScrypt() { throw new IllegalStateException("wallet encrypter is not available"); return keyCrypterScrypt; } + + private AddressEntry getAddressEntry(String addressString) { + Optional addressEntry = + btcWalletService.getAddressEntryListAsImmutableList().stream() + .filter(e -> addressString.equals(e.getAddressString())) + .findFirst(); + + if (!addressEntry.isPresent()) + throw new IllegalStateException(format("address %s not found in wallet", addressString)); + + return addressEntry.get(); + } } diff --git a/core/src/main/java/bisq/core/grpc/GrpcWalletService.java b/core/src/main/java/bisq/core/grpc/GrpcWalletService.java index 92d4cc8b81f..62373fd1be9 100644 --- a/core/src/main/java/bisq/core/grpc/GrpcWalletService.java +++ b/core/src/main/java/bisq/core/grpc/GrpcWalletService.java @@ -2,6 +2,8 @@ import bisq.proto.grpc.GetBalanceReply; import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetFundingAddressesReply; +import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.LockWalletReply; import bisq.proto.grpc.LockWalletRequest; import bisq.proto.grpc.RemoveWalletPasswordReply; @@ -20,17 +22,17 @@ class GrpcWalletService extends WalletGrpc.WalletImplBase { - private final CoreWalletService walletService; + private final CoreWalletsService walletsService; @Inject - public GrpcWalletService(CoreWalletService walletService) { - this.walletService = walletService; + public GrpcWalletService(CoreWalletsService walletsService) { + this.walletsService = walletsService; } @Override public void getBalance(GetBalanceRequest req, StreamObserver responseObserver) { try { - long result = walletService.getAvailableBalance(); + long result = walletsService.getAvailableBalance(); var reply = GetBalanceReply.newBuilder().setBalance(result).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -40,12 +42,27 @@ public void getBalance(GetBalanceRequest req, StreamObserver re throw ex; } } + + @Override + public void getFundingAddresses(GetFundingAddressesRequest req, + StreamObserver responseObserver) { + try { + String result = walletsService.getFundingAddresses(); + var reply = GetFundingAddressesReply.newBuilder().setFundingAddressesInfo(result).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (IllegalStateException cause) { + var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); + responseObserver.onError(ex); + throw ex; + } + } @Override public void setWalletPassword(SetWalletPasswordRequest req, StreamObserver responseObserver) { try { - walletService.setWalletPassword(req.getPassword(), req.getNewPassword()); + walletsService.setWalletPassword(req.getPassword(), req.getNewPassword()); var reply = SetWalletPasswordReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -60,7 +77,7 @@ public void setWalletPassword(SetWalletPasswordRequest req, public void removeWalletPassword(RemoveWalletPasswordRequest req, StreamObserver responseObserver) { try { - walletService.removeWalletPassword(req.getPassword()); + walletsService.removeWalletPassword(req.getPassword()); var reply = RemoveWalletPasswordReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -75,7 +92,7 @@ public void removeWalletPassword(RemoveWalletPasswordRequest req, public void lockWallet(LockWalletRequest req, StreamObserver responseObserver) { try { - walletService.lockWallet(); + walletsService.lockWallet(); var reply = LockWalletReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -90,7 +107,7 @@ public void lockWallet(LockWalletRequest req, public void unlockWallet(UnlockWalletRequest req, StreamObserver responseObserver) { try { - walletService.unlockWallet(req.getPassword(), req.getTimeout()); + walletsService.unlockWallet(req.getPassword(), req.getTimeout()); var reply = UnlockWalletReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index b8db4c6d24b..9e85dbe371e 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -119,6 +119,8 @@ message PlaceOfferReply { service Wallet { rpc GetBalance (GetBalanceRequest) returns (GetBalanceReply) { } + rpc GetFundingAddresses (GetFundingAddressesRequest) returns (GetFundingAddressesReply) { + } rpc SetWalletPassword (SetWalletPasswordRequest) returns (SetWalletPasswordReply) { } rpc RemoveWalletPassword (RemoveWalletPasswordRequest) returns (RemoveWalletPasswordReply) { @@ -136,6 +138,13 @@ message GetBalanceReply { uint64 balance = 1; } +message GetFundingAddressesRequest { +} + +message GetFundingAddressesReply { + string fundingAddressesInfo = 1; +} + message SetWalletPasswordRequest { string password = 1; string newPassword = 2;