package com.mygeopay.core.network;
import com.mygeopay.core.coins.CoinType;
import com.mygeopay.core.network.interfaces.BlockchainConnection;
import com.mygeopay.core.network.interfaces.ConnectionEventListener;
import com.mygeopay.core.network.interfaces.TransactionEventListener;
import com.mygeopay.stratumj.ServerAddress;
import com.mygeopay.stratumj.StratumClient;
import com.mygeopay.stratumj.messages.CallMessage;
import com.mygeopay.stratumj.messages.ResultMessage;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.Service;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.AddressFormatException;
import org.bitcoinj.core.Sha256Hash;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.TransactionOutPoint;
import org.bitcoinj.core.Utils;
import org.bitcoinj.utils.ListenerRegistration;
import org.bitcoinj.utils.Threading;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
import static com.mygeopay.core.Preconditions.checkNotNull;
import static com.mygeopay.core.Preconditions.checkState;
import static com.google.common.util.concurrent.Service.State.NEW;
/**
* @author John L. Jegutanis
*/
public class ServerClient implements BlockchainConnection {
private static final Logger log = LoggerFactory.getLogger(ServerClient.class);
private static final ScheduledThreadPoolExecutor connectionExec;
static {
connectionExec = new ScheduledThreadPoolExecutor(1);
// FIXME, causing a crash in old Androids
// connectionExec.setRemoveOnCancelPolicy(true);
}
private static final Random RANDOM = new Random();
private static final long MAX_WAIT = 16;
private final ConnectivityHelper connectivityHelper;
private CoinType type;
private final ImmutableList<ServerAddress> addresses;
private final HashSet<ServerAddress> failedAddresses;
private ServerAddress lastServerAddress;
private StratumClient stratumClient;
private long retrySeconds = 0;
private boolean stopped = false;
// TODO, only one is supported at the moment. Change when accounts are supported.
private transient CopyOnWriteArrayList<ListenerRegistration<ConnectionEventListener>> eventListeners;
private Runnable reconnectTask = new Runnable() {
public boolean isPolling = false;
@Override
public void run() {
if (!stopped) {
if (connectivityHelper.isConnected()) {
createStratumClient().startAsync();
isPolling = false;
} else {
// Start polling for connection to become available
if (!isPolling) log.info("No connectivity, starting polling.");
connectionExec.remove(reconnectTask);
connectionExec.schedule(reconnectTask, 1, TimeUnit.SECONDS);
isPolling = true;
}
} else {
log.info("{} client stopped, aborting reconnect.", type.getName());
isPolling = false;
}
}
};
private Service.Listener serviceListener = new Service.Listener() {
@Override
public void running() {
// Check if connection is up as this event is fired even if there is no connection
if (isConnected()) {
log.info("{} client connected to {}", type.getName(), lastServerAddress);
broadcastOnConnection();
retrySeconds = 0;
}
}
@Override
public void terminated(Service.State from) {
log.info("{} client stopped", type.getName());
broadcastOnDisconnect();
failedAddresses.add(lastServerAddress);
lastServerAddress = null;
stratumClient = null;
// Try to restart
if (!stopped) {
log.info("Reconnecting {} in {} seconds", type.getName(), retrySeconds);
connectionExec.remove(reconnectTask);
if (retrySeconds > 0) {
connectionExec.schedule(reconnectTask, retrySeconds, TimeUnit.SECONDS);
} else {
connectionExec.execute(reconnectTask);
}
}
}
};
public ServerClient(CoinAddress coinAddress, ConnectivityHelper connectivityHelper) {
this.connectivityHelper = connectivityHelper;
eventListeners = new CopyOnWriteArrayList<ListenerRegistration<ConnectionEventListener>>();
failedAddresses = new HashSet<ServerAddress>();
type = coinAddress.getType();
addresses = ImmutableList.copyOf(coinAddress.getAddresses());
createStratumClient();
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
stopAsync();
}
});
}
private StratumClient createStratumClient() {
checkState(stratumClient == null);
lastServerAddress = getServerAddress();
stratumClient = new StratumClient(lastServerAddress);
stratumClient.addListener(serviceListener, Threading.USER_THREAD);
return stratumClient;
}
private ServerAddress getServerAddress() {
// If we blacklisted all servers, reset and increase back-off time
if (failedAddresses.size() == addresses.size()) {
failedAddresses.clear();
retrySeconds = Math.min(Math.max(1, retrySeconds * 2), MAX_WAIT);
}
ServerAddress address;
// Not the most efficient, but does the job
while (true) {
address = addresses.get(RANDOM.nextInt(addresses.size()));
if (!failedAddresses.contains(address)) break;
}
return address;
}
public void startAsync() {
if (stratumClient == null){
log.info("Forcing service start");
connectionExec.remove(reconnectTask);
createStratumClient();
}
Service.State state = stratumClient.state();
if (state != NEW || stopped) {
log.info("Not starting service as it is already started or explicitly stopped");
return;
}
try {
stratumClient.startAsync();
} catch (IllegalStateException e) {
// This can happen if the service has already been started or stopped (e.g. by another
// service or listener). Our contract says it is safe to call this method if
// all services were NEW when it was called, and this has already been verified above, so we
// don't propagate the exception.
log.warn("Unable to start Service " + type.getName(), e);
}
}
public void stopAsync() {
if (isConnected()) broadcastOnDisconnect();
stopped = true;
connectionExec.remove(reconnectTask);
if (stratumClient != null) {
stratumClient.stopAsync();
stratumClient = null;
}
}
public boolean isConnected() {
return stratumClient != null && stratumClient.isConnected();
}
// // TODO support more than one pocket
// public void maybeSetWalletPocket(WalletPocketHD pocket) {
// if (eventListeners.isEmpty()) {
// setWalletPocket(pocket, false);
// }
// }
//
// // TODO support more than one pocket
// public void setWalletPocket(WalletPocketHD pocket, boolean reconnect) {
// if (isConnected()) broadcastOnDisconnect();
// eventListeners.clear();
// addEventListener(pocket);
// if (reconnect && isConnected()) {
// resetConnection();
// // will broadcast event on reconnect
// } else {
// if (isConnected()) broadcastOnConnection();
// }
// }
/**
* Will disconnect from the server and immediately will try to reconnect
*/
public void resetConnection() {
if (stratumClient != null) {
stratumClient.disconnect();
}
}
/**
* Adds an event listener object. Methods on this object are called when something interesting happens,
* like new connection to a server. The listener is executed by {@link org.bitcoinj.utils.Threading#USER_THREAD}.
*/
public void addEventListener(ConnectionEventListener listener) {
addEventListener(listener, Threading.USER_THREAD);
}
/**
* Adds an event listener object. Methods on this object are called when something interesting happens,
* like new connection to a server. The listener is executed by the given executor.
*/
private void addEventListener(ConnectionEventListener listener, Executor executor) {
boolean isNew = !ListenerRegistration.removeFromList(listener, eventListeners);
eventListeners.add(new ListenerRegistration<ConnectionEventListener>(listener, executor));
if (isNew && isConnected()) {
broadcastOnConnection();
}
}
/**
* Removes the given event listener object. Returns true if the listener was removed, false if that listener
* was never added.
*/
public boolean removeEventListener(ConnectionEventListener listener) {
return ListenerRegistration.removeFromList(listener, eventListeners);
}
private void broadcastOnConnection() {
for (final ListenerRegistration<ConnectionEventListener> registration : eventListeners) {
registration.executor.execute(new Runnable() {
@Override
public void run() {
registration.listener.onConnection(ServerClient.this);
}
});
}
}
private void broadcastOnDisconnect() {
for (final ListenerRegistration<ConnectionEventListener> registration : eventListeners) {
registration.executor.execute(new Runnable() {
@Override
public void run() {
registration.listener.onDisconnect();
}
});
}
}
private BlockHeader parseBlockHeader(CoinType type, JSONObject json) throws JSONException {
return new BlockHeader(type, json.getLong("timestamp"), json.getInt("block_height"));
}
@Override
public void subscribeToBlockchain(final TransactionEventListener listener) {
checkNotNull(stratumClient);
// TODO use TransactionEventListener directly because the current solution leaks memory
StratumClient.SubscribeResult blockchainHeaderHandler = new StratumClient.SubscribeResult() {
@Override
public void handle(CallMessage message) {
try {
BlockHeader header = parseBlockHeader(type, message.getParams().getJSONObject(0));
listener.onNewBlock(header);
} catch (JSONException e) {
log.error("Unexpected JSON format", e);
}
}
};
log.info("Going to subscribe to block chain headers");
CallMessage callMessage = new CallMessage("blockchain.headers.subscribe", (List)null);
ListenableFuture<ResultMessage> reply = stratumClient.subscribe(callMessage, blockchainHeaderHandler);
Futures.addCallback(reply, new FutureCallback<ResultMessage>() {
@Override
public void onSuccess(ResultMessage result) {
try {
BlockHeader header = parseBlockHeader(type, result.getResult().getJSONObject(0));
listener.onNewBlock(header);
} catch (JSONException e) {
log.error("Unexpected JSON format", e);
}
}
@Override
public void onFailure(Throwable t) {
log.error("Could not get reply for blockchain headers subscribe", t);
}
}, Threading.USER_THREAD);
}
@Override
public void subscribeToAddresses(List<Address> addresses, final TransactionEventListener listener) {
checkNotNull(stratumClient);
CallMessage callMessage = new CallMessage("blockchain.address.subscribe", (List)null);
// TODO use TransactionEventListener directly because the current solution leaks memory
StratumClient.SubscribeResult addressHandler = new StratumClient.SubscribeResult() {
@Override
public void handle(CallMessage message) {
try {
Address address = new Address(type, message.getParams().getString(0));
AddressStatus status;
if (message.getParams().isNull(1)) {
status = new AddressStatus(address, null);
}
else {
status = new AddressStatus(address, message.getParams().getString(1));
}
listener.onAddressStatusUpdate(status);
} catch (AddressFormatException e) {
log.error("Address subscribe sent a malformed address", e);
} catch (JSONException e) {
log.error("Unexpected JSON format", e);
}
}
};
for (final Address address : addresses) {
log.info("Going to subscribe to {}", address);
callMessage.setParam(address.toString());
ListenableFuture<ResultMessage> reply = stratumClient.subscribe(callMessage, addressHandler);
Futures.addCallback(reply, new FutureCallback<ResultMessage>() {
@Override
public void onSuccess(ResultMessage result) {
AddressStatus status = null;
try {
if (result.getResult().isNull(0)) {
status = new AddressStatus(address, null);
}
else {
status = new AddressStatus(address, result.getResult().getString(0));
}
listener.onAddressStatusUpdate(status);
} catch (JSONException e) {
log.error("Unexpected JSON format", e);
}
}
@Override
public void onFailure(Throwable t) {
log.error("Could not get reply for address subscribe", t);
}
}, Threading.USER_THREAD);
}
}
@Override
public void getHistoryTx(final AddressStatus status, final TransactionEventListener listener) {
checkNotNull(stratumClient);
CallMessage message = new CallMessage("blockchain.address.get_history",
Arrays.asList(status.getAddress().toString()));
final ListenableFuture<ResultMessage> result = stratumClient.call(message);
Futures.addCallback(result, new FutureCallback<ResultMessage>() {
@Override
public void onSuccess(ResultMessage result) {
JSONArray resTxs = result.getResult();
ImmutableList.Builder<HistoryTx> historyTxs = ImmutableList.builder();
try {
for (int i = 0; i < resTxs.length(); i++) {
historyTxs.add(new HistoryTx(resTxs.getJSONObject(i)));
}
} catch (JSONException e) {
onFailure(e);
return;
}
listener.onTransactionHistory(status, historyTxs.build());
}
@Override
public void onFailure(Throwable t) {
log.error("Could not get reply for blockchain.address.get_history", t);
}
}, Threading.USER_THREAD);
}
@Override
public void getTransaction(final Sha256Hash txHash, final TransactionEventListener listener) {
checkNotNull(stratumClient);
CallMessage message = new CallMessage("blockchain.transaction.get",
Arrays.asList(txHash.toString()));
final ListenableFuture<ResultMessage> result = stratumClient.call(message);
Futures.addCallback(result, new FutureCallback<ResultMessage>() {
@Override
public void onSuccess(ResultMessage result) {
try {
String rawTx = result.getResult().getString(0);
Transaction tx = new Transaction(type, Utils.HEX.decode(rawTx));
if (!tx.getHash().equals(txHash)) {
throw new Exception("Requested TX " + txHash + " but got " + tx.getHashAsString());
}
listener.onTransactionUpdate(tx);
} catch (Exception e) {
onFailure(e);
return;
}
}
@Override
public void onFailure(Throwable t) {
log.error("Could not get reply for blockchain.transaction.get", t);
}
}, Threading.USER_THREAD);
}
@Override
public void broadcastTx(final Transaction tx, @Nullable final TransactionEventListener listener) {
checkNotNull(stratumClient);
CallMessage message = new CallMessage("blockchain.transaction.broadcast",
Arrays.asList(Utils.HEX.encode(tx.bitcoinSerialize())));
final ListenableFuture<ResultMessage> result = stratumClient.call(message);
Futures.addCallback(result, new FutureCallback<ResultMessage>() {
@Override
public void onSuccess(ResultMessage result) {
try {
String txId = result.getResult().getString(0);
// FIXME could return {u'message': u'', u'code': -25}
log.info("got tx {} =?= {}", txId, tx.getHash());
checkState(tx.getHash().toString().equals(txId));
if (listener != null) listener.onTransactionBroadcast(tx);
} catch (Exception e) {
onFailure(e);
return;
}
}
@Override
public void onFailure(Throwable t) {
log.error("Could not get reply for blockchain.transaction.broadcast", t);
if (listener != null) listener.onTransactionBroadcastError(tx);
}
}, Threading.USER_THREAD);
}
@Override
public boolean broadcastTxSync(final Transaction tx) {
checkNotNull(stratumClient);
CallMessage message = new CallMessage("blockchain.transaction.broadcast",
Arrays.asList(Utils.HEX.encode(tx.bitcoinSerialize())));
try {
ResultMessage result = stratumClient.call(message).get();
String txId = result.getResult().getString(0);
// FIXME could return {u'message': u'', u'code': -25}
log.info("got tx {} =?= {}", txId, tx.getHash());
checkState(tx.getHash().toString().equals(txId));
return true;
} catch (Exception e) {
log.error("Could not get reply for blockchain.transaction.broadcast", e);
}
return false;
}
@Override
public void ping() {
checkNotNull(stratumClient);
if (!stratumClient.isConnected()) {
log.warn("There is no connection with {} server, skipping ping.", type.getName());
return;
}
CallMessage pingMsg = new CallMessage("server.version", ImmutableList.of());
ListenableFuture<ResultMessage> pong = stratumClient.call(pingMsg);
Futures.addCallback(pong, new FutureCallback<ResultMessage>() {
@Override
public void onSuccess(@Nullable ResultMessage result) {
try {
log.info("Server {} version {} OK", type.getName(),
result.getResult().get(0));
} catch (JSONException ignore) { }
}
@Override
public void onFailure(Throwable t) {
log.error("Server {} ping failed", type.getName());
}
}, Threading.USER_THREAD);
}
public static class HistoryTx {
protected Sha256Hash txHash;
protected int height;
public HistoryTx(JSONObject json) throws JSONException {
txHash = new Sha256Hash(json.getString("tx_hash"));
height = json.getInt("height");
}
public HistoryTx(TransactionOutPoint txop, int height) {
this.txHash = txop.getHash();
this.height = height;
}
public static List<? extends HistoryTx> fromArray(JSONArray jsonArray) throws JSONException {
ImmutableList.Builder<HistoryTx> list = ImmutableList.builder();
for (int i = 0; i < jsonArray.length(); i++) {
list.add(new HistoryTx(jsonArray.getJSONObject(i)));
}
return list.build();
}
public Sha256Hash getTxHash() {
return txHash;
}
public int getHeight() {
return height;
}
}
}