Merge pull request #4966 from ghubstan/10-callrate-interceptor

Prevent excessive api calls
This commit is contained in:
sqrrm 2020-12-23 16:19:29 +01:00 committed by GitHub
commit 7d7f1b09e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2026 additions and 323 deletions

View file

@ -154,7 +154,8 @@ public class Scaffold {
try { try {
log.info("Shutting down executor service ..."); log.info("Shutting down executor service ...");
executor.shutdownNow(); executor.shutdownNow();
executor.awaitTermination(config.supportingApps.size() * 2000, MILLISECONDS); //noinspection ResultOfMethodCallIgnored
executor.awaitTermination(config.supportingApps.size() * 2000L, MILLISECONDS);
SetupTask[] orderedTasks = new SetupTask[]{ SetupTask[] orderedTasks = new SetupTask[]{
bobNodeTask, aliceNodeTask, arbNodeTask, seedNodeTask, bitcoindTask}; bobNodeTask, aliceNodeTask, arbNodeTask, seedNodeTask, bitcoindTask};
@ -218,20 +219,25 @@ public class Scaffold {
if (copyBitcoinRegtestDir.run().getExitStatus() != 0) if (copyBitcoinRegtestDir.run().getExitStatus() != 0)
throw new IllegalStateException("Could not install bitcoin regtest dir"); throw new IllegalStateException("Could not install bitcoin regtest dir");
String aliceDataDir = daoSetupDir + "/" + alicedaemon.appName;
BashCommand copyAliceDataDir = new BashCommand( BashCommand copyAliceDataDir = new BashCommand(
"cp -rf " + daoSetupDir + "/" + alicedaemon.appName "cp -rf " + aliceDataDir + " " + config.rootAppDataDir);
+ " " + config.rootAppDataDir);
if (copyAliceDataDir.run().getExitStatus() != 0) if (copyAliceDataDir.run().getExitStatus() != 0)
throw new IllegalStateException("Could not install alice data dir"); throw new IllegalStateException("Could not install alice data dir");
String bobDataDir = daoSetupDir + "/" + bobdaemon.appName;
BashCommand copyBobDataDir = new BashCommand( BashCommand copyBobDataDir = new BashCommand(
"cp -rf " + daoSetupDir + "/" + bobdaemon.appName "cp -rf " + bobDataDir + " " + config.rootAppDataDir);
+ " " + config.rootAppDataDir);
if (copyBobDataDir.run().getExitStatus() != 0) if (copyBobDataDir.run().getExitStatus() != 0)
throw new IllegalStateException("Could not install bob data dir"); throw new IllegalStateException("Could not install bob data dir");
log.info("Installed dao-setup files into {}", buildDataDir); log.info("Installed dao-setup files into {}", buildDataDir);
if (!config.callRateMeteringConfigPath.isEmpty()) {
installCallRateMeteringConfiguration(aliceDataDir);
installCallRateMeteringConfiguration(bobDataDir);
}
// Copy the blocknotify script from the src resources dir to the build // Copy the blocknotify script from the src resources dir to the build
// resources dir. Users may want to edit comment out some lines when all // resources dir. Users may want to edit comment out some lines when all
// of the default block notifcation ports being will not be used (to avoid // of the default block notifcation ports being will not be used (to avoid
@ -287,6 +293,25 @@ public class Scaffold {
} }
} }
private void installCallRateMeteringConfiguration(String dataDir) throws IOException, InterruptedException {
File testRateMeteringFile = new File(config.callRateMeteringConfigPath);
if (!testRateMeteringFile.exists())
throw new FileNotFoundException(
format("Call rate metering config file '%s' not found", config.callRateMeteringConfigPath));
BashCommand copyRateMeteringConfigFile = new BashCommand(
"cp -rf " + config.callRateMeteringConfigPath + " " + dataDir);
if (copyRateMeteringConfigFile.run().getExitStatus() != 0)
throw new IllegalStateException(
format("Could not install %s file in %s",
testRateMeteringFile.getAbsolutePath(), dataDir));
Path destPath = Paths.get(dataDir, testRateMeteringFile.getName());
String chmod700Perms = "rwx------";
Files.setPosixFilePermissions(destPath, PosixFilePermissions.fromString(chmod700Perms));
log.info("Installed {} with perms {}.", destPath.toString(), chmod700Perms);
}
private void installShutdownHook() { private void installShutdownHook() {
// Background apps can be left running until the jvm is manually shutdown, // Background apps can be left running until the jvm is manually shutdown,
// so we add a shutdown hook for that use case. // so we add a shutdown hook for that use case.

View file

@ -71,6 +71,7 @@ public class ApiTestConfig {
static final String SKIP_TESTS = "skipTests"; static final String SKIP_TESTS = "skipTests";
static final String SHUTDOWN_AFTER_TESTS = "shutdownAfterTests"; static final String SHUTDOWN_AFTER_TESTS = "shutdownAfterTests";
static final String SUPPORTING_APPS = "supportingApps"; static final String SUPPORTING_APPS = "supportingApps";
static final String CALL_RATE_METERING_CONFIG_PATH = "callRateMeteringConfigPath";
static final String ENABLE_BISQ_DEBUGGING = "enableBisqDebugging"; static final String ENABLE_BISQ_DEBUGGING = "enableBisqDebugging";
// Default values for certain options // Default values for certain options
@ -102,6 +103,7 @@ public class ApiTestConfig {
public final boolean skipTests; public final boolean skipTests;
public final boolean shutdownAfterTests; public final boolean shutdownAfterTests;
public final List<String> supportingApps; public final List<String> supportingApps;
public final String callRateMeteringConfigPath;
public final boolean enableBisqDebugging; public final boolean enableBisqDebugging;
// Immutable system configurations set in the constructor. // Immutable system configurations set in the constructor.
@ -228,6 +230,12 @@ public class ApiTestConfig {
.ofType(String.class) .ofType(String.class)
.defaultsTo("bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon"); .defaultsTo("bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon");
ArgumentAcceptingOptionSpec<String> callRateMeteringConfigPathOpt =
parser.accepts(CALL_RATE_METERING_CONFIG_PATH,
"Install a ratemeters.json file to configure call rate metering interceptors")
.withRequiredArg()
.defaultsTo(EMPTY);
ArgumentAcceptingOptionSpec<Boolean> enableBisqDebuggingOpt = ArgumentAcceptingOptionSpec<Boolean> enableBisqDebuggingOpt =
parser.accepts(ENABLE_BISQ_DEBUGGING, parser.accepts(ENABLE_BISQ_DEBUGGING,
"Start Bisq apps with remote debug options") "Start Bisq apps with remote debug options")
@ -289,6 +297,7 @@ public class ApiTestConfig {
this.skipTests = options.valueOf(skipTestsOpt); this.skipTests = options.valueOf(skipTestsOpt);
this.shutdownAfterTests = options.valueOf(shutdownAfterTestsOpt); this.shutdownAfterTests = options.valueOf(shutdownAfterTestsOpt);
this.supportingApps = asList(options.valueOf(supportingAppsOpt).split(",")); this.supportingApps = asList(options.valueOf(supportingAppsOpt).split(","));
this.callRateMeteringConfigPath = options.valueOf(callRateMeteringConfigPathOpt);
this.enableBisqDebugging = options.valueOf(enableBisqDebuggingOpt); this.enableBisqDebugging = options.valueOf(enableBisqDebuggingOpt);
// Assign values to special-case static fields. // Assign values to special-case static fields.

View file

@ -19,6 +19,7 @@ package bisq.apitest;
import java.net.InetAddress; import java.net.InetAddress;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
@ -72,6 +73,16 @@ public class ApiTestCase {
// gRPC service stubs are used by method & scenario tests, but not e2e tests. // gRPC service stubs are used by method & scenario tests, but not e2e tests.
private static final Map<BisqAppConfig, GrpcStubs> grpcStubsCache = new HashMap<>(); private static final Map<BisqAppConfig, GrpcStubs> grpcStubsCache = new HashMap<>();
public static void setUpScaffold(File callRateMeteringConfigFile,
Enum<?>... supportingApps)
throws InterruptedException, ExecutionException, IOException {
scaffold = new Scaffold(stream(supportingApps).map(Enum::name)
.collect(Collectors.joining(",")))
.setUp();
config = scaffold.config;
bitcoinCli = new BitcoinCliHelper((config));
}
public static void setUpScaffold(Enum<?>... supportingApps) public static void setUpScaffold(Enum<?>... supportingApps)
throws InterruptedException, ExecutionException, IOException { throws InterruptedException, ExecutionException, IOException {
scaffold = new Scaffold(stream(supportingApps).map(Enum::name) scaffold = new Scaffold(stream(supportingApps).map(Enum::name)

View file

@ -0,0 +1,129 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.apitest.method;
import io.grpc.StatusRuntimeException;
import java.io.File;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
import static bisq.apitest.config.BisqAppConfig.alicedaemon;
import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.HOURS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import bisq.daemon.grpc.GrpcVersionService;
import bisq.daemon.grpc.interceptor.GrpcServiceRateMeteringConfig;
@Disabled
@Slf4j
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class CallRateMeteringInterceptorTest extends MethodTest {
private static final GetVersionTest getVersionTest = new GetVersionTest();
@BeforeAll
public static void setUp() {
File callRateMeteringConfigFile = buildInterceptorConfigFile();
startSupportingApps(callRateMeteringConfigFile,
false,
false,
bitcoind, alicedaemon);
}
@BeforeEach
public void sleep200Milliseconds() {
sleep(200);
}
@Test
@Order(1)
public void testGetVersionCall1IsAllowed() {
getVersionTest.testGetVersion();
}
@Test
@Order(2)
public void testGetVersionCall2ShouldThrowException() {
Throwable exception = assertThrows(StatusRuntimeException.class, getVersionTest::testGetVersion);
assertEquals("PERMISSION_DENIED: the maximum allowed number of getversion calls (1/second) has been exceeded",
exception.getMessage());
}
@Test
@Order(3)
public void testGetVersionCall3ShouldThrowException() {
Throwable exception = assertThrows(StatusRuntimeException.class, getVersionTest::testGetVersion);
assertEquals("PERMISSION_DENIED: the maximum allowed number of getversion calls (1/second) has been exceeded",
exception.getMessage());
}
@Test
@Order(4)
public void testGetVersionCall4IsAllowed() {
sleep(1100); // Let the server's rate meter reset the call count.
getVersionTest.testGetVersion();
}
@AfterAll
public static void tearDown() {
tearDownScaffold();
}
public static File buildInterceptorConfigFile() {
GrpcServiceRateMeteringConfig.Builder builder = new GrpcServiceRateMeteringConfig.Builder();
builder.addCallRateMeter(GrpcVersionService.class.getSimpleName(),
"getVersion",
1,
SECONDS);
builder.addCallRateMeter(GrpcVersionService.class.getSimpleName(),
"shouldNotBreakAnything",
1000,
DAYS);
// Only GrpcVersionService is @VisibleForTesting, so we hardcode the class names.
builder.addCallRateMeter("GrpcOffersService",
"createOffer",
5,
MINUTES);
builder.addCallRateMeter("GrpcOffersService",
"takeOffer",
10,
DAYS);
builder.addCallRateMeter("GrpcTradesService",
"withdrawFunds",
3,
HOURS);
return builder.build();
}
}

View file

@ -39,6 +39,7 @@ import bisq.proto.grpc.GetPaymentAccountFormRequest;
import bisq.proto.grpc.GetPaymentAccountsRequest; import bisq.proto.grpc.GetPaymentAccountsRequest;
import bisq.proto.grpc.GetPaymentMethodsRequest; import bisq.proto.grpc.GetPaymentMethodsRequest;
import bisq.proto.grpc.GetTradeRequest; import bisq.proto.grpc.GetTradeRequest;
import bisq.proto.grpc.GetTransactionRequest;
import bisq.proto.grpc.GetTxFeeRateRequest; import bisq.proto.grpc.GetTxFeeRateRequest;
import bisq.proto.grpc.GetUnusedBsqAddressRequest; import bisq.proto.grpc.GetUnusedBsqAddressRequest;
import bisq.proto.grpc.KeepFundsRequest; import bisq.proto.grpc.KeepFundsRequest;
@ -48,10 +49,12 @@ import bisq.proto.grpc.OfferInfo;
import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RegisterDisputeAgentRequest;
import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SendBsqRequest; import bisq.proto.grpc.SendBsqRequest;
import bisq.proto.grpc.SendBtcRequest;
import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; import bisq.proto.grpc.SetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.SetWalletPasswordRequest;
import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.TakeOfferRequest;
import bisq.proto.grpc.TradeInfo; import bisq.proto.grpc.TradeInfo;
import bisq.proto.grpc.TxInfo;
import bisq.proto.grpc.UnlockWalletRequest; import bisq.proto.grpc.UnlockWalletRequest;
import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.WithdrawFundsRequest; import bisq.proto.grpc.WithdrawFundsRequest;
@ -64,6 +67,7 @@ import java.io.IOException;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.util.List; import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static bisq.apitest.config.BisqAppConfig.alicedaemon; import static bisq.apitest.config.BisqAppConfig.alicedaemon;
@ -96,37 +100,62 @@ public class MethodTest extends ApiTestCase {
private static final CoreProtoResolver CORE_PROTO_RESOLVER = new CoreProtoResolver(); private static final CoreProtoResolver CORE_PROTO_RESOLVER = new CoreProtoResolver();
private static final Function<Enum<?>[], String> toNameList = (enums) ->
stream(enums).map(Enum::name).collect(Collectors.joining(","));
public static void startSupportingApps(File callRateMeteringConfigFile,
boolean registerDisputeAgents,
boolean generateBtcBlock,
Enum<?>... supportingApps) {
try {
setUpScaffold(new String[]{
"--supportingApps", toNameList.apply(supportingApps),
"--callRateMeteringConfigPath", callRateMeteringConfigFile.getAbsolutePath(),
"--enableBisqDebugging", "false"
});
doPostStartup(registerDisputeAgents, generateBtcBlock, supportingApps);
} catch (Exception ex) {
fail(ex);
}
}
public static void startSupportingApps(boolean registerDisputeAgents, public static void startSupportingApps(boolean registerDisputeAgents,
boolean generateBtcBlock, boolean generateBtcBlock,
Enum<?>... supportingApps) { Enum<?>... supportingApps) {
try { try {
// To run Bisq apps in debug mode, use the other setUpScaffold method: setUpScaffold(new String[]{
// setUpScaffold(new String[]{"--supportingApps", "bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon", "--supportingApps", toNameList.apply(supportingApps),
// "--enableBisqDebugging", "true"}); "--enableBisqDebugging", "false"
setUpScaffold(supportingApps); });
if (registerDisputeAgents) { doPostStartup(registerDisputeAgents, generateBtcBlock, supportingApps);
registerDisputeAgents(arbdaemon);
}
if (stream(supportingApps).map(Enum::name).anyMatch(name -> name.equals(alicedaemon.name()))) {
aliceStubs = grpcStubs(alicedaemon);
alicesDummyAcct = getDefaultPerfectDummyPaymentAccount(alicedaemon);
}
if (stream(supportingApps).map(Enum::name).anyMatch(name -> name.equals(bobdaemon.name()))) {
bobStubs = grpcStubs(bobdaemon);
bobsDummyAcct = getDefaultPerfectDummyPaymentAccount(bobdaemon);
}
// Generate 1 regtest block for alice's and/or bob's wallet to
// show 10 BTC balance, and allow time for daemons parse the new block.
if (generateBtcBlock)
genBtcBlocksThenWait(1, 1500);
} catch (Exception ex) { } catch (Exception ex) {
fail(ex); fail(ex);
} }
} }
private static void doPostStartup(boolean registerDisputeAgents,
boolean generateBtcBlock,
Enum<?>... supportingApps) {
if (registerDisputeAgents) {
registerDisputeAgents(arbdaemon);
}
if (stream(supportingApps).map(Enum::name).anyMatch(name -> name.equals(alicedaemon.name()))) {
aliceStubs = grpcStubs(alicedaemon);
alicesDummyAcct = getDefaultPerfectDummyPaymentAccount(alicedaemon);
}
if (stream(supportingApps).map(Enum::name).anyMatch(name -> name.equals(bobdaemon.name()))) {
bobStubs = grpcStubs(bobdaemon);
bobsDummyAcct = getDefaultPerfectDummyPaymentAccount(bobdaemon);
}
// Generate 1 regtest block for alice's and/or bob's wallet to
// show 10 BTC balance, and allow time for daemons parse the new block.
if (generateBtcBlock)
genBtcBlocksThenWait(1, 1500);
}
// Convenience methods for building gRPC request objects // Convenience methods for building gRPC request objects
protected final GetBalancesRequest createGetBalancesRequest(String currencyCode) { protected final GetBalancesRequest createGetBalancesRequest(String currencyCode) {
return GetBalancesRequest.newBuilder().setCurrencyCode(currencyCode).build(); return GetBalancesRequest.newBuilder().setCurrencyCode(currencyCode).build();
@ -160,8 +189,26 @@ public class MethodTest extends ApiTestCase {
return GetUnusedBsqAddressRequest.newBuilder().build(); return GetUnusedBsqAddressRequest.newBuilder().build();
} }
protected final SendBsqRequest createSendBsqRequest(String address, String amount) { protected final SendBsqRequest createSendBsqRequest(String address,
return SendBsqRequest.newBuilder().setAddress(address).setAmount(amount).build(); String amount,
String txFeeRate) {
return SendBsqRequest.newBuilder()
.setAddress(address)
.setAmount(amount)
.setTxFeeRate(txFeeRate)
.build();
}
protected final SendBtcRequest createSendBtcRequest(String address,
String amount,
String txFeeRate,
String memo) {
return SendBtcRequest.newBuilder()
.setAddress(address)
.setAmount(amount)
.setTxFeeRate(txFeeRate)
.setMemo(memo)
.build();
} }
protected final GetFundingAddressesRequest createGetFundingAddressesRequest() { protected final GetFundingAddressesRequest createGetFundingAddressesRequest() {
@ -208,10 +255,13 @@ public class MethodTest extends ApiTestCase {
.build(); .build();
} }
protected final WithdrawFundsRequest createWithdrawFundsRequest(String tradeId, String address) { protected final WithdrawFundsRequest createWithdrawFundsRequest(String tradeId,
String address,
String memo) {
return WithdrawFundsRequest.newBuilder() return WithdrawFundsRequest.newBuilder()
.setTradeId(tradeId) .setTradeId(tradeId)
.setAddress(address) .setAddress(address)
.setMemo(memo)
.build(); .build();
} }
@ -247,9 +297,36 @@ public class MethodTest extends ApiTestCase {
return grpcStubs(bisqAppConfig).walletsService.getUnusedBsqAddress(createGetUnusedBsqAddressRequest()).getAddress(); return grpcStubs(bisqAppConfig).walletsService.getUnusedBsqAddress(createGetUnusedBsqAddressRequest()).getAddress();
} }
protected final void sendBsq(BisqAppConfig bisqAppConfig, String address, String amount) { protected final TxInfo sendBsq(BisqAppConfig bisqAppConfig,
String address,
String amount) {
return sendBsq(bisqAppConfig, address, amount, "");
}
protected final TxInfo sendBsq(BisqAppConfig bisqAppConfig,
String address,
String amount,
String txFeeRate) {
//noinspection ResultOfMethodCallIgnored //noinspection ResultOfMethodCallIgnored
grpcStubs(bisqAppConfig).walletsService.sendBsq(createSendBsqRequest(address, amount)); return grpcStubs(bisqAppConfig).walletsService.sendBsq(createSendBsqRequest(address,
amount,
txFeeRate))
.getTxInfo();
}
protected final TxInfo sendBtc(BisqAppConfig bisqAppConfig, String address, String amount) {
return sendBtc(bisqAppConfig, address, amount, "", "");
}
protected final TxInfo sendBtc(BisqAppConfig bisqAppConfig,
String address,
String amount,
String txFeeRate,
String memo) {
//noinspection ResultOfMethodCallIgnored
return grpcStubs(bisqAppConfig).walletsService.sendBtc(
createSendBtcRequest(address, amount, txFeeRate, memo))
.getTxInfo();
} }
protected final String getUnusedBtcAddress(BisqAppConfig bisqAppConfig) { protected final String getUnusedBtcAddress(BisqAppConfig bisqAppConfig) {
@ -354,8 +431,11 @@ public class MethodTest extends ApiTestCase {
} }
@SuppressWarnings("ResultOfMethodCallIgnored") @SuppressWarnings("ResultOfMethodCallIgnored")
protected final void withdrawFunds(BisqAppConfig bisqAppConfig, String tradeId, String address) { protected final void withdrawFunds(BisqAppConfig bisqAppConfig,
var req = createWithdrawFundsRequest(tradeId, address); String tradeId,
String address,
String memo) {
var req = createWithdrawFundsRequest(tradeId, address, memo);
grpcStubs(bisqAppConfig).tradesService.withdrawFunds(req); grpcStubs(bisqAppConfig).tradesService.withdrawFunds(req);
} }
@ -379,6 +459,11 @@ public class MethodTest extends ApiTestCase {
grpcStubs(bisqAppConfig).walletsService.unsetTxFeeRatePreference(req).getTxFeeRateInfo()); grpcStubs(bisqAppConfig).walletsService.unsetTxFeeRatePreference(req).getTxFeeRateInfo());
} }
protected final TxInfo getTransaction(BisqAppConfig bisqAppConfig, String txId) {
var req = GetTransactionRequest.newBuilder().setTxId(txId).build();
return grpcStubs(bisqAppConfig).walletsService.getTransaction(req).getTxInfo();
}
// Static conveniences for test methods and test case fixture setups. // Static conveniences for test methods and test case fixture setups.
protected static RegisterDisputeAgentRequest createRegisterDisputeAgentRequest(String disputeAgentType) { protected static RegisterDisputeAgentRequest createRegisterDisputeAgentRequest(String disputeAgentType) {

View file

@ -64,9 +64,11 @@ public class AbstractTradeTest extends AbstractOfferTest {
TestInfo testInfo, TestInfo testInfo,
String description, String description,
TradeInfo trade) { TradeInfo trade) {
log.info(String.format("%s %s%n%s", if (log.isDebugEnabled()) {
testName(testInfo), log.debug(String.format("%s %s%n%s",
description.toUpperCase(), testName(testInfo),
format(trade))); description.toUpperCase(),
format(trade)));
}
} }
} }

View file

@ -52,6 +52,8 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
// Maker and Taker fees are in BTC. // Maker and Taker fees are in BTC.
private static final String TRADE_FEE_CURRENCY_CODE = "btc"; private static final String TRADE_FEE_CURRENCY_CODE = "btc";
private static final String WITHDRAWAL_TX_MEMO = "Bob's trade withdrawal";
@Test @Test
@Order(1) @Order(1)
public void testTakeAlicesSellOffer(final TestInfo testInfo) { public void testTakeAlicesSellOffer(final TestInfo testInfo) {
@ -147,7 +149,7 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
logTrade(log, testInfo, "Bob's view before withdrawing funds to external wallet", trade); logTrade(log, testInfo, "Bob's view before withdrawing funds to external wallet", trade);
String toAddress = bitcoinCli.getNewBtcAddress(); String toAddress = bitcoinCli.getNewBtcAddress();
withdrawFunds(bobdaemon, tradeId, toAddress); withdrawFunds(bobdaemon, tradeId, toAddress, WITHDRAWAL_TX_MEMO);
genBtcBlocksThenWait(1, 2250); genBtcBlocksThenWait(1, 2250);
@ -158,7 +160,7 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
verifyExpectedProtocolStatus(trade); verifyExpectedProtocolStatus(trade);
logTrade(log, testInfo, "Bob's view after withdrawing funds to external wallet", trade); logTrade(log, testInfo, "Bob's view after withdrawing funds to external wallet", trade);
BtcBalanceInfo currentBalance = getBtcBalances(bobdaemon); BtcBalanceInfo currentBalance = getBtcBalances(bobdaemon);
log.info("{} Bob's current available balance: {} BTC", log.debug("{} Bob's current available balance: {} BTC",
testName(testInfo), testName(testInfo),
formatSatoshis(currentBalance.getAvailableBalance())); formatSatoshis(currentBalance.getAvailableBalance()));
} }

View file

@ -108,7 +108,7 @@ public class BsqWalletTest extends MethodTest {
@Order(3) @Order(3)
public void testSendBsqAndCheckBalancesBeforeGeneratingBtcBlock(final TestInfo testInfo) { public void testSendBsqAndCheckBalancesBeforeGeneratingBtcBlock(final TestInfo testInfo) {
String bobsBsqAddress = getUnusedBsqAddress(bobdaemon); String bobsBsqAddress = getUnusedBsqAddress(bobdaemon);
sendBsq(alicedaemon, bobsBsqAddress, SEND_BSQ_AMOUNT); sendBsq(alicedaemon, bobsBsqAddress, SEND_BSQ_AMOUNT, "100");
sleep(2000); sleep(2000);
BsqBalanceInfo alicesBsqBalances = getBsqBalances(alicedaemon); BsqBalanceInfo alicesBsqBalances = getBsqBalances(alicedaemon);

View file

@ -1,6 +1,7 @@
package bisq.apitest.method.wallet; package bisq.apitest.method.wallet;
import bisq.proto.grpc.BtcBalanceInfo; import bisq.proto.grpc.BtcBalanceInfo;
import bisq.proto.grpc.TxInfo;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -20,6 +21,8 @@ import static bisq.cli.TableFormat.formatAddressBalanceTbl;
import static bisq.cli.TableFormat.formatBtcBalanceInfoTbl; import static bisq.cli.TableFormat.formatBtcBalanceInfoTbl;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation; import static org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
@ -31,6 +34,8 @@ import bisq.apitest.method.MethodTest;
@TestMethodOrder(OrderAnnotation.class) @TestMethodOrder(OrderAnnotation.class)
public class BtcWalletTest extends MethodTest { public class BtcWalletTest extends MethodTest {
private static final String TX_MEMO = "tx memo";
// All api tests depend on the DAO / regtest environment, and Bob & Alice's wallets // All api tests depend on the DAO / regtest environment, and Bob & Alice's wallets
// are initialized with 10 BTC during the scaffolding setup. // are initialized with 10 BTC during the scaffolding setup.
private static final bisq.core.api.model.BtcBalanceInfo INITIAL_BTC_BALANCES = private static final bisq.core.api.model.BtcBalanceInfo INITIAL_BTC_BALANCES =
@ -92,6 +97,50 @@ public class BtcWalletTest extends MethodTest {
formatBtcBalanceInfoTbl(btcBalanceInfo)); formatBtcBalanceInfoTbl(btcBalanceInfo));
} }
@Test
@Order(3)
public void testAliceSendBTCToBob(TestInfo testInfo) {
String bobsBtcAddress = getUnusedBtcAddress(bobdaemon);
log.debug("Sending 5.5 BTC From Alice to Bob @ {}", bobsBtcAddress);
TxInfo txInfo = sendBtc(alicedaemon,
bobsBtcAddress,
"5.50",
"100",
TX_MEMO);
assertTrue(txInfo.getIsPending());
// Note that the memo is not set on the tx yet.
assertTrue(txInfo.getMemo().isEmpty());
genBtcBlocksThenWait(1, 3000);
// Fetch the tx and check for confirmation and memo.
txInfo = getTransaction(alicedaemon, txInfo.getTxId());
assertFalse(txInfo.getIsPending());
assertEquals(TX_MEMO, txInfo.getMemo());
BtcBalanceInfo alicesBalances = getBtcBalances(alicedaemon);
log.debug("{} Alice's BTC Balances:\n{}",
testName(testInfo),
formatBtcBalanceInfoTbl(alicesBalances));
bisq.core.api.model.BtcBalanceInfo alicesExpectedBalances =
bisq.core.api.model.BtcBalanceInfo.valueOf(700000000,
0,
700000000,
0);
verifyBtcBalances(alicesExpectedBalances, alicesBalances);
BtcBalanceInfo bobsBalances = getBtcBalances(bobdaemon);
log.debug("{} Bob's BTC Balances:\n{}",
testName(testInfo),
formatBtcBalanceInfoTbl(bobsBalances));
// We cannot (?) predict the exact tx size and calculate how much in tx fees were
// deducted from the 5.5 BTC sent to Bob, but we do know Bob should have something
// between 15.49978000 and 15.49978100 BTC.
assertTrue(bobsBalances.getAvailableBalance() >= 1549978000);
assertTrue(bobsBalances.getAvailableBalance() <= 1549978100);
}
@AfterAll @AfterAll
public static void tearDown() { public static void tearDown() {
tearDownScaffold(); tearDownScaffold();

View file

@ -17,6 +17,8 @@
package bisq.apitest.scenario; package bisq.apitest.scenario;
import java.io.File;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterAll;
@ -30,10 +32,12 @@ import static bisq.apitest.Scaffold.BitcoinCoreApp.bitcoind;
import static bisq.apitest.config.BisqAppConfig.alicedaemon; import static bisq.apitest.config.BisqAppConfig.alicedaemon;
import static bisq.apitest.config.BisqAppConfig.arbdaemon; import static bisq.apitest.config.BisqAppConfig.arbdaemon;
import static bisq.apitest.config.BisqAppConfig.seednode; import static bisq.apitest.config.BisqAppConfig.seednode;
import static bisq.apitest.method.CallRateMeteringInterceptorTest.buildInterceptorConfigFile;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
import bisq.apitest.method.CallRateMeteringInterceptorTest;
import bisq.apitest.method.GetVersionTest; import bisq.apitest.method.GetVersionTest;
import bisq.apitest.method.MethodTest; import bisq.apitest.method.MethodTest;
import bisq.apitest.method.RegisterDisputeAgentsTest; import bisq.apitest.method.RegisterDisputeAgentsTest;
@ -46,7 +50,11 @@ public class StartupTest extends MethodTest {
@BeforeAll @BeforeAll
public static void setUp() { public static void setUp() {
try { try {
setUpScaffold(bitcoind, seednode, arbdaemon, alicedaemon); File callRateMeteringConfigFile = buildInterceptorConfigFile();
startSupportingApps(callRateMeteringConfigFile,
false,
false,
bitcoind, seednode, arbdaemon, alicedaemon);
} catch (Exception ex) { } catch (Exception ex) {
fail(ex); fail(ex);
} }
@ -54,13 +62,27 @@ public class StartupTest extends MethodTest {
@Test @Test
@Order(1) @Order(1)
public void testCallRateMeteringInterceptor() {
CallRateMeteringInterceptorTest test = new CallRateMeteringInterceptorTest();
test.testGetVersionCall1IsAllowed();
test.sleep200Milliseconds();
test.testGetVersionCall2ShouldThrowException();
test.sleep200Milliseconds();
test.testGetVersionCall3ShouldThrowException();
test.sleep200Milliseconds();
test.testGetVersionCall4IsAllowed();
sleep(1000); // Wait 1 second before calling getversion in next test.
}
@Test
@Order(2)
public void testGetVersion() { public void testGetVersion() {
GetVersionTest test = new GetVersionTest(); GetVersionTest test = new GetVersionTest();
test.testGetVersion(); test.testGetVersion();
} }
@Test @Test
@Order(2) @Order(3)
public void testRegisterDisputeAgents() { public void testRegisterDisputeAgents() {
RegisterDisputeAgentsTest test = new RegisterDisputeAgentsTest(); RegisterDisputeAgentsTest test = new RegisterDisputeAgentsTest();
test.testRegisterArbitratorShouldThrowException(); test.testRegisterArbitratorShouldThrowException();

View file

@ -67,6 +67,7 @@ public class WalletTest extends MethodTest {
btcWalletTest.testInitialBtcBalances(testInfo); btcWalletTest.testInitialBtcBalances(testInfo);
btcWalletTest.testFundAlicesBtcWallet(testInfo); btcWalletTest.testFundAlicesBtcWallet(testInfo);
btcWalletTest.testAliceSendBTCToBob(testInfo);
} }
@Test @Test

View file

@ -592,6 +592,12 @@ configure(project(':daemon')) {
compileOnly "org.projectlombok:lombok:$lombokVersion" compileOnly "org.projectlombok:lombok:$lombokVersion"
compileOnly "javax.annotation:javax.annotation-api:$javaxAnnotationVersion" compileOnly "javax.annotation:javax.annotation-api:$javaxAnnotationVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion" annotationProcessor "org.projectlombok:lombok:$lombokVersion"
testCompile "org.junit.jupiter:junit-jupiter-api:$jupiterVersion"
testCompile "org.junit.jupiter:junit-jupiter-params:$jupiterVersion"
testCompileOnly "org.projectlombok:lombok:$lombokVersion"
testAnnotationProcessor "org.projectlombok:lombok:$lombokVersion"
testRuntime("org.junit.jupiter:junit-jupiter-engine:$jupiterVersion")
} }
} }

View file

@ -31,6 +31,7 @@ import bisq.proto.grpc.GetPaymentAccountFormRequest;
import bisq.proto.grpc.GetPaymentAccountsRequest; import bisq.proto.grpc.GetPaymentAccountsRequest;
import bisq.proto.grpc.GetPaymentMethodsRequest; import bisq.proto.grpc.GetPaymentMethodsRequest;
import bisq.proto.grpc.GetTradeRequest; import bisq.proto.grpc.GetTradeRequest;
import bisq.proto.grpc.GetTransactionRequest;
import bisq.proto.grpc.GetTxFeeRateRequest; import bisq.proto.grpc.GetTxFeeRateRequest;
import bisq.proto.grpc.GetUnusedBsqAddressRequest; import bisq.proto.grpc.GetUnusedBsqAddressRequest;
import bisq.proto.grpc.GetVersionRequest; import bisq.proto.grpc.GetVersionRequest;
@ -40,9 +41,11 @@ import bisq.proto.grpc.OfferInfo;
import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RegisterDisputeAgentRequest;
import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SendBsqRequest; import bisq.proto.grpc.SendBsqRequest;
import bisq.proto.grpc.SendBtcRequest;
import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; import bisq.proto.grpc.SetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.SetWalletPasswordRequest;
import bisq.proto.grpc.TakeOfferRequest; import bisq.proto.grpc.TakeOfferRequest;
import bisq.proto.grpc.TxInfo;
import bisq.proto.grpc.UnlockWalletRequest; import bisq.proto.grpc.UnlockWalletRequest;
import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.WithdrawFundsRequest; import bisq.proto.grpc.WithdrawFundsRequest;
@ -110,9 +113,11 @@ public class CliMain {
getfundingaddresses, getfundingaddresses,
getunusedbsqaddress, getunusedbsqaddress,
sendbsq, sendbsq,
sendbtc,
gettxfeerate, gettxfeerate,
settxfeerate, settxfeerate,
unsettxfeerate, unsettxfeerate,
gettransaction,
lockwallet, lockwallet,
unlockwallet, unlockwallet,
removewalletpassword, removewalletpassword,
@ -259,19 +264,56 @@ public class CliMain {
throw new IllegalArgumentException("no bsq amount specified"); throw new IllegalArgumentException("no bsq amount specified");
var amount = nonOptionArgs.get(2); var amount = nonOptionArgs.get(2);
verifyStringIsValidDecimal(amount);
try { var txFeeRate = nonOptionArgs.size() == 4 ? nonOptionArgs.get(3) : "";
Double.parseDouble(amount); if (!txFeeRate.isEmpty())
} catch (NumberFormatException e) { verifyStringIsValidLong(txFeeRate);
throw new IllegalArgumentException(format("'%s' is not a number", amount));
}
var request = SendBsqRequest.newBuilder() var request = SendBsqRequest.newBuilder()
.setAddress(address) .setAddress(address)
.setAmount(amount) .setAmount(amount)
.setTxFeeRate(txFeeRate)
.build(); .build();
walletsService.sendBsq(request); var reply = walletsService.sendBsq(request);
out.printf("%s BSQ sent to %s%n", amount, address); TxInfo txInfo = reply.getTxInfo();
out.printf("%s bsq sent to %s in tx %s%n",
amount,
address,
txInfo.getTxId());
return;
}
case sendbtc: {
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no btc address specified");
var address = nonOptionArgs.get(1);
if (nonOptionArgs.size() < 3)
throw new IllegalArgumentException("no btc amount specified");
var amount = nonOptionArgs.get(2);
verifyStringIsValidDecimal(amount);
// TODO Find a better way to handle the two optional parameters.
var txFeeRate = nonOptionArgs.size() >= 4 ? nonOptionArgs.get(3) : "";
if (!txFeeRate.isEmpty())
verifyStringIsValidLong(txFeeRate);
var memo = nonOptionArgs.size() == 5 ? nonOptionArgs.get(4) : "";
var request = SendBtcRequest.newBuilder()
.setAddress(address)
.setAmount(amount)
.setTxFeeRate(txFeeRate)
.setMemo(memo)
.build();
var reply = walletsService.sendBtc(request);
TxInfo txInfo = reply.getTxInfo();
out.printf("%s btc sent to %s in tx %s%n",
amount,
address,
txInfo.getTxId());
return; return;
} }
case gettxfeerate: { case gettxfeerate: {
@ -284,13 +326,7 @@ public class CliMain {
if (nonOptionArgs.size() < 2) if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no tx fee rate specified"); throw new IllegalArgumentException("no tx fee rate specified");
long txFeeRate; var txFeeRate = toLong(nonOptionArgs.get(2));
try {
txFeeRate = Long.parseLong(nonOptionArgs.get(2));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2)));
}
var request = SetTxFeeRatePreferenceRequest.newBuilder() var request = SetTxFeeRatePreferenceRequest.newBuilder()
.setTxFeeRatePreference(txFeeRate) .setTxFeeRatePreference(txFeeRate)
.build(); .build();
@ -304,6 +340,18 @@ public class CliMain {
out.println(formatTxFeeRateInfo(reply.getTxFeeRateInfo())); out.println(formatTxFeeRateInfo(reply.getTxFeeRateInfo()));
return; return;
} }
case gettransaction: {
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no tx id specified");
var txId = nonOptionArgs.get(1);
var request = GetTransactionRequest.newBuilder()
.setTxId(txId)
.build();
var reply = walletsService.getTransaction(request);
out.println(TransactionFormat.format(reply.getTxInfo()));
return;
}
case createoffer: { case createoffer: {
if (nonOptionArgs.size() < 9) if (nonOptionArgs.size() < 9)
throw new IllegalArgumentException("incorrect parameter count," throw new IllegalArgumentException("incorrect parameter count,"
@ -413,7 +461,7 @@ public class CliMain {
return; return;
} }
case gettrade: { case gettrade: {
// TODO make short-id a valid argument // TODO make short-id a valid argument?
if (nonOptionArgs.size() < 2) if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("incorrect parameter count, " throw new IllegalArgumentException("incorrect parameter count, "
+ " expecting trade id [,showcontract = true|false]"); + " expecting trade id [,showcontract = true|false]");
@ -472,16 +520,21 @@ public class CliMain {
case withdrawfunds: { case withdrawfunds: {
if (nonOptionArgs.size() < 3) if (nonOptionArgs.size() < 3)
throw new IllegalArgumentException("incorrect parameter count, " throw new IllegalArgumentException("incorrect parameter count, "
+ " expecting trade id, bitcoin wallet address"); + " expecting trade id, bitcoin wallet address [,\"memo\"]");
var tradeId = nonOptionArgs.get(1); var tradeId = nonOptionArgs.get(1);
var address = nonOptionArgs.get(2); var address = nonOptionArgs.get(2);
// A multi-word memo must be double quoted.
var memo = nonOptionArgs.size() == 4
? nonOptionArgs.get(3)
: "";
var request = WithdrawFundsRequest.newBuilder() var request = WithdrawFundsRequest.newBuilder()
.setTradeId(tradeId) .setTradeId(tradeId)
.setAddress(address) .setAddress(address)
.setMemo(memo)
.build(); .build();
tradesService.withdrawFunds(request); tradesService.withdrawFunds(request);
out.printf("funds from trade %s sent to btc address %s%n", tradeId, address); out.printf("trade %s funds sent to btc address %s%n", tradeId, address);
return; return;
} }
case getpaymentmethods: { case getpaymentmethods: {
@ -560,12 +613,7 @@ public class CliMain {
if (nonOptionArgs.size() < 3) if (nonOptionArgs.size() < 3)
throw new IllegalArgumentException("no unlock timeout specified"); throw new IllegalArgumentException("no unlock timeout specified");
long timeout; var timeout = toLong(nonOptionArgs.get(2));
try {
timeout = Long.parseLong(nonOptionArgs.get(2));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2)));
}
var request = UnlockWalletRequest.newBuilder() var request = UnlockWalletRequest.newBuilder()
.setPassword(nonOptionArgs.get(1)) .setPassword(nonOptionArgs.get(1))
.setTimeout(timeout).build(); .setTimeout(timeout).build();
@ -627,6 +675,30 @@ public class CliMain {
return Method.valueOf(methodName.toLowerCase()); return Method.valueOf(methodName.toLowerCase());
} }
private static void verifyStringIsValidDecimal(String param) {
try {
Double.parseDouble(param);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException(format("'%s' is not a number", param));
}
}
private static void verifyStringIsValidLong(String param) {
try {
Long.parseLong(param);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException(format("'%s' is not a number", param));
}
}
private static long toLong(String param) {
try {
return Long.parseLong(param);
} catch (NumberFormatException ex) {
throw new IllegalArgumentException(format("'%s' is not a number", param));
}
}
private static File saveFileToDisk(String prefix, private static File saveFileToDisk(String prefix,
@SuppressWarnings("SameParameterValue") String suffix, @SuppressWarnings("SameParameterValue") String suffix,
String text) { String text) {
@ -663,10 +735,12 @@ public class CliMain {
stream.format(rowFormat, "getaddressbalance", "address", "Get server wallet address balance"); stream.format(rowFormat, "getaddressbalance", "address", "Get server wallet address balance");
stream.format(rowFormat, "getfundingaddresses", "", "Get BTC funding addresses"); stream.format(rowFormat, "getfundingaddresses", "", "Get BTC funding addresses");
stream.format(rowFormat, "getunusedbsqaddress", "", "Get unused BSQ address"); stream.format(rowFormat, "getunusedbsqaddress", "", "Get unused BSQ address");
stream.format(rowFormat, "sendbsq", "address, amount", "Send BSQ"); stream.format(rowFormat, "sendbsq", "address, amount [,tx fee rate (sats/byte)]", "Send BSQ");
stream.format(rowFormat, "sendbtc", "address, amount [,tx fee rate (sats/byte), \"memo\"]", "Send BTC");
stream.format(rowFormat, "gettxfeerate", "", "Get current tx fee rate in sats/byte"); stream.format(rowFormat, "gettxfeerate", "", "Get current tx fee rate in sats/byte");
stream.format(rowFormat, "settxfeerate", "satoshis (per byte)", "Set custom tx fee rate in sats/byte"); stream.format(rowFormat, "settxfeerate", "satoshis (per byte)", "Set custom tx fee rate in sats/byte");
stream.format(rowFormat, "unsettxfeerate", "", "Unset custom tx fee rate"); stream.format(rowFormat, "unsettxfeerate", "", "Unset custom tx fee rate");
stream.format(rowFormat, "gettransaction", "transaction id", "Get transaction with id");
stream.format(rowFormat, "createoffer", "payment acct id, buy | sell, currency code, \\", "Create and place an offer"); stream.format(rowFormat, "createoffer", "payment acct id, buy | sell, currency code, \\", "Create and place an offer");
stream.format(rowFormat, "", "amount (btc), min amount, use mkt based price, \\", ""); stream.format(rowFormat, "", "amount (btc), min amount, use mkt based price, \\", "");
stream.format(rowFormat, "", "fixed price (btc) | mkt price margin (%), security deposit (%) \\", ""); stream.format(rowFormat, "", "fixed price (btc) | mkt price margin (%), security deposit (%) \\", "");
@ -679,7 +753,8 @@ public class CliMain {
stream.format(rowFormat, "confirmpaymentstarted", "trade id", "Confirm payment started"); stream.format(rowFormat, "confirmpaymentstarted", "trade id", "Confirm payment started");
stream.format(rowFormat, "confirmpaymentreceived", "trade id", "Confirm payment received"); stream.format(rowFormat, "confirmpaymentreceived", "trade id", "Confirm payment received");
stream.format(rowFormat, "keepfunds", "trade id", "Keep received funds in Bisq wallet"); stream.format(rowFormat, "keepfunds", "trade id", "Keep received funds in Bisq wallet");
stream.format(rowFormat, "withdrawfunds", "trade id, bitcoin wallet address", "Withdraw received funds to external wallet address"); stream.format(rowFormat, "withdrawfunds", "trade id, bitcoin wallet address [,\"memo\"]",
"Withdraw received funds to external wallet address");
stream.format(rowFormat, "getpaymentmethods", "", "Get list of supported payment account method ids"); stream.format(rowFormat, "getpaymentmethods", "", "Get list of supported payment account method ids");
stream.format(rowFormat, "getpaymentacctform", "payment method id", "Get a new payment account form"); stream.format(rowFormat, "getpaymentacctform", "payment method id", "Get a new payment account form");
stream.format(rowFormat, "createpaymentacct", "path to payment account form", "Create a new payment account"); stream.format(rowFormat, "createpaymentacct", "path to payment account form", "Create a new payment account");

View file

@ -59,6 +59,16 @@ class ColumnHeaderConstants {
static final String COL_HEADER_TRADE_SHORT_ID = "ID"; static final String COL_HEADER_TRADE_SHORT_ID = "ID";
static final String COL_HEADER_TRADE_TX_FEE = "Tx Fee(%-3s)"; static final String COL_HEADER_TRADE_TX_FEE = "Tx Fee(%-3s)";
static final String COL_HEADER_TRADE_TAKER_FEE = "Taker Fee(%-3s)"; static final String COL_HEADER_TRADE_TAKER_FEE = "Taker Fee(%-3s)";
static final String COL_HEADER_TRADE_WITHDRAWAL_TX_ID = "Withdrawal TX ID";
static final String COL_HEADER_TX_ID = "Tx ID";
static final String COL_HEADER_TX_INPUT_SUM = "Tx Inputs (BTC)";
static final String COL_HEADER_TX_OUTPUT_SUM = "Tx Outputs (BTC)";
static final String COL_HEADER_TX_FEE = "Tx Fee (BTC)";
static final String COL_HEADER_TX_SIZE = "Tx Size (Bytes)";
static final String COL_HEADER_TX_IS_CONFIRMED = "Is Confirmed";
static final String COL_HEADER_TX_MEMO = "Memo";
static final String COL_HEADER_VOLUME = padEnd("%-3s(min - max)", 15, ' '); static final String COL_HEADER_VOLUME = padEnd("%-3s(min - max)", 15, ' ');
static final String COL_HEADER_UUID = padEnd("ID", 52, ' '); static final String COL_HEADER_UUID = padEnd("ID", 52, ' ');
} }

View file

@ -66,18 +66,19 @@ public class TradeFormat {
? String.format(headersFormat, counterCurrencyCode, baseCurrencyCode, baseCurrencyCode, baseCurrencyCode) ? String.format(headersFormat, counterCurrencyCode, baseCurrencyCode, baseCurrencyCode, baseCurrencyCode)
: String.format(headersFormat, counterCurrencyCode, baseCurrencyCode, baseCurrencyCode); : String.format(headersFormat, counterCurrencyCode, baseCurrencyCode, baseCurrencyCode);
String colDataFormat = "%-" + shortIdColWidth + "s" // left justify
+ " %-" + (roleColWidth + COL_HEADER_DELIMITER.length()) + "s" // left justify String colDataFormat = "%-" + shortIdColWidth + "s" // lt justify
+ "%" + (COL_HEADER_PRICE.length() - 1) + "s" // right justify + " %-" + (roleColWidth + COL_HEADER_DELIMITER.length()) + "s" // left
+ "%" + (COL_HEADER_TRADE_AMOUNT.length() + 1) + "s" // right justify + "%" + (COL_HEADER_PRICE.length() - 1) + "s" // rt justify
+ "%" + (COL_HEADER_TRADE_TX_FEE.length() + 1) + "s" // right justify + "%" + (COL_HEADER_TRADE_AMOUNT.length() + 1) + "s" // rt justify
+ takerFeeHeader.get() // right justify + "%" + (COL_HEADER_TRADE_TX_FEE.length() + 1) + "s" // rt justify
+ " %-" + COL_HEADER_TRADE_DEPOSIT_PUBLISHED.length() + "s" // left justify + takerFeeHeader.get() // rt justify
+ " %-" + COL_HEADER_TRADE_DEPOSIT_CONFIRMED.length() + "s" // left justify + " %-" + COL_HEADER_TRADE_DEPOSIT_PUBLISHED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_FIAT_SENT.length() + "s" // left justify + " %-" + COL_HEADER_TRADE_DEPOSIT_CONFIRMED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_FIAT_RECEIVED.length() + "s" // left justify + " %-" + COL_HEADER_TRADE_FIAT_SENT.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // left justify + " %-" + COL_HEADER_TRADE_FIAT_RECEIVED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s"; // left justify + " %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s"; // lt justify
return headerLine + return headerLine +
(isTaker (isTaker

View file

@ -0,0 +1,59 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.cli;
import bisq.proto.grpc.TxInfo;
import com.google.common.annotations.VisibleForTesting;
import static bisq.cli.ColumnHeaderConstants.*;
import static bisq.cli.CurrencyFormat.formatSatoshis;
import static com.google.common.base.Strings.padEnd;
@VisibleForTesting
public class TransactionFormat {
public static String format(TxInfo txInfo) {
String headerLine = padEnd(COL_HEADER_TX_ID, txInfo.getTxId().length(), ' ') + COL_HEADER_DELIMITER
+ COL_HEADER_TX_IS_CONFIRMED + COL_HEADER_DELIMITER
+ COL_HEADER_TX_INPUT_SUM + COL_HEADER_DELIMITER
+ COL_HEADER_TX_OUTPUT_SUM + COL_HEADER_DELIMITER
+ COL_HEADER_TX_FEE + COL_HEADER_DELIMITER
+ COL_HEADER_TX_SIZE + COL_HEADER_DELIMITER
+ (txInfo.getMemo().isEmpty() ? "" : COL_HEADER_TX_MEMO + COL_HEADER_DELIMITER)
+ "\n";
String colDataFormat = "%-" + txInfo.getTxId().length() + "s"
+ " %" + COL_HEADER_TX_IS_CONFIRMED.length() + "s"
+ " %" + COL_HEADER_TX_INPUT_SUM.length() + "s"
+ " %" + COL_HEADER_TX_OUTPUT_SUM.length() + "s"
+ " %" + COL_HEADER_TX_FEE.length() + "s"
+ " %" + COL_HEADER_TX_SIZE.length() + "s"
+ " %s";
return headerLine
+ String.format(colDataFormat,
txInfo.getTxId(),
txInfo.getIsPending() ? "NO" : "YES", // pending=true means not confirmed
formatSatoshis(txInfo.getInputSum()),
formatSatoshis(txInfo.getOutputSum()),
formatSatoshis(txInfo.getFee()),
txInfo.getSize(),
txInfo.getMemo().isEmpty() ? "" : txInfo.getMemo());
}
}

View file

@ -31,22 +31,25 @@ import bisq.core.trade.statistics.TradeStatistics3;
import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.trade.statistics.TradeStatisticsManager;
import bisq.common.app.Version; import bisq.common.app.Version;
import bisq.common.config.Config;
import bisq.common.handlers.ResultHandler; import bisq.common.handlers.ResultHandler;
import org.bitcoinj.core.Coin; import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Transaction;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; import javax.inject.Singleton;
import com.google.common.util.concurrent.FutureCallback;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
/** /**
* Provides high level interface to functionality of core Bisq features. * Provides high level interface to functionality of core Bisq features.
* E.g. useful for different APIs to access data of different domains of Bisq. * E.g. useful for different APIs to access data of different domains of Bisq.
@ -55,6 +58,8 @@ import javax.annotation.Nullable;
@Slf4j @Slf4j
public class CoreApi { public class CoreApi {
@Getter
private final Config config;
private final CoreDisputeAgentsService coreDisputeAgentsService; private final CoreDisputeAgentsService coreDisputeAgentsService;
private final CoreOffersService coreOffersService; private final CoreOffersService coreOffersService;
private final CorePaymentAccountsService paymentAccountsService; private final CorePaymentAccountsService paymentAccountsService;
@ -64,13 +69,15 @@ public class CoreApi {
private final TradeStatisticsManager tradeStatisticsManager; private final TradeStatisticsManager tradeStatisticsManager;
@Inject @Inject
public CoreApi(CoreDisputeAgentsService coreDisputeAgentsService, public CoreApi(Config config,
CoreDisputeAgentsService coreDisputeAgentsService,
CoreOffersService coreOffersService, CoreOffersService coreOffersService,
CorePaymentAccountsService paymentAccountsService, CorePaymentAccountsService paymentAccountsService,
CorePriceService corePriceService, CorePriceService corePriceService,
CoreTradesService coreTradesService, CoreTradesService coreTradesService,
CoreWalletsService walletsService, CoreWalletsService walletsService,
TradeStatisticsManager tradeStatisticsManager) { TradeStatisticsManager tradeStatisticsManager) {
this.config = config;
this.coreDisputeAgentsService = coreDisputeAgentsService; this.coreDisputeAgentsService = coreDisputeAgentsService;
this.coreOffersService = coreOffersService; this.coreOffersService = coreOffersService;
this.paymentAccountsService = paymentAccountsService; this.paymentAccountsService = paymentAccountsService;
@ -210,7 +217,7 @@ public class CoreApi {
coreTradesService.keepFunds(tradeId); coreTradesService.keepFunds(tradeId);
} }
public void withdrawFunds(String tradeId, String address, @Nullable String memo) { public void withdrawFunds(String tradeId, String address, String memo) {
coreTradesService.withdrawFunds(tradeId, address, memo); coreTradesService.withdrawFunds(tradeId, address, memo);
} }
@ -246,8 +253,19 @@ public class CoreApi {
return walletsService.getUnusedBsqAddress(); return walletsService.getUnusedBsqAddress();
} }
public void sendBsq(String address, String amount, TxBroadcaster.Callback callback) { public void sendBsq(String address,
walletsService.sendBsq(address, amount, callback); String amount,
String txFeeRate,
TxBroadcaster.Callback callback) {
walletsService.sendBsq(address, amount, txFeeRate, callback);
}
public void sendBtc(String address,
String amount,
String txFeeRate,
String memo,
FutureCallback<Transaction> callback) {
walletsService.sendBtc(address, amount, txFeeRate, memo, callback);
} }
public void getTxFeeRate(ResultHandler resultHandler) { public void getTxFeeRate(ResultHandler resultHandler) {
@ -267,6 +285,10 @@ public class CoreApi {
return walletsService.getMostRecentTxFeeRateInfo(); return walletsService.getMostRecentTxFeeRateInfo();
} }
public Transaction getTransaction(String txId) {
return walletsService.getTransaction(txId);
}
public void setWalletPassword(String password, String newPassword) { public void setWalletPassword(String password, String newPassword) {
walletsService.setWalletPassword(password, newPassword); walletsService.setWalletPassword(password, newPassword);
} }

View file

@ -41,8 +41,6 @@ import java.util.function.Consumer;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import static bisq.core.btc.model.AddressEntry.Context.TRADE_PAYOUT; import static bisq.core.btc.model.AddressEntry.Context.TRADE_PAYOUT;
import static java.lang.String.format; import static java.lang.String.format;
@ -85,6 +83,8 @@ class CoreTradesService {
String paymentAccountId, String paymentAccountId,
String takerFeeCurrencyCode, String takerFeeCurrencyCode,
Consumer<Trade> resultHandler) { Consumer<Trade> resultHandler) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
offerUtil.maybeSetFeePaymentCurrencyPreference(takerFeeCurrencyCode); offerUtil.maybeSetFeePaymentCurrencyPreference(takerFeeCurrencyCode);
@ -149,6 +149,9 @@ class CoreTradesService {
} }
void keepFunds(String tradeId) { void keepFunds(String tradeId) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
verifyTradeIsNotClosed(tradeId); verifyTradeIsNotClosed(tradeId);
var trade = getOpenTrade(tradeId).orElseThrow(() -> var trade = getOpenTrade(tradeId).orElseThrow(() ->
new IllegalArgumentException(format("trade with id '%s' not found", tradeId))); new IllegalArgumentException(format("trade with id '%s' not found", tradeId)));
@ -156,8 +159,10 @@ class CoreTradesService {
tradeManager.onTradeCompleted(trade); tradeManager.onTradeCompleted(trade);
} }
void withdrawFunds(String tradeId, String toAddress, @Nullable String memo) { void withdrawFunds(String tradeId, String toAddress, String memo) {
// An encrypted wallet must be unlocked for this operation. coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
verifyTradeIsNotClosed(tradeId); verifyTradeIsNotClosed(tradeId);
var trade = getOpenTrade(tradeId).orElseThrow(() -> var trade = getOpenTrade(tradeId).orElseThrow(() ->
new IllegalArgumentException(format("trade with id '%s' not found", tradeId))); new IllegalArgumentException(format("trade with id '%s' not found", tradeId)));
@ -172,21 +177,21 @@ class CoreTradesService {
var receiverAmount = amount.subtract(fee); var receiverAmount = amount.subtract(fee);
log.info(format("Withdrawing funds received from trade %s:" log.info(format("Withdrawing funds received from trade %s:"
+ "%n From %s%n To %s%n Amt %s%n Tx Fee %s%n Receiver Amt %s", + "%n From %s%n To %s%n Amt %s%n Tx Fee %s%n Receiver Amt %s%n Memo %s%n",
tradeId, tradeId,
fromAddressEntry.getAddressString(), fromAddressEntry.getAddressString(),
toAddress, toAddress,
amount.toFriendlyString(), amount.toFriendlyString(),
fee.toFriendlyString(), fee.toFriendlyString(),
receiverAmount.toFriendlyString())); receiverAmount.toFriendlyString(),
memo));
tradeManager.onWithdrawRequest( tradeManager.onWithdrawRequest(
toAddress, toAddress,
amount, amount,
fee, fee,
coreWalletsService.getKey(), coreWalletsService.getKey(),
trade, trade,
memo, memo.isEmpty() ? null : memo,
() -> { () -> {
}, },
(errorMessage, throwable) -> { (errorMessage, throwable) -> {
@ -196,10 +201,14 @@ class CoreTradesService {
} }
String getTradeRole(String tradeId) { String getTradeRole(String tradeId) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
return tradeUtil.getRole(getTrade(tradeId)); return tradeUtil.getRole(getTrade(tradeId));
} }
Trade getTrade(String tradeId) { Trade getTrade(String tradeId) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
return getOpenTrade(tradeId).orElseGet(() -> return getOpenTrade(tradeId).orElseGet(() ->
getClosedTrade(tradeId).orElseThrow(() -> getClosedTrade(tradeId).orElseThrow(() ->
new IllegalArgumentException(format("trade with id '%s' not found", tradeId)) new IllegalArgumentException(format("trade with id '%s' not found", tradeId))

View file

@ -23,7 +23,9 @@ import bisq.core.api.model.BsqBalanceInfo;
import bisq.core.api.model.BtcBalanceInfo; import bisq.core.api.model.BtcBalanceInfo;
import bisq.core.api.model.TxFeeRateInfo; import bisq.core.api.model.TxFeeRateInfo;
import bisq.core.btc.Balances; import bisq.core.btc.Balances;
import bisq.core.btc.exceptions.AddressEntryException;
import bisq.core.btc.exceptions.BsqChangeBelowDustException; import bisq.core.btc.exceptions.BsqChangeBelowDustException;
import bisq.core.btc.exceptions.InsufficientFundsException;
import bisq.core.btc.exceptions.TransactionVerificationException; import bisq.core.btc.exceptions.TransactionVerificationException;
import bisq.core.btc.exceptions.WalletException; import bisq.core.btc.exceptions.WalletException;
import bisq.core.btc.model.AddressEntry; import bisq.core.btc.model.AddressEntry;
@ -35,7 +37,9 @@ import bisq.core.btc.wallet.TxBroadcaster;
import bisq.core.btc.wallet.WalletsManager; import bisq.core.btc.wallet.WalletsManager;
import bisq.core.provider.fee.FeeService; import bisq.core.provider.fee.FeeService;
import bisq.core.user.Preferences; import bisq.core.user.Preferences;
import bisq.core.util.FormattingUtils;
import bisq.core.util.coin.BsqFormatter; import bisq.core.util.coin.BsqFormatter;
import bisq.core.util.coin.CoinFormatter;
import bisq.common.Timer; import bisq.common.Timer;
import bisq.common.UserThread; import bisq.common.UserThread;
@ -46,10 +50,12 @@ import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin; import org.bitcoinj.core.Coin;
import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.InsufficientMoneyException;
import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.core.TransactionConfidence;
import org.bitcoinj.crypto.KeyCrypterScrypt; import org.bitcoinj.crypto.KeyCrypterScrypt;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Named;
import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheLoader;
@ -64,6 +70,7 @@ import org.bouncycastle.crypto.params.KeyParameter;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -85,6 +92,7 @@ class CoreWalletsService {
private final BsqTransferService bsqTransferService; private final BsqTransferService bsqTransferService;
private final BsqFormatter bsqFormatter; private final BsqFormatter bsqFormatter;
private final BtcWalletService btcWalletService; private final BtcWalletService btcWalletService;
private final CoinFormatter btcFormatter;
private final FeeService feeService; private final FeeService feeService;
private final Preferences preferences; private final Preferences preferences;
@ -103,6 +111,7 @@ class CoreWalletsService {
BsqTransferService bsqTransferService, BsqTransferService bsqTransferService,
BsqFormatter bsqFormatter, BsqFormatter bsqFormatter,
BtcWalletService btcWalletService, BtcWalletService btcWalletService,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
FeeService feeService, FeeService feeService,
Preferences preferences) { Preferences preferences) {
this.balances = balances; this.balances = balances;
@ -111,6 +120,7 @@ class CoreWalletsService {
this.bsqTransferService = bsqTransferService; this.bsqTransferService = bsqTransferService;
this.bsqFormatter = bsqFormatter; this.bsqFormatter = bsqFormatter;
this.btcWalletService = btcWalletService; this.btcWalletService = btcWalletService;
this.btcFormatter = btcFormatter;
this.feeService = feeService; this.feeService = feeService;
this.preferences = preferences; this.preferences = preferences;
} }
@ -189,13 +199,27 @@ class CoreWalletsService {
void sendBsq(String address, void sendBsq(String address,
String amount, String amount,
String txFeeRate,
TxBroadcaster.Callback callback) { TxBroadcaster.Callback callback) {
verifyWalletsAreAvailable();
verifyEncryptedWalletIsUnlocked();
try { try {
LegacyAddress legacyAddress = getValidBsqLegacyAddress(address); LegacyAddress legacyAddress = getValidBsqLegacyAddress(address);
Coin receiverAmount = getValidBsqTransferAmount(amount); Coin receiverAmount = getValidTransferAmount(amount, bsqFormatter);
BsqTransferModel model = bsqTransferService.getBsqTransferModel(legacyAddress, receiverAmount); Coin txFeePerVbyte = getTxFeeRateFromParamOrPreferenceOrFeeService(txFeeRate);
BsqTransferModel model = bsqTransferService.getBsqTransferModel(legacyAddress,
receiverAmount,
txFeePerVbyte);
log.info("Sending {} BSQ to {} with tx fee rate {} sats/byte.",
amount,
address,
txFeePerVbyte.value);
bsqTransferService.sendFunds(model, callback); bsqTransferService.sendFunds(model, callback);
} catch (InsufficientMoneyException } catch (InsufficientMoneyException ex) {
log.error("", ex);
throw new IllegalStateException("cannot send bsq due to insufficient funds", ex);
} catch (NumberFormatException
| BsqChangeBelowDustException | BsqChangeBelowDustException
| TransactionVerificationException | TransactionVerificationException
| WalletException ex) { | WalletException ex) {
@ -204,6 +228,61 @@ class CoreWalletsService {
} }
} }
void sendBtc(String address,
String amount,
String txFeeRate,
String memo,
FutureCallback<Transaction> callback) {
verifyWalletsAreAvailable();
verifyEncryptedWalletIsUnlocked();
try {
Set<String> fromAddresses = btcWalletService.getAddressEntriesForAvailableBalanceStream()
.map(AddressEntry::getAddressString)
.collect(Collectors.toSet());
Coin receiverAmount = getValidTransferAmount(amount, btcFormatter);
Coin txFeePerVbyte = getTxFeeRateFromParamOrPreferenceOrFeeService(txFeeRate);
// TODO Support feeExcluded (or included), default is fee included.
// See WithdrawalView # onWithdraw (and refactor).
Transaction feeEstimationTransaction =
btcWalletService.getFeeEstimationTransactionForMultipleAddresses(fromAddresses,
receiverAmount,
txFeePerVbyte);
if (feeEstimationTransaction == null)
throw new IllegalStateException("could not estimate the transaction fee");
Coin dust = btcWalletService.getDust(feeEstimationTransaction);
Coin fee = feeEstimationTransaction.getFee().add(dust);
if (dust.isPositive()) {
fee = feeEstimationTransaction.getFee().add(dust);
log.info("Dust txo ({} sats) was detected, the dust amount has been added to the fee (was {}, now {})",
dust.value,
feeEstimationTransaction.getFee(),
fee.value);
}
log.info("Sending {} BTC to {} with tx fee of {} sats (fee rate {} sats/byte).",
amount,
address,
fee.value,
txFeePerVbyte.value);
btcWalletService.sendFundsForMultipleAddresses(fromAddresses,
address,
receiverAmount,
fee,
null,
tempAesKey,
memo.isEmpty() ? null : memo,
callback);
} catch (AddressEntryException ex) {
log.error("", ex);
throw new IllegalStateException("cannot send btc from any addresses in wallet", ex);
} catch (InsufficientFundsException | InsufficientMoneyException ex) {
log.error("", ex);
throw new IllegalStateException("cannot send btc due to insufficient funds", ex);
}
}
void getTxFeeRate(ResultHandler resultHandler) { void getTxFeeRate(ResultHandler resultHandler) {
try { try {
@SuppressWarnings({"unchecked", "Convert2MethodRef"}) @SuppressWarnings({"unchecked", "Convert2MethodRef"})
@ -252,6 +331,26 @@ class CoreWalletsService {
feeService.getLastRequest()); feeService.getLastRequest());
} }
Transaction getTransaction(String txId) {
if (txId.length() != 64)
throw new IllegalArgumentException(format("%s is not a transaction id", txId));
try {
Transaction tx = btcWalletService.getTransaction(txId);
if (tx == null)
throw new IllegalArgumentException(format("tx with id %s not found", txId));
else
return tx;
} catch (IllegalArgumentException ex) {
log.error("", ex);
throw new IllegalArgumentException(
format("could not get transaction with id %s%ncause: %s",
txId,
ex.getMessage().toLowerCase()));
}
}
int getNumConfirmationsForMostRecentTransaction(String addressString) { int getNumConfirmationsForMostRecentTransaction(String addressString) {
Address address = getAddressEntry(addressString).getAddress(); Address address = getAddressEntry(addressString).getAddress();
TransactionConfidence confidence = btcWalletService.getConfidenceForAddress(address); TransactionConfidence confidence = btcWalletService.getConfidenceForAddress(address);
@ -342,13 +441,13 @@ class CoreWalletsService {
} }
// Throws a RuntimeException if wallets are not available (encrypted or not). // Throws a RuntimeException if wallets are not available (encrypted or not).
private void verifyWalletsAreAvailable() { void verifyWalletsAreAvailable() {
if (!walletsManager.areWalletsAvailable()) if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available"); throw new IllegalStateException("wallet is not yet available");
} }
// Throws a RuntimeException if wallets are not available or not encrypted. // Throws a RuntimeException if wallets are not available or not encrypted.
private void verifyWalletIsAvailableAndEncrypted() { void verifyWalletIsAvailableAndEncrypted() {
if (!walletsManager.areWalletsAvailable()) if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available"); throw new IllegalStateException("wallet is not yet available");
@ -357,7 +456,7 @@ class CoreWalletsService {
} }
// Throws a RuntimeException if wallets are encrypted and locked. // Throws a RuntimeException if wallets are encrypted and locked.
private void verifyEncryptedWalletIsUnlocked() { void verifyEncryptedWalletIsUnlocked() {
if (walletsManager.areWalletsEncrypted() && tempAesKey == null) if (walletsManager.areWalletsEncrypted() && tempAesKey == null)
throw new IllegalStateException("wallet is locked"); throw new IllegalStateException("wallet is locked");
} }
@ -423,15 +522,22 @@ class CoreWalletsService {
} }
} }
// Returns a Coin for the amount string, or a RuntimeException if invalid. // Returns a Coin for the transfer amount string, or a RuntimeException if invalid.
private Coin getValidBsqTransferAmount(String amount) { private Coin getValidTransferAmount(String amount, CoinFormatter coinFormatter) {
Coin amountAsCoin = parseToCoin(amount, bsqFormatter); Coin amountAsCoin = parseToCoin(amount, coinFormatter);
if (amountAsCoin.isLessThan(getMinNonDustOutput())) if (amountAsCoin.isLessThan(getMinNonDustOutput()))
throw new IllegalStateException(format("%s bsq is an invalid send amount", amount)); throw new IllegalStateException(format("%s is an invalid transfer amount", amount));
return amountAsCoin; return amountAsCoin;
} }
private Coin getTxFeeRateFromParamOrPreferenceOrFeeService(String txFeeRate) {
// A non txFeeRate String value overrides the fee service and custom fee.
return txFeeRate.isEmpty()
? btcWalletService.getTxFeeForWithdrawalPerVbyte()
: Coin.valueOf(Long.parseLong(txFeeRate));
}
private KeyCrypterScrypt getKeyCrypterScrypt() { private KeyCrypterScrypt getKeyCrypterScrypt() {
KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt(); KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt();
if (keyCrypterScrypt == null) if (keyCrypterScrypt == null)

View file

@ -0,0 +1,160 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.core.api.model;
import bisq.common.Payload;
import org.bitcoinj.core.Transaction;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@EqualsAndHashCode
@Getter
public class TxInfo implements Payload {
// The client cannot see an instance of an org.bitcoinj.core.Transaction. We use the
// lighter weight TxInfo proto wrapper instead, containing just enough fields to
// view some transaction details. A block explorer or bitcoin-core client can be
// used to see more detail.
private final String txId;
private final long inputSum;
private final long outputSum;
private final long fee;
private final int size;
private final boolean isPending;
private final String memo;
public TxInfo(TxInfo.TxInfoBuilder builder) {
this.txId = builder.txId;
this.inputSum = builder.inputSum;
this.outputSum = builder.outputSum;
this.fee = builder.fee;
this.size = builder.size;
this.isPending = builder.isPending;
this.memo = builder.memo;
}
public static TxInfo toTxInfo(Transaction transaction) {
if (transaction == null)
throw new IllegalStateException("server created a null transaction");
return new TxInfo.TxInfoBuilder()
.withTxId(transaction.getTxId().toString())
.withInputSum(transaction.getInputSum().value)
.withOutputSum(transaction.getOutputSum().value)
.withFee(transaction.getFee().value)
.withSize(transaction.getMessageSize())
.withIsPending(transaction.isPending())
.withMemo(transaction.getMemo())
.build();
}
//////////////////////////////////////////////////////////////////////////////////////
// PROTO BUFFER
//////////////////////////////////////////////////////////////////////////////////////
@Override
public bisq.proto.grpc.TxInfo toProtoMessage() {
return bisq.proto.grpc.TxInfo.newBuilder()
.setTxId(txId)
.setInputSum(inputSum)
.setOutputSum(outputSum)
.setFee(fee)
.setSize(size)
.setIsPending(isPending)
.setMemo(memo == null ? "" : memo)
.build();
}
@SuppressWarnings("unused")
public static TxInfo fromProto(bisq.proto.grpc.TxInfo proto) {
return new TxInfo.TxInfoBuilder()
.withTxId(proto.getTxId())
.withInputSum(proto.getInputSum())
.withOutputSum(proto.getOutputSum())
.withFee(proto.getFee())
.withSize(proto.getSize())
.withIsPending(proto.getIsPending())
.withMemo(proto.getMemo())
.build();
}
public static class TxInfoBuilder {
private String txId;
private long inputSum;
private long outputSum;
private long fee;
private int size;
private boolean isPending;
private String memo;
public TxInfo.TxInfoBuilder withTxId(String txId) {
this.txId = txId;
return this;
}
public TxInfo.TxInfoBuilder withInputSum(long inputSum) {
this.inputSum = inputSum;
return this;
}
public TxInfo.TxInfoBuilder withOutputSum(long outputSum) {
this.outputSum = outputSum;
return this;
}
public TxInfo.TxInfoBuilder withFee(long fee) {
this.fee = fee;
return this;
}
public TxInfo.TxInfoBuilder withSize(int size) {
this.size = size;
return this;
}
public TxInfo.TxInfoBuilder withIsPending(boolean isPending) {
this.isPending = isPending;
return this;
}
public TxInfo.TxInfoBuilder withMemo(String memo) {
this.memo = memo;
return this;
}
public TxInfo build() {
return new TxInfo(this);
}
}
@Override
public String toString() {
return "TxInfo{" + "\n" +
" txId='" + txId + '\'' + "\n" +
", inputSum=" + inputSum + "\n" +
", outputSum=" + outputSum + "\n" +
", fee=" + fee + "\n" +
", size=" + size + "\n" +
", isPending=" + isPending + "\n" +
", memo='" + memo + '\'' + "\n" +
'}';
}
}

View file

@ -33,14 +33,15 @@ public class BsqTransferService {
} }
public BsqTransferModel getBsqTransferModel(LegacyAddress address, public BsqTransferModel getBsqTransferModel(LegacyAddress address,
Coin receiverAmount) Coin receiverAmount,
Coin txFeePerVbyte)
throws TransactionVerificationException, throws TransactionVerificationException,
WalletException, WalletException,
BsqChangeBelowDustException, BsqChangeBelowDustException,
InsufficientMoneyException { InsufficientMoneyException {
Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(address.toString(), receiverAmount); Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(address.toString(), receiverAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, txFeePerVbyte);
Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); Transaction signedTx = bsqWalletService.signTx(txWithBtcFee);
return new BsqTransferModel(address, return new BsqTransferModel(address,

View file

@ -440,8 +440,7 @@ public class BtcWalletService extends WalletService {
// Add fee input to prepared BSQ send tx // Add fee input to prepared BSQ send tx
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx) throws
public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx, boolean isSendTx) throws
TransactionVerificationException, WalletException, InsufficientMoneyException { TransactionVerificationException, WalletException, InsufficientMoneyException {
// preparedBsqTx has following structure: // preparedBsqTx has following structure:
// inputs [1-n] BSQ inputs // inputs [1-n] BSQ inputs
@ -455,13 +454,26 @@ public class BtcWalletService extends WalletService {
// outputs [0-1] BSQ change output // outputs [0-1] BSQ change output
// outputs [0-1] BTC change output // outputs [0-1] BTC change output
// mining fee: BTC mining fee // mining fee: BTC mining fee
return completePreparedBsqTx(preparedBsqTx, isSendTx, null); Coin txFeePerVbyte = getTxFeeForWithdrawalPerVbyte();
return completePreparedBsqTx(preparedBsqTx, null, txFeePerVbyte);
}
public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx, Coin txFeePerVbyte) throws
TransactionVerificationException, WalletException, InsufficientMoneyException {
return completePreparedBsqTx(preparedBsqTx, null, txFeePerVbyte);
} }
public Transaction completePreparedBsqTx(Transaction preparedBsqTx, public Transaction completePreparedBsqTx(Transaction preparedBsqTx,
boolean useCustomTxFee,
@Nullable byte[] opReturnData) throws @Nullable byte[] opReturnData) throws
TransactionVerificationException, WalletException, InsufficientMoneyException { TransactionVerificationException, WalletException, InsufficientMoneyException {
Coin txFeePerVbyte = getTxFeeForWithdrawalPerVbyte();
return completePreparedBsqTx(preparedBsqTx, opReturnData, txFeePerVbyte);
}
public Transaction completePreparedBsqTx(Transaction preparedBsqTx,
@Nullable byte[] opReturnData,
Coin txFeePerVbyte) throws
TransactionVerificationException, WalletException, InsufficientMoneyException {
// preparedBsqTx has following structure: // preparedBsqTx has following structure:
// inputs [1-n] BSQ inputs // inputs [1-n] BSQ inputs
@ -488,8 +500,6 @@ public class BtcWalletService extends WalletService {
int sigSizePerInput = 106; int sigSizePerInput = 106;
// typical size for a tx with 2 inputs // typical size for a tx with 2 inputs
int txVsizeWithUnsignedInputs = 203; int txVsizeWithUnsignedInputs = 203;
// If useCustomTxFee we allow overriding the estimated fee from preferences
Coin txFeePerVbyte = useCustomTxFee ? getTxFeeForWithdrawalPerVbyte() : feeService.getTxFeePerVbyte();
// In case there are no change outputs we force a change by adding min dust to the BTC input // In case there are no change outputs we force a change by adding min dust to the BTC input
Coin forcedChangeValue = Coin.ZERO; Coin forcedChangeValue = Coin.ZERO;
@ -968,7 +978,7 @@ public class BtcWalletService extends WalletService {
} }
if (sendResult != null) { if (sendResult != null) {
log.info("Broadcasting double spending transaction. " + sendResult.tx); log.info("Broadcasting double spending transaction. " + sendResult.tx);
Futures.addCallback(sendResult.broadcastComplete, new FutureCallback<Transaction>() { Futures.addCallback(sendResult.broadcastComplete, new FutureCallback<>() {
@Override @Override
public void onSuccess(Transaction result) { public void onSuccess(Transaction result) {
log.info("Double spending transaction published. " + result); log.info("Double spending transaction published. " + result);
@ -1048,6 +1058,14 @@ public class BtcWalletService extends WalletService {
public Transaction getFeeEstimationTransactionForMultipleAddresses(Set<String> fromAddresses, public Transaction getFeeEstimationTransactionForMultipleAddresses(Set<String> fromAddresses,
Coin amount) Coin amount)
throws AddressFormatException, AddressEntryException, InsufficientFundsException { throws AddressFormatException, AddressEntryException, InsufficientFundsException {
Coin txFeeForWithdrawalPerVbyte = getTxFeeForWithdrawalPerVbyte();
return getFeeEstimationTransactionForMultipleAddresses(fromAddresses, amount, txFeeForWithdrawalPerVbyte);
}
public Transaction getFeeEstimationTransactionForMultipleAddresses(Set<String> fromAddresses,
Coin amount,
Coin txFeeForWithdrawalPerVbyte)
throws AddressFormatException, AddressEntryException, InsufficientFundsException {
Set<AddressEntry> addressEntries = fromAddresses.stream() Set<AddressEntry> addressEntries = fromAddresses.stream()
.map(address -> { .map(address -> {
Optional<AddressEntry> addressEntryOptional = findAddressEntry(address, AddressEntry.Context.AVAILABLE); Optional<AddressEntry> addressEntryOptional = findAddressEntry(address, AddressEntry.Context.AVAILABLE);
@ -1070,7 +1088,6 @@ public class BtcWalletService extends WalletService {
int counter = 0; int counter = 0;
int txVsize = 0; int txVsize = 0;
Transaction tx; Transaction tx;
Coin txFeeForWithdrawalPerVbyte = getTxFeeForWithdrawalPerVbyte();
do { do {
counter++; counter++;
fee = txFeeForWithdrawalPerVbyte.multiply(txVsize); fee = txFeeForWithdrawalPerVbyte.multiply(txVsize);
@ -1097,7 +1114,11 @@ public class BtcWalletService extends WalletService {
} }
private boolean feeEstimationNotSatisfied(int counter, Transaction tx) { private boolean feeEstimationNotSatisfied(int counter, Transaction tx) {
long targetFee = getTxFeeForWithdrawalPerVbyte().multiply(tx.getVsize()).value; return feeEstimationNotSatisfied(counter, tx, getTxFeeForWithdrawalPerVbyte());
}
private boolean feeEstimationNotSatisfied(int counter, Transaction tx, Coin txFeeForWithdrawalPerVbyte) {
long targetFee = txFeeForWithdrawalPerVbyte.multiply(tx.getVsize()).value;
return counter < 10 && return counter < 10 &&
(tx.getFee().value < targetFee || (tx.getFee().value < targetFee ||
tx.getFee().value - targetFee > 1000); tx.getFee().value - targetFee > 1000);
@ -1213,7 +1234,7 @@ public class BtcWalletService extends WalletService {
Coin fee, Coin fee,
@Nullable String changeAddress, @Nullable String changeAddress,
@Nullable KeyParameter aesKey) throws @Nullable KeyParameter aesKey) throws
AddressFormatException, AddressEntryException, InsufficientMoneyException { AddressFormatException, AddressEntryException {
Transaction tx = new Transaction(params); Transaction tx = new Transaction(params);
final Coin netValue = amount.subtract(fee); final Coin netValue = amount.subtract(fee);
checkArgument(Restrictions.isAboveDust(netValue), checkArgument(Restrictions.isAboveDust(netValue),
@ -1246,12 +1267,12 @@ public class BtcWalletService extends WalletService {
sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesFromAddressEntries(addressEntries), sendRequest.coinSelector = new BtcCoinSelector(walletsSetup.getAddressesFromAddressEntries(addressEntries),
preferences.getIgnoreDustThreshold()); preferences.getIgnoreDustThreshold());
Optional<AddressEntry> addressEntryOptional = Optional.<AddressEntry>empty(); Optional<AddressEntry> addressEntryOptional = Optional.empty();
AddressEntry changeAddressAddressEntry = null;
if (changeAddress != null) if (changeAddress != null)
addressEntryOptional = findAddressEntry(changeAddress, AddressEntry.Context.AVAILABLE); addressEntryOptional = findAddressEntry(changeAddress, AddressEntry.Context.AVAILABLE);
changeAddressAddressEntry = addressEntryOptional.orElseGet(() -> getFreshAddressEntry()); AddressEntry changeAddressAddressEntry = addressEntryOptional.orElseGet(this::getFreshAddressEntry);
checkNotNull(changeAddressAddressEntry, "change address must not be null"); checkNotNull(changeAddressAddressEntry, "change address must not be null");
sendRequest.changeAddress = changeAddressAddressEntry.getAddress(); sendRequest.changeAddress = changeAddressAddressEntry.getAddress();
return sendRequest; return sendRequest;

View file

@ -103,7 +103,7 @@ public class LockupTxService {
throws InsufficientMoneyException, WalletException, TransactionVerificationException, IOException { throws InsufficientMoneyException, WalletException, TransactionVerificationException, IOException {
byte[] opReturnData = BondConsensus.getLockupOpReturnData(lockTime, lockupReason, hash); byte[] opReturnData = BondConsensus.getLockupOpReturnData(lockTime, lockupReason, hash);
Transaction preparedTx = bsqWalletService.getPreparedLockupTx(lockupAmount); Transaction preparedTx = bsqWalletService.getPreparedLockupTx(lockupAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, true, opReturnData); Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, opReturnData);
Transaction transaction = bsqWalletService.signTx(txWithBtcFee); Transaction transaction = bsqWalletService.signTx(txWithBtcFee);
log.info("Lockup tx: " + transaction); log.info("Lockup tx: " + transaction);
return transaction; return transaction;

View file

@ -103,7 +103,7 @@ public class UnlockTxService {
checkArgument(optionalLockupTxOutput.isPresent(), "lockupTxOutput must be present"); checkArgument(optionalLockupTxOutput.isPresent(), "lockupTxOutput must be present");
TxOutput lockupTxOutput = optionalLockupTxOutput.get(); TxOutput lockupTxOutput = optionalLockupTxOutput.get();
Transaction preparedTx = bsqWalletService.getPreparedUnlockTx(lockupTxOutput); Transaction preparedTx = bsqWalletService.getPreparedUnlockTx(lockupTxOutput);
Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, true, null); Transaction txWithBtcFee = btcWalletService.completePreparedBsqTx(preparedTx, null);
Transaction transaction = bsqWalletService.signTx(txWithBtcFee); Transaction transaction = bsqWalletService.signTx(txWithBtcFee);
log.info("Unlock tx: " + transaction); log.info("Unlock tx: " + transaction);
return transaction; return transaction;

View file

@ -6,8 +6,6 @@ import bisq.proto.grpc.DisputeAgentsGrpc;
import bisq.proto.grpc.RegisterDisputeAgentReply; import bisq.proto.grpc.RegisterDisputeAgentReply;
import bisq.proto.grpc.RegisterDisputeAgentRequest; import bisq.proto.grpc.RegisterDisputeAgentRequest;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import javax.inject.Inject; import javax.inject.Inject;
@ -18,10 +16,12 @@ import lombok.extern.slf4j.Slf4j;
class GrpcDisputeAgentsService extends DisputeAgentsGrpc.DisputeAgentsImplBase { class GrpcDisputeAgentsService extends DisputeAgentsGrpc.DisputeAgentsImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcDisputeAgentsService(CoreApi coreApi) { public GrpcDisputeAgentsService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
@ -32,14 +32,8 @@ class GrpcDisputeAgentsService extends DisputeAgentsGrpc.DisputeAgentsImplBase {
var reply = RegisterDisputeAgentReply.newBuilder().build(); var reply = RegisterDisputeAgentReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
} }
} }
} }

View file

@ -0,0 +1,93 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.daemon.grpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.function.Predicate;
import lombok.extern.slf4j.Slf4j;
import static io.grpc.Status.INVALID_ARGUMENT;
import static io.grpc.Status.UNKNOWN;
/**
* The singleton instance of this class handles any expected core api Throwable by
* wrapping its message in a gRPC StatusRuntimeException and sending it to the client.
* An unexpected Throwable's message will be replaced with an 'unexpected' error message.
*/
@Singleton
@Slf4j
class GrpcExceptionHandler {
private final Predicate<Throwable> isExpectedException = (t) ->
t instanceof IllegalStateException || t instanceof IllegalArgumentException;
@Inject
public GrpcExceptionHandler() {
}
public void handleException(Throwable t, StreamObserver<?> responseObserver) {
// Log the core api error (this is last chance to do that), wrap it in a new
// gRPC StatusRuntimeException, then send it to the client in the gRPC response.
log.error("", t);
var grpcStatusRuntimeException = wrapException(t);
responseObserver.onError(grpcStatusRuntimeException);
throw grpcStatusRuntimeException;
}
private StatusRuntimeException wrapException(Throwable t) {
// We want to be careful about what kinds of exception messages we send to the
// client. Expected core exceptions should be wrapped in an IllegalStateException
// or IllegalArgumentException, with a consistently styled and worded error
// message. But only a small number of the expected error types are currently
// handled this way; there is much work to do to handle the variety of errors
// that can occur in the api. In the meantime, we take care to not pass full,
// unexpected error messages to the client. If the exception type is unexpected,
// we omit details from the gRPC exception sent to the client.
if (isExpectedException.test(t)) {
if (t.getCause() != null)
return new StatusRuntimeException(mapGrpcErrorStatus(t.getCause(), t.getCause().getMessage()));
else
return new StatusRuntimeException(mapGrpcErrorStatus(t, t.getMessage()));
} else {
return new StatusRuntimeException(mapGrpcErrorStatus(t, "unexpected error on server"));
}
}
private Status mapGrpcErrorStatus(Throwable t, String description) {
// We default to the UNKNOWN status, except were the mapping of a core api
// exception to a gRPC Status is obvious. If we ever use a gRPC reverse-proxy
// to support RESTful clients, we will need to have more specific mappings
// to support correct HTTP 1.1. status codes.
//noinspection SwitchStatementWithTooFewBranches
switch (t.getClass().getSimpleName()) {
// We go ahead and use a switch statement instead of if, in anticipation
// of more, specific exception mappings.
case "IllegalArgumentException":
return INVALID_ARGUMENT.withDescription(description);
default:
return UNKNOWN.withDescription(description);
}
}
}

View file

@ -16,22 +16,27 @@ import java.util.stream.Collectors;
class GrpcGetTradeStatisticsService extends GetTradeStatisticsGrpc.GetTradeStatisticsImplBase { class GrpcGetTradeStatisticsService extends GetTradeStatisticsGrpc.GetTradeStatisticsImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcGetTradeStatisticsService(CoreApi coreApi) { public GrpcGetTradeStatisticsService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
public void getTradeStatistics(GetTradeStatisticsRequest req, public void getTradeStatistics(GetTradeStatisticsRequest req,
StreamObserver<GetTradeStatisticsReply> responseObserver) { StreamObserver<GetTradeStatisticsReply> responseObserver) {
try {
var tradeStatistics = coreApi.getTradeStatistics().stream()
.map(TradeStatistics3::toProtoTradeStatistics3)
.collect(Collectors.toList());
var tradeStatistics = coreApi.getTradeStatistics().stream() var reply = GetTradeStatisticsReply.newBuilder().addAllTradeStatistics(tradeStatistics).build();
.map(TradeStatistics3::toProtoTradeStatistics3) responseObserver.onNext(reply);
.collect(Collectors.toList()); responseObserver.onCompleted();
} catch (Throwable cause) {
var reply = GetTradeStatisticsReply.newBuilder().addAllTradeStatistics(tradeStatistics).build(); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onNext(reply); }
responseObserver.onCompleted();
} }
} }

View file

@ -31,8 +31,6 @@ import bisq.proto.grpc.GetOffersReply;
import bisq.proto.grpc.GetOffersRequest; import bisq.proto.grpc.GetOffersRequest;
import bisq.proto.grpc.OffersGrpc; import bisq.proto.grpc.OffersGrpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import javax.inject.Inject; import javax.inject.Inject;
@ -48,10 +46,12 @@ import static bisq.core.api.model.OfferInfo.toOfferInfo;
class GrpcOffersService extends OffersGrpc.OffersImplBase { class GrpcOffersService extends OffersGrpc.OffersImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcOffersService(CoreApi coreApi) { public GrpcOffersService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
@ -64,26 +64,28 @@ class GrpcOffersService extends OffersGrpc.OffersImplBase {
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@Override @Override
public void getOffers(GetOffersRequest req, public void getOffers(GetOffersRequest req,
StreamObserver<GetOffersReply> responseObserver) { StreamObserver<GetOffersReply> responseObserver) {
List<OfferInfo> result = coreApi.getOffers(req.getDirection(), req.getCurrencyCode()) try {
.stream().map(OfferInfo::toOfferInfo) List<OfferInfo> result = coreApi.getOffers(req.getDirection(), req.getCurrencyCode())
.collect(Collectors.toList()); .stream().map(OfferInfo::toOfferInfo)
var reply = GetOffersReply.newBuilder() .collect(Collectors.toList());
.addAllOffers(result.stream() var reply = GetOffersReply.newBuilder()
.map(OfferInfo::toProtoMessage) .addAllOffers(result.stream()
.collect(Collectors.toList())) .map(OfferInfo::toProtoMessage)
.build(); .collect(Collectors.toList()))
responseObserver.onNext(reply); .build();
responseObserver.onCompleted(); responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (Throwable cause) {
exceptionHandler.handleException(cause, responseObserver);
}
} }
@Override @Override
@ -111,10 +113,8 @@ class GrpcOffersService extends OffersGrpc.OffersImplBase {
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
}); });
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -126,10 +126,8 @@ class GrpcOffersService extends OffersGrpc.OffersImplBase {
var reply = CancelOfferReply.newBuilder().build(); var reply = CancelOfferReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
} }

View file

@ -31,8 +31,6 @@ import bisq.proto.grpc.GetPaymentMethodsReply;
import bisq.proto.grpc.GetPaymentMethodsRequest; import bisq.proto.grpc.GetPaymentMethodsRequest;
import bisq.proto.grpc.PaymentAccountsGrpc; import bisq.proto.grpc.PaymentAccountsGrpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import javax.inject.Inject; import javax.inject.Inject;
@ -43,10 +41,12 @@ import java.util.stream.Collectors;
class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImplBase { class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcPaymentAccountsService(CoreApi coreApi) { public GrpcPaymentAccountsService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
@ -59,14 +59,8 @@ class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImpl
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
} }
} }
@ -81,14 +75,8 @@ class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImpl
.addAllPaymentAccounts(paymentAccounts).build(); .addAllPaymentAccounts(paymentAccounts).build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
} }
} }
@ -103,14 +91,8 @@ class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImpl
.addAllPaymentMethods(paymentMethods).build(); .addAllPaymentMethods(paymentMethods).build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
} }
} }
@ -124,14 +106,8 @@ class GrpcPaymentAccountsService extends PaymentAccountsGrpc.PaymentAccountsImpl
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
} }
} }
} }

View file

@ -23,8 +23,6 @@ import bisq.proto.grpc.MarketPriceReply;
import bisq.proto.grpc.MarketPriceRequest; import bisq.proto.grpc.MarketPriceRequest;
import bisq.proto.grpc.PriceGrpc; import bisq.proto.grpc.PriceGrpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import javax.inject.Inject; import javax.inject.Inject;
@ -35,10 +33,12 @@ import lombok.extern.slf4j.Slf4j;
class GrpcPriceService extends PriceGrpc.PriceImplBase { class GrpcPriceService extends PriceGrpc.PriceImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcPriceService(CoreApi coreApi) { public GrpcPriceService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
@ -49,10 +49,8 @@ class GrpcPriceService extends PriceGrpc.PriceImplBase {
var reply = MarketPriceReply.newBuilder().setPrice(price).build(); var reply = MarketPriceReply.newBuilder().setPrice(price).build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
} }

View file

@ -17,6 +17,7 @@
package bisq.daemon.grpc; package bisq.daemon.grpc;
import bisq.common.UserThread;
import bisq.common.config.Config; import bisq.common.config.Config;
import io.grpc.Server; import io.grpc.Server;
@ -30,6 +31,12 @@ import java.io.UncheckedIOException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import static io.grpc.ServerInterceptors.interceptForward;
import bisq.daemon.grpc.interceptor.PasswordAuthInterceptor;
@Singleton @Singleton
@Slf4j @Slf4j
public class GrpcServer { public class GrpcServer {
@ -48,13 +55,14 @@ public class GrpcServer {
GrpcTradesService tradesService, GrpcTradesService tradesService,
GrpcWalletsService walletsService) { GrpcWalletsService walletsService) {
this.server = ServerBuilder.forPort(config.apiPort) this.server = ServerBuilder.forPort(config.apiPort)
.executor(UserThread.getExecutor())
.addService(disputeAgentsService) .addService(disputeAgentsService)
.addService(offersService) .addService(offersService)
.addService(paymentAccountsService) .addService(paymentAccountsService)
.addService(priceService) .addService(priceService)
.addService(tradeStatisticsService) .addService(tradeStatisticsService)
.addService(tradesService) .addService(tradesService)
.addService(versionService) .addService(interceptForward(versionService, versionService.interceptors()))
.addService(walletsService) .addService(walletsService)
.intercept(passwordAuthInterceptor) .intercept(passwordAuthInterceptor)
.build(); .build();

View file

@ -35,8 +35,6 @@ import bisq.proto.grpc.TradesGrpc;
import bisq.proto.grpc.WithdrawFundsReply; import bisq.proto.grpc.WithdrawFundsReply;
import bisq.proto.grpc.WithdrawFundsRequest; import bisq.proto.grpc.WithdrawFundsRequest;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import javax.inject.Inject; import javax.inject.Inject;
@ -49,10 +47,12 @@ import static bisq.core.api.model.TradeInfo.toTradeInfo;
class GrpcTradesService extends TradesGrpc.TradesImplBase { class GrpcTradesService extends TradesGrpc.TradesImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcTradesService(CoreApi coreApi) { public GrpcTradesService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
@ -66,10 +66,8 @@ class GrpcTradesService extends TradesGrpc.TradesImplBase {
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -88,10 +86,8 @@ class GrpcTradesService extends TradesGrpc.TradesImplBase {
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
}); });
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -103,10 +99,8 @@ class GrpcTradesService extends TradesGrpc.TradesImplBase {
var reply = ConfirmPaymentStartedReply.newBuilder().build(); var reply = ConfirmPaymentStartedReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -118,10 +112,8 @@ class GrpcTradesService extends TradesGrpc.TradesImplBase {
var reply = ConfirmPaymentReceivedReply.newBuilder().build(); var reply = ConfirmPaymentReceivedReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -133,10 +125,8 @@ class GrpcTradesService extends TradesGrpc.TradesImplBase {
var reply = KeepFundsReply.newBuilder().build(); var reply = KeepFundsReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -144,15 +134,12 @@ class GrpcTradesService extends TradesGrpc.TradesImplBase {
public void withdrawFunds(WithdrawFundsRequest req, public void withdrawFunds(WithdrawFundsRequest req,
StreamObserver<WithdrawFundsReply> responseObserver) { StreamObserver<WithdrawFundsReply> responseObserver) {
try { try {
//TODO @ghubstan Feel free to add a memo param for withdrawal requests (was just added in UI) coreApi.withdrawFunds(req.getTradeId(), req.getAddress(), req.getMemo());
coreApi.withdrawFunds(req.getTradeId(), req.getAddress(), null);
var reply = WithdrawFundsReply.newBuilder().build(); var reply = WithdrawFundsReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException | IllegalArgumentException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
} }

View file

@ -1,3 +1,20 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.daemon.grpc; package bisq.daemon.grpc;
import bisq.core.api.CoreApi; import bisq.core.api.CoreApi;
@ -6,23 +23,64 @@ import bisq.proto.grpc.GetVersionGrpc;
import bisq.proto.grpc.GetVersionReply; import bisq.proto.grpc.GetVersionReply;
import bisq.proto.grpc.GetVersionRequest; import bisq.proto.grpc.GetVersionRequest;
import io.grpc.ServerInterceptor;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import javax.inject.Inject; import javax.inject.Inject;
class GrpcVersionService extends GetVersionGrpc.GetVersionImplBase { import com.google.common.annotations.VisibleForTesting;
import java.util.HashMap;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import static bisq.daemon.grpc.interceptor.GrpcServiceRateMeteringConfig.getCustomRateMeteringInterceptor;
import static java.util.concurrent.TimeUnit.SECONDS;
import bisq.daemon.grpc.interceptor.CallRateMeteringInterceptor;
import bisq.daemon.grpc.interceptor.GrpcCallRateMeter;
@VisibleForTesting
@Slf4j
public class GrpcVersionService extends GetVersionGrpc.GetVersionImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcVersionService(CoreApi coreApi) { public GrpcVersionService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
public void getVersion(GetVersionRequest req, StreamObserver<GetVersionReply> responseObserver) { public void getVersion(GetVersionRequest req, StreamObserver<GetVersionReply> responseObserver) {
var reply = GetVersionReply.newBuilder().setVersion(coreApi.getVersion()).build(); try {
responseObserver.onNext(reply); var reply = GetVersionReply.newBuilder().setVersion(coreApi.getVersion()).build();
responseObserver.onCompleted(); responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (Throwable cause) {
exceptionHandler.handleException(cause, responseObserver);
}
}
final ServerInterceptor[] interceptors() {
Optional<ServerInterceptor> rateMeteringInterceptor = rateMeteringInterceptor();
return rateMeteringInterceptor.map(serverInterceptor ->
new ServerInterceptor[]{serverInterceptor}).orElseGet(() -> new ServerInterceptor[0]);
}
final Optional<ServerInterceptor> rateMeteringInterceptor() {
@SuppressWarnings("unused") // Defined as a usage example.
CallRateMeteringInterceptor defaultCallRateMeteringInterceptor =
new CallRateMeteringInterceptor(new HashMap<>() {{
put("getVersion", new GrpcCallRateMeter(100, SECONDS));
}});
return getCustomRateMeteringInterceptor(coreApi.getConfig().appDataDir, this.getClass())
.or(Optional::empty /* Optional.of(defaultCallRateMeteringInterceptor) */);
} }
} }

View file

@ -29,6 +29,8 @@ import bisq.proto.grpc.GetBalancesReply;
import bisq.proto.grpc.GetBalancesRequest; import bisq.proto.grpc.GetBalancesRequest;
import bisq.proto.grpc.GetFundingAddressesReply; import bisq.proto.grpc.GetFundingAddressesReply;
import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetFundingAddressesRequest;
import bisq.proto.grpc.GetTransactionReply;
import bisq.proto.grpc.GetTransactionRequest;
import bisq.proto.grpc.GetTxFeeRateReply; import bisq.proto.grpc.GetTxFeeRateReply;
import bisq.proto.grpc.GetTxFeeRateRequest; import bisq.proto.grpc.GetTxFeeRateRequest;
import bisq.proto.grpc.GetUnusedBsqAddressReply; import bisq.proto.grpc.GetUnusedBsqAddressReply;
@ -39,6 +41,8 @@ import bisq.proto.grpc.RemoveWalletPasswordReply;
import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SendBsqReply; import bisq.proto.grpc.SendBsqReply;
import bisq.proto.grpc.SendBsqRequest; import bisq.proto.grpc.SendBsqRequest;
import bisq.proto.grpc.SendBtcReply;
import bisq.proto.grpc.SendBtcRequest;
import bisq.proto.grpc.SetTxFeeRatePreferenceReply; import bisq.proto.grpc.SetTxFeeRatePreferenceReply;
import bisq.proto.grpc.SetTxFeeRatePreferenceRequest; import bisq.proto.grpc.SetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.SetWalletPasswordReply; import bisq.proto.grpc.SetWalletPasswordReply;
@ -49,27 +53,33 @@ import bisq.proto.grpc.UnsetTxFeeRatePreferenceReply;
import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest; import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.WalletsGrpc; import bisq.proto.grpc.WalletsGrpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import org.bitcoinj.core.Transaction; import org.bitcoinj.core.Transaction;
import javax.inject.Inject; import javax.inject.Inject;
import com.google.common.util.concurrent.FutureCallback;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import static bisq.core.api.model.TxInfo.toTxInfo;
@Slf4j @Slf4j
class GrpcWalletsService extends WalletsGrpc.WalletsImplBase { class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
private final CoreApi coreApi; private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject @Inject
public GrpcWalletsService(CoreApi coreApi) { public GrpcWalletsService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi; this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
} }
@Override @Override
@ -81,10 +91,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -97,10 +105,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
.setAddressBalanceInfo(balanceInfo.toProtoMessage()).build(); .setAddressBalanceInfo(balanceInfo.toProtoMessage()).build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -117,10 +123,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -134,10 +138,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
.build(); .build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -145,28 +147,69 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
public void sendBsq(SendBsqRequest req, public void sendBsq(SendBsqRequest req,
StreamObserver<SendBsqReply> responseObserver) { StreamObserver<SendBsqReply> responseObserver) {
try { try {
coreApi.sendBsq(req.getAddress(), req.getAmount(), new TxBroadcaster.Callback() { coreApi.sendBsq(req.getAddress(),
@Override req.getAmount(),
public void onSuccess(Transaction tx) { req.getTxFeeRate(),
log.info("Successfully published BSQ tx: id {}, output sum {} sats, fee {} sats, size {} bytes", new TxBroadcaster.Callback() {
tx.getTxId().toString(), @Override
tx.getOutputSum(), public void onSuccess(Transaction tx) {
tx.getFee(), log.info("Successfully published BSQ tx: id {}, output sum {} sats, fee {} sats, size {} bytes",
tx.getMessageSize()); tx.getTxId().toString(),
var reply = SendBsqReply.newBuilder().build(); tx.getOutputSum(),
responseObserver.onNext(reply); tx.getFee(),
responseObserver.onCompleted(); tx.getMessageSize());
} var reply = SendBsqReply.newBuilder()
.setTxInfo(toTxInfo(tx).toProtoMessage())
.build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
@Override @Override
public void onFailure(TxBroadcastException ex) { public void onFailure(TxBroadcastException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
} }
}); });
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex); }
throw ex; }
@Override
public void sendBtc(SendBtcRequest req,
StreamObserver<SendBtcReply> responseObserver) {
try {
coreApi.sendBtc(req.getAddress(),
req.getAmount(),
req.getTxFeeRate(),
req.getMemo(),
new FutureCallback<>() {
@Override
public void onSuccess(Transaction tx) {
if (tx != null) {
log.info("Successfully published BTC tx: id {}, output sum {} sats, fee {} sats, size {} bytes",
tx.getTxId().toString(),
tx.getOutputSum(),
tx.getFee(),
tx.getMessageSize());
var reply = SendBtcReply.newBuilder()
.setTxInfo(toTxInfo(tx).toProtoMessage())
.build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} else {
throw new IllegalStateException("btc transaction is null");
}
}
@Override
public void onFailure(@NotNull Throwable t) {
log.error("", t);
throw new IllegalStateException(t);
}
});
} catch (Throwable cause) {
exceptionHandler.handleException(cause, responseObserver);
} }
} }
@ -182,10 +225,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
}); });
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -201,10 +242,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
}); });
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -220,10 +259,23 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
}); });
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex); }
throw ex; }
@Override
public void getTransaction(GetTransactionRequest req,
StreamObserver<GetTransactionReply> responseObserver) {
try {
Transaction tx = coreApi.getTransaction(req.getTxId());
var reply = GetTransactionReply.newBuilder()
.setTxInfo(toTxInfo(tx).toProtoMessage())
.build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (Throwable cause) {
exceptionHandler.handleException(cause, responseObserver);
} }
} }
@ -235,10 +287,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
var reply = SetWalletPasswordReply.newBuilder().build(); var reply = SetWalletPasswordReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -250,10 +300,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
var reply = RemoveWalletPasswordReply.newBuilder().build(); var reply = RemoveWalletPasswordReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -265,10 +313,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
var reply = LockWalletReply.newBuilder().build(); var reply = LockWalletReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
@ -280,10 +326,8 @@ class GrpcWalletsService extends WalletsGrpc.WalletsImplBase {
var reply = UnlockWalletReply.newBuilder().build(); var reply = UnlockWalletReply.newBuilder().build();
responseObserver.onNext(reply); responseObserver.onNext(reply);
responseObserver.onCompleted(); responseObserver.onCompleted();
} catch (IllegalStateException cause) { } catch (Throwable cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage())); exceptionHandler.handleException(cause, responseObserver);
responseObserver.onError(ex);
throw ex;
} }
} }
} }

