From b2dcc29754a8ca85ad6026c1b58a1feb5165785d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 18:03:33 -0400 Subject: [PATCH 1/2] fix(balance): sum unrounded token values for the total (iOS parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet total was `sum(round(each token))` — it rounded every token to cents and then summed, which drifts the total by up to a penny versus `round(sum)`. iOS (ExchangedFiat.total) sums the unrounded native values and rounds only at display, and our own aggregateAppreciation already sums unrounded — so the total was the lone divergence, showing a 1¢ difference from iOS even when every per-token balance and the appreciation matched. Sum the unrounded per-token balances and let display formatting round the total. --- .../com/flipcash/app/tokens/ui/SelectTokenViewModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index f55f61d36..4b36bae68 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -10,7 +10,6 @@ import com.flipcash.shared.tokens.R import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.rounded import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.TokenWithLocalizedBalance import com.getcode.opencode.model.financial.sum @@ -63,7 +62,11 @@ class SelectTokenViewModel @Inject constructor( ) } - return set.map { it.balance.rounded() }.sum() + // Sum the UNROUNDED per-token values, letting display formatting round the total — + // i.e. round(sum(x)), not sum(round(x)). Rounding each token to cents first drifts the + // total by up to a penny and diverged from iOS (ExchangedFiat.total sums unrounded) and + // from our own aggregateAppreciation (which already sums unrounded). + return set.map { it.balance }.sum() } val aggregateAppreciation: LocalFiat? From 3a340816c5acf2ebf17937af2f85af6c962865d2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 18:12:56 -0400 Subject: [PATCH 2/2] test(balance): guard total = round(sum), not sum(round) Two tokens each worth $0.014 display as $0.01 apiece (sum-of-rounded = $0.02) but truly sum to $0.028 -> $0.03. Locks in the round(sum) aggregation so the per-token-rounding regression can't come back. --- .../ui/SelectTokenViewModelStateTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt new file mode 100644 index 000000000..071e8bb04 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModelStateTest.kt @@ -0,0 +1,57 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.app.core.tokens.TokenPurpose +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithLocalizedBalance +import com.getcode.opencode.model.financial.usdf +import com.getcode.solana.keys.Mint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SelectTokenViewModelStateTest { + + private fun tokenWorth(nativeUsd: Double): TokenWithLocalizedBalance { + val amount = Fiat(nativeUsd, CurrencyCode.USD) + return TokenWithLocalizedBalance( + token = Token.usdf, + balance = LocalFiat( + underlyingTokenAmount = amount, + nativeAmount = amount, + rate = Rate.oneToOne, + mint = Mint.usdf, + ), + ) + } + + /** + * The wallet total must be round(sum(x)), not sum(round(x)): rounding each token to cents first + * drifts the total by up to a penny and diverged from iOS (ExchangedFiat.total sums unrounded). + * + * Two tokens each worth $0.014 display as $0.01 apiece — so sum(round) is $0.02 — but their true + * sum is $0.028, which rounds to $0.03. + */ + @Test + fun `total sums unrounded values, then rounds for display`() { + val state = SelectTokenViewModel.State( + purpose = TokenPurpose.Balance, + rate = Rate.oneToOne, + tokens = listOf(tokenWorth(0.014), tokenWorth(0.014)), + ) + + val total = state.totalBalance!!.nativeAmount + + // Unrounded sum is retained; display formatting rounds it to $0.03. + assertEquals(0.028, total.decimalValue, 1e-3) + assertEquals(0.03, total.rounded(2).decimalValue, 1e-6) + + // Regression guard: rounding per token first (the old behaviour) would have shown $0.02. + val sumOfRounded = state.tokens!!.sumOf { it.balance.nativeAmount.rounded(2).decimalValue } + assertEquals(0.02, sumOfRounded, 1e-6) + assertNotEquals(sumOfRounded, total.rounded(2).decimalValue, 1e-6) + } +}