From 07bc724ecdbdc74f7074354795687f325a1fea8a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 11 Jan 2019 09:13:19 +0300 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle | 8 +- .../main/java/com/tangem/data/Blockchain.java | 31 +- .../tangem/data/network/ServerApiStellar.java | 151 ++++++ .../com/tangem/data/network/ServerURL.java | 1 + .../tangem/data/network/StellarRequest.java | 72 +++ .../tangem/domain/wallet/CoinEngineFactory.kt | 3 + .../com/tangem/domain/wallet/xlm/XlmData.java | 81 +++ .../tangem/domain/wallet/xlm/XlmEngine.java | 509 ++++++++++++++++++ .../src/main/res/drawable/ic_logo_stellar.png | Bin 0 -> 23528 bytes 9 files changed, 839 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/tangem/data/network/ServerApiStellar.java create mode 100644 app/src/main/java/com/tangem/data/network/StellarRequest.java create mode 100644 app/src/main/java/com/tangem/domain/wallet/xlm/XlmData.java create mode 100644 app/src/main/java/com/tangem/domain/wallet/xlm/XlmEngine.java create mode 100644 tangemcard-android/src/main/res/drawable/ic_logo_stellar.png diff --git a/app/build.gradle b/app/build.gradle index 10b187ccff..d3fe8f8d93 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,9 +40,15 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + packagingOptions { + pickFirst('META-INF/proguard/okhttp3.pro') + } buildToolsVersion '28.0.3' } +repositories { + maven { url "https://jitpack.io" } +} dependencies { implementation project(':tangemcard-common') implementation project(':tangemcard-android') @@ -72,5 +78,5 @@ dependencies { implementation 'io.reactivex.rxjava2:rxjava:2.2.0' implementation 'io.reactivex.rxjava2:rxandroid:2.0.1' implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' - + implementation 'com.github.stellar:java-stellar-sdk:0.4.1' } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/data/Blockchain.java b/app/src/main/java/com/tangem/data/Blockchain.java index 5ad40fedc9..cc5828ecda 100644 --- a/app/src/main/java/com/tangem/data/Blockchain.java +++ b/app/src/main/java/com/tangem/data/Blockchain.java @@ -6,25 +6,24 @@ import com.tangem.tangemcard.R; * Created by dvol on 06.08.2017. */ public enum Blockchain { - Unknown("", "", 1.0, R.drawable.ic_logo_unknown, ""), - Bitcoin("BTC", "BTC", 100000000.0, R.drawable.ic_logo_bitcoin, "Bitcoin"), - BitcoinTestNet("BTC/test", "BTC", 100000000.0, R.drawable.ic_logo_bitcoin_testnet, "Bitcoin Testnet"), - Ethereum("ETH", "ETH", 1.0, R.drawable.ic_logo_ethereum, "Ethereum"), - EthereumTestNet("ETH/test", "ETH", 1.0, R.drawable.ic_logo_ethereum_testnet, "Ethereum Testnet"), - Token("Token", "ERC20", 1.0, R.drawable.ic_logo_bat_token, "Ethereum"), - BitcoinCash("BCH", "BCH", 100000000.0, R.drawable.ic_logo_bitcoin_cash, "Bitcoin Cash"), - Litecoin("LTC", "LTC", 100000000.0, R.drawable.ic_logo_bitcoin, "Litecoin"); - - Blockchain(String ID, String currency, double multiplier, int imageResource, String officialName) { + Unknown("", "", R.drawable.ic_logo_unknown, ""), + Bitcoin("BTC", "BTC", R.drawable.ic_logo_bitcoin, "Bitcoin"), + BitcoinTestNet("BTC/test", "BTC", R.drawable.ic_logo_bitcoin_testnet, "Bitcoin Testnet"), + Ethereum("ETH", "ETH", R.drawable.ic_logo_ethereum, "Ethereum"), + EthereumTestNet("ETH/test", "ETH", R.drawable.ic_logo_ethereum_testnet, "Ethereum Testnet"), + Token("Token", "ERC20", R.drawable.ic_logo_bat_token, "Ethereum"), + BitcoinCash("BCH", "BCH", R.drawable.ic_logo_bitcoin_cash, "Bitcoin Cash"), + Litecoin("LTC", "LTC", R.drawable.ic_logo_bitcoin, "Litecoin"), + Stellar("XLM", "XLM", R.drawable.ic_logo_stellar, "Stellar Lumens"), + StellarTestNet("XLM/test", "XLM", R.drawable.ic_logo_bitcoin, "Stellar Lumens"); + Blockchain(String ID, String currency, int imageResource, String officialName) { mID = ID; mCurrency = currency; -// mMultiplier = multiplier; mImageResource = imageResource; mOfficialName = officialName; } private String mID, mOfficialName; - //private double mMultiplier; private String mCurrency; private int mImageResource; @@ -36,10 +35,6 @@ public enum Blockchain { return mOfficialName; } -// public double getMultiplier() { -// return mMultiplier; -// } - public String getCurrency() { return mCurrency; } @@ -83,6 +78,7 @@ public enum Blockchain { return resourceId; } + //TODO - ??? public static int getLogoImageResource(String blockchainID, String symbolName) { switch (blockchainID) { case "BTC": @@ -96,6 +92,9 @@ public enum Blockchain { case "ETH": return R.drawable.ic_logo_ethereum; + + case "XLM": + return R.drawable.ic_logo_stellar; } return R.drawable.tangem2; } diff --git a/app/src/main/java/com/tangem/data/network/ServerApiStellar.java b/app/src/main/java/com/tangem/data/network/ServerApiStellar.java new file mode 100644 index 0000000000..dd95612984 --- /dev/null +++ b/app/src/main/java/com/tangem/data/network/ServerApiStellar.java @@ -0,0 +1,151 @@ +package com.tangem.data.network; + +import android.util.Log; + +import com.tangem.App; +import com.tangem.data.Blockchain; +import com.tangem.domain.wallet.TangemContext; +import com.tangem.wallet.R; + +import org.stellar.sdk.Network; +import org.stellar.sdk.Server; + +import java.io.IOException; + +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.observers.DefaultObserver; +import io.reactivex.schedulers.Schedulers; + +/** + * Request processor for Electrum Api + * Every request live cycle: + * 1. In application create request and call {@link ServerApiStellar}.requestData(..) + * 2. Try send every request for max 4 times, + * 3. If all 4 times fail call DefaultObserver.onError (defined in .requestData(..)) and than + * {@link Listener}.onFail(...) callback + * Error can be acquired with {@link StellarRequest}.getError() method + * 4. If request network communication finished successfully then call DefaultObserver.onComplete (defined in .requestData) and than + * {@link Listener}.onSuccess(...) callback + */ +public class ServerApiStellar { + private static String TAG = ServerApiStellar.class.getSimpleName(); + + /** + * TCP, SSL + * Used in BTC, BCH + */ + private Listener listener; + + private int requestsCount=0; + + public boolean isRequestsSequenceCompleted() { + Log.i(TAG, String.format("isRequestsSequenceCompleted: %s (%d requests left)", String.valueOf(requestsCount <= 0), requestsCount)); + return requestsCount <= 0; + } + + /** + * Interface for notification every request result + */ + public interface Listener { + + /** + * Notify that request processing was successful + * @param stellarRequest - processed request containing received answer {@see stellarRequest.getAnswer() method} + */ + void onSuccess(StellarRequest.Base stellarRequest); + /** + * Notify that request processing was successful + * @param stellarRequest - processed request containing occurred error {@see stellarRequest.getError() method} + */ + void onFail(StellarRequest.Base stellarRequest); + } + + /** + * Set notificaion listener + * @param listener + */ + public void setListener(Listener listener) { + this.listener = listener; + } + + + /** + * Start process request + * @param ctx + * @param stellarRequest + */ + public void requestData(TangemContext ctx, StellarRequest.Base stellarRequest) { + requestsCount++; + Log.i(TAG, String.format("New request[%d]: %s", requestsCount,stellarRequest.getClass().getSimpleName())); + Observable stellarObserver = Observable.just(stellarRequest) + .doOnNext(stellarRequest1 -> doStellarRequest(ctx, stellarRequest)) + +// .flatMap(stellarRequest1 -> { +// if (stellarRequest1.answerData == null) { +// Log.e(TAG, "NullPointerException " + stellarRequest.getMethod()); +// return Observable.error(new NullPointerException()); +// } else +// return Observable.just(stellarRequest1); +// }) +// + .retryWhen(errors -> errors + .filter(throwable -> throwable instanceof IOException) + .zipWith(Observable.range(1, 4), (n, i) -> i)) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + stellarObserver.subscribe(new DefaultObserver() { + + @Override + public void onNext(StellarRequest.Base stellarRequest) { + + } + + @Override + public void onError(Throwable e) { + requestsCount--; + Log.e(TAG, "requestData " + stellarRequest.getClass().getSimpleName() + " onError " + e.getMessage()); + Log.e(TAG, String.format("%d requests left in processing",requestsCount)); + stellarRequest.setError(ctx.getString(R.string.cannot_obtain_data_from_blockchain)); + //setErrorOccurred(e.getMessage());//; + listener.onFail(stellarRequest); + } + + /** + * Called after completion request processing + */ + @Override + public void onComplete() { + requestsCount--; + Log.e(TAG, String.format("%d requests left in processing",requestsCount)); + if (stellarRequest.getError() != null) { + Log.i(TAG, "requestData " + stellarRequest.getClass().getSimpleName() + " onComplete, error!=null"); + listener.onFail(stellarRequest); + } else { + Log.e(TAG, "requestData " + stellarRequest.getClass().getSimpleName() + " onComplete, error==null"); + listener.onSuccess(stellarRequest); + } + } + + }); + } + + private void doStellarRequest(TangemContext ctx, StellarRequest.Base stellarRequest) throws IOException { + stellarRequest.setError(null); + try { + if( ctx.getBlockchain()==Blockchain.StellarTestNet ) { + Network.useTestNetwork(); + } + Server server = new Server(ServerURL.API_STELLAR); + stellarRequest.process(server); + } + catch (Exception e) + { + e.printStackTrace(); + stellarRequest.setError(App.getInstance().getString(R.string.cannot_obtain_data_from_blockchain_communication_error)); + throw e; + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/data/network/ServerURL.java b/app/src/main/java/com/tangem/data/network/ServerURL.java index 297b3ee60c..4703dd6436 100644 --- a/app/src/main/java/com/tangem/data/network/ServerURL.java +++ b/app/src/main/java/com/tangem/data/network/ServerURL.java @@ -6,4 +6,5 @@ class ServerURL { static final String API_INFURA = "https://mainnet.infura.io/v3/"; static final String API_ESTIMATEFEE = "https://estimatefee.com/"; static final String API_UPDATE_VERSION = "https://raw.githubusercontent.com/"; + static final String API_STELLAR = "https://horizon-testnet.stellar.org"; } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/data/network/StellarRequest.java b/app/src/main/java/com/tangem/data/network/StellarRequest.java new file mode 100644 index 0000000000..03a6fe90dc --- /dev/null +++ b/app/src/main/java/com/tangem/data/network/StellarRequest.java @@ -0,0 +1,72 @@ +package com.tangem.data.network; + +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Server; +import org.stellar.sdk.Transaction; +import org.stellar.sdk.responses.AccountResponse; +import org.stellar.sdk.responses.SubmitTransactionResponse; + +import java.io.IOException; + +/** + * Created by dvol on 16.07.2017. + */ + +public class StellarRequest { + + public static abstract class Base { + private String error = null; + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public abstract void process(Server server) throws IOException; + } + + public static class Balance extends Base { + public KeyPair accountKeyPair; + public AccountResponse accountResponse; + + public Balance(String walletAddress) + { + accountKeyPair=KeyPair.fromAccountId(walletAddress); + } + + @Override + public void process(Server server) throws IOException { + accountResponse=server.accounts().account(accountKeyPair); + } + } + + public static class SubmitTransaction extends Base { +// public KeyPair sourceAccount; +// public KeyPair targetAccount; + public Transaction transaction; + public SubmitTransactionResponse response; + + public SubmitTransaction(Transaction transaction) { + this.transaction=transaction; + } + + @Override + public void process(Server server) throws IOException { +// // First, check to make sure that the destination account exists. +// // You could skip this, but if the account does not exist, you will be charged +// // the transaction fee when the transaction fails. +// // It will throw HttpResponseException if account does not exist or there was another error. +// server.accounts().account(targetAccount); +// +// // If there was no error, load up-to-date information on your account. +// AccountResponse sourceAccount = server.accounts().account(sourceAccount); + + // And finally, send it off to Stellar! + response = server.submitTransaction(transaction); + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/domain/wallet/CoinEngineFactory.kt b/app/src/main/java/com/tangem/domain/wallet/CoinEngineFactory.kt index 951b7eb457..7685b20986 100644 --- a/app/src/main/java/com/tangem/domain/wallet/CoinEngineFactory.kt +++ b/app/src/main/java/com/tangem/domain/wallet/CoinEngineFactory.kt @@ -8,6 +8,7 @@ import com.tangem.domain.wallet.token.TokenEngine import com.tangem.domain.wallet.bch.BtcCashEngine import com.tangem.data.Blockchain import com.tangem.domain.wallet.ltc.LtcEngine +import com.tangem.domain.wallet.xlm.XlmEngine /** * Factory for create specific engine @@ -44,6 +45,8 @@ object CoinEngineFactory { TokenEngine(context) else if (Blockchain.Litecoin == context.blockchain) LtcEngine(context) + else if (Blockchain.Stellar == context.blockchain || Blockchain.StellarTestNet == context.blockchain) + XlmEngine(context) else return null } catch (e: Exception) { diff --git a/app/src/main/java/com/tangem/domain/wallet/xlm/XlmData.java b/app/src/main/java/com/tangem/domain/wallet/xlm/XlmData.java new file mode 100644 index 0000000000..a4ca60c528 --- /dev/null +++ b/app/src/main/java/com/tangem/domain/wallet/xlm/XlmData.java @@ -0,0 +1,81 @@ +package com.tangem.domain.wallet.xlm; + +import android.os.Bundle; +import android.util.Log; + +import com.tangem.domain.wallet.CoinData; +import com.tangem.domain.wallet.CoinEngine; + +import org.stellar.sdk.Account; +import org.stellar.sdk.responses.AccountResponse; +import org.stellar.sdk.responses.GsonSingleton; + +import java.math.BigInteger; + +public class XlmData extends CoinData { + private CoinEngine.InternalAmount balance = null; + private AccountResponse accountResponse = null; + + + @Override + public void clearInfo() { + super.clearInfo(); + balance = null; + } + + public CoinEngine.InternalAmount getBalanceInInternalUnits() { + return balance; + + } + + public void setBalanceInInternalUnits(CoinEngine.InternalAmount value) { + balance = value; + } + + public AccountResponse getAccountResponse() { + return accountResponse; + } + + public void setAccountResponse(AccountResponse accountResponse) { + this.accountResponse = accountResponse; + } + + @Override + public void loadFromBundle(Bundle B) { + super.loadFromBundle(B); + + if (B.containsKey("BalanceCurrency") && B.containsKey("BalanceDecimal")) { + String currency = B.getString("BalanceCurrency"); + balance = new CoinEngine.InternalAmount(B.getString("BalanceDecimal"), currency); + } else { + balance = null; + } + + if (B.containsKey("accountResponse")) { + accountResponse = GsonSingleton.getInstance().fromJson(B.getString("accountResponse"), AccountResponse.class); + } else { + accountResponse = null; + } + + } + + @Override + public void saveToBundle(Bundle B) { + super.saveToBundle(B); + try { + if (balance != null) { + B.putString("BalanceCurrency", balance.getCurrency()); + B.putString("BalanceDecimal", balance.toString()); + } + + if (accountResponse != null) { + B.putString("accountResponse", GsonSingleton.getInstance().toJson(accountResponse)); + } + + } catch (Exception e) { + Log.e("Can't save to bundle ", e.getMessage()); + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/domain/wallet/xlm/XlmEngine.java b/app/src/main/java/com/tangem/domain/wallet/xlm/XlmEngine.java new file mode 100644 index 0000000000..406a7e1804 --- /dev/null +++ b/app/src/main/java/com/tangem/domain/wallet/xlm/XlmEngine.java @@ -0,0 +1,509 @@ +package com.tangem.domain.wallet.xlm; + +import android.net.Uri; +import android.text.InputFilter; +import android.util.Log; + +import com.tangem.data.Blockchain; +import com.tangem.data.network.ServerApiStellar; +import com.tangem.data.network.StellarRequest; +import com.tangem.domain.wallet.BalanceValidator; +import com.tangem.domain.wallet.CoinData; +import com.tangem.domain.wallet.CoinEngine; +import com.tangem.domain.wallet.TangemContext; +import com.tangem.tangemcard.data.TangemCard; +import com.tangem.tangemcard.tasks.SignTask; +import com.tangem.tangemcard.util.Util; +import com.tangem.util.DecimalDigitsInputFilter; +import com.tangem.wallet.R; + +import org.stellar.sdk.AssetTypeNative; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Memo; +import org.stellar.sdk.PaymentOperation; +import org.stellar.sdk.Transaction; +import org.stellar.sdk.responses.AccountResponse; +import org.stellar.sdk.xdr.TransactionEnvelope; +import org.stellar.sdk.xdr.XdrDataInputStream; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.math.BigDecimal; + +public class XlmEngine extends CoinEngine { + + private static final String TAG = XlmEngine.class.getSimpleName(); + + public XlmData coinData = null; + + public XlmEngine(TangemContext context) throws Exception { + super(context); + if (context.getCoinData() == null) { + coinData = new XlmData(); + context.setCoinData(coinData); + } else if (context.getCoinData() instanceof XlmData) { + coinData = (XlmData) context.getCoinData(); + } else { + throw new Exception("Invalid type of Blockchain data for XlmEngine"); + } + } + + public XlmEngine() { + super(); + } + + private static int getDecimals() { + return 8; + } + + + private void checkBlockchainDataExists() throws Exception { + if (coinData == null) throw new Exception("No blockchain data"); + } + + @Override + public boolean awaitingConfirmation() { + if (coinData == null) return false; + //TODO + return false;//coinData.getBalanceUnconfirmed() != 0; + } + + @Override + public String getBalanceHTML() { + Amount balance = getBalance(); + if (balance != null) { + return balance.toDescriptionString(getDecimals()); + } else { + return ""; + } + } + + @Override + public String getBalanceCurrency() { + return "XLM"; + } + + @Override + public String getOfflineBalanceHTML() { + InternalAmount offlineInternalAmount = convertToInternalAmount(ctx.getCard().getOfflineBalance()); + Amount offlineAmount = convertToAmount(offlineInternalAmount); + return offlineAmount.toDescriptionString(getDecimals()); + } + + @Override + public boolean isBalanceNotZero() { + if (coinData == null) return false; + if (coinData.getBalanceInInternalUnits() == null) return false; + return coinData.getBalanceInInternalUnits().notZero(); + } + + @Override + public boolean hasBalanceInfo() { + if (coinData == null) return false; + return coinData.getBalanceInInternalUnits() != null; + } + + + @Override + public boolean isExtractPossible() { + if (!hasBalanceInfo()) { + ctx.setMessage(R.string.cannot_obtain_data_from_blockchain); + } else if (!isBalanceNotZero()) { + ctx.setMessage(R.string.wallet_empty); + } else if (awaitingConfirmation()) { + ctx.setMessage(R.string.please_wait_while_previous); + } else { + return true; + } + return false; + } + + @Override + public String getFeeCurrency() { + return "XLM"; + } + + @Override + public boolean validateAddress(String address) { + try { + KeyPair kp = KeyPair.fromAccountId(address); + // TODO is it possible to check address testNet or not +// if (ctx.getBlockchain() == Blockchain.StellarTestNet) { +// return false; +// } + } catch (Exception e) { + return false; + } + return true; + } + + + @Override + public boolean isNeedCheckNode() { + return false; + } + + @Override + public Uri getShareWalletUriExplorer() { + //TODO - ? + return Uri.parse((ctx.getBlockchain() == Blockchain.Bitcoin ? "https://blockchain.info/address/" : "https://testnet.blockchain.info/address/") + ctx.getCoinData().getWallet()); + } + + @Override + public Uri getShareWalletUri() { + //TODO - ? + if (ctx.getCard().getDenomination() != null) { + return Uri.parse("bitcoin:" + ctx.getCoinData().getWallet() + "?amount=" + convertToAmount(convertToInternalAmount(ctx.getCard().getDenomination())).toValueString(8)); + } else { + return Uri.parse("bitcoin:" + ctx.getCoinData().getWallet()); + } + } + + @Override + public InputFilter[] getAmountInputFilters() { + return new InputFilter[]{new DecimalDigitsInputFilter(getDecimals())}; + } + + @Override + public boolean checkNewTransactionAmount(Amount amount) { + if (coinData == null) return false; + if (amount.compareTo(convertToAmount(coinData.getBalanceInInternalUnits())) > 0) { + return false; + } + return true; + } + + @Override + public boolean checkNewTransactionAmountAndFee(Amount amountValue, Amount feeValue, Boolean isIncludeFee) { + InternalAmount fee; + InternalAmount amount; + + try { + checkBlockchainDataExists(); + amount = convertToInternalAmount(amountValue); + fee = convertToInternalAmount(feeValue); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + + if (fee == null || amount == null) + return false; + + if (fee.isZero() || amount.isZero()) + return false; + + if (isIncludeFee && (amount.compareTo(coinData.getBalanceInInternalUnits()) > 0 || amount.compareTo(fee) < 0)) + return false; + + if (!isIncludeFee && amount.add(fee).compareTo(coinData.getBalanceInInternalUnits()) > 0) + return false; + + return true; + } + + @Override + public boolean validateBalance(BalanceValidator balanceValidator) { + try { + if (((ctx.getCard().getOfflineBalance() == null) && !ctx.getCoinData().isBalanceReceived()) || (!ctx.getCoinData().isBalanceReceived() && (ctx.getCard().getRemainingSignatures() != ctx.getCard().getMaxSignatures()))) { + balanceValidator.setScore(0); + balanceValidator.setFirstLine("Unknown balance"); + balanceValidator.setSecondLine("Balance cannot be verified. Swipe down to refresh."); + return false; + } + + // Workaround before new back-end +// if (card.getRemainingSignatures() == card.getMaxSignatures()) { +// firstLine = "Verified balance"; +// secondLine = "Balance confirmed in blockchain. "; +// secondLine += "Verified note identity. "; +// return; +// } + +// if (coinData.getBalanceUnconfirmed() != 0) { +// balanceValidator.setScore(0); +// balanceValidator.setFirstLine("Transaction in progress"); +// balanceValidator.setSecondLine("Wait for confirmation in blockchain"); +// return false; +// } + + if (coinData.isBalanceReceived() && coinData.isBalanceEqual()) { + balanceValidator.setScore(100); + balanceValidator.setFirstLine("Verified balance"); + balanceValidator.setSecondLine("Balance confirmed in blockchain"); + if (coinData.getBalanceInInternalUnits().isZero()) { + balanceValidator.setFirstLine("Empty wallet"); + balanceValidator.setSecondLine(""); + } + } + + // rule 4 TODO: need to check SignedHashed against number of outputs in blockchain +// if((card.getRemainingSignatures() != card.getMaxSignatures()) && card.getBalance() != 0) +// { +// score = 80; +// firstLine = "Unguaranteed balance"; +// secondLine = "Potential unsent transaction. Redeem immediately if accept. "; +// return; +// } + + if ((ctx.getCard().getOfflineBalance() != null) && !coinData.isBalanceReceived() && (ctx.getCard().getRemainingSignatures() == ctx.getCard().getMaxSignatures()) && coinData.getBalanceInInternalUnits().notZero()) { + balanceValidator.setScore(80); + balanceValidator.setFirstLine("Verified offline balance"); + balanceValidator.setSecondLine("Can't obtain balance from blockchain. Restore internet connection to be more confident. "); + } + +// if(card.getFailedBalanceRequestCounter()!=0) { +// score -= 5 * card.getFailedBalanceRequestCounter(); +// secondLine += "Not all nodes have returned balance. Swipe down or tap again. "; +// if(score <= 0) +// return; +// } + + // +// if(card.isBalanceReceived() && !card.isBalanceEqual()) { +// score = 0; +// firstLine = "Disputed balance"; +// secondLine += " Cannot obtain trusted balance at the moment. Try to tap and check this banknote later."; +// return; +// } + + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + @Override + public Amount getBalance() { + if (!hasBalanceInfo()) return null; + return convertToAmount(coinData.getBalanceInInternalUnits()); + } + + @Override + public String evaluateFeeEquivalent(String fee) { + if (!coinData.getAmountEquivalentDescriptionAvailable()) return ""; + try { + Amount feeAmount = new Amount(fee, getFeeCurrency()); + return feeAmount.toEquivalentString(coinData.getRate()); + } catch (Exception e) { + return ""; + } + } + + @Override + public String getBalanceEquivalent() { + if (coinData == null || !coinData.getAmountEquivalentDescriptionAvailable()) return ""; + Amount balance = getBalance(); + if (balance == null) return ""; + return balance.toEquivalentString(coinData.getRate()); + } + + @Override + public String calculateAddress(byte[] pkUncompressed) { + KeyPair kp = KeyPair.fromPublicKey(pkUncompressed); + return kp.getAccountId(); + } + + private static BigDecimal multiplier = new BigDecimal("1000000"); + + @Override + public Amount convertToAmount(InternalAmount internalAmount) { + BigDecimal d = internalAmount.divide(multiplier); + return new Amount(d, getBalanceCurrency()); + } + + @Override + public Amount convertToAmount(String strAmount, String currency) { + return new Amount(strAmount, currency); + } + + @Override + public InternalAmount convertToInternalAmount(Amount amount) { + BigDecimal d = amount.multiply(multiplier); + return new InternalAmount(d, "stroops"); + } + + @Override + public InternalAmount convertToInternalAmount(byte[] bytes) { + if (bytes == null) return null; + byte[] reversed = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) reversed[i] = bytes[bytes.length - i - 1]; + return new InternalAmount(Util.byteArrayToLong(reversed), "stroops"); + } + + @Override + public byte[] convertToByteArray(InternalAmount internalAmount) { + byte[] bytes = Util.longToByteArray(internalAmount.longValueExact()); + byte[] reversed = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) reversed[i] = bytes[bytes.length - i - 1]; + return reversed; + } + + @Override + public CoinData createCoinData() { + return new XlmData(); + } + + @Override + public String getUnspentInputsDescription() { + return ""; + } + + @Override + public SignTask.PaymentToSign constructPayment(Amount amountValue, Amount feeValue, boolean IncFee, String targetAddress) throws Exception { + checkBlockchainDataExists(); + + Transaction transaction = new Transaction.Builder(coinData.getAccountResponse()) + .addOperation(new PaymentOperation.Builder(KeyPair.fromAccountId(targetAddress), new AssetTypeNative(), amountValue.toValueString()).build()) + // A memo allows you to add your own metadata to a transaction. It's + // optional and does not affect how Stellar treats the transaction. + .addMemo(Memo.text("TangemCard Transaction")) + .build(); + + if( transaction.getFee()!=convertToInternalAmount(feeValue).intValueExact() ) + { + throw new Exception("Invalid fee!"); + } + + return new SignTask.PaymentToSign() { + + @Override + public boolean isSigningMethodSupported(TangemCard.SigningMethod signingMethod) { + return signingMethod == TangemCard.SigningMethod.Sign_Hash || signingMethod == TangemCard.SigningMethod.Sign_Raw; + } + + @Override + public byte[][] getHashesToSign() throws Exception { + byte[][] dataForSign = new byte[1][]; + dataForSign[0] = transaction.hash(); + return dataForSign; + } + + @Override + public byte[] getRawDataToSign() throws Exception { + throw new Exception("Hashes length must be identical!"); + } + + @Override + public String getHashAlgToSign() { + return "sha-256x2"; + } + + @Override + public byte[] getIssuerTransactionSignature(byte[] dataToSignByIssuer) throws Exception { + throw new Exception("Issuer validation not supported!"); + } + + @Override + public byte[] onSignCompleted(byte[] signFromCard) throws Exception { + // Sign the transaction to prove you are actually the person sending it. + transaction.sign(signFromCard); + + byte[] txForSend = transaction.toEnvelopeXdrBase64().getBytes(); + notifyOnNeedSendPayment(txForSend); + return txForSend; + } + }; + } + + @Override + public void requestBalanceAndUnspentTransactions(BlockchainRequestsCallbacks blockchainRequestsCallbacks) { + final ServerApiStellar serverApi = new ServerApiStellar(); + + ServerApiStellar.Listener listener = new ServerApiStellar.Listener() { + @Override + public void onSuccess(StellarRequest.Base request) { + Log.i(TAG, "onSuccess: " + request.getClass().getSimpleName()); + if (!StellarRequest.Balance.class.isInstance(request)) { + ctx.setError("Invalid request logic"); + blockchainRequestsCallbacks.onComplete(false); + return; + } + StellarRequest.Balance balanceRequest = (StellarRequest.Balance) request; + + coinData.setAccountResponse(balanceRequest.accountResponse); + AccountResponse.Balance balance=balanceRequest.accountResponse.getBalances()[0]; + coinData.setBalanceInInternalUnits(new InternalAmount(balance.getBalance(), "stroops")); + if (serverApi.isRequestsSequenceCompleted()) { + blockchainRequestsCallbacks.onComplete(!ctx.hasError()); + } else { + blockchainRequestsCallbacks.onProgress(); + } + } + + + @Override + public void onFail(StellarRequest.Base request) { + Log.i(TAG, "onFail: " + request.getClass().getSimpleName() + " " + request.getError()); + ctx.setError(request.getError()); + if (serverApi.isRequestsSequenceCompleted()) { + blockchainRequestsCallbacks.onComplete(false); + } else { + blockchainRequestsCallbacks.onProgress(); + } + } + }; + + serverApi.setListener(listener); + + serverApi.requestData(ctx, new StellarRequest.Balance(coinData.getWallet())); + } + + @Override + public void requestFee(BlockchainRequestsCallbacks blockchainRequestsCallbacks, String targetAddress, Amount amount) throws Exception { + final int calcSize = 0; + Log.e(TAG, String.format("Estimated tx size %d", calcSize)); + coinData.minFee = null; + coinData.maxFee = null; + coinData.normalFee = null; + coinData.minFee = new Amount("0.00001", getFeeCurrency()); + coinData.normalFee = new Amount("0.00001", getFeeCurrency()); + coinData.maxFee = new Amount("0.00001", getFeeCurrency()); + blockchainRequestsCallbacks.onComplete(true); + } + + @Override + public void requestSendTransaction(BlockchainRequestsCallbacks blockchainRequestsCallbacks, byte[] txForSend) throws IOException { + final ServerApiStellar serverApi = new ServerApiStellar(); + + ServerApiStellar.Listener listener = new ServerApiStellar.Listener() { + @Override + public void onSuccess(StellarRequest.Base request) { + try { + if (!StellarRequest.SubmitTransaction.class.isInstance(request)) throw new Exception("Invalid request logic"); + StellarRequest.SubmitTransaction submitTransactionRequest = (StellarRequest.SubmitTransaction) request; + if (submitTransactionRequest.response.isSuccess()) { + ctx.setError(null); + blockchainRequestsCallbacks.onComplete(true); + } else { + ctx.setError("Rejected by node"); + blockchainRequestsCallbacks.onComplete(false); + } + } catch (Exception e) { + if (e.getMessage() != null) { + ctx.setError(e.getMessage()); + blockchainRequestsCallbacks.onComplete(false); + } else { + ctx.setError(e.getClass().getName()); + blockchainRequestsCallbacks.onComplete(false); + } + } + + } + + @Override + public void onFail(StellarRequest.Base request) { + ctx.setError(request.getError()); + blockchainRequestsCallbacks.onComplete(false); + } + }; + serverApi.setListener(listener); + + TransactionEnvelope transactionEnvelope = TransactionEnvelope.decode(new XdrDataInputStream(new ByteArrayInputStream(txForSend))); + org.stellar.sdk.Transaction transaction = org.stellar.sdk.Transaction.fromEnvelopeXdr(transactionEnvelope); + serverApi.requestData(ctx, new StellarRequest.SubmitTransaction(transaction)); + + } + +} \ No newline at end of file diff --git a/tangemcard-android/src/main/res/drawable/ic_logo_stellar.png b/tangemcard-android/src/main/res/drawable/ic_logo_stellar.png new file mode 100644 index 0000000000000000000000000000000000000000..b8fbbd75b4937406888bee3e6161cb66e5c2ab46 GIT binary patch literal 23528 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