View file

@ -0,0 +1,127 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.daemon.grpc.interceptor;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.StatusRuntimeException;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import static io.grpc.Status.PERMISSION_DENIED;
import static java.lang.String.format;
import static java.util.stream.Collectors.joining;
@Slf4j
public final class CallRateMeteringInterceptor implements ServerInterceptor {
// Maps the gRPC server method names to rate meters. This allows one interceptor
// instance to handle rate metering for any or all the methods in a Grpc*Service.
protected final Map<String, GrpcCallRateMeter> serviceCallRateMeters;
public CallRateMeteringInterceptor(Map<String, GrpcCallRateMeter> serviceCallRateMeters) {
this.serviceCallRateMeters = serviceCallRateMeters;
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall,
Metadata headers,
ServerCallHandler<ReqT, RespT> serverCallHandler) {
Optional<Map.Entry<String, GrpcCallRateMeter>> rateMeterKV = getRateMeterKV(serverCall);
rateMeterKV.ifPresentOrElse(
(kv) -> checkRateMeterAndMaybeCloseCall(kv, serverCall),
() -> handleMissingRateMeterConfiguration(serverCall));
// We leave it to the gRPC framework to clean up if the server call was closed
// above. But we still have to invoke startCall here because the method must
// return a ServerCall.Listener<RequestT>.
return serverCallHandler.startCall(serverCall, headers);
}
private void checkRateMeterAndMaybeCloseCall(Map.Entry<String, GrpcCallRateMeter> rateMeterKV,
ServerCall<?, ?> serverCall) {
String methodName = rateMeterKV.getKey();
GrpcCallRateMeter rateMeter = rateMeterKV.getValue();
if (!rateMeter.checkAndIncrement())
handlePermissionDeniedWarningAndCloseCall(methodName, rateMeter, serverCall);
else
log.info(rateMeter.getCallsCountProgress(methodName));
}
private void handleMissingRateMeterConfiguration(ServerCall<?, ?> serverCall)
throws StatusRuntimeException {
log.debug("The gRPC service's call rate metering interceptor does not"
+ " meter the {} method.",
getRateMeterKey(serverCall));
}
private void handlePermissionDeniedWarningAndCloseCall(String methodName,
GrpcCallRateMeter rateMeter,
ServerCall<?, ?> serverCall)
throws StatusRuntimeException {
String msg = getDefaultRateExceededError(methodName, rateMeter);
log.warn(StringUtils.capitalize(msg) + ".");
serverCall.close(PERMISSION_DENIED.withDescription(msg), new Metadata());
}
private String getDefaultRateExceededError(String methodName,
GrpcCallRateMeter rateMeter) {
// The derived method name may not be an exact match to CLI's method name.
String timeUnitName = StringUtils.chop(rateMeter.getTimeUnit().name().toLowerCase());
return format("the maximum allowed number of %s calls (%d/%s) has been exceeded",
methodName.toLowerCase(),
rateMeter.getAllowedCallsPerTimeWindow(),
timeUnitName);
}
private Optional<Map.Entry<String, GrpcCallRateMeter>> getRateMeterKV(ServerCall<?, ?> serverCall) {
String rateMeterKey = getRateMeterKey(serverCall);
return serviceCallRateMeters.entrySet().stream()
.filter((e) -> e.getKey().equals(rateMeterKey)).findFirst();
}
private String getRateMeterKey(ServerCall<?, ?> serverCall) {
// Get the rate meter map key from the full rpc service name. The key name
// is hard coded in the Grpc*Service interceptors() method.
String fullServiceName = serverCall.getMethodDescriptor().getServiceName();
return StringUtils.uncapitalize(Objects.requireNonNull(fullServiceName)
.substring("io.bisq.protobuffer.".length()));
}
@Override
public String toString() {
String rateMetersString =
serviceCallRateMeters.entrySet()
.stream()
.map(Object::toString)
.collect(joining("\n\t\t"));
return "CallRateMeteringInterceptor {" + "\n\t" +
"serviceCallRateMeters {" + "\n\t\t" +
rateMetersString + "\n\t" + "}" + "\n"
+ "}";
}
}

