forked from bisq-network/bisq
-
Notifications
You must be signed in to change notification settings - Fork 0
/
CoreWalletsService.java
293 lines (238 loc) · 11.9 KB
/
CoreWalletsService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
/*
* 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;
import bisq.core.api.model.AddressBalanceInfo;
import bisq.core.btc.Balances;
import bisq.core.btc.model.AddressEntry;
import bisq.core.btc.wallet.BtcWalletService;
import bisq.core.btc.wallet.WalletsManager;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.TransactionConfidence;
import org.bitcoinj.crypto.KeyCrypterScrypt;
import javax.inject.Inject;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.spongycastle.crypto.params.KeyParameter;
import java.util.List;
import java.util.Optional;
import java.util.Timer;
import java.util.TimerTask;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import static java.lang.String.format;
import static java.util.concurrent.TimeUnit.SECONDS;
// I have the feeling we are doing too much here and will end up with a different impl as it is done in
// BtcWalletService. There is a lot of complexity (and old code). I don't feel confident to review that without much
// more time and would prefer that we take the approach to refactor the existing core classes to be more usable for the
// needs of the API instead adding domain logic here.
@Slf4j
class CoreWalletsService {
private final Balances balances;
private final WalletsManager walletsManager;
private final BtcWalletService btcWalletService;
@Nullable
private TimerTask lockTask;
@Nullable
private KeyParameter tempAesKey;
@Inject
public CoreWalletsService(Balances balances,
WalletsManager walletsManager,
BtcWalletService btcWalletService) {
this.balances = balances;
this.walletsManager = walletsManager;
this.btcWalletService = btcWalletService;
}
long getAvailableBalance() {
verifyWalletsAreAvailable();
verifyEncryptedWalletIsUnlocked();
var balance = balances.getAvailableBalance().get();
if (balance == null)
throw new IllegalStateException("balance is not yet available");
return balance.getValue();
}
long getAddressBalance(String addressString) {
Address address = btcWalletService.getAddress(addressString);
return btcWalletService.getBalanceForAddress(address).value;
}
AddressBalanceInfo getAddressBalanceInfo(String addressString) {
var satoshiBalance = getAddressBalance(addressString);
var numConfirmations = getNumConfirmationsForMostRecentTransaction(addressString);
return new AddressBalanceInfo(addressString, satoshiBalance, numConfirmations);
}
List<AddressBalanceInfo> getFundingAddresses() {
verifyWalletsAreAvailable();
verifyEncryptedWalletIsUnlocked();
// Create a new funding address if none exists.
if (btcWalletService.getAvailableAddressEntries().isEmpty()) {
btcWalletService.getFreshAddressEntry();
}
List<String> addressStrings = btcWalletService
.getAvailableAddressEntries()
.stream()
.map(AddressEntry::getAddressString)
.collect(Collectors.toList());
// getAddressBalance is memoized, because we'll map it over addresses twice.
// To get the balances, we'll be using .getUnchecked, because we know that
// this::getAddressBalance cannot return null.
var balances = memoize(this::getAddressBalance);
boolean noAddressHasZeroBalance = addressStrings.stream()
.allMatch(addressString -> balances.getUnchecked(addressString) != 0);
if (noAddressHasZeroBalance) {
var newZeroBalanceAddress = btcWalletService.getFreshAddressEntry();
addressStrings.add(newZeroBalanceAddress.getAddressString());
}
return addressStrings.stream().map(address ->
new AddressBalanceInfo(address,
balances.getUnchecked(address),
getNumConfirmationsForMostRecentTransaction(address)))
.collect(Collectors.toList());
}
int getNumConfirmationsForMostRecentTransaction(String addressString) {
Address address = getAddressEntry(addressString).getAddress();
TransactionConfidence confidence = btcWalletService.getConfidenceForAddress(address);
return confidence == null ? 0 : confidence.getDepthInBlocks();
}
void setWalletPassword(String password, String newPassword) {
verifyWalletsAreAvailable();
KeyCrypterScrypt keyCrypterScrypt = getKeyCrypterScrypt();
if (newPassword != null && !newPassword.isEmpty()) {
// TODO Validate new password before replacing old password.
if (!walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is not encrypted with a password");
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
if (!walletsManager.checkAESKey(aesKey))
throw new IllegalStateException("incorrect old password");
walletsManager.decryptWallets(aesKey);
aesKey = keyCrypterScrypt.deriveKey(newPassword);
walletsManager.encryptWallets(keyCrypterScrypt, aesKey);
walletsManager.backupWallets();
return;
}
if (walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is encrypted with a password");
// TODO Validate new password.
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
walletsManager.encryptWallets(keyCrypterScrypt, aesKey);
walletsManager.backupWallets();
}
//TODO we should stick with the existing domain language we use in the app (e.g. encrypt wallet)
// Otherwise reviewers need to learn the new language and map it to the existing.
void lockWallet() {
if (!walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is not encrypted with a password");
if (tempAesKey == null)
throw new IllegalStateException("wallet is already locked");
tempAesKey = null;
}
// I think we duplicate too much domain logic here. Lets move the code where it is used in the UI to the domain
// class where it should be (walletsManager) and re-use that.
void unlockWallet(String password, long timeout) {
verifyWalletIsAvailableAndEncrypted();
KeyCrypterScrypt keyCrypterScrypt = getKeyCrypterScrypt();
// The aesKey is also cached for timeout (secs) after being used to decrypt the
// wallet, in case the user wants to manually lock the wallet before the timeout.
tempAesKey = keyCrypterScrypt.deriveKey(password);
if (!walletsManager.checkAESKey(tempAesKey))
throw new IllegalStateException("incorrect password");
if (lockTask != null) {
// The user is overriding a prior unlock timeout. Cancel the existing
// lock TimerTask to prevent it from calling lockWallet() before or after the
// new timer task does.
lockTask.cancel();
// Avoid the synchronized(lock) overhead of an unnecessary lockTask.cancel()
// call the next time 'unlockwallet' is called.
lockTask = null;
}
// TODO This adds new not existing and problematic functionality. If a user has open offers he need the key in case
// a taker takes the offer. If the timeout has removed the key take offer fails.
// As we are in the core app domain now we should use existing framework for timers (UserThread.runAfter)
// We need to take care that we do not introduce threading issues. The UserThread.setExecutor() was set in the
// main app.
lockTask = new TimerTask() {
@Override
public void run() {
if (tempAesKey != null) {
// Do not try to lock wallet after timeout if the user has already
// done so via 'lockwallet'
log.info("Locking wallet after {} second timeout expired.", timeout);
tempAesKey = null;
}
}
};
Timer timer = new Timer("Lock Wallet Timer");
timer.schedule(lockTask, SECONDS.toMillis(timeout));
}
// Provided for automated wallet protection method testing, despite the
// security risks exposed by providing users the ability to decrypt their wallets.
void removeWalletPassword(String password) {
verifyWalletIsAvailableAndEncrypted();
KeyCrypterScrypt keyCrypterScrypt = getKeyCrypterScrypt();
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
if (!walletsManager.checkAESKey(aesKey))
throw new IllegalStateException("incorrect password");
walletsManager.decryptWallets(aesKey);
walletsManager.backupWallets();
}
// Throws a RuntimeException if wallets are not available (encrypted or not).
private 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() {
if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available");
if (!walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is not encrypted with a password");
}
// Throws a RuntimeException if wallets are encrypted and locked.
private void verifyEncryptedWalletIsUnlocked() {
if (walletsManager.areWalletsEncrypted() && tempAesKey == null)
throw new IllegalStateException("wallet is locked");
}
private KeyCrypterScrypt getKeyCrypterScrypt() {
KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt();
if (keyCrypterScrypt == null)
throw new IllegalStateException("wallet encrypter is not available");
return keyCrypterScrypt;
}
private AddressEntry getAddressEntry(String addressString) {
Optional<AddressEntry> addressEntry =
btcWalletService.getAddressEntryListAsImmutableList().stream()
.filter(e -> addressString.equals(e.getAddressString()))
.findFirst();
if (!addressEntry.isPresent())
throw new IllegalStateException(format("address %s not found in wallet", addressString));
return addressEntry.get();
}
/**
* Memoization stores the results of expensive function calls and returns
* the cached result when the same input occurs again.
*
* Resulting LoadingCache is used by calling `.get(input I)` or
* `.getUnchecked(input I)`, depending on whether or not `f` can return null.
* That's because CacheLoader throws an exception on null output from `f`.
*/
private static <I, O> LoadingCache<I, O> memoize(Function<I, O> f) {
// f::apply is used, because Guava 20.0 Function doesn't yet extend
// Java Function.
return CacheBuilder.newBuilder().build(CacheLoader.from(f::apply));
}
}