Java tutorial
/* * Copyright 2016 The Coinblesk team and the CSG Group at University of Zurich * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.coinblesk.server.service; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import org.apache.commons.lang3.ArrayUtils; import org.bitcoinj.core.Address; import org.bitcoinj.core.BlockChain; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.PeerGroup; import org.bitcoinj.core.Sha256Hash; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionBroadcast; import org.bitcoinj.core.TransactionInput; import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.core.listeners.DownloadProgressTracker; import org.bitcoinj.net.discovery.DnsDiscovery; import org.bitcoinj.net.discovery.PeerDiscovery; import org.bitcoinj.script.Script; import org.bitcoinj.store.BlockStore; import org.bitcoinj.store.BlockStoreException; import org.bitcoinj.store.SPVBlockStore; import org.bitcoinj.utils.ContextPropagatingThreadFactory; import org.bitcoinj.wallet.UnreadableWalletException; import org.bitcoinj.wallet.Wallet; import org.bitcoinj.wallet.Wallet.BalanceType; import org.bitcoinj.wallet.WalletTransaction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.coinblesk.bitcoin.AddressCoinSelector; import com.coinblesk.bitcoin.BitcoinNet; import com.coinblesk.server.config.AppConfig; import com.coinblesk.server.entity.Keys; import com.coinblesk.server.entity.TimeLockedAddressEntity; import com.coinblesk.util.BitcoinUtils; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; /** * * @author Thomas Bocek */ @Service public class WalletService { private final static Logger LOG = LoggerFactory.getLogger(WalletService.class); @Autowired private AppConfig appConfig; @Autowired private KeyService keyService; @Autowired private TransactionService transactionService; @Autowired private TxQueueService txQueueService; private Wallet wallet; private BlockChain blockChain; private PeerGroup peerGroup; private BlockStore blockStore; private Set<Sha256Hash> removed = Collections.synchronizedSet(new HashSet<Sha256Hash>()); @PostConstruct public void init() throws IOException, UnreadableWalletException, BlockStoreException, InterruptedException { final NetworkParameters params = appConfig.getNetworkParameters(); // Create config directory if necessary final File directory = appConfig.getConfigDir().getFile(); if (!directory.exists()) { if (!directory.mkdirs()) { throw new IOException("Could not create directory " + directory.getAbsolutePath()); } } // Init chain and wallet files final File chainFile = new File(directory, "coinblesk2-" + appConfig.getBitcoinNet() + ".spvchain"); final File walletFile = new File(directory, "coinblesk2-" + appConfig.getBitcoinNet() + ".wallet"); // Delete chain and wallet file when using UNITTEST network if (appConfig.getBitcoinNet() == BitcoinNet.UNITTEST) { chainFile.delete(); walletFile.delete(); LOG.info("Deleted file {} and {}", chainFile.getName(), walletFile.getName()); } if (walletFile.exists()) { wallet = Wallet.loadFromFile(walletFile); if (!chainFile.exists()) { wallet.reset(); } } else { // TODO: add keychaingroup for restoring wallet wallet = new Wallet(params); } wallet.autosaveToFile(walletFile, 5, TimeUnit.SECONDS, null); walletWatchKeysP2SH(params); walletWatchKeysCLTV(params); walletWatchKeysPot(params); blockStore = new SPVBlockStore(params, chainFile); blockChain = new BlockChain(params, blockStore); peerGroup = new PeerGroup(params, blockChain); blockChain.addWallet(wallet); peerGroup.addWallet(wallet); // If we're in unittest net we don't need any peer discovery or chain download logic // and we are done here. if (appConfig.getBitcoinNet() == BitcoinNet.UNITTEST) { LOG.info("wallet init done."); return; } PeerDiscovery discovery = null; String[] ownFullnodes = new String[] { appConfig.getFirstSeedNode() }; switch (appConfig.getBitcoinNet()) { case MAINNET: // For mainnet we use the default seed list but with out own fullnode added as the first node discovery = new DnsDiscovery(ArrayUtils.addAll(ownFullnodes, params.getDnsSeeds()), params); break; case TESTNET: // For testnet we don't bother with other fullnodes and keep things simple by only connecting to // our own fullnode(s). discovery = new DnsDiscovery(ownFullnodes, params); peerGroup.setMaxConnections(ownFullnodes.length); break; } peerGroup.addPeerDiscovery(discovery); peerGroup.start(); // Download block chain (blocking) final DownloadProgressTracker downloadListener = new DownloadProgressTracker() { @Override protected void doneDownload() { LOG.info("downloading done"); // once we downloaded all the blocks we need to broadcast the // stored approved tx List<Transaction> txs = transactionService.listApprovedTransactions(params); for (Transaction tx : txs) { broadcast(tx); } // Be notified when the confidence of relevant transactions // change (number of confirmations). addConficenceChangedHandler(); } @Override protected void progress(double pct, int blocksSoFar, Date date) { LOG.info("downloading: {}%", (int) pct); } }; if (appConfig.getBitcoinNet() != BitcoinNet.UNITTEST) { peerGroup.startBlockChainDownload(downloadListener); downloadListener.await(); } // Broadcast pending transactions pendingTransactions(); LOG.info("wallet init done."); } private void walletWatchKeysP2SH(final NetworkParameters params) { StringBuilder sb = new StringBuilder(); final List<List<ECKey>> all = keyService.all(); final List<Script> scripts = new ArrayList<>(); for (List<ECKey> keys : all) { final Script script = BitcoinUtils.createP2SHOutputScript(2, keys); script.setCreationTimeSeconds(0); scripts.add(script); sb.append(script.getToAddress(params)).append("\n"); } wallet.addWatchedScripts(scripts); LOG.info("walletWatchKeysP2SH:\n{}", sb.toString()); } private void walletWatchKeysCLTV(final NetworkParameters params) { StringBuilder sb = new StringBuilder(); for (Keys key : keyService.allKeys()) { for (TimeLockedAddressEntity address : key.timeLockedAddresses()) { wallet.addWatchedAddress(address.toAddress(params), address.getTimeCreated()); sb.append(address.toAddress(params)).append("\n"); } } LOG.info("walletWatchKeysCLTV:\n{}", sb.toString()); } private void walletWatchKeysPot(final NetworkParameters params) { ECKey potAddress = appConfig.getPotPrivateKeyAddress(); wallet.addWatchedAddress(potAddress.toAddress(params), appConfig.getPotCreationTime()); LOG.info("walletWatchKeysPot: {}", potAddress.toAddress(params)); } /*** * Add a listener for when a transaction we are watching's confidence * changed due to a new block. * * After the transaction is {bitcoin.minconf} blocks deep, we remove the tx * from the database, as it is considered safe. * * The method should only be called after complete download of the * blockchain, since the handler is called for every block and transaction * we are watching, which will result in high CPU and memory consumption and * might exceed the JVM memory limit. After download is complete, blocks * arrive only sporadically and this is not a problem. */ private void addConficenceChangedHandler() { // Use a custom thread pool to speed up the processing of transactions. // Queue is blocking and limited to 10'000 // to avoid memory exhaustion. After threshold is reached, the // CallerRunsPolicy() forces blocking behavior. ContextPropagatingThreadFactory factory = new ContextPropagatingThreadFactory("listenerFactory"); Executor listenerExecutor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().availableProcessors(), 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(10000), factory, new ThreadPoolExecutor.CallerRunsPolicy()); wallet.addTransactionConfidenceEventListener(listenerExecutor, (wallet, tx) -> { if (tx.getConfidence().getDepthInBlocks() >= appConfig.getMinConf() && !removed.contains(tx.getHash())) { LOG.debug("remove tx we got from the network {}", tx); try { transactionService.removeTransaction(tx); } catch (EmptyResultDataAccessException e) { LOG.debug("tx was not in tx table {}", tx); } try { txQueueService.removeTx(tx); } catch (EmptyResultDataAccessException e) { LOG.debug("tx was not in txqueue table {}", tx); } removed.add(tx.getHash()); } }); } public List<TransactionOutput> potTransactionOutput(final NetworkParameters params) { ECKey potAddress = appConfig.getPotPrivateKeyAddress(); final List<TransactionOutput> retVal = new ArrayList<TransactionOutput>(); for (TransactionOutput output : wallet.getWatchedOutputs(true)) { Address to = output.getScriptPubKey().getToAddress(params); if (!to.isP2SHAddress()) { if (to.equals(potAddress.toAddress(params))) { retVal.add(output); } } } return retVal; } public BlockChain blockChain() { return blockChain; } public void addWatching(Address address) { wallet.addWatchedAddress(address); } public void addWatching(Script script) { List<Script> list = new ArrayList<>(1); list.add(script); wallet.addWatchedScripts(list); } /** * The unspent tx also contains spentOutputs, where the approved tx should * mark the unspent as spent! * * @param params * @return */ @Transactional(readOnly = true) public Map<Sha256Hash, Transaction> verifiedTransactions(NetworkParameters params) { Map<Sha256Hash, Transaction> copy = new HashMap<>( wallet.getTransactionPool(WalletTransaction.Pool.UNSPENT)); Map<Sha256Hash, Transaction> copy2 = new HashMap<>(copy); // also add approved Tx for (Transaction t : copy.values()) { if (t.getConfidence().getDepthInBlocks() < appConfig.getMinConf()) { LOG.debug("not enough confirmations for {}", t.getHash()); copy2.remove(t.getHash()); } } for (Transaction t : copy2.values()) { LOG.debug("unspent tx from Network: {}", t.getHash()); } List<Transaction> approvedTx = transactionService.listApprovedTransactions(params); for (Transaction t : approvedTx) { LOG.debug("adding approved tx, which can be used for spending: {}", t); copy2.put(t.getHash(), t); } return copy2; } @Transactional(readOnly = true) public List<TransactionOutput> verifiedOutputs(NetworkParameters params, Address p2shAddress) { List<TransactionOutput> retVal = new ArrayList<>(); Map<Sha256Hash, Transaction> unspent = verifiedTransactions(params); for (Transaction t : unspent.values()) { for (TransactionOutput out : t.getOutputs()) { if (p2shAddress.equals(out.getAddressFromP2SH(appConfig.getNetworkParameters()))) { if (out.isAvailableForSpending()) { // now check if not spent retVal.add(out); LOG.debug("this txout is unspent : {}, point: {}, spent {}", out, out.getOutPointFor()); } else { LOG.debug("this txout is spent!: {}, point: {}out.getOutPointFor()", out, out.getOutPointFor()); } } } } return retVal; } public long balance(NetworkParameters params, Address p2shAddress) { long balance = 0; for (TransactionOutput transactionOutput : verifiedOutputs(params, p2shAddress)) { balance += transactionOutput.getValue().value; } return balance; } @PreDestroy public void shutdown() { try { if (peerGroup != null && peerGroup.isRunning()) { LOG.info("stopping peer group..."); peerGroup.stop(); peerGroup = null; } } catch (Exception e) { LOG.error("cannot stop peerGroup in shutdown", e); } try { if (blockStore != null) { LOG.info("closing block store..."); blockStore.close(); blockStore = null; } } catch (Exception e) { LOG.error("cannot close blockStore in shutdown", e); } try { if (wallet != null) { // TODO: the method name is misleading, it stops auto-save, but // does not save. LOG.info("shutdown wallet..."); wallet.shutdownAutosaveAndWait(); wallet = null; } } catch (Exception e) { LOG.error("cannot shutdown wallet in shutdown", e); } } public PeerGroup peerGroup() { return peerGroup; } public Transaction receivePending(Transaction fullTx) { wallet.receivePending(fullTx, null, false); return wallet.getTransaction(fullTx.getHash()); } public TransactionOutput findOutputFor(TransactionInput input) { Transaction tx = wallet.getTransaction(input.getOutpoint().getHash()); if (tx == null) { // TODO: useDB? /*List<TransactionOutput> touts = wallet.getWatchedOutputs(true); for(TransactionOutput to:touts) { if(to.getOutPointFor().getHash().equals(input.getOutpoint().getHash()) && to.getOutPointFor().getIndex() == input.getOutpoint().getIndex()) { LOG.debug("could not get TX from wallet, but we have it..." + to); return to; } } LOG.debug("we only have the following tx hashes:"); for(TransactionOutput to:touts) { LOG.debug("to:"+to.getOutPointFor().getHash()+":"+to.getOutPointFor().getIndex()); }*/ return null; } return tx.getOutput(input.getOutpoint().getIndex()); } public void broadcast(final Transaction fullTx) { txQueueService.addTx(fullTx); // broadcast immediately final TransactionBroadcast broadcast = peerGroup().broadcastTransaction(fullTx); Futures.addCallback(broadcast.future(), new FutureCallback<Transaction>() { @Override public void onSuccess(Transaction transaction) { LOG.debug("broadcast success, transaction is out {}", fullTx.getHash()); txQueueService.removeTx(fullTx); } @Override public void onFailure(Throwable throwable) { LOG.error("broadcast failed, transaction is " + fullTx.getHash(), throwable); try { // wait ten minutes Thread.sleep(10 * 60 * 1000); broadcast(fullTx); } catch (InterruptedException ex) { LOG.debug("don't wait for tx {}", fullTx.getHash()); } } }); } private void pendingTransactions() { final NetworkParameters params = appConfig.getNetworkParameters(); for (Transaction tx : txQueueService.all(params)) { broadcast(tx); } } /*** * Gets the current balance from the wallet including all tracked addresses * and pot itself. Used by AdminController. * * @return Coin */ public Coin getBalance() { return wallet.getBalance(BalanceType.ESTIMATED); } /*** * Returns a Map listing all watched scripts and their corresponding balance. * Used by AdminController. * * @return */ public Map<Address, Coin> getBalanceByAddresses() { final NetworkParameters params = appConfig.getNetworkParameters(); AddressCoinSelector selector = new AddressCoinSelector(null, params); wallet.getBalance(selector); // getBalance considers UTXO: add zero balance for all watched scripts // without unspent outputs Map<Address, Coin> fullBalances = new HashMap<>(selector.getAddressBalances()); for (Script watched : wallet.getWatchedScripts()) { Address address = watched.getToAddress(params); if (!fullBalances.containsKey(address)) { fullBalances.put(address, Coin.ZERO); } } return fullBalances; } /*** * List all unspent outputs tracked by the wallet. * Used by AdminController. * * @return List of {@link org.bitcoinj.core.TransactionOutput} of all unspent outputs tracked by bitcoinj. */ public List<TransactionOutput> getUnspentOutputs() { return wallet.getUnspents(); } }