View file

@ -0,0 +1,92 @@
package bisq.daemon.grpc.interceptor;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayDeque;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import static java.lang.String.format;
import static java.lang.System.currentTimeMillis;
@Slf4j
public class GrpcCallRateMeter {
@Getter
private final int allowedCallsPerTimeWindow;
@Getter
private final TimeUnit timeUnit;
@Getter
private final int numTimeUnits;
@Getter
private transient final long timeUnitIntervalInMilliseconds;
private transient final ArrayDeque<Long> callTimestamps;
public GrpcCallRateMeter(int allowedCallsPerTimeWindow, TimeUnit timeUnit) {
this(allowedCallsPerTimeWindow, timeUnit, 1);
}
public GrpcCallRateMeter(int allowedCallsPerTimeWindow, TimeUnit timeUnit, int numTimeUnits) {
this.allowedCallsPerTimeWindow = allowedCallsPerTimeWindow;
this.timeUnit = timeUnit;
this.numTimeUnits = numTimeUnits;
this.timeUnitIntervalInMilliseconds = timeUnit.toMillis(1) * numTimeUnits;
this.callTimestamps = new ArrayDeque<>();
}
public boolean checkAndIncrement() {
if (getCallsCount() < allowedCallsPerTimeWindow) {
incrementCallsCount();
return true;
} else {
return false;
}
}
public int getCallsCount() {
removeStaleCallTimestamps();
return callTimestamps.size();
}
public String getCallsCountProgress(String calledMethodName) {
String shortTimeUnitName = StringUtils.chop(timeUnit.name().toLowerCase());
return format("%s has been called %d time%s in the last %s, rate limit is %d/%s",
calledMethodName,
callTimestamps.size(),
callTimestamps.size() == 1 ? "" : "s",
shortTimeUnitName,
allowedCallsPerTimeWindow,
shortTimeUnitName);
}
private void incrementCallsCount() {
callTimestamps.add(currentTimeMillis());
}
private void removeStaleCallTimestamps() {
while (!callTimestamps.isEmpty() && isStale.test(callTimestamps.peek())) {
callTimestamps.remove();
}
}
private final Predicate<Long> isStale = (t) -> {
long stale = currentTimeMillis() - this.getTimeUnitIntervalInMilliseconds();
// Is the given timestamp before the current time minus 1 timeUnit in millis?
return t < stale;
};
@Override
public String toString() {
return "GrpcCallRateMeter{" +
"allowedCallsPerTimeWindow=" + allowedCallsPerTimeWindow +
", timeUnit=" + timeUnit.name() +
", timeUnitIntervalInMilliseconds=" + timeUnitIntervalInMilliseconds +
", callsCount=" + callTimestamps.size() +
'}';
}
}

