package com.mygeopay.core.wallet;
import com.mygeopay.core.coins.CoinType;
import com.mygeopay.core.protos.Protos;
import com.mygeopay.core.wallet.exceptions.NoSuchPocketException;
import org.bitcoinj.core.Address;
import org.bitcoinj.crypto.DeterministicHierarchy;
import org.bitcoinj.crypto.DeterministicKey;
import org.bitcoinj.crypto.HDKeyDerivation;
import org.bitcoinj.crypto.KeyCrypter;
import org.bitcoinj.crypto.MnemonicCode;
import org.bitcoinj.crypto.MnemonicException;
import org.bitcoinj.store.UnreadableWalletException;
import org.bitcoinj.utils.Threading;
import org.bitcoinj.wallet.DeterministicSeed;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.spongycastle.crypto.params.KeyParameter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
/**
* @author John L. Jegutanis
*/
final public class Wallet {
private static final Logger log = LoggerFactory.getLogger(Wallet.class);
public static final int ENTROPY_SIZE_DEBUG = -1;
private final ReentrantLock lock = Threading.lock("KeyChain");
@GuardedBy("lock") private final LinkedHashMap<CoinType, ArrayList<WalletAccount>> accountsByType;
@GuardedBy("lock") private final LinkedHashMap<String, WalletAccount> accounts;
@Nullable private DeterministicSeed seed;
private DeterministicKey masterKey;
protected volatile WalletFiles vFileManager;
// FIXME, make multi account capable
private final static int ACCOUNT_ZERO = 0;
private int version;
public Wallet(List<String> mnemonic) throws MnemonicException {
this(mnemonic, null);
}
public Wallet(List<String> mnemonic, @Nullable String password) throws MnemonicException {
MnemonicCode.INSTANCE.check(mnemonic);
password = password == null ? "" : password;
seed = new DeterministicSeed(mnemonic, null, password, 0);
masterKey = HDKeyDerivation.createMasterPrivateKey(seed.getSeedBytes());
accountsByType = new LinkedHashMap<CoinType, ArrayList<WalletAccount>>();
accounts = new LinkedHashMap<String, WalletAccount>();
}
public Wallet(DeterministicKey masterKey, @Nullable DeterministicSeed seed) {
this.seed = seed;
this.masterKey = masterKey;
accountsByType = new LinkedHashMap<CoinType, ArrayList<WalletAccount>>();
accounts = new LinkedHashMap<String, WalletAccount>();
}
public static List<String> generateMnemonic(int entropyBitsSize) {
byte[] entropy;
if (ENTROPY_SIZE_DEBUG > 0) {
entropy = new byte[ENTROPY_SIZE_DEBUG];
} else {
entropy = new byte[entropyBitsSize / 8];
}
SecureRandom sr = new SecureRandom();
sr.nextBytes(entropy);
List<String> mnemonic;
try {
mnemonic = MnemonicCode.INSTANCE.toMnemonic(entropy);
} catch (MnemonicException.MnemonicLengthException e) {
throw new RuntimeException(e); // should not happen, we have 16bytes of entropy
}
return mnemonic;
}
public static String generateMnemonicString(int entropyBitsSize) {
List<String> mnemonicWords = Wallet.generateMnemonic(entropyBitsSize);
return mnemonicToString(mnemonicWords);
}
public static String mnemonicToString(List<String> mnemonicWords) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < mnemonicWords.size(); i++) {
sb.append(mnemonicWords.get(i));
sb.append(' ');
}
return sb.toString();
}
public WalletAccount createAccount(CoinType coin, boolean generateAllKeys,
@Nullable KeyParameter key) {
return createAccounts(Lists.newArrayList(coin), generateAllKeys, key).get(0);
}
public List<WalletAccount> createAccounts(List<CoinType> coins, boolean generateAllKeys,
@Nullable KeyParameter key) {
lock.lock();
try {
ImmutableList.Builder<WalletAccount> newAccounts = ImmutableList.builder();
for (CoinType coin : coins) {
log.info("Creating coin pocket for {}", coin);
WalletPocketHD newAccount = createAndAddAccount(coin, key);
if (generateAllKeys) {
newAccount.maybeInitializeAllKeys();
}
newAccounts.add(newAccount);
}
return newAccounts.build();
} finally {
lock.unlock();
}
}
/**
* Check if at least one account exists for a specific coin
*/
public boolean isAccountExists(CoinType coinType) {
lock.lock();
try {
return accountsByType.containsKey(coinType);
} finally {
lock.unlock();
}
}
/**
* Check if account exists
*/
public boolean isAccountExists(@Nullable String accountId) {
if (accountId == null) return false;
lock.lock();
try {
return accounts.containsKey(accountId);
} finally {
lock.unlock();
}
}
/**
* Get a specific account, null if does not exist
*/
@Nullable
public WalletAccount getAccount(@Nullable String accountId) {
if (accountId == null) return null;
lock.lock();
try {
return accounts.get(accountId);
} finally {
lock.unlock();
}
}
/**
* Get accounts for a specific coin type. Returns empty list if no account exists
*/
public List<WalletAccount> getAccounts(CoinType coinType) {
return getAccounts(Lists.newArrayList(coinType));
}
/**
* Get accounts for a specific coin type. Returns empty list if no account exists
*/
public List<WalletAccount> getAccounts(List<CoinType> types) {
lock.lock();
try {
ImmutableList.Builder<WalletAccount> builder = ImmutableList.builder();
for (CoinType type : types) {
if (isAccountExists(type)) {
builder.addAll(accountsByType.get(type));
}
}
return builder.build();
} finally {
lock.unlock();
}
}
/**
* Get accounts that watch a specific address. Returns empty list if no account exists
*/
public List<WalletAccount> getAccounts(final Address address) {
lock.lock();
try {
ImmutableList.Builder<WalletAccount> builder = ImmutableList.builder();
CoinType type = (CoinType) address.getParameters();
if (isAccountExists(type)) {
for (WalletAccount account : accountsByType.get(type)) {
if (account.isAddressMine(address)) {
builder.add(account);
}
}
}
return builder.build();
} finally {
lock.unlock();
}
}
public List<WalletAccount> getAllAccounts() {
lock.lock();
try {
return ImmutableList.copyOf(accounts.values());
} finally {
lock.unlock();
}
}
public List getAccountIds() {
lock.lock();
try {
return ImmutableList.copyOf(accounts.keySet());
} finally {
lock.unlock();
}
}
/**
* Generate and add a new BIP44 account for a specific coin type
*/
private WalletPocketHD createAndAddAccount(CoinType coinType, @Nullable KeyParameter key) {
checkState(lock.isHeldByCurrentThread(), "Lock is held by another thread");
checkNotNull(coinType, "Attempting to create a pocket for a null coin");
// TODO, currently we support a single account so return the existing account
List<WalletAccount> currentAccount = getAccounts(coinType);
if (!currentAccount.isEmpty()) {
return (WalletPocketHD) currentAccount.get(0);
}
// TODO ///////////////
DeterministicHierarchy hierarchy;
if (isEncrypted()) {
hierarchy = new DeterministicHierarchy(masterKey.decrypt(getKeyCrypter(), key));
} else {
hierarchy= new DeterministicHierarchy(masterKey);
}
int newIndex = getLastAccountIndex(coinType) + 1;
DeterministicKey rootKey = hierarchy.get(coinType.getBip44Path(newIndex), false, true);
WalletPocketHD newPocket = new WalletPocketHD(rootKey, coinType, getKeyCrypter(), key);
if (isEncrypted() && !newPocket.isEncrypted()) {
newPocket.encrypt(getKeyCrypter(), key);
}
addAccount(newPocket);
return newPocket;
}
/**
* Get the last BIP44 account index of an account in this wallet. If no accounts found return -1
*/
private int getLastAccountIndex(CoinType coinType) {
if (!isAccountExists(coinType)) {
return -1;
}
int lastIndex = -1;
for (WalletAccount account : accountsByType.get(coinType)) {
if (account instanceof WalletPocketHD) {
int index = ((WalletPocketHD) account).getAccountIndex();
if (index > lastIndex) {
lastIndex = index;
}
}
}
return lastIndex;
}
/* package */ void addAccount(WalletAccount pocket) {
lock.lock();
try {
String id = pocket.getId();
CoinType type = pocket.getCoinType();
checkState(!accounts.containsKey(id), "Cannot replace an existing wallet pocket");
if (!accountsByType.containsKey(type)) {
accountsByType.put(type, new ArrayList<WalletAccount>());
}
accountsByType.get(type).add(pocket);
accounts.put(pocket.getId(), pocket);
pocket.setWallet(this);
} finally {
lock.unlock();
}
}
/**
* Make the wallet generate all the needed lookahead keys if needed
*/
public void maybeInitializeAllPockets() {
lock.lock();
try {
for (WalletAccount account : accounts.values()) {
if (account instanceof WalletPocketHD) {
((WalletPocketHD)account).maybeInitializeAllKeys();
}
}
} finally {
lock.unlock();
}
}
//TODO remove public and implement seed password protection check
public DeterministicKey getMasterKey() {
lock.lock();
try {
return masterKey;
} finally {
lock.unlock();
}
}
/** Returns a list of words that represent the seed or null if this chain is a watching chain. */
@Nullable
public List<String> getMnemonicCode() {
if (seed == null) return null;
lock.lock();
try {
return seed.getMnemonicCode();
} finally {
lock.unlock();
}
}
@Nullable
public DeterministicSeed getSeed() {
lock.lock();
try {
return seed;
} finally {
lock.unlock();
}
}
// //TODO
// @Deprecated
// public List<CoinType> getCoinTypes() {
// lock.lock();
// try {
// return ImmutableList.copyOf(accountsByType.keySet());
// } finally {
// lock.unlock();
// }
// }
/** Returns the {@link KeyCrypter} in use or null if the key chain is not encrypted. */
@Nullable
public KeyCrypter getKeyCrypter() {
lock.lock();
try {
return masterKey.getKeyCrypter();
} finally {
lock.unlock();
}
}
// public SendRequest sendCoinsOffline(Address address, Coin amount)
// throws InsufficientMoneyException, NoSuchPocketException {
// return sendCoinsOffline(address, amount, null);
// }
//
// public SendRequest sendCoinsOffline(Address address, Coin amount, @Nullable String password)
// throws InsufficientMoneyException, NoSuchPocketException {
// CoinType type = (CoinType) address.getParameters();
// WalletPocketHD pocket = getPocket(type);
// SendRequest request = null;
// if (pocket != null) {
// request = pocket.sendCoinsOffline(address, amount, password);
// } else {
// throwNoSuchPocket(type);
// }
// return request;
// }
//
// public void completeAndSignTx(SendRequest request) throws InsufficientMoneyException, NoSuchPocketException {
// WalletPocketHD pocket = getPocket(request.type);
// if (pocket != null) {
// if (request.completed) {
// pocket.signTransaction(request);
// } else {
// pocket.completeTx(request);
// }
// } else {
// throwNoSuchPocket(request.type);
// }
// }
private void throwNoSuchPocket(CoinType type) throws NoSuchPocketException {
throw new NoSuchPocketException("Tried to send from pocket " + type.getName() +
" but no such pocket in wallet.");
}
public void setVersion(int version) {
this.version = version;
}
public int getVersion() {
return version;
}
public WalletAccount refresh(String accountIdToReset) {
lock.lock();
try {
WalletAccount account = getAccount(accountIdToReset);
if (account != null) {
account.refresh();
saveLater();
}
return account;
} finally {
lock.unlock();
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Serialization support
//
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@VisibleForTesting
Protos.Wallet toProtobuf() {
lock.lock();
try {
return WalletProtobufSerializer.toProtobuf(this);
} finally {
lock.unlock();
}
}
/**
* Returns a wallet deserialized from the given file.
*/
public static Wallet loadFromFile(File f) throws UnreadableWalletException {
try {
FileInputStream stream = null;
try {
stream = new FileInputStream(f);
return loadFromFileStream(stream);
} finally {
if (stream != null) stream.close();
}
} catch (IOException e) {
throw new UnreadableWalletException("Could not open file", e);
}
}
/**
* Returns a wallet deserialized from the given input stream.
*/
public static Wallet loadFromFileStream(InputStream stream) throws UnreadableWalletException {
return WalletProtobufSerializer.readWallet(stream);
}
/**
* Uses protobuf serialization to save the wallet to the given file stream. To learn more about this file format, see
* {@link WalletProtobufSerializer}.
*/
public void saveToFileStream(OutputStream f) throws IOException {
lock.lock();
try {
WalletProtobufSerializer.writeWallet(this, f);
} finally {
lock.unlock();
}
}
/** Saves the wallet first to the given temp file, then renames to the dest file. */
public void saveToFile(File temp, File destFile) throws IOException {
FileOutputStream stream = null;
lock.lock();
try {
stream = new FileOutputStream(temp);
saveToFileStream(stream);
// Attempt to force the bits to hit the disk. In reality the OS or hard disk itself may still decide
// to not write through to physical media for at least a few seconds, but this is the best we can do.
stream.flush();
stream.getFD().sync();
stream.close();
stream = null;
if (!temp.renameTo(destFile)) {
throw new IOException("Failed to rename " + temp + " to " + destFile);
}
} catch (RuntimeException e) {
log.error("Failed whilst saving wallet", e);
throw e;
} finally {
lock.unlock();
if (stream != null) {
stream.close();
}
if (temp.exists()) {
log.warn("Temp file still exists after failed save.");
}
}
}
/**
* <p>Sets up the wallet to auto-save itself to the given file, using temp files with atomic renames to ensure
* consistency. After connecting to a file, you no longer need to save the wallet manually, it will do it
* whenever necessary. Protocol buffer serialization will be used.</p>
*
* <p>If delayTime is set, a background thread will be created and the wallet will only be saved to
* disk every so many time units. If no changes have occurred for the given time period, nothing will be written.
* In this way disk IO can be rate limited. It's a good idea to set this as otherwise the wallet can change very
* frequently, eg if there are a lot of transactions in it or during block sync, and there will be a lot of redundant
* writes. Note that when a new key is added, that always results in an immediate save regardless of
* delayTime. <b>You should still save the wallet manually when your program is about to shut down as the JVM
* will not wait for the background thread.</b></p>
*
* <p>An event listener can be provided. If a delay >0 was specified, it will be called on a background thread
* with the wallet locked when an auto-save occurs. If delay is zero or you do something that always triggers
* an immediate save, like adding a key, the event listener will be invoked on the calling threads.</p>
*
* @param f The destination file to save to.
* @param delayTime How many time units to wait until saving the wallet on a background thread.
* @param timeUnit the unit of measurement for delayTime.
* @param eventListener callback to be informed when the auto-save thread does things, or null
*/
public WalletFiles autosaveToFile(File f, long delayTime, TimeUnit timeUnit,
@Nullable WalletFiles.Listener eventListener) {
lock.lock();
try {
checkState(vFileManager == null, "Already auto saving this wallet.");
WalletFiles manager = new WalletFiles(this, f, delayTime, timeUnit);
if (eventListener != null) {
manager.setListener(eventListener);
}
vFileManager = manager;
return manager;
} finally {
lock.unlock();
}
}
/**
* <p>
* Disables auto-saving, after it had been enabled with
* {@link Wallet#autosaveToFile(java.io.File, long, java.util.concurrent.TimeUnit,com.mygeopay.core.wallet.WalletFiles.Listener)}
* before. This method blocks until finished.
* </p>
*/
public void shutdownAutosaveAndWait() {
lock.lock();
try {
WalletFiles files = vFileManager;
vFileManager = null;
if (files != null) {
files.shutdownAndWait();
}
} finally {
lock.unlock();
}
}
/** Requests an asynchronous save on a background thread */
public void saveLater() {
lock.lock();
try {
WalletFiles files = vFileManager;
if (files != null) {
files.saveLater();
}
} finally {
lock.unlock();
}
}
/** If auto saving is enabled, do an immediate sync write to disk ignoring any delays. */
public void saveNow() {
lock.lock();
try {
WalletFiles files = vFileManager;
if (files != null) {
try {
files.saveNow(); // This calls back into saveToFile().
} catch (IOException e) {
// Can't really do much at this point, just let the API user know.
log.error("Failed to save wallet to disk!", e);
Thread.UncaughtExceptionHandler handler = Threading.uncaughtExceptionHandler;
if (handler != null)
handler.uncaughtException(Thread.currentThread(), e);
}
}
} finally {
lock.unlock();
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Encryption support
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public boolean isEncrypted() {
lock.lock();
try {
return masterKey.isEncrypted();
} finally {
lock.unlock();
}
}
/**
* Encrypt the keys in the group using the KeyCrypter and the AES key. A good default KeyCrypter to use is
* {@link org.bitcoinj.crypto.KeyCrypterScrypt}.
*
* @throws org.bitcoinj.crypto.KeyCrypterException Thrown if the wallet encryption fails for some reason,
* leaving the group unchanged.
*/
public void encrypt(KeyCrypter keyCrypter, KeyParameter aesKey) {
checkNotNull(keyCrypter, "Attempting to encrypt with a null KeyCrypter");
checkNotNull(aesKey, "Attempting to encrypt with a null KeyParameter");
lock.lock();
try {
if (seed != null) seed = seed.encrypt(keyCrypter, aesKey);
masterKey = masterKey.encrypt(keyCrypter, aesKey, null);
for (WalletAccount account : accounts.values()) {
if (account.isEncryptable()) {
account.encrypt(keyCrypter, aesKey);
}
}
} finally {
lock.unlock();
}
}
/* package */ void decrypt(KeyParameter aesKey) {
checkNotNull(aesKey, "Attemting to decrypt with a null KeyParameter");
lock.lock();
try {
checkState(isEncrypted(), "Wallet is already decrypted");
if (seed != null) {
checkState(seed.isEncrypted(), "Seed is already decrypted");
List<String> mnemonic = null;
try {
mnemonic = decodeMnemonicCode(getKeyCrypter().decrypt(seed.getEncryptedData(), aesKey));
} catch (UnreadableWalletException e) {
throw new RuntimeException(e);
}
seed = new DeterministicSeed(new byte[16], mnemonic, 0);
}
masterKey = masterKey.decrypt(getKeyCrypter(), aesKey);
for (WalletAccount account : accounts.values()) {
if (account.isEncryptable()) {
account.decrypt(aesKey);
}
}
} finally {
lock.unlock();
}
}
private static List<String> decodeMnemonicCode(byte[] mnemonicCode) throws UnreadableWalletException {
try {
return Splitter.on(" ").splitToList(new String(mnemonicCode, "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new UnreadableWalletException(e.toString());
}
}
// TODO
// public void broadcastTx(SendRequest request) throws IOException {
// getPocket(request.type).broadcastTx(request.tx);
// }
}