package com.greenaddress.greenapi; import android.text.TextUtils; import android.util.Log; import com.blockstream.libwally.Wally; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.google.common.base.Function; import com.google.common.base.Throwables; import com.google.common.collect.ImmutableMap; 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.ListeningExecutorService; import com.google.common.util.concurrent.SettableFuture; import com.greenaddress.greenbits.GaService; import com.greenaddress.greenbits.spv.Socks5SocketFactory; import com.greenaddress.greenbits.ui.BuildConfig; import com.squareup.okhttp.OkHttpClient; import org.bitcoinj.core.Coin; import org.bitcoinj.core.Sha256Hash; import org.bitcoinj.core.Transaction; import org.codehaus.jackson.map.MappingJsonFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.RejectedExecutionException; import javax.net.ssl.SSLException; import javax.net.ssl.TrustManagerFactory; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import rx.Scheduler; import rx.functions.Action0; import rx.functions.Action1; import rx.schedulers.Schedulers; import ws.wamp.jawampa.ApplicationError; import ws.wamp.jawampa.CallFlags; import ws.wamp.jawampa.PubSubData; import ws.wamp.jawampa.Reply; import ws.wamp.jawampa.WampClient; import ws.wamp.jawampa.WampClientBuilder; import ws.wamp.jawampa.connection.IWampConnectorProvider; import ws.wamp.jawampa.transport.netty.NettyWampClientConnectorProvider; import ws.wamp.jawampa.transport.netty.NettyWampConnectionConfig; public class WalletClient { private static final String TAG = WalletClient.class.getSimpleName(); private static final String GA_KEY = "GreenAddress.it HD wallet path"; private static final String GA_PATH = "greenaddress_path"; // v2: API version 2, sw: Opt in/out segwit private static final String FEATURES = "v2,sw"; private static final String USER_AGENT = String.format("[%s]%s;%s;%s;%s", FEATURES, BuildConfig.VERSION_CODE, BuildConfig.BUILD_TYPE, android.os.Build.VERSION.SDK_INT, System.getProperty("os.arch")); private final Scheduler mScheduler = Schedulers.newThread(); private final ListeningExecutorService mExecutor; private final INotificationHandler mNotificationHandler; private SocketAddress mProxyAddress; private final OkHttpClient mHttpClient = new OkHttpClient(); private boolean mTorEnabled; private WampClient mConnection; private LoginData mLoginData; private Map<String, Object> mFeeEstimates; private ISigningWallet mHDParent; private String mWatchOnlyUsername; private String mWatchOnlyPassword; private String mMnemonics; private String h(final byte[] data) { return Wally.hex_from_bytes(data); } public WalletClient(final INotificationHandler notificationHandler, final ListeningExecutorService es) { mNotificationHandler = notificationHandler; mExecutor = es; } /** * Call handler. */ public interface CallHandler { /** * Fired on successful completion of call. * * @param result The RPC result transformed into the type that was specified in call. */ void onResult(Object result); } public interface ErrorHandler { /** * Fired on call failure. * * @param uri The URI or CURIE of the error that occurred. * @param err A human readable description of the error. */ void onError(String uri, String err); } private <V> CallHandler simpleHandler(final SettableFuture<V> f) { return new CallHandler() { public void onResult(final Object result) { f.set((V) result); } }; } private <V> CallHandler stringHandler(final SettableFuture<V> f) { return new CallHandler() { public void onResult(final Object result) { f.set((V)result.toString()); } }; } /** * Handler for PubSub events. */ public interface EventHandler { /** * Fired when an event for the PubSub subscription is received. * * @param topicUri The URI or CURIE of the topic the event was published to. * @param event The event, transformed into the type that was specified when subscribing. */ void onEvent(String topicUri, Object event); } private static final String DELIM = ", "; private static void logCallDetails(final String procedure, final String result, final Object... args) { final ArrayList<Object> expanded_args = new ArrayList<>(); for (final Object o : args) { if (o instanceof Object[]) expanded_args.add(String.format("[%s]", TextUtils.join(DELIM, (Object[]) o))); else expanded_args.add(o); } Log.v(TAG, String.format("%s(%s)\n\t -> %s", procedure, TextUtils.join(DELIM, expanded_args), result)); } private void onCallError(final SettableFuture rpc, final String procedure, final ErrorHandler errHandler, final String uri, final String err, final Object... args) { Log.d(TAG, procedure + "->" + uri + ':' + err); if (BuildConfig.DEBUG) logCallDetails(procedure, err, args); if (errHandler != null) errHandler.onError(uri, err); else rpc.setException(new GAException(err)); } private SettableFuture clientCall(final SettableFuture rpc, final String procedure, final Class result, final CallHandler handler, final ErrorHandler errHandler, final Object... args) { final ObjectMapper mapper = new ObjectMapper(); final ArrayNode argsNode = mapper.valueToTree(Arrays.asList(args)); final Action1<Reply> replyHandler = new Action1<Reply>() { @Override public void call(final Reply reply) { final JsonNode node = reply.arguments().get(0); if (BuildConfig.DEBUG) logCallDetails(procedure, node.toString(), args); handler.onResult(mapper.convertValue(node, result)); } }; final Action1<Throwable> errorHandler = new Action1<Throwable>() { @Override public void call(final Throwable err) { if (err instanceof ApplicationError) { final ArrayNode a = ((ApplicationError) err).arguments(); if (a != null && a.size() >= 2) { onCallError(rpc, procedure, errHandler, a.get(0).asText(), a.get(1).asText(), args); return; } } onCallError(rpc, procedure, errHandler, err.toString(), err.toString(), args); } }; try { if (mConnection != null) { final EnumSet<CallFlags> flags = EnumSet.of(CallFlags.DiscloseMe); final String callName = "com.greenaddress." + procedure; mConnection.call(callName, flags, argsNode, null) .observeOn(mScheduler) .subscribe(replyHandler, errorHandler); return rpc; } } catch (final RejectedExecutionException e) { // Fall through } onCallError(rpc, procedure, errHandler, "not connected", "not connected", args); return rpc; } private SettableFuture clientCall(final SettableFuture rpc, final String procedure, final Class result, final CallHandler handler, final Object... args) { return clientCall(rpc, procedure, result, handler, null, args); } private <V> ListenableFuture<V> simpleCall(final String procedure, final Class result, final Object... args) { final SettableFuture<V> rpc = SettableFuture.create(); final CallHandler handler = result == null ? stringHandler(rpc) : simpleHandler(rpc); final Class resultClass = result == null ? String.class : result; return clientCall(rpc, procedure, resultClass, handler, args); } private <T> T syncCall(final String procedure, final Class result, final Object... args) throws Exception { if (mConnection == null) throw new GAException("not connected"); final ObjectMapper mapper = new ObjectMapper(); final ArrayNode argsNode = mapper.valueToTree(Arrays.asList(args)); try { final EnumSet<CallFlags> flags = EnumSet.of(CallFlags.DiscloseMe); final String callName = "com.greenaddress." + procedure; final Reply reply; reply = mConnection.call(callName, flags, argsNode, null) .observeOn(mScheduler).toBlocking().single(); final JsonNode node = reply.arguments().get(0); if (BuildConfig.DEBUG) logCallDetails(procedure, node.toString(), args); return (T)mapper.convertValue(node, result); } catch (final RejectedExecutionException e) { if (BuildConfig.DEBUG) logCallDetails(procedure, e.getMessage(), args); throw new GAException("rejected"); } catch (final Exception e) { Log.d(TAG, "Sync RPC exception: (" + procedure + ")->" + e.toString()); String error = e.toString(); if (e instanceof ApplicationError) { final ArrayNode a = ((ApplicationError) e).arguments(); if (a != null && a.size() >= 2) { // Throw the actual error message and ignore the URI error = a.get(1).asText(); } } if (BuildConfig.DEBUG) logCallDetails(procedure, error, args); throw new GAException(error); } } private void clientSubscribe(final String s, final Class mapClass, final EventHandler eventHandler) { final String topic = "com.greenaddress." + s; mConnection.makeSubscription(topic) .observeOn(mScheduler) .subscribe(new Action1<PubSubData>() { @Override public void call(final PubSubData pubSubData) { final ObjectMapper mapper = new ObjectMapper(); eventHandler.onEvent(topic, mapper.convertValue( pubSubData.arguments().get(0), mapClass )); } }, new Action1<Throwable>() { @Override public void call(final Throwable throwable) { Log.w(TAG, throwable); Log.i(TAG, "Subscribe failed (" + topic + "): " + throwable.toString()); } }); } public void setProxy(final String host, final String port) { if (TextUtils.isEmpty(host) || TextUtils.isEmpty(port)) { mProxyAddress = null; mHttpClient.setSocketFactory(null); return; } try { mProxyAddress = new InetSocketAddress(host, Integer.parseInt(port)); mHttpClient.setSocketFactory(new Socks5SocketFactory(host, port)); } catch (final UnknownHostException e) { e.printStackTrace(); throw new RuntimeException(e); } } public void setTorEnabled(final boolean torEnabled) { mTorEnabled = torEnabled; } public LoginData getLoginData() { return mLoginData; } public Map<String, Object> getFeeEstimates() { return mFeeEstimates; } private static byte[] mnemonicToPath(final String mnemonic) { final byte[] hash = CryptoHelper.pbkdf2_hmac_sha512(mnemonic.getBytes(), GA_PATH.getBytes()); return Wally.hmac_sha512(GA_KEY.getBytes(), hash); } private static byte[] extendedKeyToPath(final byte[] publicKey, final byte[] chainCode) { final byte[] data = new byte[publicKey.length + chainCode.length]; System.arraycopy(chainCode, 0, data, 0, chainCode.length); System.arraycopy(publicKey, 0, data, chainCode.length, publicKey.length); return Wally.hmac_sha512(GA_KEY.getBytes(), data); } public String getMnemonics() { return mMnemonics; } public void disconnect() { // FIXME: Server should handle logout without having to disconnect mLoginData = null; mMnemonics = null; mWatchOnlyUsername = null; mHDParent = null; if (mConnection != null) { mConnection.close(); mConnection = null; } } public void registerUser(final ISigningWallet signingWallet, final String mnemonics, final byte[] pubkey, final byte[] chaincode, final byte[] pathPubkey, final byte[] pathChaincode, final String deviceId) throws Exception { String agent = USER_AGENT; final byte[] path; if (mnemonics != null) { mMnemonics = mnemonics; // Software Wallet path = mnemonicToPath(mnemonics); } else { agent += " HW"; // Hardware Wallet path = extendedKeyToPath(pathPubkey, pathChaincode); } // We don't check the return value of login.register (never returns false) syncCall("login.register", Boolean.class, h(pubkey), h(chaincode), agent, h(path)); loginImpl(signingWallet, deviceId); HDKey.resetCache(mLoginData.mGaitPath); } public ListenableFuture<Map<String, Object>> getSubaccountBalance(final int subAccount) { return simpleCall("txs.get_balance", Map.class, subAccount); } // FIXME: Get rid of this public ListenableFuture<Map<?, ?>> getTwoFactorConfig() { return simpleCall("twofactor.get_config", Map.class); } public Map<?, ?> getTwoFactorConfigSync() throws Exception { return syncCall("twofactor.get_config", Map.class); } public ListenableFuture<Map<?, ?>> getAvailableCurrencies() { return simpleCall("login.available_currencies", Map.class); } public void changeTxLimits(final long newTotalValue, final Map<String, String> twoFacData) throws Exception { final Map<String, Object> limits = new HashMap<>(); limits.put("total", newTotalValue); limits.put("per_tx", 0); limits.put("is_fiat", false); syncCall("login.change_settings", Boolean.class, "tx_limits", limits, twoFacData); } private void onAuthenticationComplete(final Map<String, Object> loginData, final ISigningWallet wallet, final String username, final String password) { mLoginData = new LoginData(loginData); if (loginData.containsKey("fee_estimates")) mFeeEstimates = (Map) loginData.get("fee_estimates"); else mFeeEstimates = null; mHDParent = wallet; mWatchOnlyUsername = username; mWatchOnlyPassword = password; if (mHDParent != null) HDClientKey.resetCache(mLoginData.mSubAccounts, mHDParent); final boolean rbf = mLoginData.get("rbf"); if (rbf && getUserConfig("replace_by_fee") == null) { // Enable rbf if server supports it and not disabled by user explicitly // FIXME: The server should do this surely? final Object t = Boolean.TRUE; setUserConfig(ImmutableMap.of("replace_by_fee", t), false); } clientSubscribe("txs.wallet_" + mLoginData.get("receiving_id"), Map.class, new EventHandler() { @Override public void onEvent(final String topicUri, final Object event) { final Map<?, ?> res = (Map) event; final Object subAccounts = res.get("subaccounts"); final int affectedSubAccounts[]; if (subAccounts instanceof Number) { affectedSubAccounts = new int[1]; affectedSubAccounts[0] = ((Number) subAccounts).intValue(); } else { final ArrayList values = (ArrayList) subAccounts; if (values == null) affectedSubAccounts = new int[0]; else { affectedSubAccounts = new int[values.size()]; for (int i = 0; i < values.size(); ++i) { final int v = (values.get(i) == null ? 0 : (Integer) values.get(i)); affectedSubAccounts[i] = v; } } } mNotificationHandler.onNewTransaction(affectedSubAccounts); } }); } private NettyWampConnectionConfig getNettyConfig() throws SSLException { final int TWO_MB = 2 * 1024 * 1024; // Max message size in bytes final NettyWampConnectionConfig.Builder configBuilder; configBuilder = new NettyWampConnectionConfig.Builder() .withMaxFramePayloadLength(TWO_MB); if (Network.GAIT_WAMP_CERT_PINS != null && !isTorEnabled()) { final TrustManagerFactory tmf; tmf = new FingerprintTrustManagerFactorySHA256(Network.GAIT_WAMP_CERT_PINS); final SslContext ctx = SslContextBuilder.forClient().trustManager(tmf).build(); configBuilder.withSslContext(ctx); } return configBuilder.build(); } private String getUri() { if (isTorEnabled()) return String.format("ws://%s/v2/ws/", Network.GAIT_ONION); return Network.GAIT_WAMP_URL; } private boolean isTorEnabled() { return mTorEnabled && mProxyAddress != null; } public ListenableFuture<Void> connect() { final SettableFuture<Void> rpc = SettableFuture.create(); mScheduler.createWorker().schedule(new Action0() { @Override public void call() { final String wsuri = getUri(); Log.i(TAG, "Proxy is configured " + mProxyAddress); Log.i(TAG, "Connecting to " + wsuri); final WampClientBuilder builder = new WampClientBuilder(); final IWampConnectorProvider connectorProvider = new NettyWampClientConnectorProvider(); try { builder.withConnectorProvider(connectorProvider) .withProxyAddress(mProxyAddress) .withUri(wsuri) .withRealm("realm1") .withNrReconnects(0) .withConnectionConfiguration(getNettyConfig()); } catch (final ApplicationError | SSLException e) { e.printStackTrace(); rpc.setException(e); return; } try { mConnection = builder.build(); } catch (final Exception e) { e.printStackTrace(); rpc.setException(new GAException(e.toString())); return; } mConnection.statusChanged() .observeOn(mScheduler) .subscribe(new Action1<WampClient.State>() { boolean initialDisconnectedStateSeen; boolean connected; @Override public void call(final WampClient.State newStatus) { if (newStatus instanceof WampClient.ConnectedState) { // Client got connected to the remote router // and the session was established connected = true; rpc.set(null); return; } if (newStatus instanceof WampClient.DisconnectedState) if (!initialDisconnectedStateSeen) // First state set is always 'disconnected' initialDisconnectedStateSeen = true; else if (connected) // Client got disconnected from the remote router mNotificationHandler.onConnectionClosed(0); else { // or the last possible connect attempt failed final Throwable t = ((WampClient.DisconnectedState) newStatus).disconnectReason(); if (t != null) rpc.setException(t); else rpc.setException(new GAException("Disconnected")); } } }, new Action1<Throwable>() { @Override public void call(final Throwable throwable) { throwable.printStackTrace(); Log.d(TAG, throwable.toString()); } }); try { mConnection.open(); } catch (final IllegalStateException e) { // already disconnected e.printStackTrace(); } } }); Futures.addCallback(rpc, new FutureCallback<Void>() { @Override public void onSuccess(final Void result) { clientSubscribe("blocks", Map.class, new EventHandler() { @Override public void onEvent(final String topicUri, final Object event) { Log.i(TAG, "BLOCKS IS " + event.toString()); mNotificationHandler.onNewBlock(Integer.parseInt(((Map) event).get("count").toString())); } }); clientSubscribe("fee_estimates", Map.class, new EventHandler() { @Override public void onEvent(final String topicUri, final Object newFeeEstimates) { Log.i(TAG, "FEE_ESTIMATES IS " + newFeeEstimates.toString()); mFeeEstimates = (Map) newFeeEstimates; } }); } @Override public void onFailure(final Throwable t) { t.printStackTrace(); } }, mExecutor); return rpc; } private LoginData watchOnlyLoginImpl(final String username, final String password) throws Exception { final Map<String, Object> loginData; loginData = syncCall("login.watch_only_v2", Map.class, "custom", ImmutableMap.of("username", username, "password", password), USER_AGENT); onAuthenticationComplete(loginData, null, username, password); // requires receivingId to be set return mLoginData; } public void disableWatchOnly() throws Exception { syncCall("addressbook.disable_sync", Void.class, "custom"); mWatchOnlyPassword = null; mWatchOnlyUsername = null; } public boolean isWatchOnly() { return !TextUtils.isEmpty(mWatchOnlyUsername) && !TextUtils.isEmpty(mWatchOnlyPassword); } public void registerWatchOnly(final String username, final String password) throws Exception { syncCall("addressbook.sync_custom", Boolean.class, username , password); mWatchOnlyUsername = username; } public String getWatchOnlyUsername() throws Exception { if (mWatchOnlyUsername == null) { final Map<?, ?> sync_status = syncCall("addressbook.get_sync_status", Map.class); mWatchOnlyUsername = (String) sync_status.get("username"); } return mWatchOnlyUsername; } public String getWatchOnlyPassword() { return mWatchOnlyPassword; } private LoginData loginImpl(final ISigningWallet signingWallet, final String deviceId) throws Exception { // FIXME: Unify this RPC call, this is ugly final Object[] args = signingWallet.getChallengeArguments(); final String challengeString; if (args.length == 2) challengeString = syncCall((String) args[0], String.class, args[1]); else challengeString = syncCall((String) args[0], String.class, args[1], args[2]); final String[] challengePath = new String[1]; final String[] signatures = signingWallet.signChallenge(challengeString, challengePath); final Object ret = syncCall("login.authenticate", Object.class, signatures, true, challengePath[0], deviceId, USER_AGENT); if (ret instanceof Boolean) { // FIXME: One RPC call should not have multiple return types throw new LoginFailed(); } onAuthenticationComplete((Map <String, Object>) ret, signingWallet, null, null); // requires receivingId to be set return mLoginData; } public ListenableFuture<LoginData> watchOnlylogin(final String username, final String password) { return mExecutor.submit(new Callable<LoginData>() { @Override public LoginData call() { try { return watchOnlyLoginImpl(username, password); } catch (final Throwable t) { throw Throwables.propagate(t); } } }); } public ListenableFuture<LoginData> login(final ISigningWallet signingWallet, final String deviceId, final String mnemonics) { if (mnemonics != null) mMnemonics = mnemonics; return mExecutor.submit(new Callable<LoginData>() { @Override public LoginData call() { try { return loginImpl(signingWallet, deviceId); } catch (final Throwable t) { throw Throwables.propagate(t); } } }); } public byte[] getPinPassword(final String pinIdentifier, final String pin) throws Exception { final String password = syncCall("pin.get_password", String.class, pin, pinIdentifier); return password.getBytes(); } public JSONMap getNewAddress(final int subAccount, final String addrType) { try { final JSONMap m = new JSONMap((Map<String, Object>) syncCall("vault.fund", Map.class, subAccount, true, addrType)); return m.mData == null ? null : m; } catch (final Exception e) { e.printStackTrace(); return null; } } public Map<String, Object> getMyTransactions(final String searchQuery, final int subAccount) throws Exception { return syncCall("txs.get_list_v2", Map.class, null, searchQuery, null, null, subAccount); } public PinData setPin(final String mnemonic, final String pin, final String deviceName) throws Exception { mMnemonics = mnemonic; // FIXME: set_pin_login could return the password as well, saving a // round-trip vs calling getPinPassword() below. final String pinIdentifier = syncCall("pin.set_pin_login", String.class, pin, deviceName); final byte[] password = getPinPassword(pinIdentifier, pin); return PinData.fromMnemonic(pinIdentifier, mnemonic, password); } public ListenableFuture<Map<?, ?>> processBip70URL(final String url) { return simpleCall("vault.process_bip0070_url", Map.class, url); } public ListenableFuture<PreparedTransaction> preparePayreq(final Coin amount, final Map<?, ?> data, final JSONMap privateData) { final SettableFuture<PreparedTransaction.PreparedData> rpc = SettableFuture.create(); final Map dataClone = new HashMap<>(); for (final Object k : data.keySet()) dataClone.put(k, data.get(k)); if (privateData != null && privateData.containsKey("subaccount")) dataClone.put("subaccount", privateData.get("subaccount")); clientCall(rpc, "vault.prepare_payreq", Map.class, new CallHandler() { public void onResult(final Object prepared) { rpc.set(new PreparedTransaction.PreparedData((Map) prepared, privateData.mData, mLoginData.mSubAccounts, mHttpClient)); } }, amount.longValue(), dataClone, privateData); return Futures.transform(rpc, new Function<PreparedTransaction.PreparedData, PreparedTransaction>() { @Override public PreparedTransaction apply(final PreparedTransaction.PreparedData ptxData) { return new PreparedTransaction(ptxData); } }, mExecutor); } public ListenableFuture<Map<?, ?>> prepareSweepSocial(final byte[] pubKey, final boolean useElectrum) { final Integer[] pubKeyObjs = new Integer[pubKey.length]; for (int i = 0; i < pubKey.length; ++i) pubKeyObjs[i] = pubKey[i] & 0xff; return simpleCall("vault.prepare_sweep_social", Map.class, new ArrayList<>(Arrays.asList(pubKeyObjs)), useElectrum); } public ListenableFuture<String> sendTransaction(final List<byte[]> signatures, final Object TfaData) { final List<String> args = new ArrayList<>(); for (final byte[] s : signatures) args.add(h(s)); return simpleCall("vault.send_tx", null, args, TfaData); } public ListenableFuture<Map<String, Object>> sendRawTransaction(final Transaction tx, final Map<String, Object> twoFacData, final JSONMap privateData, final boolean returnErrorUri) { final SettableFuture<Map<String, Object>> rpc = SettableFuture.create(); final ErrorHandler errHandler = new ErrorHandler() { public void onError(final String uri, final String err) { rpc.setException(new GAException(returnErrorUri ? uri : err)); } }; return clientCall(rpc, "vault.send_raw_tx", Map.class, simpleHandler(rpc), errHandler, h(tx.bitcoinSerialize()), twoFacData, privateData == null ? null : privateData.mData); } public ListenableFuture<List<byte[]>> signTransaction(final ISigningWallet signingWallet, final PreparedTransaction ptx) { return mExecutor.submit(new Callable<List<byte[]>>() { @Override public List<byte[]> call() { return signingWallet.signTransaction(ptx); } }); } public Object getUserConfig(final String key) { return mLoginData.mUserConfig.get(key); } // Returns True if the user hasn't elected to use segwit yet public boolean isSegwitUnconfirmed() { return getUserConfig("use_segwit") == null; } // Returns True iff the user has elected to use segwit public boolean isSegwitEnabled() { return !isSegwitUnconfirmed() && (Boolean) getUserConfig("use_segwit"); } private static <T> ByteArrayOutputStream serializeJSON(final T src) throws GAException { final ByteArrayOutputStream b = new ByteArrayOutputStream(); try { new MappingJsonFactory().getCodec().writeValue(b, src); } catch (final IOException e) { throw new GAException(e.getMessage()); } return b; } private static void updateMap(final Map<String, Object> dest, final Map<String, Object> src, final Set<String> keys) { for (final String k : keys) dest.put(k, src.get(k)); } public ListenableFuture<Boolean> setUserConfig(final Map<String, Object> values, final boolean updateImmediately) { // Create updated JSON config for the RPC call final Map<String, Object> clonedConfig = new HashMap<>(mLoginData.mUserConfig); updateMap(clonedConfig, values, values.keySet()); final String newJSON; try { newJSON = serializeJSON(clonedConfig).toString(); } catch (final GAException e) { return Futures.immediateFailedFuture(e); } final Map<String, Object> oldValues = new HashMap<>(); if (updateImmediately) { // Save old values and update current config updateMap(oldValues, mLoginData.mUserConfig, values.keySet()); updateMap(mLoginData.mUserConfig, values, values.keySet()); } final SettableFuture<Boolean> rpc = SettableFuture.create(); final CallHandler handler = new CallHandler() { public void onResult(final Object result) { // Update local config if it wasn't updated previously if (!updateImmediately) updateMap(mLoginData.mUserConfig, values, values.keySet()); rpc.set(true); } }; final ErrorHandler errHandler = new ErrorHandler() { public void onError(final String uri, final String err) { Log.d(TAG, "updateAppearance failed: " + err); // Restore local config if it was updated previously if (updateImmediately) updateMap(mLoginData.mUserConfig, oldValues, oldValues.keySet()); rpc.setException(new GAException(err)); } }; return clientCall(rpc, "login.set_appearance", Map.class, handler, errHandler, newJSON); } public ListenableFuture<Object> requestTwoFacCode(final String type, final String action, final Object data) { return simpleCall("twofactor.request_" + type, Object.class, action, data); } public ISigningWallet getSigningWallet() { return mHDParent; } public ListenableFuture<List<JSONMap>> getAllUnspentOutputs(final int confs, final Integer subAccount) { final ListenableFuture<ArrayList> rpc; rpc = simpleCall("txs.get_all_unspent_outputs", ArrayList.class, confs, subAccount, "any"); return Futures.transform(rpc, new Function<ArrayList, List<JSONMap>>() { @Override public List<JSONMap> apply(final ArrayList utxos) { return JSONMap.fromList(utxos); } }); } private ListenableFuture<Transaction> transactionCall(final String procedure, final Object... args) { final SettableFuture<Transaction> rpc = SettableFuture.create(); final CallHandler handler = new CallHandler() { public void onResult(final Object tx) { rpc.set(GaService.buildTransaction((String) tx)); } }; return clientCall(rpc, procedure, String.class, handler, args); } public ListenableFuture<Transaction> getRawUnspentOutput(final Sha256Hash txHash) { return transactionCall("txs.get_raw_unspent_output", txHash.toString()); } // FIXME: Share this with getRawOutputHex/ un-async it public ListenableFuture<Transaction> getRawOutput(final Sha256Hash txHash) { return transactionCall("txs.get_raw_output", txHash.toString()); } public String getRawOutputHex(final Sha256Hash txHash) throws Exception { return syncCall("txs.get_raw_output", String.class, txHash.toString()); } public ListenableFuture<Boolean> changeMemo(final Sha256Hash txHash, final String memo) { return simpleCall("txs.change_memo", Boolean.class, txHash.toString(), memo); } public ListenableFuture<Boolean> setPricingSource(final String currency, final String exchange) { return simpleCall("login.set_pricing_source", Boolean.class, currency, exchange); } public ListenableFuture<Boolean> initEnableTwoFac(final String type, final String details, final Map<?, ?> twoFacData) { return simpleCall("twofactor.init_enable_" + type, Boolean.class, details, twoFacData); } public ListenableFuture<Boolean> enableTwoFactor(final String type, final String code, final Object twoFacData) { if (twoFacData == null) return simpleCall("twofactor.enable_" + type, Boolean.class, code); return simpleCall("twofactor.enable_" + type, Boolean.class, code, twoFacData); } public Boolean disableTwoFactor(final String type, final Map<String, String> twoFacData) throws Exception { return syncCall("twofactor.disable_" + type, Boolean.class, twoFacData); } }