View file

@ -0,0 +1,287 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.daemon.grpc.interceptor;
import io.grpc.ServerInterceptor;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.common.annotations.VisibleForTesting;
import java.nio.file.Paths;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import static bisq.common.file.FileUtil.deleteFileIfExists;
import static bisq.common.file.FileUtil.renameFile;
import static com.google.common.base.Preconditions.checkNotNull;
import static java.lang.String.format;
import static java.lang.System.getProperty;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.Files.readAllBytes;
@VisibleForTesting
@Slf4j
public class GrpcServiceRateMeteringConfig {
public static final String RATE_METERS_CONFIG_FILENAME = "ratemeters.json";
private static final String KEY_GRPC_SERVICE_CLASS_NAME = "grpcServiceClassName";
private static final String KEY_METHOD_RATE_METERS = "methodRateMeters";
private static final String KEY_ALLOWED_CALL_PER_TIME_WINDOW = "allowedCallsPerTimeWindow";
private static final String KEY_TIME_UNIT = "timeUnit";
private static final String KEY_NUM_TIME_UNITS = "numTimeUnits";
private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private final List<Map<String, GrpcCallRateMeter>> methodRateMeters;
private final String grpcServiceClassName;
public GrpcServiceRateMeteringConfig(String grpcServiceClassName) {
this(grpcServiceClassName, new ArrayList<>());
}
public GrpcServiceRateMeteringConfig(String grpcServiceClassName,
List<Map<String, GrpcCallRateMeter>> methodRateMeters) {
this.grpcServiceClassName = grpcServiceClassName;
this.methodRateMeters = methodRateMeters;
}
public GrpcServiceRateMeteringConfig addMethodCallRateMeter(String methodName,
int maxCalls,
TimeUnit timeUnit) {
return addMethodCallRateMeter(methodName, maxCalls, timeUnit, 1);
}
public GrpcServiceRateMeteringConfig addMethodCallRateMeter(String methodName,
int maxCalls,
TimeUnit timeUnit,
int numTimeUnits) {
methodRateMeters.add(new LinkedHashMap<>() {{
put(methodName, new GrpcCallRateMeter(maxCalls, timeUnit, numTimeUnits));
}});
return this;
}
public boolean isConfigForGrpcService(Class<?> clazz) {
return isConfigForGrpcService(clazz.getSimpleName());
}
public boolean isConfigForGrpcService(String grpcServiceClassSimpleName) {
return this.grpcServiceClassName.equals(grpcServiceClassSimpleName);
}
@Override
public String toString() {
return "GrpcServiceRateMeteringConfig{" + "\n" +
" grpcServiceClassName='" + grpcServiceClassName + '\'' + "\n" +
", methodRateMeters=" + methodRateMeters + "\n" +
'}';
}
public static Optional<ServerInterceptor> getCustomRateMeteringInterceptor(File installationDir,
Class<?> grpcServiceClass) {
File configFile = new File(installationDir, RATE_METERS_CONFIG_FILENAME);
return configFile.exists()
? toServerInterceptor(configFile, grpcServiceClass)
: Optional.empty();
}
public static Optional<ServerInterceptor> toServerInterceptor(File configFile, Class<?> grpcServiceClass) {
// From a global rate metering config file, create a specific gRPC service
// interceptor configuration in the form of an interceptor constructor argument,
// a map<method-name, rate-meter>.
// Transforming json into the List<Map<String, GrpcCallRateMeter>> is a bit
// convoluted due to Gson's loss of generic type information during deserialization.
Optional<GrpcServiceRateMeteringConfig> grpcServiceConfig = getAllDeserializedConfigs(configFile)
.stream().filter(x -> x.isConfigForGrpcService(grpcServiceClass)).findFirst();
if (grpcServiceConfig.isPresent()) {
Map<String, GrpcCallRateMeter> serviceCallRateMeters = new HashMap<>();
for (Map<String, GrpcCallRateMeter> methodToRateMeterMap : grpcServiceConfig.get().methodRateMeters) {
Map.Entry<String, GrpcCallRateMeter> entry = methodToRateMeterMap.entrySet().stream().findFirst().orElseThrow(()
-> new IllegalStateException("Gson deserialized a method rate meter configuration into an empty map."));
serviceCallRateMeters.put(entry.getKey(), entry.getValue());
}
return Optional.of(new CallRateMeteringInterceptor(serviceCallRateMeters));
} else {
return Optional.empty();
}
}
@SuppressWarnings("unchecked")
private static List<Map<String, GrpcCallRateMeter>> getMethodRateMetersMap(Map<String, Object> gsonMap) {
List<Map<String, GrpcCallRateMeter>> rateMeters = new ArrayList<>();
// Each gsonMap is a Map<String, Object> with a single entry:
// {getVersion={allowedCallsPerTimeUnit=8.0, timeUnit=SECONDS, callsCount=0.0, isRunning=false}}
// Convert it to a multiple entry Map<String, GrpcCallRateMeter>, where the key
// is a method name.
for (Map<String, Object> singleEntryRateMeterMap : (List<Map<String, Object>>) gsonMap.get(KEY_METHOD_RATE_METERS)) {
log.debug("Gson's single entry {} {}<String, Object> = {}",
gsonMap.get(KEY_GRPC_SERVICE_CLASS_NAME),
singleEntryRateMeterMap.getClass().getSimpleName(),
singleEntryRateMeterMap);
Map.Entry<String, Object> entry = singleEntryRateMeterMap.entrySet().stream().findFirst().orElseThrow(()
-> new IllegalStateException("Gson deserialized a method rate meter configuration into an empty map."));
String methodName = entry.getKey();
GrpcCallRateMeter rateMeter = getGrpcCallRateMeter(entry);
rateMeters.add(new LinkedHashMap<>() {{
put(methodName, rateMeter);
}});
}
return rateMeters;
}
@SuppressWarnings({"rawtypes", "unchecked"})
public static List<GrpcServiceRateMeteringConfig> deserialize(File configFile) {
verifyConfigFile(configFile);
List<GrpcServiceRateMeteringConfig> serviceMethodConfigurations = new ArrayList<>();
// Gson cannot deserialize a json string to List<GrpcServiceRateMeteringConfig>
// so easily for us, so we do it here before returning the list of configurations.
List rawConfigList = gson.fromJson(toJson(configFile), ArrayList.class);
// Gson gave us a list of maps with keys grpcServiceClassName, methodRateMeters:
// String grpcServiceClassName
// List<Map> methodRateMeters
for (Object rawConfig : rawConfigList) {
Map<String, Object> gsonMap = (Map<String, Object>) rawConfig;
String grpcServiceClassName = (String) gsonMap.get(KEY_GRPC_SERVICE_CLASS_NAME);
List<Map<String, GrpcCallRateMeter>> rateMeters = getMethodRateMetersMap(gsonMap);
serviceMethodConfigurations.add(new GrpcServiceRateMeteringConfig(grpcServiceClassName, rateMeters));
}
return serviceMethodConfigurations;
}
@SuppressWarnings("unchecked")
private static GrpcCallRateMeter getGrpcCallRateMeter(Map.Entry<String, Object> gsonEntry) {
Map<String, Object> valueMap = (Map<String, Object>) gsonEntry.getValue();
int allowedCallsPerTimeWindow = ((Number) valueMap.get(KEY_ALLOWED_CALL_PER_TIME_WINDOW)).intValue();
TimeUnit timeUnit = TimeUnit.valueOf((String) valueMap.get(KEY_TIME_UNIT));
int numTimeUnits = ((Number) valueMap.get(KEY_NUM_TIME_UNITS)).intValue();
return new GrpcCallRateMeter(allowedCallsPerTimeWindow, timeUnit, numTimeUnits);
}
private static void verifyConfigFile(File configFile) {
if (configFile == null)
throw new IllegalStateException("Cannot read null json config file.");
if (!configFile.exists())
throw new IllegalStateException(format("cannot find json config file %s", configFile.getAbsolutePath()));
}
private static String toJson(File configFile) {
try {
return new String(readAllBytes(Paths.get(configFile.getAbsolutePath())));
} catch (IOException ex) {
throw new IllegalStateException(format("Cannot read json string from file %s.",
configFile.getAbsolutePath()));
}
}
private static List<GrpcServiceRateMeteringConfig> allDeserializedConfigs;
private static List<GrpcServiceRateMeteringConfig> getAllDeserializedConfigs(File configFile) {
// We deserialize once, not for each gRPC service wanting an interceptor.
if (allDeserializedConfigs == null)
allDeserializedConfigs = deserialize(configFile);
return allDeserializedConfigs;
}
@VisibleForTesting
public static class Builder {
private final List<GrpcServiceRateMeteringConfig> rateMeterConfigs = new ArrayList<>();
public void addCallRateMeter(String grpcServiceClassName,
String methodName,
int maxCalls,
TimeUnit timeUnit) {
addCallRateMeter(grpcServiceClassName,
methodName,
maxCalls,
timeUnit,
1);
}
public void addCallRateMeter(String grpcServiceClassName,
String methodName,
int maxCalls,
TimeUnit timeUnit,
int numTimeUnits) {
log.info("Adding call rate metering definition {}.{} ({}/{}ms).",
grpcServiceClassName,
methodName,
maxCalls,
timeUnit.toMillis(1) * numTimeUnits);
rateMeterConfigs.stream().filter(c -> c.isConfigForGrpcService(grpcServiceClassName))
.findFirst().ifPresentOrElse(
(config) -> config.addMethodCallRateMeter(methodName, maxCalls, timeUnit, numTimeUnits),
() -> rateMeterConfigs.add(new GrpcServiceRateMeteringConfig(grpcServiceClassName)
.addMethodCallRateMeter(methodName, maxCalls, timeUnit, numTimeUnits)));
}
public File build() {
File tmpFile = serializeRateMeterDefinitions();
File configFile = Paths.get(getProperty("java.io.tmpdir"), "ratemeters.json").toFile();
try {
deleteFileIfExists(configFile);
renameFile(tmpFile, configFile);
} catch (IOException ex) {
throw new IllegalStateException(format("Could not create config file %s.",
configFile.getAbsolutePath()), ex);
}
return configFile;
}
private File serializeRateMeterDefinitions() {
String json = gson.toJson(rateMeterConfigs);
File file = createTmpFile();
try (OutputStreamWriter outputStreamWriter =
new OutputStreamWriter(new FileOutputStream(checkNotNull(file), false), UTF_8)) {
outputStreamWriter.write(json);
} catch (Exception ex) {
throw new IllegalStateException(format("Cannot write file for json string %s.", json), ex);
}
return file;
}
private File createTmpFile() {
File file;
try {
file = File.createTempFile("ratemeters_",
".tmp",
Paths.get(getProperty("java.io.tmpdir")).toFile());
} catch (IOException ex) {
throw new IllegalStateException("Cannot create tmp ratemeters json file.", ex);
}
return file;
}
}
}

