diff --git a/app/build.gradle b/app/build.gradle index af2ce223fa..b612618a1f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -59,13 +59,13 @@ android { } packagingOptions { pickFirst('META-INF/proguard/okhttp3.pro') - // for one.block:eosiojava:0.1.0 +// for one.block:eosiojava:0.1.0 exclude 'lib/x86_64/darwin/libscrypt.dylib' exclude 'lib/x86_64/freebsd/libscrypt.so' exclude 'lib/x86_64/linux/libscrypt.so' exclude 'org.slf4j:slf4j-jdk14:1.7.25' } - buildToolsVersion '28.0.3' + buildToolsVersion '29.0.3' } repositories { @@ -132,6 +132,7 @@ dependencies { implementation 'com.google.firebase:firebase-crashlytics:17.0.0-beta04' implementation 'com.google.firebase:firebase-perf:19.0.6' + //dependencies for XRP implementation files('libs/ripple-core-0.0.1.jar') //4 dependencies for ripple-core TODO: move to module? implementation 'net.i2p.crypto:eddsa:0.3.0' diff --git a/app/src/main/java/com/tangem/data/network/PayIdApi.java b/app/src/main/java/com/tangem/data/network/PayIdApi.java new file mode 100644 index 0000000000..032968baaa --- /dev/null +++ b/app/src/main/java/com/tangem/data/network/PayIdApi.java @@ -0,0 +1,17 @@ +package com.tangem.data.network; + +import com.tangem.data.network.model.PayIdResponse; + +import io.reactivex.Single; +import retrofit2.http.GET; +import retrofit2.http.Headers; +import retrofit2.http.Path; + +public interface PayIdApi { + @Headers({ + "Accept: application/xrpl-mainnet+json", + "PayID-Version: 1.0" + }) + @GET("{user}") + Single getAddress(@Path("user") String user); +} diff --git a/app/src/main/java/com/tangem/data/network/ServerApiPayId.java b/app/src/main/java/com/tangem/data/network/ServerApiPayId.java new file mode 100644 index 0000000000..237fcb279d --- /dev/null +++ b/app/src/main/java/com/tangem/data/network/ServerApiPayId.java @@ -0,0 +1,48 @@ +package com.tangem.data.network; + +import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import com.tangem.App; +import com.tangem.data.network.model.PayIdResponse; +import com.tangem.tangem_card.util.Log; + +import io.reactivex.Single; +import io.reactivex.SingleObserver; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +public class ServerApiPayId { + private static String TAG = ServerApiPayId.class.getSimpleName(); + + 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; + } + + public void getAddress(String payID, SingleObserver addressObserver) { + requestsCount++; + Log.i(TAG, "new getAddress request"); + + String[] addressParts = payID.split("\\$"); + String user = addressParts[0]; + String domain = addressParts[1]; + + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + domain + "/") + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build(); + + PayIdApi api = retrofit.create(PayIdApi.class); + + Single addressSingle = api.getAddress(user) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnEvent((object, throwable) -> requestsCount--); + + addressSingle.subscribe(addressObserver); + } +} diff --git a/app/src/main/java/com/tangem/data/network/model/PayIdResponse.kt b/app/src/main/java/com/tangem/data/network/model/PayIdResponse.kt new file mode 100644 index 0000000000..583ae73462 --- /dev/null +++ b/app/src/main/java/com/tangem/data/network/model/PayIdResponse.kt @@ -0,0 +1,24 @@ +package com.tangem.data.network.model + +import com.google.gson.annotations.SerializedName + +data class PayIdResponse( + @SerializedName("addresses") + var addresses: List? = null +) + +data class PayIdAddress( + @SerializedName("paymentNetwork") + var paymentNetwork: String? = null, + + @SerializedName("environment") + var environment: String? = null, + + @SerializedName("addressDetails") + var addressDetails: PayIdAddressDetails? = null +) + +data class PayIdAddressDetails( + @SerializedName("address") + var address: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/wallet/xrp/XrpBase58.java b/app/src/main/java/com/tangem/wallet/xrp/XrpBase58.java deleted file mode 100644 index c149776f2c..0000000000 --- a/app/src/main/java/com/tangem/wallet/xrp/XrpBase58.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.tangem.wallet.xrp; - -import java.math.BigInteger; - -/** - * Created by Ilia on 15.02.2018. - */ - -public class XrpBase58 { - private static final char[] BASE58 = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz".toCharArray(); - - private static final int BASE58_CHUNK_DIGITS = 10;//how many base 58 digits fits in long - private static final BigInteger BASE58_CHUNK_MOD = BigInteger.valueOf(0x5fa8624c7fba400L); //58^BASE58_CHUNK_DIGITS - private static final byte[] BASE58_VALUES = new byte[]{-1, -1, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, -1, -1, -1, -1, -1, -1, - -1, 9, 10, 11, 12, 13, 14, 15, 16, -1, 17, 18, 19, 20, 21, -1, - 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, -1, -1, -1, -1, -1, - -1, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, -1, 44, 45, 46, - 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; - - public static byte[] decodeBase58(String input) { - if (input == null) { - return null; - } - input = input.trim(); - if (input.length() == 0) { - return new byte[0]; - } - BigInteger resultNum = BigInteger.ZERO; - int nLeadingZeros = 0; - while (nLeadingZeros < input.length() && input.charAt(nLeadingZeros) == BASE58[0]) { - nLeadingZeros++; - } - long acc = 0; - int nDigits = 0; - int p = nLeadingZeros; - while (p < input.length()) { - int v = BASE58_VALUES[input.charAt(p) & 0xff]; - if (v >= 0) { - acc *= 58; - acc += v; - nDigits++; - if (nDigits == BASE58_CHUNK_DIGITS) { - resultNum = resultNum.multiply(BASE58_CHUNK_MOD).add(BigInteger.valueOf(acc)); - acc = 0; - nDigits = 0; - } - p++; - } else { - break; - } - } - if (nDigits > 0) { - long mul = 58; - while (--nDigits > 0) { - mul *= 58; - } - resultNum = resultNum.multiply(BigInteger.valueOf(mul)).add(BigInteger.valueOf(acc)); - } - final int BASE58_SPACE = -2; - while (p < input.length() && BASE58_VALUES[input.charAt(p) & 0xff] == BASE58_SPACE) { - p++; - } - if (p < input.length()) { - return null; - } - byte[] plainNumber = resultNum.toByteArray(); - int plainNumbersOffs = plainNumber[0] == 0 ? 1 : 0; - byte[] result = new byte[nLeadingZeros + plainNumber.length - plainNumbersOffs]; - System.arraycopy(plainNumber, plainNumbersOffs, result, nLeadingZeros, plainNumber.length - plainNumbersOffs); - return result; - } - - public static String encodeBase58(byte[] input) { - if (input == null) { - return null; - } - StringBuilder str = new StringBuilder((input.length * 350) / 256 + 1); - BigInteger bn = new BigInteger(1, input); - long rem; - while (true) { - BigInteger[] divideAndRemainder = bn.divideAndRemainder(BASE58_CHUNK_MOD); - bn = divideAndRemainder[0]; - rem = divideAndRemainder[1].longValue(); - if (bn.compareTo(BigInteger.ZERO) == 0) { - break; - } - for (int i = 0; i < BASE58_CHUNK_DIGITS; i++) { - str.append(BASE58[(int) (rem % 58)]); - rem /= 58; - } - } - while (rem != 0) { - str.append(BASE58[(int) (rem % 58)]); - rem /= 58; - } - str.reverse(); - int nLeadingZeros = 0; - while (nLeadingZeros < input.length && input[nLeadingZeros] == 0) { - str.insert(0, BASE58[0]); - nLeadingZeros++; - } - return str.toString(); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/wallet/xrp/XrpData.java b/app/src/main/java/com/tangem/wallet/xrp/XrpData.java index ca79936381..09bc6276fa 100644 --- a/app/src/main/java/com/tangem/wallet/xrp/XrpData.java +++ b/app/src/main/java/com/tangem/wallet/xrp/XrpData.java @@ -19,6 +19,8 @@ public class XrpData extends CoinData { private Boolean accountNotFound, targetAccountCreated = false; + private String resolvedPayIdAddress = null; + @Override public void loadFromBundle(Bundle B) { super.loadFromBundle(B); @@ -35,6 +37,8 @@ public class XrpData extends CoinData { else accountNotFound = false; if (B.containsKey("TargetAccountCreated")) targetAccountCreated = B.getBoolean("TargetAccountCreated"); else targetAccountCreated = false; + if (B.containsKey("ResolvedPayIdAddress")) resolvedPayIdAddress = B.getString("ResolvedPayIdAddress"); + else resolvedPayIdAddress = null; } @Override @@ -47,6 +51,7 @@ public class XrpData extends CoinData { if (reserve != null) B.putLong("Reserve", reserve); if (accountNotFound != null) B.putBoolean("AccoundNotFound", accountNotFound); if (targetAccountCreated != null) B.putBoolean("TargetAccountCreated", targetAccountCreated); + if (resolvedPayIdAddress != null) B.putString("ResolvedPayIdAddress", resolvedPayIdAddress); } catch (Exception e) { Log.e("Can't save to bundle ", e.getMessage()); } @@ -61,6 +66,7 @@ public class XrpData extends CoinData { reserve = 20000000L; accountNotFound = false; targetAccountCreated = false; + resolvedPayIdAddress = null; } // balanceUnconfirmed is just the latest balance, it equals balanceConfirmed if no unconfirmed transaction present @@ -124,4 +130,12 @@ public class XrpData extends CoinData { public boolean hasUnconfirmed() { return hasBalanceInfo() && !balanceConfirmed.equals(balanceUnconfirmed); } + + public String getResolvedPayIdAddress() { + return resolvedPayIdAddress; + } + + public void setResolvedPayIdAddress(String resolvedPayIdAddress) { + this.resolvedPayIdAddress = resolvedPayIdAddress; + } } diff --git a/app/src/main/java/com/tangem/wallet/xrp/XrpEngine.java b/app/src/main/java/com/tangem/wallet/xrp/XrpEngine.java index ff06160ba2..f7d551992d 100644 --- a/app/src/main/java/com/tangem/wallet/xrp/XrpEngine.java +++ b/app/src/main/java/com/tangem/wallet/xrp/XrpEngine.java @@ -10,7 +10,10 @@ import com.ripple.crypto.ecdsa.ECDSASignature; import com.ripple.encodings.addresses.Addresses; import com.ripple.utils.HashUtils; import com.tangem.App; +import com.tangem.data.network.ServerApiPayId; import com.tangem.data.network.ServerApiRipple; +import com.tangem.data.network.model.PayIdAddress; +import com.tangem.data.network.model.PayIdResponse; import com.tangem.data.network.model.RippleResponse; import com.tangem.tangem_card.data.TangemCard; import com.tangem.tangem_card.reader.CardProtocol; @@ -27,8 +30,12 @@ import com.tangem.wallet.TangemContext; import java.math.BigDecimal; import java.math.BigInteger; +import java.net.URL; import java.util.Arrays; +import io.reactivex.SingleObserver; +import io.reactivex.observers.DisposableSingleObserver; + public class XrpEngine extends CoinEngine { private static final String TAG = XrpEngine.class.getSimpleName(); @@ -116,25 +123,26 @@ public class XrpEngine extends CoinEngine { if (address == null || address.isEmpty()) { return false; } + if (address.contains("$")) { // PayID + String[] addressParts = address.split("\\$"); - if (address.length() < 25) { - return false; - } - - if (address.length() > 35) { - return false; - } - - if (!address.startsWith("r")) { - return false; + if (addressParts.length != 2) { + return false; + } + String addressURL = "https://" + addressParts[1] + "/" + addressParts[0]; + try { + new URL(addressURL).toURI(); + return true; + } catch (Exception e) { + return false; + } } try { Addresses.decodeAccountID(address); + return true; } catch (Exception e) { - return false; + return XrpXAddressService.Companion.validate(address); } - - return true; } @Override @@ -342,7 +350,22 @@ public class XrpEngine extends CoinEngine { public SignTask.TransactionToSign constructTransaction(Amount amountValue, Amount feeValue, boolean IncFee, String targetAddress) throws Exception { checkBlockchainDataExists(); - String amount, fee; + String amount, fee, destination; + Integer destinationTag = null; + + //PayID + if (coinData.getResolvedPayIdAddress() != null) { + destination = coinData.getResolvedPayIdAddress(); + } else { + destination = targetAddress; + } + + //X-address + XrpXAddressDecoded decodedXAddress = XrpXAddressService.Companion.decode(destination); + if (decodedXAddress != null) { + destination = decodedXAddress.getAddress(); + destinationTag = decodedXAddress.getDestinationTag(); + } if (IncFee) { amount = convertToInternalAmount(amountValue).subtract(convertToInternalAmount(feeValue)).setScale(0).toPlainString(); @@ -363,10 +386,13 @@ public class XrpEngine extends CoinEngine { // Put `as` AccountID field Account, `Object` o payment.as(AccountID.Account, coinData.getWallet()); - payment.as(AccountID.Destination, targetAddress); + payment.as(AccountID.Destination, destination); payment.as(com.ripple.core.coretypes.Amount.Amount, amount); payment.as(UInt32.Sequence, coinData.getSequence()); payment.as(com.ripple.core.coretypes.Amount.Fee, fee); + if (destinationTag != null) { + payment.as(UInt32.DestinationTag, destinationTag); + } XrpSignedTransaction signedTx = payment.prepare(canonisePubKey(ctx.getCard().getWalletPublicKeyRar())); @@ -525,13 +551,14 @@ public class XrpEngine extends CoinEngine { @Override public void requestFee(BlockchainRequestsCallbacks blockchainRequestsCallbacks, String targetAddress, Amount amount) { final ServerApiRipple serverApiRipple = new ServerApiRipple(); + final ServerApiPayId serverApiPayId = new ServerApiPayId(); ServerApiRipple.ResponseListener rippleListener = new ServerApiRipple.ResponseListener() { @Override public void onSuccess(String method, RippleResponse rippleResponse) { Log.i(TAG, "onSuccess: " + method); switch (method) { - case ServerApiRipple.RIPPLE_ACCOUNT_INFO: { + case ServerApiRipple.RIPPLE_ACCOUNT_INFO: { //check if target account is created try { if (rippleResponse.getResult().getError_code().equals(19)) { // "Account not found" coinData.setTargetAccountCreated(false); @@ -542,7 +569,7 @@ public class XrpEngine extends CoinEngine { coinData.setTargetAccountCreated(true); //expected behaviour, if account exists, there should be no error code -> null pointer } - if (serverApiRipple.isRequestsSequenceCompleted()) { + if (serverApiRipple.isRequestsSequenceCompleted() && serverApiPayId.isRequestsSequenceCompleted()) { blockchainRequestsCallbacks.onComplete(!ctx.hasError()); } else { blockchainRequestsCallbacks.onProgress(); @@ -566,7 +593,7 @@ public class XrpEngine extends CoinEngine { ctx.setError(e.getMessage()); } - if (serverApiRipple.isRequestsSequenceCompleted()) { + if (serverApiRipple.isRequestsSequenceCompleted() && serverApiPayId.isRequestsSequenceCompleted()) { blockchainRequestsCallbacks.onComplete(!ctx.hasError()); } else { blockchainRequestsCallbacks.onProgress(); @@ -585,9 +612,61 @@ public class XrpEngine extends CoinEngine { }; serverApiRipple.setResponseListener(rippleListener); - - serverApiRipple.requestData(ServerApiRipple.RIPPLE_ACCOUNT_INFO, targetAddress, ""); serverApiRipple.requestData(ServerApiRipple.RIPPLE_FEE, "", ""); + + if (targetAddress.contains("$")) { // PayID + + SingleObserver observer = new DisposableSingleObserver() { + @Override + public void onSuccess(PayIdResponse payIdResponse) { + try { + String resolvedAddress = null; + for (PayIdAddress address : payIdResponse.getAddresses()) { + if (address.getPaymentNetwork().equals("XRPL") && + address.getEnvironment().equals("MAINNET")) { + resolvedAddress = address.getAddressDetails().getAddress(); + break; + } + } + if (validateAddress(resolvedAddress)) { + coinData.setResolvedPayIdAddress(resolvedAddress); + + XrpXAddressDecoded xAddressDecoded = XrpXAddressService.Companion.decode(resolvedAddress); + if (xAddressDecoded == null) { // classic address + serverApiRipple.requestData(ServerApiRipple.RIPPLE_ACCOUNT_INFO, resolvedAddress, ""); //TODO: maybe just assume PayID account is created? + } else { // X-address + serverApiRipple.requestData(ServerApiRipple.RIPPLE_ACCOUNT_INFO, xAddressDecoded.getAddress(), ""); + } + } else { + ctx.setError("Unknown address format in PayID response"); + } + } catch (Exception e) { + ctx.setError("Unknown response format on PayID request"); + } + if (serverApiRipple.isRequestsSequenceCompleted() && serverApiPayId.isRequestsSequenceCompleted()) { + blockchainRequestsCallbacks.onComplete(!ctx.hasError()); + } else { + blockchainRequestsCallbacks.onProgress(); + } + } + + @Override + public void onError(Throwable e) { + Log.i(TAG, "onFail: " + "payID" + " " + e.getMessage()); + ctx.setError("PayID error:" + e.getMessage()); + blockchainRequestsCallbacks.onComplete(false); + } + }; + + serverApiPayId.getAddress(targetAddress, observer); + } else { + XrpXAddressDecoded xAddressDecoded = XrpXAddressService.Companion.decode(targetAddress); + if (xAddressDecoded == null) { // classic address + serverApiRipple.requestData(ServerApiRipple.RIPPLE_ACCOUNT_INFO, targetAddress, ""); + } else { // X-address + serverApiRipple.requestData(ServerApiRipple.RIPPLE_ACCOUNT_INFO, xAddressDecoded.getAddress(), ""); + } + } } @Override diff --git a/app/src/main/java/com/tangem/wallet/xrp/XrpTaggedAddress.kt b/app/src/main/java/com/tangem/wallet/xrp/XrpTaggedAddress.kt new file mode 100644 index 0000000000..b0e63aa0e0 --- /dev/null +++ b/app/src/main/java/com/tangem/wallet/xrp/XrpTaggedAddress.kt @@ -0,0 +1,53 @@ +package com.tangem.wallet.xrp + +import com.ripple.encodings.addresses.Addresses +import com.ripple.encodings.base58.B58 +import org.kethereum.extensions.toBigInteger + +class XrpXAddressService { + companion object { + private val xrpBase58 = B58("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz") + private val xrpMainnetPrefix = byteArrayOf(0x05, 0x44) + private val zeroTagBytes = ByteArray(4) { 0 } + + fun validate(address: String): Boolean { + return decode(address) != null + } + + fun decode(address: String): XrpXAddressDecoded? { + try { + val addressBytes = xrpBase58.decodeChecked(address) + if (addressBytes.size != 31) return null + + val prefix = addressBytes.slice(0..1).toByteArray() + if (!prefix.contentEquals(xrpMainnetPrefix)) return null + + val accountBytes = addressBytes.slice(2..21).toByteArray() + val classicAddress = Addresses.encodeAccountID(accountBytes) + + val flag = addressBytes[22] + + val tagBytes = addressBytes.slice(23..26).toByteArray() + val reservedTagBytes = addressBytes.slice(27..30).toByteArray() + if (!reservedTagBytes.contentEquals(zeroTagBytes)) return null + + var tag: Int? = null + when (flag) { + 0.toByte() -> if (!tagBytes.contentEquals(zeroTagBytes)) return null + 1.toByte() -> { + tag = tagBytes.reversedArray().toBigInteger().toInt() + } + else -> return null + } + return XrpXAddressDecoded(classicAddress, tag) + } catch (e: Exception) { + return null + } + } + } +} + +data class XrpXAddressDecoded( + val address: String, + val destinationTag: Int? +) \ No newline at end of file diff --git a/build.gradle b/build.gradle index 511fa6ac99..40bc31028b 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { maven { url 'https://maven.fabric.io/public' } } dependencies { - classpath "com.android.tools.build:gradle:$versions.build_gradle" + classpath "com.android.tools.build:gradle:${versions.build_gradle}" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin" classpath "com.github.dcendents:android-maven-gradle-plugin:2.1" classpath 'com.google.gms:google-services:4.3.3' diff --git a/dependencies.gradle b/dependencies.gradle index 3c7aaf12d8..f61f047e5a 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,4 +1,4 @@ ext.versions = [ kotlin : '1.3.72', - build_gradle: '3.6.3', + build_gradle: '4.0.0', ] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3cbbb9a496..01e71a44df 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -#Mon Mar 30 09:15:33 MSK 2020 +#Thu Jun 11 11:33:17 MSK 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/tangem-sdk-old/build.gradle b/tangem-sdk-old/build.gradle index e7ef5a0015..189bc3b334 100644 --- a/tangem-sdk-old/build.gradle +++ b/tangem-sdk-old/build.gradle @@ -24,7 +24,7 @@ android { versionNameSuffix "-beta" } } - buildToolsVersion '28.0.3' + buildToolsVersion '29.0.2' compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 diff --git a/tangem-sdk/build.gradle b/tangem-sdk/build.gradle index de05139eb5..0664ab0e25 100644 --- a/tangem-sdk/build.gradle +++ b/tangem-sdk/build.gradle @@ -10,7 +10,7 @@ version "$jitpackSdk.version" android { compileSdkVersion 29 - buildToolsVersion "29.0.2" + buildToolsVersion "29.0.3" defaultConfig {