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 {
log.info("Shutting down executor service ...");
executor.shutdownNow();
executor.awaitTermination(config.supportingApps.size() * 2000, MILLISECONDS);
//noinspection ResultOfMethodCallIgnored
executor.awaitTermination(config.supportingApps.size() * 2000L, MILLISECONDS);
SetupTask[] orderedTasks = new SetupTask[]{
bobNodeTask, aliceNodeTask, arbNodeTask, seedNodeTask, bitcoindTask};
@ -218,20 +219,25 @@ public class Scaffold {
if (copyBitcoinRegtestDir.run().getExitStatus() != 0)
throw new IllegalStateException("Could not install bitcoin regtest dir");
String aliceDataDir = daoSetupDir + "/" + alicedaemon.appName;
BashCommand copyAliceDataDir = new BashCommand(
"cp -rf " + daoSetupDir + "/" + alicedaemon.appName
+ " " + config.rootAppDataDir);
"cp -rf " + aliceDataDir + " " + config.rootAppDataDir);
if (copyAliceDataDir.run().getExitStatus() != 0)
throw new IllegalStateException("Could not install alice data dir");
String bobDataDir = daoSetupDir + "/" + bobdaemon.appName;
BashCommand copyBobDataDir = new BashCommand(
"cp -rf " + daoSetupDir + "/" + bobdaemon.appName
+ " " + config.rootAppDataDir);
"cp -rf " + bobDataDir + " " + config.rootAppDataDir);
if (copyBobDataDir.run().getExitStatus() != 0)
throw new IllegalStateException("Could not install bob data dir");
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
// 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
@ -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() {
// Background apps can be left running until the jvm is manually shutdown,
// 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 SHUTDOWN_AFTER_TESTS = "shutdownAfterTests";
static final String SUPPORTING_APPS = "supportingApps";
static final String CALL_RATE_METERING_CONFIG_PATH = "callRateMeteringConfigPath";
static final String ENABLE_BISQ_DEBUGGING = "enableBisqDebugging";
// Default values for certain options
@ -102,6 +103,7 @@ public class ApiTestConfig {
public final boolean skipTests;
public final boolean shutdownAfterTests;
public final List<String> supportingApps;
public final String callRateMeteringConfigPath;
public final boolean enableBisqDebugging;
// Immutable system configurations set in the constructor.
@ -228,6 +230,12 @@ public class ApiTestConfig {
.ofType(String.class)
.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 =
parser.accepts(ENABLE_BISQ_DEBUGGING,
"Start Bisq apps with remote debug options")
@ -289,6 +297,7 @@ public class ApiTestConfig {
this.skipTests = options.valueOf(skipTestsOpt);
this.shutdownAfterTests = options.valueOf(shutdownAfterTestsOpt);
this.supportingApps = asList(options.valueOf(supportingAppsOpt).split(","));
this.callRateMeteringConfigPath = options.valueOf(callRateMeteringConfigPathOpt);
this.enableBisqDebugging = options.valueOf(enableBisqDebuggingOpt);
// Assign values to special-case static fields.

View file

@ -19,6 +19,7 @@ package bisq.apitest;
import java.net.InetAddress;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
@ -72,6 +73,16 @@ public class ApiTestCase {
// gRPC service stubs are used by method & scenario tests, but not e2e tests.
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)
throws InterruptedException, ExecutionException, IOException {
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.GetPaymentMethodsRequest;
import bisq.proto.grpc.GetTradeRequest;
import bisq.proto.grpc.GetTransactionRequest;
import bisq.proto.grpc.GetTxFeeRateRequest;
import bisq.proto.grpc.GetUnusedBsqAddressRequest;
import bisq.proto.grpc.KeepFundsRequest;
@ -48,10 +49,12 @@ import bisq.proto.grpc.OfferInfo;
import bisq.proto.grpc.RegisterDisputeAgentRequest;
import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SendBsqRequest;
import bisq.proto.grpc.SendBtcRequest;
import bisq.proto.grpc.SetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.SetWalletPasswordRequest;
import bisq.proto.grpc.TakeOfferRequest;
import bisq.proto.grpc.TradeInfo;
import bisq.proto.grpc.TxInfo;
import bisq.proto.grpc.UnlockWalletRequest;
import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.WithdrawFundsRequest;
@ -64,6 +67,7 @@ import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
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 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,
boolean generateBtcBlock,
Enum<?>... supportingApps) {
try {
// To run Bisq apps in debug mode, use the other setUpScaffold method:
// setUpScaffold(new String[]{"--supportingApps", "bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon",
// "--enableBisqDebugging", "true"});
setUpScaffold(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);
setUpScaffold(new String[]{
"--supportingApps", toNameList.apply(supportingApps),
"--enableBisqDebugging", "false"
});
doPostStartup(registerDisputeAgents, generateBtcBlock, supportingApps);
} catch (Exception 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
protected final GetBalancesRequest createGetBalancesRequest(String currencyCode) {
return GetBalancesRequest.newBuilder().setCurrencyCode(currencyCode).build();
@ -160,8 +189,26 @@ public class MethodTest extends ApiTestCase {
return GetUnusedBsqAddressRequest.newBuilder().build();
}
protected final SendBsqRequest createSendBsqRequest(String address, String amount) {
return SendBsqRequest.newBuilder().setAddress(address).setAmount(amount).build();
protected final SendBsqRequest createSendBsqRequest(String address,
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() {
@ -208,10 +255,13 @@ public class MethodTest extends ApiTestCase {
.build();
}
protected final WithdrawFundsRequest createWithdrawFundsRequest(String tradeId, String address) {
protected final WithdrawFundsRequest createWithdrawFundsRequest(String tradeId,
String address,
String memo) {
return WithdrawFundsRequest.newBuilder()
.setTradeId(tradeId)
.setAddress(address)
.setMemo(memo)
.build();
}
@ -247,9 +297,36 @@ public class MethodTest extends ApiTestCase {
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
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) {
@ -354,8 +431,11 @@ public class MethodTest extends ApiTestCase {
}
@SuppressWarnings("ResultOfMethodCallIgnored")
protected final void withdrawFunds(BisqAppConfig bisqAppConfig, String tradeId, String address) {
var req = createWithdrawFundsRequest(tradeId, address);
protected final void withdrawFunds(BisqAppConfig bisqAppConfig,
String tradeId,
String address,
String memo) {
var req = createWithdrawFundsRequest(tradeId, address, memo);
grpcStubs(bisqAppConfig).tradesService.withdrawFunds(req);
}
@ -379,6 +459,11 @@ public class MethodTest extends ApiTestCase {
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.
protected static RegisterDisputeAgentRequest createRegisterDisputeAgentRequest(String disputeAgentType) {

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package bisq.apitest.method.wallet;
import bisq.proto.grpc.BtcBalanceInfo;
import bisq.proto.grpc.TxInfo;
import lombok.extern.slf4j.Slf4j;
@ -20,6 +21,8 @@ import static bisq.cli.TableFormat.formatAddressBalanceTbl;
import static bisq.cli.TableFormat.formatBtcBalanceInfoTbl;
import static java.util.Collections.singletonList;
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;
@ -31,6 +34,8 @@ import bisq.apitest.method.MethodTest;
@TestMethodOrder(OrderAnnotation.class)
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
// are initialized with 10 BTC during the scaffolding setup.
private static final bisq.core.api.model.BtcBalanceInfo INITIAL_BTC_BALANCES =
@ -92,6 +97,50 @@ public class BtcWalletTest extends MethodTest {
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
public static void tearDown() {
tearDownScaffold();

View file

@ -17,6 +17,8 @@
package bisq.apitest.scenario;
import java.io.File;
import lombok.extern.slf4j.Slf4j;
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.arbdaemon;
import static bisq.apitest.config.BisqAppConfig.seednode;
import static bisq.apitest.method.CallRateMeteringInterceptorTest.buildInterceptorConfigFile;
import static org.junit.jupiter.api.Assertions.fail;
import bisq.apitest.method.CallRateMeteringInterceptorTest;
import bisq.apitest.method.GetVersionTest;
import bisq.apitest.method.MethodTest;
import bisq.apitest.method.RegisterDisputeAgentsTest;
@ -46,7 +50,11 @@ public class StartupTest extends MethodTest {
@BeforeAll
public static void setUp() {
try {
setUpScaffold(bitcoind, seednode, arbdaemon, alicedaemon);
File callRateMeteringConfigFile = buildInterceptorConfigFile();
startSupportingApps(callRateMeteringConfigFile,
false,
false,
bitcoind, seednode, arbdaemon, alicedaemon);
} catch (Exception ex) {
fail(ex);
}
@ -54,13 +62,27 @@ public class StartupTest extends MethodTest {
@Test
@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() {
GetVersionTest test = new GetVersionTest();
test.testGetVersion();
}
@Test
@Order(2)
@Order(3)
public void testRegisterDisputeAgents() {
RegisterDisputeAgentsTest test = new RegisterDisputeAgentsTest();
test.testRegisterArbitratorShouldThrowException();

View file

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

View file

@ -592,6 +592,12 @@ configure(project(':daemon')) {
compileOnly "org.projectlombok:lombok:$lombokVersion"
compileOnly "javax.annotation:javax.annotation-api:$javaxAnnotationVersion"
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.GetPaymentMethodsRequest;
import bisq.proto.grpc.GetTradeRequest;
import bisq.proto.grpc.GetTransactionRequest;
import bisq.proto.grpc.GetTxFeeRateRequest;
import bisq.proto.grpc.GetUnusedBsqAddressRequest;
import bisq.proto.grpc.GetVersionRequest;
@ -40,9 +41,11 @@ import bisq.proto.grpc.OfferInfo;
import bisq.proto.grpc.RegisterDisputeAgentRequest;
import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SendBsqRequest;
import bisq.proto.grpc.SendBtcRequest;
import bisq.proto.grpc.SetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.SetWalletPasswordRequest;
import bisq.proto.grpc.TakeOfferRequest;
import bisq.proto.grpc.TxInfo;
import bisq.proto.grpc.UnlockWalletRequest;
import bisq.proto.grpc.UnsetTxFeeRatePreferenceRequest;
import bisq.proto.grpc.WithdrawFundsRequest;
@ -110,9 +113,11 @@ public class CliMain {
getfundingaddresses,
getunusedbsqaddress,
sendbsq,
sendbtc,
gettxfeerate,
settxfeerate,
unsettxfeerate,
gettransaction,
lockwallet,
unlockwallet,
removewalletpassword,
@ -259,19 +264,56 @@ public class CliMain {
throw new IllegalArgumentException("no bsq amount specified");
var amount = nonOptionArgs.get(2);
verifyStringIsValidDecimal(amount);
try {
Double.parseDouble(amount);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", amount));
}
var txFeeRate = nonOptionArgs.size() == 4 ? nonOptionArgs.get(3) : "";
if (!txFeeRate.isEmpty())
verifyStringIsValidLong(txFeeRate);
var request = SendBsqRequest.newBuilder()
.setAddress(address)
.setAmount(amount)
.setTxFeeRate(txFeeRate)
.build();
walletsService.sendBsq(request);
out.printf("%s BSQ sent to %s%n", amount, address);
var reply = walletsService.sendBsq(request);
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;
}
case gettxfeerate: {
@ -284,13 +326,7 @@ public class CliMain {
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no tx fee rate specified");
long txFeeRate;
try {
txFeeRate = Long.parseLong(nonOptionArgs.get(2));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2)));
}
var txFeeRate = toLong(nonOptionArgs.get(2));
var request = SetTxFeeRatePreferenceRequest.newBuilder()
.setTxFeeRatePreference(txFeeRate)
.build();
@ -304,6 +340,18 @@ public class CliMain {
out.println(formatTxFeeRateInfo(reply.getTxFeeRateInfo()));
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: {
if (nonOptionArgs.size() < 9)
throw new IllegalArgumentException("incorrect parameter count,"
@ -413,7 +461,7 @@ public class CliMain {
return;
}
case gettrade: {
// TODO make short-id a valid argument
// TODO make short-id a valid argument?
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("incorrect parameter count, "
+ " expecting trade id [,showcontract = true|false]");
@ -472,16 +520,21 @@ public class CliMain {
case withdrawfunds: {
if (nonOptionArgs.size() < 3)
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 address = nonOptionArgs.get(2);
// A multi-word memo must be double quoted.
var memo = nonOptionArgs.size() == 4
? nonOptionArgs.get(3)
: "";
var request = WithdrawFundsRequest.newBuilder()
.setTradeId(tradeId)
.setAddress(address)
.setMemo(memo)
.build();
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;
}
case getpaymentmethods: {
@ -560,12 +613,7 @@ public class CliMain {
if (nonOptionArgs.size() < 3)
throw new IllegalArgumentException("no unlock timeout specified");
long timeout;
try {
timeout = Long.parseLong(nonOptionArgs.get(2));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2)));
}
var timeout = toLong(nonOptionArgs.get(2));
var request = UnlockWalletRequest.newBuilder()
.setPassword(nonOptionArgs.get(1))
.setTimeout(timeout).build();
@ -627,6 +675,30 @@ public class CliMain {
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,
@SuppressWarnings("SameParameterValue") String suffix,
String text) {
@ -663,10 +735,12 @@ public class CliMain {
stream.format(rowFormat, "getaddressbalance", "address", "Get server wallet address balance");
stream.format(rowFormat, "getfundingaddresses", "", "Get BTC funding addresses");
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, "settxfeerate", "satoshis (per byte)", "Set custom tx fee rate in sats/byte");
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, "", "amount (btc), min amount, use mkt based price, \\", "");
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, "confirmpaymentreceived", "trade id", "Confirm payment received");
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, "getpaymentacctform", "payment method id", "Get a new payment account form");
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_TX_FEE = "Tx 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_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);
String colDataFormat = "%-" + shortIdColWidth + "s" // left justify
+ " %-" + (roleColWidth + COL_HEADER_DELIMITER.length()) + "s" // left justify
+ "%" + (COL_HEADER_PRICE.length() - 1) + "s" // right justify
+ "%" + (COL_HEADER_TRADE_AMOUNT.length() + 1) + "s" // right justify
+ "%" + (COL_HEADER_TRADE_TX_FEE.length() + 1) + "s" // right justify
+ takerFeeHeader.get() // right justify
+ " %-" + COL_HEADER_TRADE_DEPOSIT_PUBLISHED.length() + "s" // left justify
+ " %-" + COL_HEADER_TRADE_DEPOSIT_CONFIRMED.length() + "s" // left justify
+ " %-" + COL_HEADER_TRADE_FIAT_SENT.length() + "s" // left justify
+ " %-" + COL_HEADER_TRADE_FIAT_RECEIVED.length() + "s" // left justify
+ " %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // left justify
+ " %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s"; // left justify
String colDataFormat = "%-" + shortIdColWidth + "s" // lt justify
+ " %-" + (roleColWidth + COL_HEADER_DELIMITER.length()) + "s" // left
+ "%" + (COL_HEADER_PRICE.length() - 1) + "s" // rt justify
+ "%" + (COL_HEADER_TRADE_AMOUNT.length() + 1) + "s" // rt justify
+ "%" + (COL_HEADER_TRADE_TX_FEE.length() + 1) + "s" // rt justify
+ takerFeeHeader.get() // rt justify
+ " %-" + COL_HEADER_TRADE_DEPOSIT_PUBLISHED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_DEPOSIT_CONFIRMED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_FIAT_SENT.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_FIAT_RECEIVED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_PAYOUT_PUBLISHED.length() + "s" // lt justify
+ " %-" + COL_HEADER_TRADE_WITHDRAWN.length() + "s"; // lt justify
return headerLine +
(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.common.app.Version;
import bisq.common.config.Config;
import bisq.common.handlers.ResultHandler;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Transaction;
import javax.inject.Inject;
import javax.inject.Singleton;
import com.google.common.util.concurrent.FutureCallback;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
/**
* Provides high level interface to functionality of core Bisq features.
* E.g. useful for different APIs to access data of different domains of Bisq.
@ -55,6 +58,8 @@ import javax.annotation.Nullable;
@Slf4j
public class CoreApi {
@Getter
private final Config config;
private final CoreDisputeAgentsService coreDisputeAgentsService;
private final CoreOffersService coreOffersService;
private final CorePaymentAccountsService paymentAccountsService;
@ -64,13 +69,15 @@ public class CoreApi {
private final TradeStatisticsManager tradeStatisticsManager;
@Inject
public CoreApi(CoreDisputeAgentsService coreDisputeAgentsService,
public CoreApi(Config config,
CoreDisputeAgentsService coreDisputeAgentsService,
CoreOffersService coreOffersService,
CorePaymentAccountsService paymentAccountsService,
CorePriceService corePriceService,
CoreTradesService coreTradesService,
CoreWalletsService walletsService,
TradeStatisticsManager tradeStatisticsManager) {
this.config = config;
this.coreDisputeAgentsService = coreDisputeAgentsService;
this.coreOffersService = coreOffersService;
this.paymentAccountsService = paymentAccountsService;
@ -210,7 +217,7 @@ public class CoreApi {
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);
}
@ -246,8 +253,19 @@ public class CoreApi {
return walletsService.getUnusedBsqAddress();
}
public void sendBsq(String address, String amount, TxBroadcaster.Callback callback) {
walletsService.sendBsq(address, amount, callback);
public void sendBsq(String address,
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) {
@ -267,6 +285,10 @@ public class CoreApi {
return walletsService.getMostRecentTxFeeRateInfo();
}
public Transaction getTransaction(String txId) {
return walletsService.getTransaction(txId);
}
public void setWalletPassword(String password, String newPassword) {
walletsService.setWalletPassword(password, newPassword);
}

View file

@ -41,8 +41,6 @@ import java.util.function.Consumer;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import static bisq.core.btc.model.AddressEntry.Context.TRADE_PAYOUT;
import static java.lang.String.format;
@ -85,6 +83,8 @@ class CoreTradesService {
String paymentAccountId,
String takerFeeCurrencyCode,
Consumer<Trade> resultHandler) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
offerUtil.maybeSetFeePaymentCurrencyPreference(takerFeeCurrencyCode);
@ -149,6 +149,9 @@ class CoreTradesService {
}
void keepFunds(String tradeId) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
verifyTradeIsNotClosed(tradeId);
var trade = getOpenTrade(tradeId).orElseThrow(() ->
new IllegalArgumentException(format("trade with id '%s' not found", tradeId)));
@ -156,8 +159,10 @@ class CoreTradesService {
tradeManager.onTradeCompleted(trade);
}
void withdrawFunds(String tradeId, String toAddress, @Nullable String memo) {
// An encrypted wallet must be unlocked for this operation.
void withdrawFunds(String tradeId, String toAddress, String memo) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
verifyTradeIsNotClosed(tradeId);
var trade = getOpenTrade(tradeId).orElseThrow(() ->
new IllegalArgumentException(format("trade with id '%s' not found", tradeId)));
@ -172,21 +177,21 @@ class CoreTradesService {
var receiverAmount = amount.subtract(fee);
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,
fromAddressEntry.getAddressString(),
toAddress,
amount.toFriendlyString(),
fee.toFriendlyString(),
receiverAmount.toFriendlyString()));
receiverAmount.toFriendlyString(),
memo));
tradeManager.onWithdrawRequest(
toAddress,
amount,
fee,
coreWalletsService.getKey(),
trade,
memo,
memo.isEmpty() ? null : memo,
() -> {
},
(errorMessage, throwable) -> {
@ -196,10 +201,14 @@ class CoreTradesService {
}
String getTradeRole(String tradeId) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
return tradeUtil.getRole(getTrade(tradeId));
}
Trade getTrade(String tradeId) {
coreWalletsService.verifyWalletsAreAvailable();
coreWalletsService.verifyEncryptedWalletIsUnlocked();
return getOpenTrade(tradeId).orElseGet(() ->
getClosedTrade(tradeId).orElseThrow(() ->
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.TxFeeRateInfo;
import bisq.core.btc.Balances;
import bisq.core.btc.exceptions.AddressEntryException;
import bisq.core.btc.exceptions.BsqChangeBelowDustException;
import bisq.core.btc.exceptions.InsufficientFundsException;
import bisq.core.btc.exceptions.TransactionVerificationException;
import bisq.core.btc.exceptions.WalletException;
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.provider.fee.FeeService;
import bisq.core.user.Preferences;
import bisq.core.util.FormattingUtils;
import bisq.core.util.coin.BsqFormatter;
import bisq.core.util.coin.CoinFormatter;
import bisq.common.Timer;
import bisq.common.UserThread;
@ -46,10 +50,12 @@ import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.InsufficientMoneyException;
import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.TransactionConfidence;
import org.bitcoinj.crypto.KeyCrypterScrypt;
import javax.inject.Inject;
import javax.inject.Named;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
@ -64,6 +70,7 @@ import org.bouncycastle.crypto.params.KeyParameter;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -85,6 +92,7 @@ class CoreWalletsService {
private final BsqTransferService bsqTransferService;
private final BsqFormatter bsqFormatter;
private final BtcWalletService btcWalletService;
private final CoinFormatter btcFormatter;
private final FeeService feeService;
private final Preferences preferences;
@ -103,6 +111,7 @@ class CoreWalletsService {
BsqTransferService bsqTransferService,
BsqFormatter bsqFormatter,
BtcWalletService btcWalletService,
@Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
FeeService feeService,
Preferences preferences) {
this.balances = balances;
@ -111,6 +120,7 @@ class CoreWalletsService {
this.bsqTransferService = bsqTransferService;
this.bsqFormatter = bsqFormatter;
this.btcWalletService = btcWalletService;
this.btcFormatter = btcFormatter;
this.feeService = feeService;
this.preferences = preferences;
}
@ -189,13 +199,27 @@ class CoreWalletsService {
void sendBsq(String address,
String amount,
String txFeeRate,
TxBroadcaster.Callback callback) {
verifyWalletsAreAvailable();
verifyEncryptedWalletIsUnlocked();
try {
LegacyAddress legacyAddress = getValidBsqLegacyAddress(address);
Coin receiverAmount = getValidBsqTransferAmount(amount);
BsqTransferModel model = bsqTransferService.getBsqTransferModel(legacyAddress, receiverAmount);
Coin receiverAmount = getValidTransferAmount(amount, bsqFormatter);
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);
} catch (InsufficientMoneyException
} catch (InsufficientMoneyException ex) {
log.error("", ex);
throw new IllegalStateException("cannot send bsq due to insufficient funds", ex);
} catch (NumberFormatException
| BsqChangeBelowDustException
| TransactionVerificationException
| 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) {
try {
@SuppressWarnings({"unchecked", "Convert2MethodRef"})
@ -252,6 +331,26 @@ class CoreWalletsService {
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) {
Address address = getAddressEntry(addressString).getAddress();
TransactionConfidence confidence = btcWalletService.getConfidenceForAddress(address);
@ -342,13 +441,13 @@ class CoreWalletsService {
}
// Throws a RuntimeException if wallets are not available (encrypted or not).
private void verifyWalletsAreAvailable() {
void verifyWalletsAreAvailable() {
if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available");
}
// Throws a RuntimeException if wallets are not available or not encrypted.
private void verifyWalletIsAvailableAndEncrypted() {
void verifyWalletIsAvailableAndEncrypted() {
if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available");
@ -357,7 +456,7 @@ class CoreWalletsService {
}
// Throws a RuntimeException if wallets are encrypted and locked.
private void verifyEncryptedWalletIsUnlocked() {
void verifyEncryptedWalletIsUnlocked() {
if (walletsManager.areWalletsEncrypted() && tempAesKey == null)
throw new IllegalStateException("wallet is locked");
}
@ -423,15 +522,22 @@ class CoreWalletsService {
}
}
// Returns a Coin for the amount string, or a RuntimeException if invalid.
private Coin getValidBsqTransferAmount(String amount) {
Coin amountAsCoin = parseToCoin(amount, bsqFormatter);
// Returns a Coin for the transfer amount string, or a RuntimeException if invalid.
private Coin getValidTransferAmount(String amount, CoinFormatter coinFormatter) {
Coin amountAsCoin = parseToCoin(amount, coinFormatter);
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;
}
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() {
KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt();
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,
Coin receiverAmount)
Coin receiverAmount,
Coin txFeePerVbyte)
throws TransactionVerificationException,
WalletException,
BsqChangeBelowDustException,
InsufficientMoneyException {
Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(address.toString(), receiverAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, txFeePerVbyte);
Transaction signedTx = bsqWalletService.signTx(txWithBtcFee);
return new BsqTransferModel(address,

View file

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

View file

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

View file

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

View file

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

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 {
private final CoreApi coreApi;
private final GrpcExceptionHandler exceptionHandler;
@Inject
public GrpcGetTradeStatisticsService(CoreApi coreApi) {
public GrpcGetTradeStatisticsService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) {
this.coreApi = coreApi;
this.exceptionHandler = exceptionHandler;
}
@Override
public void getTradeStatistics(GetTradeStatisticsRequest req,
StreamObserver<GetTradeStatisticsReply> responseObserver) {
try {
var tradeStatistics = coreApi.getTradeStatistics().stream()
.map(TradeStatistics3::toProtoTradeStatistics3)
.collect(Collectors.toList());
var tradeStatistics = coreApi.getTradeStatistics().stream()
.map(TradeStatistics3::toProtoTradeStatistics3)
.collect(Collectors.toList());
var reply = GetTradeStatisticsReply.newBuilder().addAllTradeStatistics(tradeStatistics).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
var reply = GetTradeStatisticsReply.newBuilder().addAllTradeStatistics(tradeStatistics).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (Throwable cause) {
exceptionHandler.handleException(cause, responseObserver);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

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/>.
*/
package bisq.daemon.grpc;
package bisq.daemon.grpc.interceptor;
import bisq.common.config.Config;
@ -38,7 +38,7 @@ import static java.lang.String.format;
*
* @see bisq.common.config.Config#apiPassword
*/
class PasswordAuthInterceptor implements ServerInterceptor {
public class PasswordAuthInterceptor implements ServerInterceptor {
private static final String PASSWORD_KEY = "password";
@ -50,7 +50,8 @@ class PasswordAuthInterceptor implements ServerInterceptor {
}
@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) {
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);
try {
Transaction preparedSendTx = bsqWalletService.getPreparedSendBsqTx(receiversAddressString, receiverAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx);
Transaction signedTx = bsqWalletService.signTx(txWithBtcFee);
Coin miningFee = signedTx.getFee();
int txVsize = signedTx.getVsize();
@ -305,7 +305,7 @@ public class BsqSendView extends ActivatableView<GridPane, Void> implements BsqB
Coin receiverAmount = bsqFormatter.parseToBTC(btcAmountInputTextField.getText());
try {
Transaction preparedSendTx = bsqWalletService.getPreparedSendBtcTx(receiversAddressString, receiverAmount);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx, true);
Transaction txWithBtcFee = btcWalletService.completePreparedSendBsqTx(preparedSendTx);
Transaction signedTx = bsqWalletService.signTx(txWithBtcFee);
Coin miningFee = signedTx.getFee();

View file

@ -255,6 +255,7 @@ message KeepFundsReply {
message WithdrawFundsRequest {
string tradeId = 1;
string address = 2;
string memo = 3;
}
message WithdrawFundsReply {
@ -287,6 +288,27 @@ message TradeInfo {
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
///////////////////////////////////////////////////////////////////////////////////////////
@ -300,11 +322,15 @@ service Wallets {
}
rpc SendBsq (SendBsqRequest) returns (SendBsqReply) {
}
rpc SendBtc (SendBtcRequest) returns (SendBtcReply) {
}
rpc GetTxFeeRate (GetTxFeeRateRequest) returns (GetTxFeeRateReply) {
}
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) {
}
@ -344,9 +370,22 @@ message GetUnusedBsqAddressReply {
message SendBsqRequest {
string address = 1;
string amount = 2;
string txFeeRate = 3;
}
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 {
@ -371,6 +410,14 @@ message UnsetTxFeeRatePreferenceReply {
TxFeeRateInfo txFeeRateInfo = 1;
}
message GetTransactionRequest {
string txId = 1;
}
message GetTransactionReply {
TxInfo txInfo = 1;
}
message GetFundingAddressesRequest {
}
@ -437,13 +484,6 @@ message AddressBalanceInfo {
int64 numConfirmations = 3;
}
message TxFeeRateInfo {
bool useCustomTxFeeRate = 1;
uint64 customTxFeeRate = 2;
uint64 feeServiceRate = 3;
uint64 lastFeeServiceRequestTs = 4;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Version
///////////////////////////////////////////////////////////////////////////////////////////