View file

@ -15,7 +15,7 @@
* along with Bisq. If not, see <http://www.gnu.org/licenses/>. * along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/ */
package bisq.daemon.grpc; package bisq.daemon.grpc.interceptor;
import bisq.common.config.Config; import bisq.common.config.Config;
@ -38,7 +38,7 @@ import static java.lang.String.format;
* *
* @see bisq.common.config.Config#apiPassword * @see bisq.common.config.Config#apiPassword
*/ */
class PasswordAuthInterceptor implements ServerInterceptor { public class PasswordAuthInterceptor implements ServerInterceptor {
private static final String PASSWORD_KEY = "password"; private static final String PASSWORD_KEY = "password";
@ -50,7 +50,8 @@ class PasswordAuthInterceptor implements ServerInterceptor {
} }
@Override @Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata headers, public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall,
Metadata headers,
ServerCallHandler<ReqT, RespT> serverCallHandler) { ServerCallHandler<ReqT, RespT> serverCallHandler) {
var actualPasswordValue = headers.get(Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER)); var actualPasswordValue = headers.get(Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER));

View file

@ -0,0 +1,190 @@
/*
* This file is part of Bisq.
*
* Bisq 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.
*
* Bisq 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 Bisq. If not, see <http://www.gnu.org/licenses/>.
*/
package bisq.daemon.grpc.interceptor;
import io.grpc.ServerInterceptor;
import java.nio.file.Paths;
import java.io.File;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import static bisq.daemon.grpc.interceptor.GrpcServiceRateMeteringConfig.getCustomRateMeteringInterceptor;
import static java.lang.System.getProperty;
import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.HOURS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import bisq.daemon.grpc.GrpcVersionService;
@Slf4j
public class GrpcServiceRateMeteringConfigTest {
private static final GrpcServiceRateMeteringConfig.Builder builder = new GrpcServiceRateMeteringConfig.Builder();
private static File configFile;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private static Optional<ServerInterceptor> versionServiceInterceptor;
@BeforeClass
public static void setup() {
// This is the tested rate meter, it allows 3 calls every 2 seconds.
builder.addCallRateMeter(GrpcVersionService.class.getSimpleName(),
"getVersion",
3,
SECONDS,
2);
builder.addCallRateMeter(GrpcVersionService.class.getSimpleName(),
"badMethodNameDoesNotBreakAnything",
100,
DAYS);
// The other Grpc*Service classes are not @VisibleForTesting, so we hardcode
// the simple class name.
builder.addCallRateMeter("GrpcOffersService",
"createOffer",
5,
MINUTES);
builder.addCallRateMeter("GrpcOffersService",
"takeOffer",
10,
DAYS);
builder.addCallRateMeter("GrpcWalletsService",
"sendBtc",
3,
HOURS);
}
@Before
public void buildConfigFile() {
if (configFile == null)
configFile = builder.build();
}
@Test
public void testConfigFileBuild() {
assertNotNull(configFile);
assertTrue(configFile.exists());
assertTrue(configFile.length() > 0);
String expectedConfigFilePath = Paths.get(getProperty("java.io.tmpdir"), "ratemeters.json").toString();
assertEquals(expectedConfigFilePath, configFile.getAbsolutePath());
}
@Test
public void testGetVersionCallRateMeter() {
// Check the interceptor has 2 rate meters, for getVersion and badMethodNameDoesNotBreakAnything.
CallRateMeteringInterceptor versionServiceInterceptor = buildInterceptor();
assertEquals(2, versionServiceInterceptor.serviceCallRateMeters.size());
// Check the rate meter config.
GrpcCallRateMeter rateMeter = versionServiceInterceptor.serviceCallRateMeters.get("getVersion");
assertEquals(3, rateMeter.getAllowedCallsPerTimeWindow());
assertEquals(SECONDS, rateMeter.getTimeUnit());
assertEquals(2, rateMeter.getNumTimeUnits());
assertEquals(2 * 1000, rateMeter.getTimeUnitIntervalInMilliseconds());
// Do as many calls as allowed within rateMeter.getTimeUnitIntervalInMilliseconds().
doMaxIsAllowedChecks(true,
rateMeter.getAllowedCallsPerTimeWindow(),
rateMeter);
// The next 3 calls are blocked because we've exceeded the 3calls/2s limit.
doMaxIsAllowedChecks(false,
rateMeter.getAllowedCallsPerTimeWindow(),
rateMeter);
// Let all of the rate meter's cached call timestamps become stale by waiting for
// 2001 ms, then we can call getversion another 'allowedCallsPerTimeUnit' times.
rest(1 + rateMeter.getTimeUnitIntervalInMilliseconds());
// All the stale call timestamps are gone and the call count is back to zero.
assertEquals(0, rateMeter.getCallsCount());
doMaxIsAllowedChecks(true,
rateMeter.getAllowedCallsPerTimeWindow(),
rateMeter);
// We've exceeded the call/second limit.
assertFalse(rateMeter.checkAndIncrement());
// Let all of the call timestamps go stale again by waiting for 2001 ms.
rest(1 + rateMeter.getTimeUnitIntervalInMilliseconds());
// Call twice, resting 0.5s after each call.
for (int i = 0; i < 2; i++) {
assertTrue(rateMeter.checkAndIncrement());
rest(500);
}
// Call the 3rd time, then let one of the rate meter's timestamps go stale.
assertTrue(rateMeter.checkAndIncrement());
rest(1001);
// The call count was decremented by one because one timestamp went stale.
assertEquals(2, rateMeter.getCallsCount());
assertTrue(rateMeter.checkAndIncrement());
assertEquals(rateMeter.getAllowedCallsPerTimeWindow(), rateMeter.getCallsCount());
// We've exceeded the call limit again.
assertFalse(rateMeter.checkAndIncrement());
}
private void doMaxIsAllowedChecks(boolean expectedIsAllowed,
int expectedCallsCount,
GrpcCallRateMeter rateMeter) {
for (int i = 1; i <= rateMeter.getAllowedCallsPerTimeWindow(); i++) {
assertEquals(expectedIsAllowed, rateMeter.checkAndIncrement());
}
assertEquals(expectedCallsCount, rateMeter.getCallsCount());
}
@AfterClass
public static void teardown() {
if (configFile != null)
configFile.deleteOnExit();
}
private void rest(long milliseconds) {
try {
TimeUnit.MILLISECONDS.sleep(milliseconds);
} catch (InterruptedException ignored) {
}
}
private CallRateMeteringInterceptor buildInterceptor() {
//noinspection OptionalAssignedToNull
if (versionServiceInterceptor == null) {
versionServiceInterceptor = getCustomRateMeteringInterceptor(
configFile.getParentFile(),
GrpcVersionService.class);
}
assertTrue(versionServiceInterceptor.isPresent());
return (CallRateMeteringInterceptor) versionServiceInterceptor.get();
}
}

