From 6a3cfe4f00b53569cb1e82524d2de4770fe866fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 10:49:51 -0400 Subject: [PATCH 1/2] fix(activity): read the transaction's recorded token quantity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1417 shipped the details screen's Tokens row as an estimate, and said so: it priced the entry's value against the mint's current circulating supply, so a historical entry stated what that value buys today rather than what moved at the time. The feed has carried the real number all along. Every entry's amount comes from the proto's `CryptoPaymentAmount`, and `ActivityFeedMessageMapper` puts `quarks` straight into `LocalFiat.underlyingTokenAmount` — USD quarks for the reserve, that mint's own quarks for anything else. It survives persistence intact as `MessageEntity.amountUsdc`. `tokenAmountOf` now scales that by the mint's decimals and formats it, with no curve in the path. Dropping the round trip also drops two scale bugs it carried. It sold token quarks to a USD value at `token.decimals` and bought them back at a hardcoded six, which is wrong for every launchpad mint at ten; and it priced against `launchpadMetadata?.currentCirculatingSupplyQuarks ?: 0`, so a mint whose metadata had not resolved was valued against a supply of zero. Trailing zeros are now trimmed rather than padded to the mint's decimals, since `1,204.9050000000` states no more than `1,204.905`. A USDF entry that read `20.000000` reads `20`. iOS reads the same field for the same row in code-payments/code-ios-app#733, so the two screens state one number. The screenshot fixtures declared a six-decimal Jeffy, which no launchpad mint is; it is ten now, and the hand-written USDF quantities match what the mapper actually produces. --- .../internal/TransactionDetailsMapper.kt | 30 ++++--- .../TransactionDetailsMapperTest.kt | 82 +++++++++++++++++-- .../TransactionDetailsSamples.kt | 23 ++++-- 3 files changed, 110 insertions(+), 25 deletions(-) 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..7d93f92a0 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,10 +14,13 @@ 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.util.resources.ResourceHelper import com.getcode.utils.base58 import com.getcode.utils.hexEncodedString +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.util.Locale import javax.inject.Inject /** @@ -165,18 +168,25 @@ private fun statusOf(state: MessageState, swapState: SwapState?): TransactionSta /** * How many tokens the entry moved, formatted to the mint's own precision. * - * 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. + * + * Trailing zeros are dropped rather than padded to [Token.decimals]: a launchpad mint has ten of + * them, and "1,204.9050000000" states no more than "1,204.905". */ 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) + val quantity = BigDecimal.valueOf(underlying.quarks).movePointLeft(token.decimals) + val formatter = DecimalFormat.getInstance(Locale.US).apply { + maximumFractionDigits = token.decimals + minimumFractionDigits = 0 + roundingMode = RoundingMode.HALF_UP } - return quantity.estimatedTokenAmountIn(token) + return formatter.format(quantity) } 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( From 0684db7ca72812f0842b8f0ceceb61640dc6c589 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 10:56:07 -0400 Subject: [PATCH 2/2] refactor(activity): move the token quantity formatting onto Token The details mapper was holding the BigDecimal shift and the DecimalFormat itself, which is scale arithmetic rather than mapping. `Token.formattedQuantity` takes both, next to the `decimals` the shift reads. FeedItemDetails needs the same reading, so having one place for it is the point. --- .../internal/TransactionDetailsMapper.kt | 19 ++----- .../opencode/model/financial/MintMetadata.kt | 26 ++++++++++ .../model/financial/MintMetadataTest.kt | 50 +++++++++++++++++++ 3 files changed, 81 insertions(+), 14 deletions(-) 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 7d93f92a0..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,13 +14,10 @@ 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.opencode.model.financial.formattedQuantity import com.getcode.util.resources.ResourceHelper import com.getcode.utils.base58 import com.getcode.utils.hexEncodedString -import java.math.BigDecimal -import java.math.RoundingMode -import java.text.DecimalFormat -import java.util.Locale import javax.inject.Inject /** @@ -166,7 +163,7 @@ 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. * * 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 @@ -176,17 +173,11 @@ private fun statusOf(state: MessageState, swapState: SwapState?): TransactionSta * 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. * - * Trailing zeros are dropped rather than padded to [Token.decimals]: a launchpad mint has ten of - * them, and "1,204.9050000000" states no more than "1,204.905". + * 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 = BigDecimal.valueOf(underlying.quarks).movePointLeft(token.decimals) - val formatter = DecimalFormat.getInstance(Locale.US).apply { - maximumFractionDigits = token.decimals - minimumFractionDigits = 0 - roundingMode = RoundingMode.HALF_UP - } - return formatter.format(quantity) + return token.formattedQuantity(underlying.quarks) } 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