com.ethercamp.harmony.service.WalletService.java Source code

Java tutorial

Introduction

Here is the source code for com.ethercamp.harmony.service.WalletService.java

Source

/*
 * Copyright 2015, 2016 Ether.Camp Inc. (US)
 * This file is part of Ethereum Harmony.
 *
 * Ethereum Harmony is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Ethereum Harmony 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Ethereum Harmony.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.ethercamp.harmony.service;

import com.ethercamp.harmony.dto.WalletAddressDTO;
import com.ethercamp.harmony.dto.WalletConfirmTransactionDTO;
import com.ethercamp.harmony.dto.WalletInfoDTO;
import com.ethercamp.harmony.keystore.Keystore;
import com.ethercamp.harmony.service.wallet.FileSystemWalletStore;
import com.ethercamp.harmony.service.wallet.WalletAddressItem;
import lombok.AllArgsConstructor;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import org.ethereum.config.SystemProperties;
import org.ethereum.core.*;
import org.ethereum.crypto.ECKey;
import org.ethereum.db.ByteArrayWrapper;
import org.ethereum.facade.Ethereum;
import org.ethereum.listener.EthereumListenerAdapter;
import org.ethereum.sync.SyncManager;
import org.ethereum.util.ByteUtil;
import org.spongycastle.util.encoders.Hex;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.math.BigInteger;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.Random;

/**
 * Wallet logic:
 *  - show list of addresses owned by users from next locations:
 *      a) default ethereum keystore folder in filesystem;
 *      b) manually added addresses which are stored in wallet.json
 *  - import / generate new address;
 *  - remove address;
 *  - show pending balance;
 *  - notify client if interesting tx was included in block.
 *
 * This class operate with addresses in lowercase form without 0x prefix.
 *
 * Created by Stan Reshetnyk on 24.08.16.
 */
@Service
@Slf4j(topic = "harmony")
public class WalletService {

    private static final BigInteger gasLimit = BigInteger.valueOf(21_000L);

    @Autowired
    Ethereum ethereum;

    @Autowired
    Repository repository;

    @Autowired
    Blockchain blockchain;

    @Autowired
    FileSystemWalletStore fileSystemWalletStore;

    @Autowired
    ClientMessageService clientMessageService;

    @Autowired
    Keystore keystore;

    @Autowired
    Environment env;

    @Autowired
    SyncManager syncManager;

    @Autowired
    protected JdbcTemplate jdbcTemplate;

    EmbeddedDatabase wordsDatabase;

    @Autowired
    SystemProperties config;

    /**
     * key - hex address in lower case
     * value - address user friendly name
     */
    final Map<String, String> addresses = new ConcurrentHashMap<>();

    final Map<String, TransactionInfo> pendingSendTransactions = new ConcurrentHashMap<>();
    final Map<String, TransactionInfo> pendingReceiveTransactions = new ConcurrentHashMap<>();

    private boolean subscribedForEvents;

    @PostConstruct
    public void init() {
        addresses.clear();

        final AtomicInteger index = new AtomicInteger();
        Arrays.asList(keystore.listStoredKeys())
                .forEach(a -> addresses.put(cleanAddress(a), "Account " + index.incrementAndGet()));

        fileSystemWalletStore.fromStore().stream().forEach(a -> addresses.put(a.address, a.name));

        // workaround issue in ethereumJ-core, where single miner could never got sync done event
        if (config.minerStart()) {
            subscribeOnce();
        } else {
            ethereum.addListener(new EthereumListenerAdapter() {
                @Override
                public void onSyncDone(SyncState state) {
                    if (state == SyncState.UNSECURE || state == SyncState.COMPLETE) {
                        subscribeOnce();
                    }
                }
            });
        }
    }

    private void subscribeOnce() {
        if (!subscribedForEvents) {
            subscribedForEvents = true;
            ethereum.addListener(new EthereumListenerAdapter() {
                @Override
                public void onPendingTransactionsReceived(List<Transaction> list) {
                    handlePendingTransactionsReceived(list);
                }

                @Override
                public void onBlock(BlockSummary blockSummary) {
                    handleBlock(blockSummary);
                }
            });
        }
    }