View file

@ -247,7 +247,7 @@ public class BsqSendView extends ActivatableView<GridPane, Void> implements BsqB
Coin receiverAmount = ParsingUtils.parseToCoin(amountInputTextField.getText(), bsqFormatter); Coin receiverAmount = ParsingUtils.parseToCoin(amountInputTextField.getText(), bsqFormatter);
try { try {
Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(receiversAddressString, receiverAmount); Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(receiversAddressString, receiverAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx);
Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); Transaction signedTx = bsqWalletService.signTx(txWithBtcFee);
Coin miningFee = signedTx.getFee(); Coin miningFee = signedTx.getFee();
int txVsize = signedTx.getVsize(); int txVsize = signedTx.getVsize();
@ -305,7 +305,7 @@ public class BsqSendView extends ActivatableView<GridPane, Void> implements BsqB
Coin receiverAmount = bsqFormatter.parseToBTC(btcAmountInputTextField.getText()); Coin receiverAmount = bsqFormatter.parseToBTC(btcAmountInputTextField.getText());
try { try {
Transaction preparedSendTx = bsqWalletService.getPreparedSendBtcTx(receiversAddressString, receiverAmount); Transaction preparedSendTx = bsqWalletService.getPreparedSendBtcTx(receiversAddressString, receiverAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true); Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx);
Transaction signedTx = bsqWalletService.signTx(txWithBtcFee); Transaction signedTx = bsqWalletService.signTx(txWithBtcFee);
Coin miningFee = signedTx.getFee(); Coin miningFee = signedTx.getFee();

View file

@ -255,6 +255,7 @@ message KeepFundsReply {
message WithdrawFundsRequest { message WithdrawFundsRequest {
string tradeId = 1; string tradeId = 1;
string address = 2; string address = 2;
string memo = 3;
} }
message WithdrawFundsReply { message WithdrawFundsReply {
@ -287,6 +288,27 @@ message TradeInfo {
string contractAsJson = 24; string contractAsJson = 24;
} }
///////////////////////////////////////////////////////////////////////////////////////////
// Transactions
///////////////////////////////////////////////////////////////////////////////////////////
message TxFeeRateInfo {
bool useCustomTxFeeRate = 1;
uint64 customTxFeeRate = 2;
uint64 feeServiceRate = 3;
uint64 lastFeeServiceRequestTs = 4;
}
message TxInfo {
string txId = 1;
uint64 inputSum = 2;
uint64 outputSum = 3;
uint64 fee = 4;
int32 size = 5;
bool isPending = 6;
string memo = 7;
}
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Wallets // Wallets
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
@ -300,11 +322,15 @@ service Wallets {
} }
rpc SendBsq (SendBsqRequest) returns (SendBsqReply) { rpc SendBsq (SendBsqRequest) returns (SendBsqReply) {
} }
rpc SendBtc (SendBtcRequest) returns (SendBtcReply) {
}
rpc GetTxFeeRate (GetTxFeeRateRequest) returns (GetTxFeeRateReply) { rpc GetTxFeeRate (GetTxFeeRateRequest) returns (GetTxFeeRateReply) {
} }
rpc SetTxFeeRatePreference (SetTxFeeRatePreferenceRequest) returns (SetTxFeeRatePreferenceReply) { rpc SetTxFeeRatePreference (SetTxFeeRatePreferenceRequest) returns (SetTxFeeRatePreferenceReply) {
} }
rpc unsetTxFeeRatePreference (UnsetTxFeeRatePreferenceRequest) returns (UnsetTxFeeRatePreferenceReply) { rpc UnsetTxFeeRatePreference (UnsetTxFeeRatePreferenceRequest) returns (UnsetTxFeeRatePreferenceReply) {
}
rpc GetTransaction (GetTransactionRequest) returns (GetTransactionReply) {
} }
rpc GetFundingAddresses (GetFundingAddressesRequest) returns (GetFundingAddressesReply) { rpc GetFundingAddresses (GetFundingAddressesRequest) returns (GetFundingAddressesReply) {
} }
@ -344,9 +370,22 @@ message GetUnusedBsqAddressReply {
message SendBsqRequest { message SendBsqRequest {
string address = 1; string address = 1;
string amount = 2; string amount = 2;
string txFeeRate = 3;
} }
message SendBsqReply { message SendBsqReply {
TxInfo txInfo = 1;
}
message SendBtcRequest {
string address = 1;
string amount = 2;
string txFeeRate = 3;
string memo = 4;
}
message SendBtcReply {
TxInfo txInfo = 1;
} }
message GetTxFeeRateRequest { message GetTxFeeRateRequest {
@ -371,6 +410,14 @@ message UnsetTxFeeRatePreferenceReply {
TxFeeRateInfo txFeeRateInfo = 1; TxFeeRateInfo txFeeRateInfo = 1;
} }
message GetTransactionRequest {
string txId = 1;
}
message GetTransactionReply {
TxInfo txInfo = 1;
}
message GetFundingAddressesRequest { message GetFundingAddressesRequest {
} }
@ -437,13 +484,6 @@ message AddressBalanceInfo {
int64 numConfirmations = 3; int64 numConfirmations = 3;
} }
message TxFeeRateInfo {
bool useCustomTxFeeRate = 1;
uint64 customTxFeeRate = 2;
uint64 feeServiceRate = 3;
uint64 lastFeeServiceRequestTs = 4;
}
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Version // Version
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////