@file:Suppress("TooManyFunctions", "MatchingDeclarationName") package cash.z.ecc.android.sdk.ext import cash.z.ecc.android.sdk.ext.Conversions.USD_FORMATTER import cash.z.ecc.android.sdk.ext.Conversions.ZEC_FORMATTER import cash.z.ecc.android.sdk.internal.Twig import cash.z.ecc.android.sdk.model.Zatoshi import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode import java.text.NumberFormat import java.util.Locale /* * Convenience functions for converting currency values for display in user interfaces. The * calculations done here are not intended for financial purposes, because all such transactions * are done using Zatoshis in the Rust layer. Instead, these functions are focused on displaying * accurately rounded values to the user. */ // TODO [#678]: provide a dynamic way to configure this globally for the SDK // For now, just make these vars so at least they could be modified in one place // TODO [#678]: https://github.com/zcash/zcash-android-wallet-sdk/issues/678 @Suppress("MagicNumber", "ktlint:standard:property-naming") object Conversions { var ONE_ZEC_IN_ZATOSHI = BigDecimal(Zatoshi.ZATOSHI_PER_ZEC, MathContext.DECIMAL128) var ZEC_FORMATTER = NumberFormat.getInstance(Locale.getDefault()).apply { roundingMode = RoundingMode.HALF_EVEN maximumFractionDigits = 6 minimumFractionDigits = 0 minimumIntegerDigits = 1 } var USD_FORMATTER = NumberFormat.getInstance(Locale.getDefault()).apply { roundingMode = RoundingMode.HALF_EVEN maximumFractionDigits = 2 minimumFractionDigits = 2 minimumIntegerDigits = 1 } } /** * Format a Zatoshi value into ZEC with the given number of digits, represented as a string. * Start with Zatoshi -> End with ZEC. * * @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is * better than USD. * @param minDecimals the minimum number of digits to allow to the right of the decimal. * * @return this Zatoshi value represented as ZEC, in a string with at least [minDecimals] and at * most [maxDecimals] */ fun Zatoshi?.convertZatoshiToZecString( maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits ): String { return currencyFormatter(maxDecimals, minDecimals).format(convertZatoshiToZec(maxDecimals)) } /** * Format a ZEC value into ZEC with the given number of digits, represented as a string. * Start with ZEC -> End with ZEC. * * @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is * better when right. * @param minDecimals the minimum number of digits to allow to the right of the decimal. * * @return this Double ZEC value represented as a string with at least [minDecimals] and at most * [maxDecimals]. */ fun Double?.toZecString( maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits ): String { return currencyFormatter(maxDecimals, minDecimals).format(this.toZec(maxDecimals)) } /** * Format a Zatoshi value into ZEC with the given number of decimal places, represented as a string. * Start with ZeC -> End with ZEC. * * @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is * better than bread. * @param minDecimals the minimum number of digits to allow to the right of the decimal. * * @return this BigDecimal ZEC value represented as a string with at least [minDecimals] and at most * [maxDecimals]. */ fun BigDecimal?.toZecString( maxDecimals: Int = ZEC_FORMATTER.maximumFractionDigits, minDecimals: Int = ZEC_FORMATTER.minimumFractionDigits ): String { return currencyFormatter(maxDecimals, minDecimals).format(this.toZec(maxDecimals)) } /** * Format a USD value into USD with the given number of digits, represented as a string. * Start with USD -> end with USD. * * @param maxDecimals the number of decimal places to use in the format. Default is 6 because * ZEC is glorious. * @param minDecimals the minimum number of digits to allow to the right of the decimal. * * @return this Double ZEC value represented as a string with at least [minDecimals] and at most * [maxDecimals], which is 2 by default. Zero is always represented without any decimals. */ fun Double?.toUsdString( maxDecimals: Int = USD_FORMATTER.maximumFractionDigits, minDecimals: Int = USD_FORMATTER.minimumFractionDigits ): String { return if (this == 0.0) { "0" } else { currencyFormatter(maxDecimals, minDecimals).format(this.toUsd(maxDecimals)) } } /** * Format a USD value into USD with the given number of decimal places, represented as a string. * Start with USD -> end with USD. * * @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is * glorious. * @param minDecimals the minimum number of digits to allow to the right of the decimal. * * @return this BigDecimal USD value represented as a string with at least [minDecimals] and at most * [maxDecimals], which is 2 by default. */ fun BigDecimal?.toUsdString( maxDecimals: Int = USD_FORMATTER.maximumFractionDigits, minDecimals: Int = USD_FORMATTER.minimumFractionDigits ): String { return currencyFormatter(maxDecimals, minDecimals).format(this.toUsd(maxDecimals)) } /** * Create a number formatter for use with converting currency to strings. This probably isn't needed * externally since the other formatting functions leverage this, instead. Leverages the default * rounding mode for ZEC found in ZEC_FORMATTER. * * @param maxDecimals the number of decimal places to use in the format. Default is 6 because ZEC is * glorious. * @param minDecimals the minimum number of digits to allow to the right of the decimal. * * @return a currency formatter, appropriate for the default locale. */ fun currencyFormatter( maxDecimals: Int, minDecimals: Int ): NumberFormat { return NumberFormat.getInstance(Locale.getDefault()).apply { roundingMode = ZEC_FORMATTER.roundingMode maximumFractionDigits = maxDecimals minimumFractionDigits = minDecimals minimumIntegerDigits = 1 } } /** * Convert a Zatoshi value into ZEC, right-padded to the given number of fraction digits, * represented as a BigDecimal in order to preserve rounding that minimizes cumulative error when * applied repeatedly over a sequence of calculations. * Start with Zatoshi -> End with ZEC. * * @param scale the number of digits to the right of the decimal place. Right-padding will be added, * if necessary. * * @return this Long Zatoshi value represented as ZEC using a BigDecimal with the given scale, * rounded accurately out to 128 digits. */ fun Zatoshi?.convertZatoshiToZec(scale: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal { return BigDecimal(this?.value ?: 0L, MathContext.DECIMAL128).divide( Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128 ).setScale(scale, ZEC_FORMATTER.roundingMode) } /** * Convert a ZEC value into Zatoshi. * Start with ZEC -> End with Zatoshi. * * @return this ZEC value represented as Zatoshi, rounded accurately out to 128 digits, in order to * minimize cumulative errors when applied repeatedly over a sequence of calculations. */ fun BigDecimal?.convertZecToZatoshi(): Zatoshi { if (this == null) return Zatoshi(0L) if (this < BigDecimal.ZERO) { throw IllegalArgumentException( "Invalid ZEC value: $this. ZEC is represented by notes and" + " cannot be negative" ) } return Zatoshi(this.multiply(Conversions.ONE_ZEC_IN_ZATOSHI, MathContext.DECIMAL128).toLong()) } /** * Format a Double ZEC value as a BigDecimal ZEC value, right-padded to the given number of fraction * digits. * Start with ZEC -> End with ZEC. * * @param decimals the scale to use for the resulting BigDecimal. * * @return this Double ZEC value converted into a BigDecimal, with the proper rounding mode for use * with other formatting functions. */ fun Double?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal { return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale( decimals, ZEC_FORMATTER.roundingMode ) } /** * Format a Double ZEC value as a Long Zatoshi value, by first converting to ZEC with the given * precision. * Start with ZEC -> End with Zatoshi. * * @param decimals the scale to use for the intermediate BigDecimal. * * @return this Double ZEC value converted into Zatoshi, with proper rounding and precision by * leveraging an intermediate BigDecimal object. */ fun Double?.convertZecToZatoshi(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): Zatoshi { return this.toZec(decimals).convertZecToZatoshi() } /** * Format a BigDecimal ZEC value as a BigDecimal ZEC value, right-padded to the given number of * fraction digits. * Start with ZEC -> End with ZEC. * * @param decimals the scale to use for the resulting BigDecimal. * * @return this BigDecimal ZEC adjusted to the default scale and rounding mode. */ fun BigDecimal?.toZec(decimals: Int = ZEC_FORMATTER.maximumFractionDigits): BigDecimal { return (this ?: BigDecimal.ZERO).setScale(decimals, ZEC_FORMATTER.roundingMode) } /** * Format a Double USD value as a BigDecimal USD value, right-padded to the given number of fraction * digits. * Start with USD -> End with USD. * * @param decimals the scale to use for the resulting BigDecimal. * * @return this Double USD value converted into a BigDecimal, with proper rounding and precision. */ fun Double?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal { return BigDecimal(this?.toString() ?: "0.0", MathContext.DECIMAL128).setScale( decimals, USD_FORMATTER.roundingMode ) } /** * Format a BigDecimal USD value as a BigDecimal USD value, right-padded to the given number of * fraction digits. * Start with USD -> End with USD. * * @param decimals the scale to use for the resulting BigDecimal. * * @return this BigDecimal USD value converted into USD, with proper rounding and precision. */ fun BigDecimal?.toUsd(decimals: Int = USD_FORMATTER.maximumFractionDigits): BigDecimal { return (this ?: BigDecimal.ZERO).setScale(decimals, USD_FORMATTER.roundingMode) } /** * Convert this ZEC value to USD, using the given price per ZEC. * Start with ZEC -> End with USD. * * @param zecPrice the current price of ZEC represented as USD per ZEC * * @return this BigDecimal USD value converted into USD, with proper rounding and precision. */ fun BigDecimal?.convertZecToUsd(zecPrice: BigDecimal): BigDecimal { if (this == null) return BigDecimal.ZERO if (this < BigDecimal.ZERO) { throw IllegalArgumentException( "Invalid ZEC value: ${zecPrice.toDouble()}. ZEC is" + " represented by notes and cannot be negative" ) } return this.multiply(zecPrice, MathContext.DECIMAL128) } /** * Convert this USD value to ZEC, using the given price per ZEC. * Start with USD -> End with ZEC. * * @param zecPrice the current price of ZEC represented as USD per ZEC. * * @return this BigDecimal USD value converted into ZEC, with proper rounding and precision. */ fun BigDecimal?.convertUsdToZec(zecPrice: BigDecimal): BigDecimal { if (this == null) return BigDecimal.ZERO if (this < BigDecimal.ZERO) { throw IllegalArgumentException( "Invalid USD value: ${zecPrice.toDouble()}. Converting" + " this would result in negative ZEC and ZEC is represented by notes and cannot be" + " negative" ) } return this.divide(zecPrice, MathContext.DECIMAL128) } /** * Convert this value from one currency to the other, based on given price and whether this value is * USD. * If starting with USD -> End with ZEC. * If starting with ZEC -> End with USD. * * @param isUSD whether this value represents USD or not (ZEC) * * @return this BigDecimal value converted from one currency into the other, based on the given * price. */ fun BigDecimal.convertCurrency( zecPrice: BigDecimal, isUsd: Boolean ): BigDecimal { return if (isUsd) { this.convertUsdToZec(zecPrice) } else { this.convertZecToUsd(zecPrice) } } /** * Parse this string into a BigDecimal, ignoring all non numeric characters. * * @return this string as a BigDecimal or null when parsing fails. */ fun String?.safelyConvertToBigDecimal(decimalSeparator: Char): BigDecimal? { if (this.isNullOrEmpty()) { return BigDecimal.ZERO } val result = try { // ignore commas and whitespace val sanitizedInput = this.filter { it.isDigit() or (it == decimalSeparator) } BigDecimal.ZERO.max(BigDecimal(sanitizedInput, MathContext.DECIMAL128)) } catch (nfe: NumberFormatException) { Twig.debug(nfe) { "Exception while converting String to BigDecimal" } null } return result } /** * Abbreviates this string which is assumed to be an address. * * @param startLength the number of characters to show before the elipsis. * @param endLength the number of characters to show after the elipsis. * * @return the abbreviated string unless the string is too short, in which case the original string * is returned. */ fun String.toAbbreviatedAddress( startLength: Int = 8, endLength: Int = 8 ) = if (length > startLength + endLength) "${take(startLength)}…${takeLast(endLength)}" else this /** * Masks the current string for use in logs. If this string appears to be an address, the last * [addressCharsToShow] characters will be visible. * * @param addressCharsToShow the number of chars to show at the end, if this value appears to be an * address. * * @return the masked version of this string, typically for use in logs. */ internal fun String.masked(addressCharsToShow: Int = 4): String = if (startsWith("ztest") || startsWith("zs")) { "****${takeLast(addressCharsToShow)}" } else { "***masked***" } /** * Convenience function that returns true when this string starts with 'z'. * * @return true when this function starts with 'z' rather than 't'. */ fun String?.isShielded() = this != null && startsWith('z')