zcash-android-wallet-sdk/sdk-lib/src/main/java/cash/z/ecc/android/sdk/ext/CurrencyFormatter.kt

389 lines
14 KiB
Kotlin

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