diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt index 989407b87..2aa4e58ec 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -14,7 +14,7 @@ import com.flipcash.shared.transactionhistory.convertOf import com.getcode.opencode.mapper.Mapper import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Token -import com.getcode.solana.keys.Mint +import com.getcode.opencode.model.financial.formattedQuantity import com.getcode.util.resources.ResourceHelper import com.getcode.utils.base58 import com.getcode.utils.hexEncodedString @@ -163,20 +163,21 @@ private fun statusOf(state: MessageState, swapState: SwapState?): TransactionSta } /** - * How many tokens the entry moved, formatted to the mint's own precision. + * How many tokens the entry moved. * - * The reserve is one-to-one with its USD value, so it needs no curve. Every other mint is priced by - * the bonding curve, and [Fiat.estimatedTokenAmountIn] prices it against the mint's *current* - * supply — so this is the quantity that value is worth now, not what it bought at the time. Stating - * it is still better than leaving the row blank, since the value is the same value the header shows. + * This is the quantity the feed recorded, not one re-derived from the mint's current supply. The + * server sends it on every entry — `CryptoPaymentAmount.quarks`, which + * [com.getcode.opencode.model.financial.LocalFiat.underlyingTokenAmount] carries verbatim: dollars + * for the reserve, that mint's own quarks for everything else. Pricing the value through the + * bonding curve instead would answer a different question — what it would buy today — and would + * drift further from what moved the longer ago the entry was. iOS reads the same field for the + * same row (`ExchangedFiat.onChainAmount`), so the two screens state one number. + * + * Without the mint there is no scale to write the quarks at, and a quantity at the wrong scale is + * worse than none — so an unresolved mint leaves the row out until it lands. */ private fun tokenAmountOf(underlying: Fiat?, token: Token?): String? { underlying ?: return null token ?: return null - val quantity = if (token.address == Mint.usdf) { - underlying - } else { - Fiat.tokenBalance(underlying.quarks, token) - } - return quantity.estimatedTokenAmountIn(token) + return token.formattedQuantity(underlying.quarks) } diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt index c4ab7276f..a0f3b5870 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt @@ -14,6 +14,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.HolderMetrics import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.VmMetadata import com.getcode.solana.keys.Mint @@ -48,17 +49,22 @@ class TransactionDetailsMapperTest { private val vault = PublicKey.fromBase58("11111111111111111111111111111111") + private fun mint(byte: Byte) = Mint(List(32) { byte }) + + private val twentyDollars = LocalFiat( + usdf = Fiat(20.0, CurrencyCode.USD), + nativeAmount = Fiat(20.0, CurrencyCode.USD), + ) + private fun feedMessage( metadata: MessageMetadata?, text: String = "Sent", state: MessageState = MessageState.COMPLETED, + amount: LocalFiat = twentyDollars, ): ActivityFeedMessage = ActivityFeedMessage( id = listOf(0x01, 0x02, 0x03).map { it.toByte() }, text = text, - amount = LocalFiat( - usdf = Fiat(20.0, CurrencyCode.USD), - nativeAmount = Fiat(20.0, CurrencyCode.USD), - ), + amount = amount, timestamp = Instant.fromEpochSeconds(1700000000L), state = state, metadata = metadata, @@ -70,17 +76,23 @@ class TransactionDetailsMapperTest { state: MessageState = MessageState.COMPLETED, token: Token? = null, toToken: Token? = null, + amount: LocalFiat = twentyDollars, ): TransactionDetails = mapper.map( ActivityFeedMessageWithToken( - feedMessage(metadata, text, state), + feedMessage(metadata, text, state, amount), token = token, toToken = toToken, ) to cached ) - private fun token(address: Mint, name: String, symbol: String): Token = MintMetadata( + private fun token( + address: Mint, + name: String, + symbol: String, + decimals: Int = 6, + ): Token = MintMetadata( address = address, - decimals = 6, + decimals = decimals, name = name, symbol = symbol, createdAt = null, @@ -222,4 +234,60 @@ class TransactionDetailsMapperTest { // To/From row has nothing to render until one does. assertNull(map(MessageMetadata.WithdrewCrypto()).account) } + + // region tokens + + @Test + fun `the tokens row states the quantity the feed recorded, at the mint's own scale`() { + val jeffy = token(mint(1), "Jeffy", "JEFFY", decimals = 10) + // 1,204.905 JEFFY as the server sends it: ten-decimal quarks, verbatim. + val amount = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 12_049_050_000_000L), + nativeAmount = Fiat(20.0, CurrencyCode.USD), + rate = Rate(fx = 1.0, currency = CurrencyCode.USD), + mint = jeffy.address, + ) + + val details = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId), token = jeffy, amount = amount) + + assertEquals("1,204.905", details.tokenAmount) + } + + @Test + fun `a mint with no launchpad metadata still states its quantity`() { + // The old reading priced the value against `launchpadMetadata.currentCirculatingSupplyQuarks`, + // which falls back to a supply of zero when the metadata hasn't resolved. Reading the + // recorded quantity has nothing to fall back from. + val jeffy = token(mint(1), "Jeffy", "JEFFY", decimals = 10) + val amount = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 5_000_000_000L), + nativeAmount = Fiat(20.0, CurrencyCode.USD), + rate = Rate(fx = 1.0, currency = CurrencyCode.USD), + mint = jeffy.address, + ) + + assertNull(jeffy.launchpadMetadata) + assertEquals( + "0.5", + map(MessageMetadata.BoughtToken, token = jeffy, amount = amount).tokenAmount, + ) + } + + @Test + fun `the reserve's quantity is its dollars`() { + // USDF is one-to-one with its USD value and shares Fiat's six decimals, so the $20 the + // header shows is twenty tokens. + val details = map(MessageMetadata.DepositedCrypto, token = token(Mint.usdf, "Dollars", "USDF")) + + assertEquals("20", details.tokenAmount) + } + + @Test + fun `an unresolved mint leaves the tokens row out`() { + // Without the mint's decimals there is no scale to state the quarks at, and a wrong + // quantity is worse than none. + assertNull(map(MessageMetadata.DepositedCrypto, token = null).tokenAmount) + } + + // endregion } diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt index 27b97d73a..afea484fd 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsSamples.kt @@ -32,9 +32,15 @@ internal object TransactionDetailsSamples { * test's image loader answers from `src/test/resources/tokens/` — the tokens' own icons, * through the same [com.flipcash.app.core.ui.TokenIcon] path the app uses. */ - private fun token(name: String, symbol: String, address: Mint, image: String): Token = MintMetadata( + private fun token( + name: String, + symbol: String, + address: Mint, + image: String, + decimals: Int, + ): Token = MintMetadata( address = address, - decimals = 6, + decimals = decimals, name = name, symbol = symbol, createdAt = At, @@ -47,10 +53,11 @@ internal object TransactionDetailsSamples { holderMetrics = HolderMetrics.None, ) - val Jeffy: Token = token("Jeffy", "JEFFY", mint(1), "jeffy.png") + /** Ten decimals, like every launchpad mint — which is what makes the Tokens row trim its zeros. */ + val Jeffy: Token = token("Jeffy", "JEFFY", mint(1), "jeffy.png", decimals = 10) /** The reserve — the real mint, since the screen keys off it the way the rest of the app does. */ - val Dollars: Token = token("Dollars", "USDF", Mint.usdf, "dollars.webp") + val Dollars: Token = token("Dollars", "USDF", Mint.usdf, "dollars.webp", decimals = 6) /** No profile picture, so the avatar draws her initials — the app's real no-photo state. */ val Sally = UserProfile.Empty.copy(displayName = "Sally The Streamer") @@ -114,7 +121,7 @@ internal object TransactionDetailsSamples { avatar = TransactionAvatar.TokenIcon(Dollars), amount = usd(1.00), token = Dollars, - tokenAmount = "1.000000", + tokenAmount = "1", subtitle = "In Person", prefix = "+", ) @@ -155,7 +162,7 @@ internal object TransactionDetailsSamples { avatar = TransactionAvatar.TokenIcon(Dollars), amount = usd(120.00), token = Dollars, - tokenAmount = "120.000000", + tokenAmount = "120", subtitle = null, prefix = "-", ).copy(account = TransactionAccount(Account, TransactionAccount.Direction.To)) @@ -165,7 +172,7 @@ internal object TransactionDetailsSamples { avatar = TransactionAvatar.TokenIcon(Dollars), amount = usd(250.00), token = Dollars, - tokenAmount = "250.000000", + tokenAmount = "250", subtitle = null, prefix = "+", ).copy(account = TransactionAccount(Account, TransactionAccount.Direction.From)) @@ -175,7 +182,7 @@ internal object TransactionDetailsSamples { avatar = TransactionAvatar.SwapTokens(from = Dollars, to = Jeffy), amount = usd(40.00), token = Dollars, - tokenAmount = "40.000000", + tokenAmount = "40", subtitle = "Dollars → Jeffy", prefix = "-", ).copy( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt index fcf261ded..6e9cb183e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt @@ -12,6 +12,10 @@ import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.util.Locale import kotlin.time.Clock import kotlin.time.Instant @@ -179,6 +183,28 @@ data class MintMetadata( companion object } +/** + * [quarks] of this mint, written out as a quantity of tokens. + * + * The unit a quark is a fraction of is the mint's own, so the scale has to come from + * [MintMetadata.decimals] rather than a constant: the reserve has six, every launchpad mint has ten + * (`DefaultMintQuarksPerUnit`). Ten of them puts a full supply at 2.1e17 quarks, past the range + * where a `Double` still counts integers exactly, so the shift is done in [BigDecimal] — roughly + * 900,000 tokens is where dividing by a power of ten in floating point starts losing the low + * digits. + * + * Trailing zeros are dropped rather than padded out to [MintMetadata.decimals], since + * "1,204.9050000000" states no more than "1,204.905". + */ +fun Token.formattedQuantity(quarks: Long): String { + val formatter = DecimalFormat.getInstance(Locale.US).apply { + maximumFractionDigits = decimals + minimumFractionDigits = 0 + roundingMode = RoundingMode.HALF_UP + } + return formatter.format(BigDecimal.valueOf(quarks).movePointLeft(decimals)) +} + /** * Represents metadata associated with a VM. * diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/MintMetadataTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/MintMetadataTest.kt index e66ce1b9d..3c73dcc1f 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/MintMetadataTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/MintMetadataTest.kt @@ -1,6 +1,7 @@ package com.getcode.opencode.model.financial import com.getcode.opencode.model.ui.WindowedRange +import com.getcode.solana.keys.Mint import com.getcode.opencode.tests.generateRandomPublicKeyForTest import org.junit.Test import kotlin.test.assertEquals @@ -84,6 +85,55 @@ class MintMetadataTest { // endregion + // region formattedQuantity + + private fun mint(decimals: Int): Token { + val key = generateRandomPublicKeyForTest() + return MintMetadata( + address = Mint(key.bytes), + decimals = decimals, + name = "Jeffy", + symbol = "JEFFY", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata(vm = key, authority = key, lockDurationInDays = 21), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + } + + @Test + fun `quarks are shifted by the mint's own decimals, not a constant`() { + // The same number of quarks is a thousandfold difference between a launchpad mint and the + // reserve, which is the whole reason the scale comes off the mint. + assertEquals("1,204.905", mint(decimals = 10).formattedQuantity(12_049_050_000_000L)) + assertEquals("12,049,050", mint(decimals = 6).formattedQuantity(12_049_050_000_000L)) + } + + @Test + fun `trailing zeros are dropped`() { + assertEquals("0.5", mint(decimals = 10).formattedQuantity(5_000_000_000L)) + assertEquals("20", mint(decimals = 6).formattedQuantity(20_000_000L)) + assertEquals("0", mint(decimals = 10).formattedQuantity(0L)) + } + + @Test + fun `a quantity past a Double's exact range keeps its low digits`() { + // 1.2e17 quarks is well past 2^53, where a Double stops counting integers one at a time — + // shifting this in floating point loses the tail. A launchpad mint holds ten decimals and + // supplies run to 21 million tokens, so this is inside the range the screen has to state, + // not a contrived edge. + assertEquals( + "12,345,678.9012345678", + mint(decimals = 10).formattedQuantity(123_456_789_012_345_678L), + ) + } + + // endregion + // region LaunchpadMetadata @Test