Updated on 2026-08-14

This commit is contained in:
Tangem 2020-06-15 10:26:50 +00:00
commit d8f9a85560
13 changed files with 263 additions and 142 deletions

View file

@ -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'

View file

@ -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<PayIdResponse> getAddress(@Path("user") String user);
}

View file

@ -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<PayIdResponse> 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<PayIdResponse> addressSingle = api.getAddress(user)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnEvent((object, throwable) -> requestsCount--);
addressSingle.subscribe(addressObserver);
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.network.model
import com.google.gson.annotations.SerializedName
data class PayIdResponse(
@SerializedName("addresses")
var addresses: List<PayIdAddress>? = 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
)

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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<PayIdResponse> observer = new DisposableSingleObserver<PayIdResponse>() {
@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

View file

@ -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?
)

View file

@ -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'

View file

@ -1,4 +1,4 @@
ext.versions = [
kotlin : '1.3.72',
build_gradle: '3.6.3',
build_gradle: '4.0.0',
]

View file

@ -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

View file

@ -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

View file

@ -10,7 +10,7 @@ version "$jitpackSdk.version"
android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
buildToolsVersion "29.0.3"
defaultConfig {