    public void handleBlock(BlockSummary blockSummary) {
        checkForChangesInWallet(blockSummary, blockSummary.getReceipts().stream()
                .map(receipt -> receipt.getTransaction()).collect(Collectors.toList()), (info) -> {
                    pendingSendTransactions.remove(info.getHash());
                    if (!syncManager.isSyncDone()) {
                        clientMessageService.sendToTopic("/topic/confirmTransaction",
                                new WalletConfirmTransactionDTO(info.getHash(), info.getAmount(),
                                        info.getSending()));
                    }
                }, (info) -> pendingReceiveTransactions.remove(info.getHash()));
    }

    public void handlePendingTransactionsReceived(List<Transaction> list) {
        checkForChangesInWallet(null, list, (info) -> pendingSendTransactions.put(info.getHash(), info),
                (info) -> pendingReceiveTransactions.put(info.getHash(), info));
    }

    private void checkForChangesInWallet(BlockSummary blockSummary, List<Transaction> transactions,
            Consumer<TransactionInfo> sendHandler, Consumer<TransactionInfo> receiveHandler) {
        final Set<ByteArrayWrapper> subscribed = addresses.keySet().stream().map(a -> cleanAddress(a))
                .flatMap(a -> {
                    try {
                        return Stream.of(new ByteArrayWrapper(Hex.decode(a)));
                    } catch (Exception e) {
                        log.error("Problem getting bytes representation from " + a);
                        return Stream.empty();
                    }
                }).collect(Collectors.toSet());

        final List<Transaction> confirmedTransactions = transactions.stream()
                .filter(transaction -> isSetContains(subscribed, transaction.getReceiveAddress())
                        || isSetContains(subscribed, transaction.getSender()))
                .collect(Collectors.toList());

        confirmedTransactions.forEach(transaction -> {
            final String hash = toHexString(transaction.getHash());
            final BigInteger amount = ByteUtil.bytesToBigInteger(transaction.getValue());
            final boolean hasSender = isSetContains(subscribed, transaction.getSender());
            final boolean hasReceiver = isSetContains(subscribed, transaction.getReceiveAddress());
            log.debug("Handle transaction hash:" + hash + ", hasSender:" + hasSender + ", amount:" + amount);

            if (hasSender) {
                sendHandler
                        .accept(new TransactionInfo(hash, amount, hasSender, toHexString(transaction.getSender())));
            }
            if (hasReceiver) {
                receiveHandler.accept(
                        new TransactionInfo(hash, amount, hasSender, toHexString(transaction.getReceiveAddress())));
            }
        });

        // check if balance changes due to block reward
        final Optional<ByteArrayWrapper> coinbase = Optional.ofNullable(blockSummary)
                .map(bs -> new ByteArrayWrapper(bs.getBlock().getCoinbase()));
        final Boolean matchCoinbase = coinbase.map(c -> subscribed.stream().anyMatch(a -> c.equals(a)))
                .orElse(false);

        final boolean needToSendUpdate = matchCoinbase || !confirmedTransactions.isEmpty();
        if (needToSendUpdate) {
            // update wallet if transactions are related to wallet addresses
            clientMessageService.sendToTopic("/topic/getWalletInfo", getWalletInfo());
        }
    }

    @Scheduled(fixedRate = 60000)
    private void doSendWalletInfo() {
        clientMessageService.sendToTopic("/topic/getWalletInfo", getWalletInfo());
    }

    private String cleanAddress(String input) {
        if (input != null) {
            if (input.startsWith("0x")) {
                return input.substring(2).toLowerCase();
            }
            return input.toLowerCase();
        }
        return input;
    }

