Skip to content

Commit a6c47f7

Browse files
committed
fix(withdrawal): resolve fee currency mismatch in TransactionReceipt
Convert the server fee (always USD) to the user's entry currency in the ViewModel before passing it to the receipt composable. This prevents Cannot subtract different currencies crashes for non-USD users. - Add entryRate to WithdrawalViewModel.State, dispatched from observeEntryRate() - Add feeInEntryCurrency and netTransferAmount computed properties - Use convertingToUsdIfNeeded in TransactionReceipt for token estimation - Fix OnWithdraw handler to use pre-computed netTransferAmount - Fix preview fees to match their balance currencies Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 6bcdfaf commit a6c47f7

3 files changed

Lines changed: 57 additions & 15 deletions

File tree

‎apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.getcode.opencode.model.financial.LocalFiat
2626
import com.getcode.opencode.model.financial.Rate
2727
import com.getcode.opencode.model.financial.TokenWithBalance
2828
import com.getcode.opencode.model.financial.minus
29+
import com.getcode.opencode.model.financial.toFiat
2930
import com.getcode.opencode.model.transactions.WithdrawalAvailability
3031
import com.getcode.solana.keys.Mint
3132
import com.getcode.ui.components.text.AmountAnimatedInputUiModel
@@ -77,7 +78,7 @@ internal class WithdrawalViewModel @Inject constructor(
7778
clipboardManager: ClipboardManager,
7879
activityFeedCoordinator: ActivityFeedCoordinator,
7980
analytics: FlipcashAnalyticsService,
80-
tokenCoordinator: TokenCoordinator,
81+
private val tokenCoordinator: TokenCoordinator,
8182
dispatchers: DispatcherProvider,
8283
) : BaseViewModel2<WithdrawalViewModel.State, WithdrawalViewModel.Event>(
8384
initialState = State(),
@@ -90,6 +91,7 @@ internal class WithdrawalViewModel @Inject constructor(
9091
internal data class State(
9192
val selectedTokenAddress: Mint? = null,
9293
val token: TokenWithBalance? = null,
94+
val entryRate: Rate? = null,
9395
val amountEntryState: AmountEntryState = AmountEntryState(),
9496
val destinationState: DestinationState = DestinationState(),
9597
val withdrawalState: LoadingSuccessState = LoadingSuccessState(),
@@ -100,6 +102,20 @@ internal class WithdrawalViewModel @Inject constructor(
100102
val tokenBalance: Fiat
101103
get() = token?.balance ?: Fiat.Zero
102104

105+
val feeInEntryCurrency: Fiat?
106+
get() {
107+
val fee = destinationState.availability?.feeAmount ?: return null
108+
val rate = entryRate ?: return fee
109+
return fee.convertingTo(rate)
110+
}
111+
112+
val netTransferAmount: Fiat
113+
get() {
114+
val amount = amountEntryState.selectedAmount.localFiat.nativeAmount
115+
val fee = feeInEntryCurrency ?: 0.toFiat(amount.currencyCode)
116+
return amount - fee
117+
}
118+
103119
val isError: Boolean
104120
get() {
105121
if (amountEntryState.amountAnimatedModel.amountData.isEmpty()) return false
@@ -125,6 +141,7 @@ internal class WithdrawalViewModel @Inject constructor(
125141
data class OnAmountChanged(val amountAnimatedModel: AmountAnimatedInputUiModel) : Event
126142
data class OnCurrencyChanged(val currency: Currency) : Event
127143
data object OnAmountConfirmed : Event
144+
data class OnEntryRateUpdated(val rate: Rate) : Event
128145
data class OnAmountAccepted(val amount: VerifiedFiat) : Event
129146
data object OnDestinationConfirmed : Event
130147
data class UpdateConfirmingAmountState(
@@ -218,6 +235,7 @@ internal class WithdrawalViewModel @Inject constructor(
218235
// reset when entry rate changes
219236
numberInputHelper.reset()
220237
dispatchEvent(Event.OnAmountChanged(AmountAnimatedInputUiModel()))
238+
dispatchEvent(Event.OnEntryRateUpdated(it))
221239
}.launchIn(viewModelScope)
222240

223241
eventFlow
@@ -269,14 +287,14 @@ internal class WithdrawalViewModel @Inject constructor(
269287
.filterNot { checkBalanceLimit() }
270288
.onEach { data ->
271289
dispatchEvent(Event.UpdateConfirmingAmountState(loading = true))
272-
val rate = exchange.rateForUsd()
290+
val rate = exchange.entryRate
273291
val token = stateFlow.value.token!!.token
274292
val amountVerified = verifiedFiatCalculator.compute(
275293
amount = Fiat(data.amountData.amount, rate.currency),
276294
token = token,
277295
balance = stateFlow.value.token!!.balance,
278296
rate = rate,
279-
).getOrElse { cause ->
297+
).getOrElse {
280298
dispatchEvent(Event.UpdateConfirmingAmountState(loading = false))
281299
BottomBarManager.showAlert(
282300
title = resources.getString(R.string.error_title_staleRates),
@@ -350,10 +368,7 @@ internal class WithdrawalViewModel @Inject constructor(
350368
eventFlow
351369
.filterIsInstance<Event.OnWithdraw>()
352370
.onEach {
353-
val amount = stateFlow.value.amountEntryState.selectedAmount.localFiat
354-
val withdrawalChecks = stateFlow.value.destinationState.availability
355-
val fee = withdrawalChecks?.feeAmount
356-
if (amount.nativeAmount - (fee ?: Fiat.Zero) < Fiat.Zero) {
371+
if (stateFlow.value.netTransferAmount < Fiat.Zero) {
357372
dispatchEvent(Event.UpdateWithdrawalState(loading = false))
358373
dispatchEvent(Event.OnWithdrawalTooSmall)
359374
return@onEach
@@ -402,6 +417,23 @@ internal class WithdrawalViewModel @Inject constructor(
402417
return@mapNotNull null
403418
}
404419

420+
// Refresh balance from network before submitting to ensure
421+
// the on-chain balance matches what we're about to withdraw.
422+
tokenCoordinator.updateTokenAccount(token.address)
423+
424+
// underlyingTokenAmount.quarks are token quarks, not USD —
425+
// convert back through the bonding curve for an apples-to-apples comparison.
426+
val amountInUsd = Fiat.tokenBalance(amount.localFiat.underlyingTokenAmount.quarks, token)
427+
val refreshedBalance = tokenCoordinator.balanceForToken(token)
428+
if (amountInUsd > refreshedBalance) {
429+
dispatchEvent(Event.UpdateWithdrawalState(loading = false))
430+
BottomBarManager.showAlert(
431+
title = resources.getString(R.string.error_title_insufficientFunds),
432+
message = resources.getString(R.string.error_description_insufficientFunds)
433+
)
434+
return@mapNotNull null
435+
}
436+
405437
val sendingVault = owner.withTimelockForToken(token)
406438

407439
val feeInMint = feeInUsd?.let { fee ->
@@ -503,6 +535,10 @@ internal class WithdrawalViewModel @Inject constructor(
503535
)
504536
}
505537

538+
is Event.OnEntryRateUpdated -> { state ->
539+
state.copy(entryRate = event.rate)
540+
}
541+
506542
is Event.OnAmountAccepted -> { state ->
507543
val entryState = state.amountEntryState
508544
state.copy(

‎apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/components/TransactionReceipt.kt‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import com.getcode.opencode.model.financial.Rate
4848
import com.getcode.opencode.model.financial.Token
4949
import com.getcode.opencode.model.financial.TokenWithBalance
5050
import com.getcode.opencode.model.financial.minus
51+
import com.getcode.opencode.model.financial.toFiat
5152
import com.getcode.opencode.model.financial.usdf
5253
import com.getcode.theme.CodeTheme
5354
import com.getcode.theme.White05
@@ -62,6 +63,7 @@ internal fun TransactionReceipt(
6263
modifier: Modifier = Modifier,
6364
onLearnMoreClicked: () -> Unit
6465
) {
66+
val exchange = LocalExchange.current
6567
Column(
6668
modifier = Modifier
6769
.fillMaxWidth()
@@ -81,7 +83,7 @@ internal fun TransactionReceipt(
8183
) {
8284
val netTransferAmount by remember(tokenWithBalance, fee) {
8385
derivedStateOf {
84-
tokenWithBalance.balance - (fee ?: Fiat.Zero)
86+
tokenWithBalance.balance - (fee ?: 0.toFiat(tokenWithBalance.balance.currencyCode))
8587
}
8688
}
8789

@@ -107,7 +109,8 @@ internal fun TransactionReceipt(
107109
showName = false,
108110
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
109111
formattedBalance = { amount ->
110-
amount.estimatedTokenAmountIn(tokenWithBalance.token, fractionDigits = 2)
112+
amount.convertingToUsdIfNeeded(exchange.entryRate)
113+
.estimatedTokenAmountIn(tokenWithBalance.token, fractionDigits = 2)
111114
},
112115
sizing = rememberTokenBalanceRowSizing(
113116
balanceTextStyle = CodeTheme.typography.displayMedium.bolded(),
@@ -181,10 +184,12 @@ private fun LineItems(
181184
)
182185
}
183186

187+
val exchange = LocalExchange.current
184188
ReceiptLineItem(
185189
modifier = Modifier.fillMaxWidth(),
186190
label = AnnotatedString("Amount in ${tokenWithBalance.token.name}"),
187-
amount = transferAmount.estimatedTokenAmountIn(tokenWithBalance.token, fractionDigits = 2),
191+
amount = transferAmount.convertingToUsdIfNeeded(exchange.entryRate)
192+
.estimatedTokenAmountIn(tokenWithBalance.token, fractionDigits = 2),
188193
)
189194
}
190195
}
@@ -199,7 +204,8 @@ private val rates = mapOf(
199204
CurrencyCode.USD to cadToUsdRate,
200205
)
201206

202-
private val fee = Fiat(0.50, CurrencyCode.USD)
207+
private val feeUsd = Fiat(0.50, CurrencyCode.USD)
208+
private val feeCad = feeUsd.convertingTo(usdToCadRate)
203209

204210
@Preview
205211
@Composable
@@ -217,7 +223,7 @@ private fun Preview_CadWithdrawalWithFeeReceipt() {
217223
token = Token.usdf,
218224
balance = fiveCad
219225
),
220-
fee = fee,
226+
fee = feeCad,
221227
) {
222228

223229
}
@@ -265,7 +271,7 @@ private fun Preview_UsdWithdrawalWithFeeReceipt() {
265271
token = Token.usdf,
266272
balance = fiveUsd
267273
),
268-
fee = fee,
274+
fee = feeUsd,
269275
) {
270276

271277
}
@@ -313,7 +319,7 @@ private fun Preview_CadWithdrawalWithFeeButTooSmallReceipt() {
313319
token = Token.usdf,
314320
balance = halfDollarCad
315321
),
316-
fee = fee,
322+
fee = feeCad,
317323
) {
318324

319325
}

‎apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ private fun WithdrawalConfirmationScreenContent(
7676
balance = state.amountEntryState.selectedAmount.localFiat.nativeAmount,
7777
),
7878
destination = state.destinationState.textFieldState.text.toString(),
79-
fee = state.destinationState.availability?.feeAmount,
79+
fee = state.feeInEntryCurrency,
8080
onLearnMoreClicked = {
8181
dispatchEvent(WithdrawalViewModel.Event.OnLearnAboutFee)
8282
}

0 commit comments

Comments
 (0)