diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index e5463e11cfb..a60d41bd2b0 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -17,14 +17,20 @@ package bisq.cli; +import bisq.proto.grpc.AddressBalanceInfo; +import bisq.proto.grpc.CreatePaymentAccountRequest; +import bisq.proto.grpc.GetAddressBalanceRequest; import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetFundingAddressesRequest; +import bisq.proto.grpc.GetPaymentAccountsRequest; import bisq.proto.grpc.GetVersionGrpc; import bisq.proto.grpc.GetVersionRequest; import bisq.proto.grpc.LockWalletRequest; +import bisq.proto.grpc.PaymentAccountsGrpc; import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.UnlockWalletRequest; -import bisq.proto.grpc.WalletGrpc; +import bisq.proto.grpc.WalletsGrpc; import io.grpc.ManagedChannelBuilder; import io.grpc.StatusRuntimeException; @@ -41,6 +47,8 @@ import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -48,16 +56,28 @@ import static java.lang.System.err; import static java.lang.System.exit; import static java.lang.System.out; +import static java.util.Collections.singletonList; /** * A command-line client for the Bisq gRPC API. */ +@SuppressWarnings("ResultOfMethodCallIgnored") @Slf4j public class CliMain { + private static final BigDecimal SATOSHI_DIVISOR = new BigDecimal(100000000); + private static final DecimalFormat BTC_FORMAT = new DecimalFormat("###,##0.00000000"); + @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") + private static final Function formatSatoshis = (sats) -> + BTC_FORMAT.format(BigDecimal.valueOf(sats).divide(SATOSHI_DIVISOR)); + private enum Method { + createpaymentacct, + getpaymentaccts, getversion, getbalance, + getaddressbalance, + getfundingaddresses, lockwallet, unlockwallet, removewalletpassword, @@ -131,7 +151,8 @@ public static void run(String[] args) { })); var versionService = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials); - var walletService = WalletGrpc.newBlockingStub(channel).withCallCredentials(credentials); + var paymentAccountsService = PaymentAccountsGrpc.newBlockingStub(channel).withCallCredentials(credentials); + var walletsService = WalletsGrpc.newBlockingStub(channel).withCallCredentials(credentials); try { switch (method) { @@ -143,18 +164,68 @@ public static void run(String[] args) { } case getbalance: { var request = GetBalanceRequest.newBuilder().build(); - var reply = walletService.getBalance(request); - var satoshiBalance = reply.getBalance(); - var satoshiDivisor = new BigDecimal(100000000); - var btcFormat = new DecimalFormat("###,##0.00000000"); - @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") - var btcBalance = btcFormat.format(BigDecimal.valueOf(satoshiBalance).divide(satoshiDivisor)); + var reply = walletsService.getBalance(request); + var btcBalance = formatSatoshis.apply(reply.getBalance()); out.println(btcBalance); return; } + case getaddressbalance: { + if (nonOptionArgs.size() < 2) + throw new IllegalArgumentException("no address specified"); + + var request = GetAddressBalanceRequest.newBuilder() + .setAddress(nonOptionArgs.get(1)).build(); + var reply = walletsService.getAddressBalance(request); + out.println(formatTable(singletonList(reply.getAddressBalanceInfo()))); + return; + } + case getfundingaddresses: { + var request = GetFundingAddressesRequest.newBuilder().build(); + var reply = walletsService.getFundingAddresses(request); + out.println(formatTable(reply.getAddressBalanceInfoList())); + return; + } + case createpaymentacct: { + if (nonOptionArgs.size() < 2) + throw new IllegalArgumentException("no account name specified"); + + var accountName = nonOptionArgs.get(1); + + if (nonOptionArgs.size() < 3) + throw new IllegalArgumentException("no account number specified"); + + var accountNumber = nonOptionArgs.get(2); + + if (nonOptionArgs.size() < 4) + throw new IllegalArgumentException("no fiat currency specified"); + + var fiatCurrencyCode = nonOptionArgs.get(3).toUpperCase(); + + var request = CreatePaymentAccountRequest.newBuilder() + .setAccountName(accountName) + .setAccountNumber(accountNumber) + .setFiatCurrencyCode(fiatCurrencyCode).build(); + paymentAccountsService.createPaymentAccount(request); + out.println(format("payment account %s saved", accountName)); + return; + } + case getpaymentaccts: { + var request = GetPaymentAccountsRequest.newBuilder().build(); + var reply = paymentAccountsService.getPaymentAccounts(request); + var columnFormatSpec = "%-41s %-25s %-14s %s"; + out.println(format(columnFormatSpec, "ID", "Name", "Currency", "Payment Method")); + out.println(reply.getPaymentAccountsList().stream() + .map(a -> format(columnFormatSpec, + a.getId(), + a.getAccountName(), + a.getSelectedTradeCurrency().getCode(), + a.getPaymentMethod().getId())) + .collect(Collectors.joining("\n"))); + return; + } case lockwallet: { var request = LockWalletRequest.newBuilder().build(); - walletService.lockWallet(request); + walletsService.lockWallet(request); out.println("wallet locked"); return; } @@ -174,7 +245,7 @@ public static void run(String[] args) { var request = UnlockWalletRequest.newBuilder() .setPassword(nonOptionArgs.get(1)) .setTimeout(timeout).build(); - walletService.unlockWallet(request); + walletsService.unlockWallet(request); out.println("wallet unlocked"); return; } @@ -183,7 +254,7 @@ public static void run(String[] args) { throw new IllegalArgumentException("no password specified"); var request = RemoveWalletPasswordRequest.newBuilder().setPassword(nonOptionArgs.get(1)).build(); - walletService.removeWalletPassword(request); + walletsService.removeWalletPassword(request); out.println("wallet decrypted"); return; } @@ -195,13 +266,13 @@ public static void run(String[] args) { var hasNewPassword = nonOptionArgs.size() == 3; if (hasNewPassword) requestBuilder.setNewPassword(nonOptionArgs.get(2)); - walletService.setWalletPassword(requestBuilder.build()); + walletsService.setWalletPassword(requestBuilder.build()); out.println("wallet encrypted" + (hasNewPassword ? " with new password" : "")); return; } default: { throw new RuntimeException(format("unhandled method '%s'", method)); - } + } } } catch (StatusRuntimeException ex) { // Remove the leading gRPC status code (e.g. "UNKNOWN: ") from the message @@ -218,18 +289,32 @@ private static void printHelp(OptionParser parser, PrintStream stream) { stream.println(); parser.printHelpOn(stream); stream.println(); - stream.format("%-19s%-30s%s%n", "Method", "Params", "Description"); - 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", "lockwallet", "", "Remove wallet password from memory, locking the wallet"); - stream.format("%-19s%-30s%s%n", "unlockwallet", "password timeout", + stream.format("%-22s%-50s%s%n", "Method", "Params", "Description"); + stream.format("%-22s%-50s%s%n", "------", "------", "------------"); + stream.format("%-22s%-50s%s%n", "getversion", "", "Get server version"); + stream.format("%-22s%-50s%s%n", "getbalance", "", "Get server wallet balance"); + stream.format("%-22s%-50s%s%n", "getaddressbalance", "address", "Get server wallet address balance"); + stream.format("%-22s%-50s%s%n", "getfundingaddresses", "", "Get BTC funding addresses"); + stream.format("%-22s%-50s%s%n", "createpaymentacct", "account name, account number, currency code", "Create PerfectMoney dummy account"); + stream.format("%-22s%-50s%s%n", "getpaymentaccts", "", "Get user payment accounts"); + stream.format("%-22s%-50s%s%n", "lockwallet", "", "Remove wallet password from memory, locking the wallet"); + stream.format("%-22s%-50s%s%n", "unlockwallet", "password timeout", "Store wallet password in memory for timeout seconds"); - stream.format("%-19s%-30s%s%n", "setwalletpassword", "password [newpassword]", + stream.format("%-22s%-50s%s%n", "setwalletpassword", "password [newpassword]", "Encrypt wallet with password, or set new password on encrypted wallet"); stream.println(); } catch (IOException ex) { ex.printStackTrace(stream); } } + + private static String formatTable(List addressBalanceInfo) { + return format("%-35s %13s %s%n", "Address", "Balance", "Confirmations") + + addressBalanceInfo.stream() + .map(info -> format("%-35s %13s %14d", + info.getAddress(), + formatSatoshis.apply(info.getBalance()), + info.getNumConfirmations())) + .collect(Collectors.joining("\n")); + } } diff --git a/cli/test.sh b/cli/test.sh index 94aae7d25b6..eaa64c9ebc8 100755 --- a/cli/test.sh +++ b/cli/test.sh @@ -48,28 +48,139 @@ 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 getaddressbalance missing address argument" { + run ./bisq-cli --password=xyz getaddressbalance + [ "$status" -eq 1 ] + echo "actual output: $output" >&2 + [ "$output" = "Error: no address specified" ] +} + +@test "test getaddressbalance bogus address argument" { + run ./bisq-cli --password=xyz getaddressbalance bogus + [ "$status" -eq 1 ] + echo "actual output: $output" >&2 + [ "$output" = "Error: address bogus not found in wallet" ] +} + +@test "test createpaymentacct PerfectMoneyDummy 0123456789 USD" { + run ./bisq-cli --password=xyz createpaymentacct PerfectMoneyDummy 0123456789 USD + [ "$status" -eq 0 ] +} + +@test "test getpaymentaccts" { + run ./bisq-cli --password=xyz getpaymentaccts + [ "$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 +188,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/CoreApi.java b/core/src/main/java/bisq/core/grpc/CoreApi.java index a0671f4d3b0..27d4d42573d 100644 --- a/core/src/main/java/bisq/core/grpc/CoreApi.java +++ b/core/src/main/java/bisq/core/grpc/CoreApi.java @@ -17,6 +17,7 @@ package bisq.core.grpc; +import bisq.core.grpc.model.AddressBalanceInfo; import bisq.core.monetary.Price; import bisq.core.offer.CreateOfferService; import bisq.core.offer.Offer; @@ -47,6 +48,8 @@ */ @Slf4j public class CoreApi { + private final CorePaymentAccountsService paymentAccountsService; + private final CoreWalletsService walletsService; private final OfferBookService offerBookService; private final TradeStatisticsManager tradeStatisticsManager; private final CreateOfferService createOfferService; @@ -54,11 +57,15 @@ public class CoreApi { private final User user; @Inject - public CoreApi(OfferBookService offerBookService, + public CoreApi(CorePaymentAccountsService paymentAccountsService, + CoreWalletsService walletsService, + OfferBookService offerBookService, TradeStatisticsManager tradeStatisticsManager, CreateOfferService createOfferService, OpenOfferManager openOfferManager, User user) { + this.paymentAccountsService = paymentAccountsService; + this.walletsService = walletsService; this.offerBookService = offerBookService; this.tradeStatisticsManager = tradeStatisticsManager; this.createOfferService = createOfferService; @@ -70,16 +77,52 @@ public String getVersion() { return Version.VERSION; } + /////////////////////////////////////////////////////////////////////////////////////////// + // Wallets + /////////////////////////////////////////////////////////////////////////////////////////// + + public long getAvailableBalance() { + return walletsService.getAvailableBalance(); + } + + public long getAddressBalance(String addressString) { + return walletsService.getAddressBalance(addressString); + } + + public AddressBalanceInfo getAddressBalanceInfo(String addressString) { + return walletsService.getAddressBalanceInfo(addressString); + } + + public List getFundingAddresses() { + return walletsService.getFundingAddresses(); + } + + public void setWalletPassword(String password, String newPassword) { + walletsService.setWalletPassword(password, newPassword); + } + + public void lockWallet() { + walletsService.lockWallet(); + } + + public void unlockWallet(String password, long timeout) { + walletsService.unlockWallet(password, timeout); + } + + public void removeWalletPassword(String password) { + walletsService.removeWalletPassword(password); + } + public List getTradeStatistics() { return new ArrayList<>(tradeStatisticsManager.getObservableTradeStatisticsSet()); } - public List getOffers() { - return offerBookService.getOffers(); + public int getNumConfirmationsForMostRecentTransaction(String addressString) { + return walletsService.getNumConfirmationsForMostRecentTransaction(addressString); } - public Set getPaymentAccounts() { - return user.getPaymentAccounts(); + public List getOffers() { + return offerBookService.getOffers(); } public void placeOffer(String currencyCode, @@ -145,4 +188,15 @@ public void placeOffer(String offerId, log::error); } + /////////////////////////////////////////////////////////////////////////////////////////// + // PaymentAccounts + /////////////////////////////////////////////////////////////////////////////////////////// + + public void createPaymentAccount(String accountName, String accountNumber, String fiatCurrencyCode) { + paymentAccountsService.createPaymentAccount(accountName, accountNumber, fiatCurrencyCode); + } + + public Set getPaymentAccounts() { + return paymentAccountsService.getPaymentAccounts(); + } } diff --git a/core/src/main/java/bisq/core/grpc/CorePaymentAccountsService.java b/core/src/main/java/bisq/core/grpc/CorePaymentAccountsService.java new file mode 100644 index 00000000000..db2d3be4a03 --- /dev/null +++ b/core/src/main/java/bisq/core/grpc/CorePaymentAccountsService.java @@ -0,0 +1,57 @@ +package bisq.core.grpc; + +import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.locale.FiatCurrency; +import bisq.core.payment.PaymentAccount; +import bisq.core.payment.PaymentAccountFactory; +import bisq.core.payment.PerfectMoneyAccount; +import bisq.core.payment.payload.PaymentMethod; +import bisq.core.user.User; + +import bisq.common.config.Config; + +import javax.inject.Inject; + +import java.util.Set; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CorePaymentAccountsService { + + private final Config config; + private final AccountAgeWitnessService accountAgeWitnessService; + private final User user; + + @Inject + public CorePaymentAccountsService(Config config, + AccountAgeWitnessService accountAgeWitnessService, + User user) { + this.config = config; + this.accountAgeWitnessService = accountAgeWitnessService; + this.user = user; + } + + public void createPaymentAccount(String accountName, String accountNumber, String fiatCurrencyCode) { + // Create and persist a PerfectMoney dummy payment account. There is no guard + // against creating accounts with duplicate names & numbers, only the uuid and + // creation date are unique. + PaymentMethod dummyPaymentMethod = PaymentMethod.getDummyPaymentMethod(PaymentMethod.PERFECT_MONEY_ID); + PaymentAccount paymentAccount = PaymentAccountFactory.getPaymentAccount(dummyPaymentMethod); + paymentAccount.init(); + paymentAccount.setAccountName(accountName); + ((PerfectMoneyAccount) paymentAccount).setAccountNr(accountNumber); + paymentAccount.setSingleTradeCurrency(new FiatCurrency(fiatCurrencyCode)); + user.addPaymentAccount(paymentAccount); + + // Don't do this on mainnet until thoroughly tested. + if (config.baseCurrencyNetwork.isRegtest()) + accountAgeWitnessService.publishMyAccountAgeWitness(paymentAccount.getPaymentAccountPayload()); + + log.info("Payment account {} saved", paymentAccount.getId()); + } + + public Set getPaymentAccounts() { + return user.getPaymentAccounts(); + } +} 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..d7696dce1f3 100644 --- a/core/src/main/java/bisq/core/grpc/CoreWalletService.java +++ b/core/src/main/java/bisq/core/grpc/CoreWalletsService.java @@ -1,28 +1,43 @@ 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.core.grpc.model.AddressBalanceInfo; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.crypto.KeyCrypterScrypt; import javax.inject.Inject; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + import org.spongycastle.crypto.params.KeyParameter; +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; @@ -31,17 +46,19 @@ class CoreWalletService { private KeyParameter tempAesKey; @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() { if (!walletsManager.areWalletsAvailable()) throw new IllegalStateException("wallet is not yet available"); - if (walletsManager.areWalletsEncrypted() && tempAesKey == null) - throw new IllegalStateException("wallet is locked"); + verifyEncryptedWalletIsUnlocked(); var balance = balances.getAvailableBalance().get(); if (balance == null) @@ -50,6 +67,61 @@ public long getAvailableBalance() { return balance.getValue(); } + public long getAddressBalance(String addressString) { + Address address = getAddressEntry(addressString).getAddress(); + return btcWalletService.getBalanceForAddress(address).value; + } + + public AddressBalanceInfo getAddressBalanceInfo(String addressString) { + var satoshiBalance = getAddressBalance(addressString); + var numConfirmations = getNumConfirmationsForMostRecentTransaction(addressString); + return new AddressBalanceInfo(addressString, satoshiBalance, numConfirmations); + } + + public List getFundingAddresses() { + if (!walletsManager.areWalletsAvailable()) + throw new IllegalStateException("wallet is not yet available"); + + verifyEncryptedWalletIsUnlocked(); + + // Create a new funding address if none exists. + if (btcWalletService.getAvailableAddressEntries().size() == 0) + btcWalletService.getFreshAddressEntry(); + + List addressStrings = + btcWalletService + .getAvailableAddressEntries() + .stream() + .map(AddressEntry::getAddressString) + .collect(Collectors.toList()); + + // getAddressBalance is memoized, because we'll map it over addresses twice. + // To get the balances, we'll be using .getUnchecked, because we know that + // this::getAddressBalance cannot return null. + var balances = memoize(this::getAddressBalance); + + boolean noAddressHasZeroBalance = + addressStrings.stream() + .allMatch(addressString -> balances.getUnchecked(addressString) != 0); + + if (noAddressHasZeroBalance) { + var newZeroBalanceAddress = btcWalletService.getFreshAddressEntry(); + addressStrings.add(newZeroBalanceAddress.getAddressString()); + } + + return addressStrings.stream().map(address -> + new AddressBalanceInfo(address, + balances.getUnchecked(address), + getNumConfirmationsForMostRecentTransaction(address))) + .collect(Collectors.toList()); + } + + 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"); @@ -150,10 +222,42 @@ private void verifyWalletIsAvailableAndEncrypted() { throw new IllegalStateException("wallet is not encrypted with a password"); } + // Throws a RuntimeException if wallets are encrypted and locked. + private void verifyEncryptedWalletIsUnlocked() { + if (walletsManager.areWalletsEncrypted() && tempAesKey == null) + throw new IllegalStateException("wallet is locked"); + } + private KeyCrypterScrypt getKeyCrypterScrypt() { KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt(); if (keyCrypterScrypt == null) 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(); + } + + /** + * Memoization stores the results of expensive function calls and returns + * the cached result when the same input occurs again. + * + * Resulting LoadingCache is used by calling `.get(input I)` or + * `.getUnchecked(input I)`, depending on whether or not `f` can return null. + * That's because CacheLoader throws an exception on null output from `f`. + */ + private static LoadingCache memoize(Function f) { + // f::apply is used, because Guava 20.0 Function doesn't yet extend + // Java Function. + return CacheBuilder.newBuilder().build(CacheLoader.from(f::apply)); + } } diff --git a/core/src/main/java/bisq/core/grpc/GrpcPaymentAccountsService.java b/core/src/main/java/bisq/core/grpc/GrpcPaymentAccountsService.java new file mode 100644 index 00000000000..f2a9abf0bbb --- /dev/null +++ b/core/src/main/java/bisq/core/grpc/GrpcPaymentAccountsService.java @@ -0,0 +1,46 @@ +package bisq.core.grpc; + +import bisq.core.payment.PaymentAccount; + +import bisq.proto.grpc.CreatePaymentAccountReply; +import bisq.proto.grpc.CreatePaymentAccountRequest; +import bisq.proto.grpc.GetPaymentAccountsReply; +import bisq.proto.grpc.GetPaymentAccountsRequest; +import bisq.proto.grpc.PaymentAccountsGrpc; + +import io.grpc.stub.StreamObserver; + +import javax.inject.Inject; + +import java.util.stream.Collectors; + + +public class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImplBase { + + private final CoreApi coreApi; + + @Inject + public GrpcPaymentAccountsService(CoreApi coreApi) { + this.coreApi = coreApi; + } + + @Override + public void createPaymentAccount(CreatePaymentAccountRequest req, + StreamObserver responseObserver) { + coreApi.createPaymentAccount(req.getAccountName(), req.getAccountNumber(), req.getFiatCurrencyCode()); + var reply = CreatePaymentAccountReply.newBuilder().build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void getPaymentAccounts(GetPaymentAccountsRequest req, + StreamObserver responseObserver) { + var tradeStatistics = coreApi.getPaymentAccounts().stream() + .map(PaymentAccount::toProtoMessage) + .collect(Collectors.toList()); + var reply = GetPaymentAccountsReply.newBuilder().addAllPaymentAccounts(tradeStatistics).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } +} diff --git a/core/src/main/java/bisq/core/grpc/GrpcServer.java b/core/src/main/java/bisq/core/grpc/GrpcServer.java index 2b3543572b1..6fa6dad9faf 100644 --- a/core/src/main/java/bisq/core/grpc/GrpcServer.java +++ b/core/src/main/java/bisq/core/grpc/GrpcServer.java @@ -18,7 +18,6 @@ package bisq.core.grpc; import bisq.core.offer.Offer; -import bisq.core.payment.PaymentAccount; import bisq.core.trade.handlers.TransactionResultHandler; import bisq.core.trade.statistics.TradeStatistics2; @@ -27,9 +26,6 @@ import bisq.proto.grpc.GetOffersGrpc; import bisq.proto.grpc.GetOffersReply; import bisq.proto.grpc.GetOffersRequest; -import bisq.proto.grpc.GetPaymentAccountsGrpc; -import bisq.proto.grpc.GetPaymentAccountsReply; -import bisq.proto.grpc.GetPaymentAccountsRequest; import bisq.proto.grpc.GetTradeStatisticsGrpc; import bisq.proto.grpc.GetTradeStatisticsReply; import bisq.proto.grpc.GetTradeStatisticsRequest; @@ -60,14 +56,17 @@ public class GrpcServer { private final Server server; @Inject - public GrpcServer(Config config, CoreApi coreApi, GrpcWalletService walletService) { + public GrpcServer(Config config, + CoreApi coreApi, + GrpcPaymentAccountsService paymentAccountsService, + GrpcWalletsService walletService) { this.coreApi = coreApi; this.server = ServerBuilder.forPort(config.apiPort) .addService(new GetVersionService()) .addService(new GetTradeStatisticsService()) .addService(new GetOffersService()) - .addService(new GetPaymentAccountsService()) .addService(new PlaceOfferService()) + .addService(paymentAccountsService) .addService(walletService) .intercept(new PasswordAuthInterceptor(config.apiPassword)) .build(); @@ -125,21 +124,6 @@ public void getOffers(GetOffersRequest req, StreamObserver respo } } - class GetPaymentAccountsService extends GetPaymentAccountsGrpc.GetPaymentAccountsImplBase { - @Override - public void getPaymentAccounts(GetPaymentAccountsRequest req, - StreamObserver responseObserver) { - - var tradeStatistics = coreApi.getPaymentAccounts().stream() - .map(PaymentAccount::toProtoMessage) - .collect(Collectors.toList()); - - var reply = GetPaymentAccountsReply.newBuilder().addAllPaymentAccounts(tradeStatistics).build(); - responseObserver.onNext(reply); - responseObserver.onCompleted(); - } - } - class PlaceOfferService extends PlaceOfferGrpc.PlaceOfferImplBase { @Override public void placeOffer(PlaceOfferRequest req, StreamObserver responseObserver) { diff --git a/core/src/main/java/bisq/core/grpc/GrpcWalletService.java b/core/src/main/java/bisq/core/grpc/GrpcWalletsService.java similarity index 58% rename from core/src/main/java/bisq/core/grpc/GrpcWalletService.java rename to core/src/main/java/bisq/core/grpc/GrpcWalletsService.java index 92d4cc8b81f..0e44e8b329b 100644 --- a/core/src/main/java/bisq/core/grpc/GrpcWalletService.java +++ b/core/src/main/java/bisq/core/grpc/GrpcWalletsService.java @@ -1,7 +1,13 @@ package bisq.core.grpc; +import bisq.core.grpc.model.AddressBalanceInfo; + +import bisq.proto.grpc.GetAddressBalanceReply; +import bisq.proto.grpc.GetAddressBalanceRequest; 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; @@ -10,7 +16,7 @@ import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.UnlockWalletReply; import bisq.proto.grpc.UnlockWalletRequest; -import bisq.proto.grpc.WalletGrpc; +import bisq.proto.grpc.WalletsGrpc; import io.grpc.Status; import io.grpc.StatusRuntimeException; @@ -18,19 +24,22 @@ import javax.inject.Inject; -class GrpcWalletService extends WalletGrpc.WalletImplBase { +import java.util.List; +import java.util.stream.Collectors; + +class GrpcWalletsService extends WalletsGrpc.WalletsImplBase { - private final CoreWalletService walletService; + private final CoreApi coreApi; @Inject - public GrpcWalletService(CoreWalletService walletService) { - this.walletService = walletService; + public GrpcWalletsService(CoreApi coreApi) { + this.coreApi = coreApi; } @Override public void getBalance(GetBalanceRequest req, StreamObserver responseObserver) { try { - long result = walletService.getAvailableBalance(); + long result = coreApi.getAvailableBalance(); var reply = GetBalanceReply.newBuilder().setBalance(result).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -41,11 +50,46 @@ public void getBalance(GetBalanceRequest req, StreamObserver re } } + @Override + public void getAddressBalance(GetAddressBalanceRequest req, + StreamObserver responseObserver) { + try { + AddressBalanceInfo result = coreApi.getAddressBalanceInfo(req.getAddress()); + var reply = GetAddressBalanceReply.newBuilder().setAddressBalanceInfo(result.toProtoMessage()).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 getFundingAddresses(GetFundingAddressesRequest req, + StreamObserver responseObserver) { + try { + List result = coreApi.getFundingAddresses(); + var reply = GetFundingAddressesReply.newBuilder() + .addAllAddressBalanceInfo( + result.stream() + .map(AddressBalanceInfo::toProtoMessage) + .collect(Collectors.toList())) + .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()); + coreApi.setWalletPassword(req.getPassword(), req.getNewPassword()); var reply = SetWalletPasswordReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -60,7 +104,7 @@ public void setWalletPassword(SetWalletPasswordRequest req, public void removeWalletPassword(RemoveWalletPasswordRequest req, StreamObserver responseObserver) { try { - walletService.removeWalletPassword(req.getPassword()); + coreApi.removeWalletPassword(req.getPassword()); var reply = RemoveWalletPasswordReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -75,7 +119,7 @@ public void removeWalletPassword(RemoveWalletPasswordRequest req, public void lockWallet(LockWalletRequest req, StreamObserver responseObserver) { try { - walletService.lockWallet(); + coreApi.lockWallet(); var reply = LockWalletReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); @@ -90,7 +134,7 @@ public void lockWallet(LockWalletRequest req, public void unlockWallet(UnlockWalletRequest req, StreamObserver responseObserver) { try { - walletService.unlockWallet(req.getPassword(), req.getTimeout()); + coreApi.unlockWallet(req.getPassword(), req.getTimeout()); var reply = UnlockWalletReply.newBuilder().build(); responseObserver.onNext(reply); responseObserver.onCompleted(); diff --git a/core/src/main/java/bisq/core/grpc/model/AddressBalanceInfo.java b/core/src/main/java/bisq/core/grpc/model/AddressBalanceInfo.java new file mode 100644 index 00000000000..e452f999416 --- /dev/null +++ b/core/src/main/java/bisq/core/grpc/model/AddressBalanceInfo.java @@ -0,0 +1,43 @@ +package bisq.core.grpc.model; + +import bisq.common.Payload; + +public class AddressBalanceInfo implements Payload { + + private final String address; + private final long balance; // address' balance in satoshis + private final long numConfirmations; // # confirmations for address' most recent tx + + public AddressBalanceInfo(String address, long balance, long numConfirmations) { + this.address = address; + this.balance = balance; + this.numConfirmations = numConfirmations; + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public bisq.proto.grpc.AddressBalanceInfo toProtoMessage() { + return bisq.proto.grpc.AddressBalanceInfo.newBuilder() + .setAddress(address) + .setBalance(balance) + .setNumConfirmations(numConfirmations).build(); + } + + public static AddressBalanceInfo fromProto(bisq.proto.grpc.AddressBalanceInfo proto) { + return new AddressBalanceInfo(proto.getAddress(), + proto.getBalance(), + proto.getNumConfirmations()); + } + + @Override + public String toString() { + return "AddressBalanceInfo{" + + "address='" + address + '\'' + + ", balance=" + balance + + ", numConfirmations=" + numConfirmations + + '}'; + } +} diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index b8db4c6d24b..7168d04928c 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -72,14 +72,25 @@ message GetOffersReply { } /////////////////////////////////////////////////////////////////////////////////////////// -// PaymentAccount +// PaymentAccounts /////////////////////////////////////////////////////////////////////////////////////////// -service GetPaymentAccounts { +service PaymentAccounts { + rpc CreatePaymentAccount (CreatePaymentAccountRequest) returns (CreatePaymentAccountReply) { + } rpc GetPaymentAccounts (GetPaymentAccountsRequest) returns (GetPaymentAccountsReply) { } } +message CreatePaymentAccountRequest { + string accountName = 1; + string accountNumber = 2; + string fiatCurrencyCode = 3; +} + +message CreatePaymentAccountReply { +} + message GetPaymentAccountsRequest { } @@ -113,12 +124,16 @@ message PlaceOfferReply { } /////////////////////////////////////////////////////////////////////////////////////////// -// Wallet +// Wallets /////////////////////////////////////////////////////////////////////////////////////////// -service Wallet { +service Wallets { rpc GetBalance (GetBalanceRequest) returns (GetBalanceReply) { } + rpc GetAddressBalance (GetAddressBalanceRequest) returns (GetAddressBalanceReply) { + } + rpc GetFundingAddresses (GetFundingAddressesRequest) returns (GetFundingAddressesReply) { + } rpc SetWalletPassword (SetWalletPasswordRequest) returns (SetWalletPasswordReply) { } rpc RemoveWalletPassword (RemoveWalletPasswordRequest) returns (RemoveWalletPasswordReply) { @@ -136,6 +151,21 @@ message GetBalanceReply { uint64 balance = 1; } +message GetAddressBalanceRequest { + string address = 1; +} + +message GetAddressBalanceReply { + AddressBalanceInfo addressBalanceInfo = 1; +} + +message GetFundingAddressesRequest { +} + +message GetFundingAddressesReply { + repeated AddressBalanceInfo addressBalanceInfo = 1; +} + message SetWalletPasswordRequest { string password = 1; string newPassword = 2; @@ -164,3 +194,9 @@ message UnlockWalletRequest { message UnlockWalletReply { } + +message AddressBalanceInfo { + string address = 1; + int64 balance = 2; + int64 numConfirmations = 3; +}