    public WalletInfoDTO getWalletInfo() {
        BigInteger gasPrice = BigInteger.valueOf(ethereum.getGasPrice());
        BigInteger txFee = gasLimit.multiply(gasPrice);

        List<WalletAddressDTO> list = addresses.entrySet().stream().flatMap(e -> {
            final String hexAddress = e.getKey();
            try {
                final byte[] address = Hex.decode(hexAddress);
                final BigInteger balance = repository.getBalance(address);
                final BigInteger sendBalance = calculatePendingChange(pendingSendTransactions, hexAddress, txFee);
                final BigInteger receiveBalance = calculatePendingChange(pendingReceiveTransactions, hexAddress,
                        BigInteger.ZERO);

                return Stream.of(new WalletAddressDTO(e.getValue(), e.getKey(), balance,
                        receiveBalance.subtract(sendBalance), keystore.hasStoredKey(e.getKey())));
            } catch (Exception exception) {
                log.error("Error in making wallet address " + hexAddress, exception);
                return Stream.empty();
            }
        }).collect(Collectors.toList());

        BigInteger totalAmount = list.stream().map(t -> t.getAmount()).reduce(BigInteger.ZERO,
                (state, amount) -> state.add(amount));

        WalletInfoDTO result = new WalletInfoDTO(totalAmount);

        result.getAddresses().addAll(list);
        return result;
    }

    private BigInteger calculatePendingChange(Map<String, TransactionInfo> transactions, String hexAddress,
            BigInteger txFee) {
        return transactions.values().stream().filter(info -> info.getAddress().equals(hexAddress))
                .map(info -> info.getAmount())
                .reduce(BigInteger.ZERO, (state, amount) -> state.add(amount).add(txFee));
    }

    /**
     * Generate new key and address. Key will be kept in keystore.
     */
    public String newAddress(String name, String password) {
        log.info("newAddress " + name);
        // generate new private key
        final ECKey key = new ECKey();
        final Account account = new Account();
        account.init(key);
        final String address = cleanAddress(toHexString(account.getAddress()));

        keystore.storeKey(key, password);
        addresses.put(address, name);

        flushWalletToDisk();

        clientMessageService.sendToTopic("/topic/getWalletInfo", getWalletInfo());

        return address;
    }

    /**
     * Import address without keeping key on server.
     */
    public String importAddress(String addressValue, String name) {
        Objects.requireNonNull(addressValue);

        final String address = cleanAddress(addressValue);

        validateAddress(address);

        addresses.put(address, name);

        flushWalletToDisk();

        clientMessageService.sendToTopic("/topic/getWalletInfo", getWalletInfo());

        return address;
    }

    private void validateAddress(String value) {
        Objects.requireNonNull(value);
        if (value.length() != 40) {
            throw new RuntimeException("Address value is invalid");
        }
        Hex.decode(value);
    }

    public void removeAddress(String value) {
        Objects.requireNonNull(value);

        final String address = cleanAddress(value);
        addresses.remove(address);
        keystore.removeKey(address);

        flushWalletToDisk();

        clientMessageService.sendToTopic("/topic/getWalletInfo", getWalletInfo());
    }

    public List<String> generateWords(int wordsCount) {
        if (wordsDatabase == null) {
            EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
            wordsDatabase = builder.setType(EmbeddedDatabaseType.H2).addScript("words/V10.0__word_dict.sql")
                    .build();

            jdbcTemplate.queryForObject("select count (*) from word_dictionary", Integer.class);
        }

        Integer totalWords = jdbcTemplate.queryForObject("select count (*) from word_dictionary", Integer.class);

        final List<String> words = new Random()
                .ints(0, totalWords - 1).limit(wordsCount).mapToObj(i -> jdbcTemplate
                        .queryForObject("select word from word_dictionary offset ? limit 1", String.class, i))
                .collect(Collectors.toList());

        log.debug("Generated words " + words + " from totalWords: " + totalWords);
        return words;
    }

    private String toHexString(byte[] value) {
        return value == null ? "" : Hex.toHexString(value);
    }

    private boolean isSetContains(Set<ByteArrayWrapper> set, byte[] value) {
        return value != null && set.contains(new ByteArrayWrapper(value));
    }

    private void flushWalletToDisk() {
        fileSystemWalletStore.toStore(addresses.entrySet().stream()
                .map(e -> new WalletAddressItem(e.getKey(), e.getValue())).collect(Collectors.toList()));
    }

    @Value
    @AllArgsConstructor
    static class TransactionInfo {

        private String hash;

        private BigInteger amount;

        private Boolean sending;

        private String address;
    }
}