diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt index 8b15bb221..a0b3a9fba 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/internal/bill/BillController.kt @@ -87,9 +87,10 @@ class BillController @Inject constructor( amount: LocalFiat, token: Token, owner: AccountCluster, + verifiedState: VerifiedState, onFunded: suspend (LocalFiat) -> Unit, onError: (Throwable) -> Unit, - ) = transactionManager.fundGiftCard(giftCard, amount, owner, token, onFunded, onError) + ) = transactionManager.fundGiftCard(giftCard, amount, owner, token, verifiedState, onFunded, onError) /** Initiates the **receive cash link** flow — claims a gift card by entropy. */ fun receiveGiftCard( diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index 9b2101ae7..ed2650476 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -11,6 +11,7 @@ import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.financial.Currency import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -47,6 +48,7 @@ import kotlin.math.min internal class CashScreenViewModel @Inject constructor( private val resources: ResourceHelper, private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, tokenCoordinator: TokenCoordinator, transactionController: TransactionOperations, dispatchers: DispatcherProvider, @@ -140,11 +142,11 @@ internal class CashScreenViewModel @Inject constructor( viewModelScope.launch { val rate = exchange.entryRate val (token, balance) = stateFlow.value.token!! - val amountFiat = LocalFiat.valueExchangeIn( + val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(amount, rate.currency), token = token, rate = rate, - ) + ).localFiat val neededAmount = amountFiat.nativeAmount - tokenBalance println("entered amount ${amountFiat.nativeAmount}, tokenbalace=$tokenBalance, needed=$neededAmount") @@ -302,7 +304,7 @@ internal class CashScreenViewModel @Inject constructor( val (token, balance) = stateFlow.value.token!! val rate = exchange.entryRate - val amountFiat = LocalFiat.valueExchangeIn( + val result = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = token, balance = balance.underlyingTokenAmount, @@ -311,7 +313,8 @@ internal class CashScreenViewModel @Inject constructor( val bill = Bill.Cash( token = stateFlow.value.token!!.token, - amount = amountFiat + amount = result.localFiat, + verifiedState = result.verifiedState, ) dispatchEvent(Event.UpdateLoadingState(loading = false, success = true)) diff --git a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt index c2d7cf551..b58852bc6 100644 --- a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt +++ b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt @@ -10,6 +10,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.financial.Currency import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -54,6 +55,7 @@ class CashScreenViewModelTest { private val resources: ResourceHelper = mockk(relaxed = true) private val exchange: Exchange = mockk(relaxed = true) + private val verifiedFiatCalculator: VerifiedFiatCalculator = mockk(relaxed = true) private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) private val transactionController: TransactionOperations = mockk(relaxed = true) @@ -90,6 +92,7 @@ class CashScreenViewModelTest { return CashScreenViewModel( resources = resources, exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, tokenCoordinator = tokenCoordinator, transactionController = transactionController, dispatchers = dispatchers, diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index 0fde33389..45284b79a 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -37,18 +37,22 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.core.errors.CheckTokenAvailabilityError import com.getcode.opencode.model.core.errors.GetMintsError import com.getcode.opencode.model.core.errors.LaunchTokenError import com.getcode.opencode.model.core.errors.ValidationException 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.TokenCreateRequest import com.getcode.opencode.model.financial.fromLaunch import com.getcode.opencode.model.financial.minus import com.getcode.opencode.model.financial.orZero import com.getcode.opencode.model.financial.plus +import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.moderation.ModerationAttestation import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.solana.keys.Mint @@ -87,6 +91,7 @@ internal class CurrencyCreatorViewModel @Inject constructor( moderationController: ModerationController, currencyController: CurrencyController, transactionController: TransactionController, + private val verifiedFiatCalculator: VerifiedFiatCalculator, externalWalletController: ExternalWalletOnRampController, tokenCoordinator: TokenCoordinator, balancePoller: BalancePoller, @@ -513,8 +518,11 @@ internal class CurrencyCreatorViewModel @Inject constructor( AppRoute.Token.CurrencyCreator, OnRampProvider.Phantom ) - val totalAmount = LocalFiat(usdf = event.context.amount) - println("total amount ${totalAmount.underlyingTokenAmount}") + val totalAmount = verifiedFiatCalculator.compute( + amount = event.context.amount, + token = Token.usdf, + rate = Rate.oneToOne, + ) val feeAmount = event.context.feeAmount?.let { LocalFiat(usdf = it) } externalWalletController.setAmount(amount = totalAmount, feeAmount = feeAmount) externalWalletController.setTokenToPurchase(event.context.token) @@ -529,10 +537,16 @@ internal class CurrencyCreatorViewModel @Inject constructor( } .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } .map { (owner, context) -> + val totalAmount = verifiedFiatCalculator.compute( + amount = context.amount, + token = Token.usdf, + rate = Rate.oneToOne, + ) + val feeAmount = context.feeAmount?.let { LocalFiat(usdf = it) } transactionController.buy( owner = owner, - amount = LocalFiat(usdf = context.amount), - feeAmount = context.feeAmount?.let { LocalFiat(usdf = it) }, + amount = totalAmount, + feeAmount = feeAmount, of = context.token, source = SwapFundingSource.SubmitIntent(), fund = null, diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt index 3a4c2faf2..c4b5711ff 100644 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt +++ b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/OnRampCustomAmountScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.flipcash.app.core.AppRoute import com.getcode.solana.keys.Mint + import com.flipcash.app.onramp.internal.OnRampViewModel import com.flipcash.app.onramp.internal.screens.OnRampAmountScreen import com.flipcash.features.onramp.R diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt index c41354f29..1aa268c76 100644 --- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt +++ b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt @@ -16,6 +16,8 @@ import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.financial.Currency import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -24,6 +26,7 @@ import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.toFiat +import com.getcode.opencode.model.financial.usdf import com.getcode.solana.keys.Mint import com.getcode.ui.components.text.AmountAnimatedInputUiModel import com.getcode.ui.components.text.NumberInputHelper @@ -45,7 +48,7 @@ internal data class AmountEntryState( val currencyModel: CurrencyHolder = CurrencyHolder(), val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), val confirmingAmount: LoadingSuccessState = LoadingSuccessState(), - val selectedAmount: LocalFiat = LocalFiat.Zero, + val selectedAmount: VerifiedFiat = VerifiedFiat(LocalFiat.Zero, null), ) { val canAdd: Boolean get() = (amountAnimatedModel.amountData.amount) > 0.00 @@ -71,6 +74,7 @@ internal data class AmountEntryState( @HiltViewModel internal class OnRampViewModel @Inject constructor( private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, private val resources: ResourceHelper, private val onRampController: CoinbaseOnRampController, tokenController: TokenController, @@ -127,9 +131,9 @@ internal class OnRampViewModel @Inject constructor( val success: Boolean = false ) : Event - data class OnAmountAccepted(val amount: LocalFiat) : Event + data class OnAmountAccepted(val amount: VerifiedFiat) : Event - data class CreateAndSendTransactionToWallet(val amount: LocalFiat) : Event + data class CreateAndSendTransactionToWallet(val amount: VerifiedFiat) : Event // endregion } @@ -279,9 +283,10 @@ internal class OnRampViewModel @Inject constructor( } } - val amountFiat = LocalFiat( - usdf = localizedAmount.convertingTo(exchange.rateToUsd(rate.currency)!!), - nativeAmount = localizedAmount, + val amountFiat = verifiedFiatCalculator.compute( + amount = localizedAmount, + token = Token.usdf, + rate = rate, ) dispatchEvent(Event.OnAmountAccepted(amountFiat)) @@ -314,9 +319,9 @@ internal class OnRampViewModel @Inject constructor( } onRampController.placeOrderAndStartPayment( - amount = selectedAmount.underlyingTokenAmount, + amount = selectedAmount.localFiat.underlyingTokenAmount, token = token, - localFiat = selectedAmount, + verifiedFiat = selectedAmount, ).onFailure { error -> dispatchEvent(Event.UpdateConfirmingAmountState()) when (error) { diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt index f11233ce2..1c9ee30f7 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt @@ -17,6 +17,8 @@ import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.financial.Currency import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -55,7 +57,7 @@ internal data class AmountEntryState( val currencyModel: CurrencyHolder = CurrencyHolder(), val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), val confirmingAmount: LoadingSuccessState = LoadingSuccessState(), - val selectedAmount: LocalFiat = LocalFiat.Zero, + val selectedAmount: VerifiedFiat = VerifiedFiat(LocalFiat.Zero, null), ) internal data class DestinationState( @@ -68,6 +70,7 @@ internal data class DestinationState( internal class WithdrawalViewModel @Inject constructor( private val resources: ResourceHelper, private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, private val userManager: UserManager, transactionController: TransactionOperations, clipboardManager: ClipboardManager, @@ -121,7 +124,7 @@ internal class WithdrawalViewModel @Inject constructor( data class OnAmountChanged(val amountAnimatedModel: AmountAnimatedInputUiModel) : Event data class OnCurrencyChanged(val currency: Currency) : Event data object OnAmountConfirmed : Event - data class OnAmountAccepted(val amount: LocalFiat) : Event + data class OnAmountAccepted(val amount: VerifiedFiat) : Event data object OnDestinationConfirmed : Event data class UpdateConfirmingAmountState( val loading: Boolean = false, @@ -267,7 +270,7 @@ internal class WithdrawalViewModel @Inject constructor( dispatchEvent(Event.UpdateConfirmingAmountState(loading = true)) val rate = exchange.rateForUsd() val token = stateFlow.value.token!!.token - val amountFiat = LocalFiat.valueExchangeIn( + val amountVerified = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = token, balance = stateFlow.value.token!!.balance, @@ -275,7 +278,7 @@ internal class WithdrawalViewModel @Inject constructor( ) dispatchEvent(Event.UpdateConfirmingAmountState(loading = false, success = true)) - dispatchEvent(Event.OnAmountAccepted(amountFiat)) + dispatchEvent(Event.OnAmountAccepted(amountVerified)) }.launchIn(viewModelScope) eventFlow @@ -339,7 +342,7 @@ internal class WithdrawalViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { - val amount = stateFlow.value.amountEntryState.selectedAmount + val amount = stateFlow.value.amountEntryState.selectedAmount.localFiat val withdrawalChecks = stateFlow.value.destinationState.availability val fee = withdrawalChecks?.feeAmount if (amount.nativeAmount - (fee ?: Fiat.Zero) < Fiat.Zero) { @@ -394,12 +397,12 @@ internal class WithdrawalViewModel @Inject constructor( val sendingVault = owner.withTimelockForToken(token) val feeInMint = feeInUsd?.let { fee -> - LocalFiat.valueExchangeIn( - fee, + verifiedFiatCalculator.compute( + amount = fee, token = token, balance = stateFlow.value.token!!.balance, rate = exchange.rateToUsd(CurrencyCode.USD)!!, - ).underlyingTokenAmount + ).localFiat.underlyingTokenAmount } transactionController.withdraw( @@ -415,7 +418,7 @@ internal class WithdrawalViewModel @Inject constructor( onError = { analytics.transfer( event = Analytics.Transfer.Withdrawal, - amount = stateFlow.value.amountEntryState.selectedAmount, + amount = stateFlow.value.amountEntryState.selectedAmount.localFiat, successful = false, error = it, ) @@ -428,7 +431,7 @@ internal class WithdrawalViewModel @Inject constructor( onSuccess = { analytics.transfer( event = Analytics.Transfer.Withdrawal, - amount = stateFlow.value.amountEntryState.selectedAmount, + amount = stateFlow.value.amountEntryState.selectedAmount.localFiat, ) viewModelScope.launch { coroutineScope { diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt index 419ad28e0..29ddb71d0 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/internal/confirmation/WithdrawalConfirmationScreen.kt @@ -73,7 +73,7 @@ private fun WithdrawalConfirmationScreenContent( TransferInfo( tokenWithBalance = TokenWithBalance( state.token!!.token, - balance = state.amountEntryState.selectedAmount.nativeAmount, + balance = state.amountEntryState.selectedAmount.localFiat.nativeAmount, ), destination = state.destinationState.textFieldState.text.toString(), fee = state.destinationState.availability?.feeAmount, diff --git a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt index f6cc986fb..cd5f6ad04 100644 --- a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt +++ b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt @@ -9,6 +9,7 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.util.resources.ResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers @@ -32,6 +33,7 @@ class WithdrawalViewModelErrorTest { private val resources = mockk(relaxed = true) private val exchange = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) private val userManager = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) private val clipboardManager = mockk(relaxed = true) @@ -58,6 +60,7 @@ class WithdrawalViewModelErrorTest { return WithdrawalViewModel( resources = resources, exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, userManager = userManager, transactionController = transactionController, clipboardManager = clipboardManager, diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 3e0dc7e93..4502ae126 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -16,11 +16,12 @@ import com.getcode.network.jwt.Jwt import com.getcode.network.jwt.JwtSecuredEndpoint import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts import com.getcode.opencode.internal.solana.model.SwapId 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.Token import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.transactions.SwapFundingSource @@ -58,7 +59,7 @@ class CoinbaseOnRampController @Inject constructor( private val _state = MutableStateFlow(CoinbaseOnRampState.Idle) val state: StateFlow = _state.asStateFlow() - fun startPayment(order: OnrampOrder, token: Token, amount: LocalFiat) { + fun startPayment(order: OnrampOrder, token: Token, amount: VerifiedFiat) { _state.value = CoinbaseOnRampState.Paying(order, token, amount) } @@ -86,7 +87,7 @@ class CoinbaseOnRampController @Inject constructor( suspend fun placeOrderAndStartPayment( amount: Fiat, token: Token, - localFiat: LocalFiat, + verifiedFiat: VerifiedFiat, ): Result { when (googlePayReadiness.check()) { GooglePayReadiness.Status.NotSupported -> @@ -99,7 +100,7 @@ class CoinbaseOnRampController @Inject constructor( return placeOrderInclusiveOfFees(amount) .map { (orderId, paymentLink) -> val order = OnrampOrder(orderId, paymentLink.url) - startPayment(order, token, localFiat) + startPayment(order, token, verifiedFiat) } } diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt index ee0f30e2f..301edcc1d 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampState.kt @@ -3,7 +3,8 @@ package com.flipcash.app.onramp import android.os.Parcelable import com.flipcash.app.onramp.internal.CoinbaseOnRampWebError import com.getcode.opencode.internal.solana.model.SwapId -import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.exchange.VerifiedFiat + import com.getcode.opencode.model.financial.Token import kotlinx.parcelize.Parcelize @@ -15,8 +16,8 @@ data class OnrampOrder( sealed interface CoinbaseOnRampState { data object Idle : CoinbaseOnRampState - data class Paying(val order: OnrampOrder, val token: Token, val amount: LocalFiat) : CoinbaseOnRampState - data class Processing(val orderId: String, val token: Token, val amount: LocalFiat) : CoinbaseOnRampState + data class Paying(val order: OnrampOrder, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState + data class Processing(val orderId: String, val token: Token, val amount: VerifiedFiat) : CoinbaseOnRampState data class Completed(val swapId: SwapId) : CoinbaseOnRampState data class Failed(val error: CoinbaseOnRampWebError) : CoinbaseOnRampState } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt index 48bbbae36..d3ebcfedf 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt @@ -2,13 +2,13 @@ package com.flipcash.app.onramp import com.flipcash.app.core.AppRoute import com.flipcash.app.core.encryption.boxOpen -import com.flipcash.app.core.encryption.toPublicKey import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.onramp.deeplinks.ExternalWalletConnection import com.flipcash.app.core.onramp.deeplinks.ExternallySignedTransaction import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.user.UserManager import com.getcode.opencode.controllers.TransactionOperations +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount import com.getcode.opencode.internal.solana.model.LiquidityPool import com.getcode.opencode.internal.solana.model.SwapId @@ -68,8 +68,8 @@ class ExternalWalletOnRampController @Inject constructor( private val _pendingNavigation = MutableSharedFlow(extraBufferCapacity = 1) val pendingNavigation: SharedFlow = _pendingNavigation.asSharedFlow() - private val _amount = MutableStateFlow(null) - val amount: StateFlow = _amount.asStateFlow() + private val _amount = MutableStateFlow(null) + val amount: StateFlow = _amount.asStateFlow() private val _feeAmount = MutableStateFlow(null) val feeAmount: StateFlow = _feeAmount.asStateFlow() @@ -85,7 +85,7 @@ class ExternalWalletOnRampController @Inject constructor( _state.value = ExternalWalletOnRampState.Started(origin, provider) } - fun setAmount(amount: LocalFiat?, feeAmount: LocalFiat? = null) { + fun setAmount(amount: VerifiedFiat?, feeAmount: LocalFiat? = null) { _amount.value = amount _feeAmount.value = feeAmount } @@ -156,7 +156,7 @@ class ExternalWalletOnRampController @Inject constructor( val amount = _amount.value ?: return val fee = _feeAmount.value ?: LocalFiat.Zero - createUsdcToUsdfSwapTransaction(state, amount) + createUsdcToUsdfSwapTransaction(state, amount.localFiat) .onFailure { fail(DeeplinkOnRampError.FailedToCreateTransaction(message = it.message, cause = it), state) } .fold( onSuccess = { (transaction, swapId) -> @@ -194,7 +194,7 @@ class ExternalWalletOnRampController @Inject constructor( val amount = _amount.value ?: return val fee = _feeAmount.value ?: LocalFiat.Zero - createDepositTransaction(state, amount) + createDepositTransaction(state, amount.localFiat) .onFailure { fail(DeeplinkOnRampError.FailedToCreateTransaction(message = it.message, cause = it), state) } .fold( onSuccess = { transaction -> diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt index 874521140..7b3663bb4 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampHandler.kt @@ -177,7 +177,7 @@ fun ExternalWalletOnRampHandler( ) } - analytics.amountSelectedForWalletTransfer(current.provider, current.amount.underlyingTokenAmount) + analytics.amountSelectedForWalletTransfer(current.provider, current.amount.localFiat.underlyingTokenAmount) uriHandler.openUri(uri.toString()) } diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampState.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampState.kt index c8b430017..fc1a85694 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampState.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampState.kt @@ -3,6 +3,7 @@ package com.flipcash.app.onramp import com.flipcash.app.core.AppRoute import com.flipcash.app.core.onramp.deeplinks.ExternalWalletConnection import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -34,7 +35,7 @@ sealed interface ExternalWalletOnRampState { val connection: ExternalWalletConnection, val encryptionPublicKey: List, val unsignedTransaction: List, - val amount: LocalFiat, + val amount: VerifiedFiat, val fee: LocalFiat, val token: Token?, val swapId: SwapId?, @@ -46,7 +47,7 @@ sealed interface ExternalWalletOnRampState { val connection: ExternalWalletConnection, val signedTransaction: String, val signature: Signature, - val amount: LocalFiat, + val amount: VerifiedFiat, val fee: LocalFiat, val token: Token?, val swapId: SwapId?, diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index ea460bc18..356cdfeb1 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -37,6 +37,7 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.accounts.GiftCardAccount @@ -329,7 +330,12 @@ class RealSessionController @Inject constructor( action = { billController.cancelAwaitForGrab() - shareGiftCard(bill.amount, bill.token, owner) { + shareGiftCard( + amount = bill.amount, + token = bill.token, + owner = owner, + verifiedState = bill.verifiedState!! + ) { trace( tag = "Session", message = "Cash link not sent. Restarting awaiting grab", @@ -413,6 +419,7 @@ class RealSessionController @Inject constructor( amount: LocalFiat, token: Token, owner: AccountCluster, + verifiedState: VerifiedState, restartBillGrabber: () -> Unit ) { val giftCard = GiftCardAccount.create(token) @@ -428,7 +435,13 @@ class RealSessionController @Inject constructor( is ShareResult.ActionTaken -> { scope.launch action@{ // immediately fund the gift card - val fundingResult = initiateGiftCardFunding(giftCard, owner, amount, token) + val fundingResult = initiateGiftCardFunding( + giftCard = giftCard, + owner = owner, + amount = amount, + token = token, + verifiedState = verifiedState + ) if (fundingResult.isFailure) { return@action } @@ -566,12 +579,14 @@ class RealSessionController @Inject constructor( owner: AccountCluster, amount: LocalFiat, token: Token, + verifiedState: VerifiedState, ): Result = suspendCancellableCoroutine { cont -> billController.fundGiftCard( giftCard = giftCard, amount = amount, token = token, owner = owner, + verifiedState = verifiedState, onFunded = { tokenCoordinator.subtract(token, amount) shareSheetController.reset() @@ -863,5 +878,4 @@ class RealSessionController @Inject constructor( } } -private val AIRDROP_INITIAL_DELAY = 1.seconds private val CASH_LINK_CONFIRMATION_DELAY = 500.milliseconds \ No newline at end of file diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 15a80ce57..0e81edfaa 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -15,6 +15,7 @@ import com.flipcash.app.tokens.core.ReservesBalanceProvider import com.getcode.opencode.controllers.AccountController import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.ui.WindowedRange import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.CurrencyCode @@ -82,6 +83,7 @@ class TokenCoordinator @Inject constructor( private val accountController: AccountController, private val networkObserver: NetworkConnectivityListener, private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, private val dataSource: TokenDataSource, ) : TokenMetadataProvider, SessionListener, DefaultLifecycleObserver, ReservesBalanceProvider { @@ -457,51 +459,50 @@ class TokenCoordinator @Inject constructor( ).collect { response -> trace(tag = TAG, message = "Received ${response.reserveStates.size} reserve state updates", type = TraceType.Process) - _state.update { state -> - var updatedTokens = state.tokens - var updatedBalances = state.balances - var updatedAppreciation = state.appreciation + val state = _state.value + var updatedTokens = state.tokens + var updatedBalances = state.balances + var updatedAppreciation = state.appreciation - response.reserveStates.forEach { update -> - val mint = update.reserveState.mint - val token = state.tokens[mint] ?: return@forEach - val launchpad = token.launchpadMetadata ?: return@forEach + for (update in response.reserveStates) { + val mint = update.reserveState.mint + val token = state.tokens[mint] ?: continue + val launchpad = token.launchpadMetadata ?: continue - val updatedToken = token.copy( - launchpadMetadata = launchpad.copy( - currentCirculatingSupplyQuarks = update.reserveState.currentSupply - ) + val updatedToken = token.copy( + launchpadMetadata = launchpad.copy( + currentCirculatingSupplyQuarks = update.reserveState.currentSupply ) - updatedTokens = updatedTokens + (mint to updatedToken) - - state.balances[mint]?.let { balance -> - val exchangedValue = runCatching { - LocalFiat.valueExchangeIn( - amount = balance, - token = updatedToken, - balance = balance, - rate = Rate.oneToOne, - debug = false, - trace = false, - ).underlyingTokenAmount - }.getOrNull() - - if (exchangedValue != null) { - val newBalance = Fiat.tokenBalance( - quarks = exchangedValue.quarks, - token = updatedToken - ) - updatedBalances = updatedBalances + (mint to newBalance) - - val currentAppreciation = state.appreciation[mint] ?: Fiat.Zero - val costBasis = balance - currentAppreciation - val newAppreciation = newBalance - costBasis - updatedAppreciation = updatedAppreciation + (mint to newAppreciation) - } - } + ) + updatedTokens = updatedTokens + (mint to updatedToken) + + val balance = state.balances[mint] ?: continue + val exchangedValue = runCatching { + verifiedFiatCalculator.compute( + amount = balance, + token = updatedToken, + balance = balance, + rate = Rate.oneToOne, + trace = false, + ).localFiat.underlyingTokenAmount + }.getOrNull() + + if (exchangedValue != null) { + val newBalance = Fiat.tokenBalance( + quarks = exchangedValue.quarks, + token = updatedToken + ) + updatedBalances = updatedBalances + (mint to newBalance) + + val currentAppreciation = state.appreciation[mint] ?: Fiat.Zero + val costBasis = balance - currentAppreciation + val newAppreciation = newBalance - costBasis + updatedAppreciation = updatedAppreciation + (mint to newAppreciation) } + } - state.copy(tokens = updatedTokens, balances = updatedBalances, appreciation = updatedAppreciation) + _state.update { + it.copy(tokens = updatedTokens, balances = updatedBalances, appreciation = updatedAppreciation) } } } 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 1aa63ab23..cfa1dc3cb 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 @@ -15,6 +15,7 @@ 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 @@ -66,7 +67,7 @@ class SelectTokenViewModel @Inject constructor( ) } - return set.map { it.balance }.sum() + return set.map { it.balance.rounded() }.sum() } val aggregateAppreciation: LocalFiat? diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 157e1e0bc..adbad4ad7 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -15,6 +15,8 @@ import com.flipcash.shared.tokens.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.core.errors.SwapError import com.getcode.opencode.model.financial.Currency @@ -60,13 +62,14 @@ data class AmountEntryState( val currencyModel: CurrencyHolder = CurrencyHolder(), val maxToAdd: Pair? = null, val amountAnimatedModel: AmountAnimatedInputUiModel = AmountAnimatedInputUiModel(), - val selectedAmount: LocalFiat = LocalFiat.Zero, + val selectedAmount: VerifiedFiat = VerifiedFiat(LocalFiat.Zero, null), ) @HiltViewModel class SwapViewModel @Inject constructor( userManager: UserManager, private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, transactionController: TransactionOperations, resources: ResourceHelper, tokenCoordinator: TokenCoordinator, @@ -206,12 +209,12 @@ class SwapViewModel @Inject constructor( data class OnSwapIdChanged(val swapId: SwapId) : Event - data class CreateAndSendTransactionToWallet(val token: Token, val amount: LocalFiat) : Event + data class CreateAndSendTransactionToWallet(val token: Token, val amount: VerifiedFiat) : Event - data class OnAmountAccepted(val amount: LocalFiat, val netTransferAmount: Fiat) : Event + data class OnAmountAccepted(val amount: VerifiedFiat, val netTransferAmount: Fiat) : Event - data class ProceedWithPurchase(val amount: LocalFiat) : Event - data class ProceedWithSale(val amount: LocalFiat) : Event + data class ProceedWithPurchase(val amount: VerifiedFiat) : Event + data class ProceedWithSale(val amount: VerifiedFiat) : Event data object ShowSellReceipt : Event @@ -475,13 +478,13 @@ class SwapViewModel @Inject constructor( is SwapPurpose.Buy -> { val rate = exchange.entryRate // buy with reserves - val amountFiat = LocalFiat.valueExchangeIn( + val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = Token.usdf, balance = stateFlow.value.reservesBalance.convertingToUsdIfNeeded(rate), rate = rate ) - val netAmount = amountFiat.nativeAmount + val netAmount = amountFiat.localFiat.nativeAmount dispatchEvent(Event.UpdateBuyState(loading = true)) dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = netAmount)) @@ -490,15 +493,15 @@ class SwapViewModel @Inject constructor( is SwapPurpose.FundWithWallet -> { val rate = exchange.rateForUsd() - // funding through external wallet - val nativeAmount = Fiat(data.amountData.amount, rate.currency) - val underlyingAmount = nativeAmount.convertingToUsdIfNeeded(rate) - val amountFiat = LocalFiat( - usdf = underlyingAmount, - nativeAmount = nativeAmount, + // funding through external wallet — no balance cap, + // funds come from the external wallet not reserves + val amountFiat = verifiedFiatCalculator.compute( + amount = Fiat(data.amountData.amount, rate.currency), + token = Token.usdf, + rate = rate, ) - dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = nativeAmount)) + dispatchEvent(Event.OnAmountAccepted(amountFiat, netTransferAmount = amountFiat.localFiat.nativeAmount)) dispatchEvent(Event.UpdateBuyState(loading = true)) dispatchEvent( Event.CreateAndSendTransactionToWallet( @@ -511,7 +514,7 @@ class SwapViewModel @Inject constructor( is SwapPurpose.Sell -> { val rate = exchange.entryRate val tokenWithBalance = stateFlow.value.tokenWithBalance!! - val amountFiat = LocalFiat.valueExchangeIn( + val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = tokenWithBalance.token, balance = tokenWithBalance.balance, @@ -553,7 +556,7 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.OnPurchaseSubmitted(token, swapId)) dispatchEvent(Event.UpdateBuyState(loading = false, success = true)) // buy submitted from reserves, drop reserves balance - tokenCoordinator.subtract(Token.usdf, amount) + tokenCoordinator.subtract(Token.usdf, amount.localFiat) }.onFailure { cause -> trackTransaction(token, error = cause) dispatchEvent(Event.UpdateBuyState(loading = false, success = false)) @@ -594,7 +597,7 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.OnSellSubmitted(token, swapId)) dispatchEvent(Event.UpdateSellState(loading = false, success = true)) // sell submitted, drop from balance - tokenCoordinator.subtract(token, amount) + tokenCoordinator.subtract(token, amount.localFiat) }.onFailure { cause -> trackTransaction(token, error = cause) dispatchEvent(Event.UpdateSellState(loading = false, success = false)) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 92c4341c5..75205344f 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -9,6 +9,8 @@ import com.flipcash.shared.tokens.R import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -47,6 +49,7 @@ class SwapViewModelErrorTest { private val userManager = mockk(relaxed = true) private val exchange = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) // Mockito for Result-returning methods (MockK double-boxes Result inline class) private val transactionController: TransactionOperations = mock() private val resources = mockk(relaxed = true) @@ -85,6 +88,7 @@ class SwapViewModelErrorTest { return SwapViewModel( userManager = userManager, exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, transactionController = transactionController, resources = resources, tokenCoordinator = tokenCoordinator, @@ -106,7 +110,7 @@ class SwapViewModelErrorTest { val tokenWithBalance = mockk(relaxed = true) { every { this@mockk.token } returns token } - val amount = mockk(relaxed = true) + val amount = VerifiedFiat(mockk(relaxed = true), null) val vm = createViewModel() vm.dispatchEvent(SwapViewModel.Event.OnPurposeChanged(SwapPurpose.Buy(mockk(relaxed = true)))) @@ -127,7 +131,7 @@ class SwapViewModelErrorTest { val tokenWithBalance = mockk(relaxed = true) { every { this@mockk.token } returns token } - val amount = mockk(relaxed = true) + val amount = VerifiedFiat(mockk(relaxed = true), null) val vm = createViewModel() vm.dispatchEvent(SwapViewModel.Event.OnPurposeChanged(SwapPurpose.Sell(mockk(relaxed = true)))) diff --git a/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt b/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt index ef3ca6b88..5cc214d26 100644 --- a/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt +++ b/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt @@ -8,6 +8,7 @@ import androidx.work.WorkerParameters import com.flipcash.app.auth.AuthManager import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.user.UserManager +import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.managers.BillTransactionManager import com.getcode.opencode.managers.GiftCardManager import com.getcode.opencode.model.accounts.GiftCardAccount @@ -41,6 +42,7 @@ internal class GiftCardFundingWorker @AssistedInject constructor( private val transactionManager: BillTransactionManager, private val giftCardManager: GiftCardManager, private val tokenCoordinator: TokenCoordinator, + private val verifiedStateManager: VerifiedProtoManager, ) : CoroutineWorker(appContext, workerParams) { internal companion object { fun tagFor(giftCard: GiftCardAccount) = "gift_card_funding-${giftCard.entropy}" @@ -128,11 +130,19 @@ internal class GiftCardFundingWorker @AssistedInject constructor( ): kotlin.Result = suspendCancellableCoroutine { cont -> authenticateIfNeeded { try { + val verifiedState = verifiedStateManager.getVerifiedStateFor( + amount.rate.currency, token.address + ) + if (verifiedState == null) { + cont.resume(kotlin.Result.failure(IllegalStateException("No verified state found"))) + return@authenticateIfNeeded + } transactionManager.fundGiftCard( giftCard = giftCard, amount = amount, token = token, owner = userManager.accountCluster!!, + verifiedState = verifiedState, onFunded = { trace( tag = "GiftCardFundingWorker", diff --git a/apps/flipcash/shared/workers/src/test/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorkerTest.kt b/apps/flipcash/shared/workers/src/test/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorkerTest.kt index 30c70b509..31a9fe341 100644 --- a/apps/flipcash/shared/workers/src/test/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorkerTest.kt +++ b/apps/flipcash/shared/workers/src/test/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorkerTest.kt @@ -7,6 +7,7 @@ import androidx.work.WorkerParameters import com.flipcash.app.auth.AuthManager import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.user.UserManager +import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.managers.BillTransactionManager import com.getcode.opencode.managers.GiftCardManager import com.getcode.opencode.model.financial.CurrencyCode @@ -37,6 +38,7 @@ class GiftCardFundingWorkerTest { private val transactionManager: BillTransactionManager = mockk(relaxed = true) private val giftCardManager: GiftCardManager = mockk(relaxed = true) private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) + private val verifiedStateManager: VerifiedProtoManager = mockk(relaxed = true) private fun createWorker(inputData: Data = Data.EMPTY): GiftCardFundingWorker { every { workerParams.inputData } returns inputData @@ -48,6 +50,7 @@ class GiftCardFundingWorkerTest { transactionManager = transactionManager, giftCardManager = giftCardManager, tokenCoordinator = tokenCoordinator, + verifiedStateManager = verifiedStateManager, ) } diff --git a/gradle.properties b/gradle.properties index aca52ca3d..72118171d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -33,4 +33,7 @@ android.nonTransitiveRClass=false # Enables static R class generation by default for build features # When true, generates R class with final fields, potentially improving build performance # Useful for catching resource reference errors at compile time -android.defaults.buildfeatures.usestaticrclass=true \ No newline at end of file +android.defaults.buildfeatures.usestaticrclass=true +# Enables Kotlin compilation for Android testFixtures source sets +android.experimental.enableTestFixturesKotlinSupport=true +android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.experimental.enableTestFixturesKotlinSupport \ No newline at end of file diff --git a/libs/currency-math/build.gradle.kts b/libs/currency-math/build.gradle.kts index 9650ef7a1..289c15b7c 100644 --- a/libs/currency-math/build.gradle.kts +++ b/libs/currency-math/build.gradle.kts @@ -6,6 +6,9 @@ plugins { android { namespace = "${Gradle.codeNamespace}.libs.currency.math" + testFixtures { + enable = true + } } dependencies { @@ -16,6 +19,8 @@ dependencies { implementation(libs.javax.inject) implementation(libs.hilt.android) + testFixturesImplementation(libs.kotlinx.coroutines.core) + testImplementation(kotlin("test")) } diff --git a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/DiscreteBondingCurveTests.kt b/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/DiscreteBondingCurveTests.kt index ad3c40350..3da44e27c 100644 --- a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/DiscreteBondingCurveTests.kt +++ b/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/DiscreteBondingCurveTests.kt @@ -1,8 +1,6 @@ package com.flipcash.libs.currency.math import com.flipcash.libs.currency.math.internal.curves.DiscreteBondingCurve -import com.flipcash.libs.currency.math.loader.FileTableLoader -import kotlinx.coroutines.runBlocking import kotlin.test.BeforeTest import java.math.BigDecimal import java.math.RoundingMode @@ -18,7 +16,7 @@ class DiscreteSpotPriceTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -104,7 +102,7 @@ class DiscreteTokensToValueTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -293,7 +291,7 @@ class DiscreteValueToTokensTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -411,7 +409,7 @@ class DiscreteRoundtripTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -491,7 +489,7 @@ class DiscreteTableValidationTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -582,7 +580,7 @@ class DiscreteEdgeCaseTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -646,7 +644,7 @@ class DiscreteTokensForValueExchangeTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } @@ -810,7 +808,7 @@ class DiscreteRealWorldTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() curve = DiscreteBondingCurve.getOrThrow() } diff --git a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimationTests.kt b/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimationTests.kt index 99757e1ce..2ef4ccb28 100644 --- a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimationTests.kt +++ b/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimationTests.kt @@ -1,8 +1,5 @@ package com.flipcash.libs.currency.math -import com.flipcash.libs.currency.math.internal.curves.DiscreteBondingCurve -import com.flipcash.libs.currency.math.loader.FileTableLoader -import kotlinx.coroutines.runBlocking import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode @@ -15,7 +12,7 @@ class EstimationTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() } @Test diff --git a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimatorValueExchangeTests.kt b/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimatorValueExchangeTests.kt index 2b28bf4ba..fed7ae349 100644 --- a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimatorValueExchangeTests.kt +++ b/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/EstimatorValueExchangeTests.kt @@ -1,8 +1,5 @@ package com.flipcash.libs.currency.math -import com.flipcash.libs.currency.math.internal.curves.DiscreteBondingCurve -import com.flipcash.libs.currency.math.loader.FileTableLoader -import kotlinx.coroutines.runBlocking import java.math.BigDecimal import kotlin.test.BeforeTest import kotlin.test.Test @@ -15,7 +12,7 @@ class EstimatorValueExchangeAsTokensTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() } // -- valueExchangeAsTokens -- @@ -209,7 +206,7 @@ class EstimatorMultipleMintDecimalsTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() } @Test @@ -314,7 +311,7 @@ class EstimatorEdgeCaseTests { @BeforeTest fun initializeCurve() { - runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + CurveTestInitializer.initialize() } // -- Zero amount edge cases -- diff --git a/libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/CurveTestInitializer.kt b/libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/CurveTestInitializer.kt new file mode 100644 index 000000000..6a23027ed --- /dev/null +++ b/libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/CurveTestInitializer.kt @@ -0,0 +1,11 @@ +package com.flipcash.libs.currency.math + +import com.flipcash.libs.currency.math.internal.curves.DiscreteBondingCurve +import com.flipcash.libs.currency.math.loader.FileTableLoader +import kotlinx.coroutines.runBlocking + +object CurveTestInitializer { + fun initialize() { + runBlocking { DiscreteBondingCurve.initialize(FileTableLoader()) } + } +} diff --git a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/loader/FileTableLoader.kt b/libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/loader/FileTableLoader.kt similarity index 76% rename from libs/currency-math/src/test/java/com/flipcash/libs/currency/math/loader/FileTableLoader.kt rename to libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/loader/FileTableLoader.kt index d2670ac71..175354bd8 100644 --- a/libs/currency-math/src/test/java/com/flipcash/libs/currency/math/loader/FileTableLoader.kt +++ b/libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/loader/FileTableLoader.kt @@ -1,9 +1,6 @@ package com.flipcash.libs.currency.math.loader -import com.flipcash.libs.currency.math.divideWithHighPrecision import java.io.File -import java.math.BigDecimal -import java.math.BigInteger import java.nio.ByteBuffer import java.nio.ByteOrder @@ -14,22 +11,21 @@ class FileTableLoader(private val assetsDir: File) : TableLoader { companion object { private fun resolveAssetsDir(): File { val projectRoot = findProjectRoot() - return File(projectRoot, "src/main/assets") // Adjust if module name differs + return File(projectRoot, "libs/currency-math/src/main/assets") } private fun findProjectRoot(): File { var currentDir = File(System.getProperty("user.dir")!!).absoluteFile while (true) { - val gradleFiles = currentDir.listFiles { _, name -> - name == "settings.gradle" || name == "settings.gradle.kts" || - name == "build.gradle" || name == "build.gradle.kts" - } - if (!gradleFiles.isNullOrEmpty()) { + val hasSettings = currentDir.listFiles { _, name -> + name == "settings.gradle" || name == "settings.gradle.kts" + }?.isNotEmpty() == true + if (hasSettings) { return currentDir } val parent = currentDir.parentFile if (parent == null || parent == currentDir) { - throw IllegalStateException("Could not locate project root (no Gradle files found)") + throw IllegalStateException("Could not locate project root (no settings.gradle found)") } currentDir = parent } @@ -56,4 +52,4 @@ class FileTableLoader(private val assetsDir: File) : TableLoader { Table(lowBits, highBits) } } -} \ No newline at end of file +} diff --git a/services/opencode/build.gradle.kts b/services/opencode/build.gradle.kts index 22f55c04a..9b8b70c61 100644 --- a/services/opencode/build.gradle.kts +++ b/services/opencode/build.gradle.kts @@ -94,4 +94,5 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":libs:currency-math"))) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt index 38cc322bd..3afddcb10 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/ControllerFactory.kt @@ -36,7 +36,6 @@ object ControllerFactory { swapRepository = RepositoryFactory.createSwapRepository(context, config), accountController = createAccountController(context, config), eventBus = module.providesEventBus(), - verifiedStateManager = ManagerFactory.createVerifiedStateManager(), ) } fun createCurrencyController(context: Context, config: ProtocolConfig): CurrencyController { diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index f2c2f3318..e23e203b7 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt @@ -2,7 +2,8 @@ package com.getcode.opencode.controllers import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.events.Events -import com.getcode.opencode.internal.manager.VerifiedProtoManager +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.network.api.intents.IntentDistribution import com.getcode.opencode.internal.network.api.intents.IntentRemoteReceive import com.getcode.opencode.internal.network.api.intents.IntentRemoteSend @@ -12,7 +13,6 @@ import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.accounts.GiftCardAccount import com.getcode.opencode.model.core.errors.GetIntentMetadataError -import com.getcode.opencode.model.core.errors.GetLimitsError import com.getcode.opencode.model.core.errors.SwapError import com.getcode.opencode.model.financial.Distribution import com.getcode.opencode.model.financial.Fee @@ -46,13 +46,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile -import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton @@ -65,7 +63,6 @@ class TransactionController @Inject constructor( private val swapRepository: SwapRepository, private val accountController: AccountController, private val eventBus: ChannelEventBus, - private val verifiedStateManager: VerifiedProtoManager, ) : TransactionOperations { val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -121,7 +118,7 @@ class TransactionController @Inject constructor( } override suspend fun withdraw( - amount: LocalFiat, + amount: VerifiedFiat, mint: Mint, owner: AccountCluster, destination: PublicKey, @@ -129,11 +126,11 @@ class TransactionController @Inject constructor( fee: Fiat?, scope: CoroutineScope, ): Result { - val verifiedState = verifiedStateManager.getVerifiedStateFor(amount.rate.currency, mint) + val verifiedState = amount.verifiedState ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) val intent = IntentWithdraw.create( - amount = amount, + amount = amount.localFiat, mint = mint, fee = fee?.let { Fee(it, FeeType.CreateOnSendWithdrawal) }, sourceCluster = owner, @@ -155,11 +152,9 @@ class TransactionController @Inject constructor( owner: AccountCluster, source: AccountCluster, rendezvous: PublicKey, + verifiedState: VerifiedState, scope: CoroutineScope = this.scope, ): Result { - val verifiedState = - verifiedStateManager.getVerifiedStateFor(amount.rate.currency, token.address) - ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) val intent = IntentRemoteSend.create( amount = amount, @@ -236,14 +231,14 @@ class TransactionController @Inject constructor( override suspend fun buy( owner: AccountCluster, - amount: LocalFiat, + amount: VerifiedFiat, feeAmount: LocalFiat?, swapId: SwapId?, of: Token, source: SwapFundingSource, fund: (suspend (SwapRequest) -> Result)?, ): Result { - trace("Starting ${amount.nativeAmount.formatted()} buy of ${of.symbol}") + trace("Starting ${amount.localFiat.nativeAmount.formatted()} buy of ${of.symbol}") val tokenizedOwner = owner.withTimelockForToken(of) // A Token whose launchpadMetadata is null and whose address isn't USDF is @@ -265,9 +260,8 @@ class TransactionController @Inject constructor( ) } - val verifiedState = - verifiedStateManager.getVerifiedStateFor(amount.rate.currency, Mint.usdf) - ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) + val verifiedState = amount.verifiedState + ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) return accountResult.fold( onSuccess = { @@ -275,7 +269,7 @@ class TransactionController @Inject constructor( scope = scope, swapId = swapId, owner = owner, - amount = amount, + amount = amount.localFiat, feeAmount = feeAmount, of = of, source = source, @@ -297,17 +291,16 @@ class TransactionController @Inject constructor( override suspend fun sell( owner: AccountCluster, - amount: LocalFiat, + amount: VerifiedFiat, of: Token, ): Result { - val verifiedState = - verifiedStateManager.getVerifiedStateFor(amount.rate.currency, of.address) - ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) + val verifiedState = amount.verifiedState + ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) return repository.sell( scope = scope, owner = owner, - amount = amount, + amount = amount.localFiat, of = of, verifiedState = verifiedState ) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt index 260722b62..69360d33b 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt @@ -1,5 +1,6 @@ package com.getcode.opencode.controllers +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.Fiat @@ -29,7 +30,7 @@ interface TransactionOperations { suspend fun buy( owner: AccountCluster, - amount: LocalFiat, + amount: VerifiedFiat, feeAmount: LocalFiat? = null, swapId: SwapId? = null, of: Token, @@ -39,7 +40,7 @@ interface TransactionOperations { suspend fun sell( owner: AccountCluster, - amount: LocalFiat, + amount: VerifiedFiat, of: Token, ): Result @@ -57,7 +58,7 @@ interface TransactionOperations { ): Result suspend fun withdraw( - amount: LocalFiat, + amount: VerifiedFiat, mint: Mint, owner: AccountCluster, destination: PublicKey, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt new file mode 100644 index 000000000..23532dfa6 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt @@ -0,0 +1,22 @@ +package com.getcode.opencode.exchange + +import com.getcode.opencode.internal.manager.VerifiedState +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 + +data class VerifiedFiat( + val localFiat: LocalFiat, + val verifiedState: VerifiedState?, +) + +interface VerifiedFiatCalculator { + suspend fun compute( + amount: Fiat, + token: Token, + balance: Fiat? = null, + rate: Rate, + trace: Boolean = true, + ): VerifiedFiat +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt index 7c489f899..bf97c902c 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt @@ -9,6 +9,7 @@ import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.internal.annotations.OpenCodeManagedChannel import com.getcode.opencode.internal.annotations.OpenCodeManagedStreamingChannel import com.getcode.opencode.internal.annotations.OpenCodeProtocol @@ -19,6 +20,7 @@ import com.getcode.opencode.internal.domain.repositories.InternalMessagingReposi import com.getcode.opencode.internal.domain.repositories.InternalSwapRepository import com.getcode.opencode.internal.domain.repositories.InternalTransactionRepository import com.getcode.opencode.internal.exchange.OpenCodeExchange +import com.getcode.opencode.internal.exchange.RealVerifiedFiatCalculator import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.internal.network.pollers.SwapPoller import com.getcode.opencode.internal.network.services.AccountService @@ -191,4 +193,8 @@ object OpenCodeModule { @Provides @Singleton fun bindTransactionOperations(impl: TransactionController): TransactionOperations = impl + + @Provides + @Singleton + internal fun bindVerifiedFiatCalculator(impl: RealVerifiedFiatCalculator): VerifiedFiatCalculator = impl } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt new file mode 100644 index 000000000..2566549f4 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt @@ -0,0 +1,170 @@ +package com.getcode.opencode.internal.exchange + +import com.flipcash.libs.currency.math.Estimator +import com.flipcash.libs.currency.math.divideWithHighPrecision +import com.flipcash.libs.currency.math.units +import com.getcode.opencode.controllers.CurrencyController +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.internal.manager.VerifiedProtoManager +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Fiat.FormattingRule +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.min +import com.getcode.services.opencode.BuildConfig +import com.getcode.solana.keys.Mint +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import java.math.BigDecimal +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class RealVerifiedFiatCalculator @Inject constructor( + private val verifiedStateManager: VerifiedProtoManager, + private val currencyController: CurrencyController, +) : VerifiedFiatCalculator { + + private val resolveScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private suspend fun resolveVerifiedState( + currencyCode: CurrencyCode, + mint: Mint, + ): VerifiedState? { + verifiedStateManager.getVerifiedStateFor(currencyCode, mint)?.let { cached -> + val needsReserve = mint != Mint.usdf + if (!needsReserve || cached.reserveProto != null) return cached + } + + trace( + tag = "VerifiedFiatCalculator", + message = "live mint data is either expired or nonexistent. Fetching fresh", + type = TraceType.Log, + ) + + currencyController.getLiveMintData(resolveScope, mint) + .onFailure { cause -> + trace( + tag = "VerifiedFiatCalculator", + message = "Live mint data fetch failed for $mint", + type = TraceType.Error, + error = cause + ) + } + + return verifiedStateManager.getVerifiedStateFor(currencyCode, mint) + } + + override suspend fun compute( + amount: Fiat, + token: Token, + balance: Fiat?, + rate: Rate, + trace: Boolean, + ): VerifiedFiat { + val usdValue = amount.convertingToUsdIfNeeded(rate) + // cap the entered amount as well, since our display rounds HALF_UP + // e,g entered 0.02 USD, but balance is 0.016 USD + val cappedValue = balance?.let { min(it, usdValue) } ?: usdValue + + val verifiedState = resolveVerifiedState(rate.currency, token.address) + + if (token.address == Mint.usdf) { + // this doesn't need a calculated value exchange since we are USDC + val localFiat = if (rate.currency != CurrencyCode.USD) { + LocalFiat( + usdf = cappedValue, + rate = rate, + mint = token.address, + ) + } else { + LocalFiat(usdf = cappedValue) + } + return VerifiedFiat(localFiat, verifiedState) + } + + val verifiedSupply = verifiedState?.reserveProto?.reserveState?.supplyFromBonding + + val supply = verifiedSupply ?: token.launchpadMetadata?.currentCirculatingSupplyQuarks ?: 0 + + // determine quarks to exchange for the desired amount + val valuation = Estimator.valueExchangeAsQuarks( + valueInQuarks = cappedValue.quarks, + currentSupplyInQuarks = supply, + mintDecimals = 6, // usdf is 6 decimals + ).getOrThrow() + + val (quarks, _) = valuation + val units = quarks.units() + val underlyingTokenAmount = Fiat(quarks = quarks.toLong(), currencyCode = CurrencyCode.USD) + + val sellEstimate = Fiat.tokenBalance(quarks.toLong(), token, supply).convertingTo(rate) + val fx = sellEstimate.decimalValue.toBigDecimal().divideWithHighPrecision(units).toDouble() + + if (trace) { + logExchange( + rate = rate, + amount = amount, + usdValue = usdValue, + balance = balance, + cappedValue = cappedValue, + token = token, + supply = supply, + quarks = quarks, + units = units, + fx = fx, + sellEstimate = sellEstimate + ) + } + + return VerifiedFiat( + localFiat = LocalFiat( + underlyingTokenAmount = underlyingTokenAmount, + // our native amount for the transfer is the valuation of the quarks from a sell + nativeAmount = sellEstimate, + mint = token.address, + rate = Rate(fx = fx, currency = rate.currency), + ), + verifiedState = verifiedState, + ) + } + + private fun logExchange( + rate: Rate, + amount: Fiat, + usdValue: Fiat, + balance: Fiat?, + cappedValue: Fiat, + token: Token, + supply: Long, + quarks: BigDecimal, + units: BigDecimal, + fx: Double, + sellEstimate: Fiat, + ) { + trace( + tag = "VerifiedFiatCalculator", + message = "currency exchange", + metadata = { + "requested currency" to rate.currency.name + "original currency fx" to rate.fx + "requested amount" to amount.formatted() + "requested quarks (in USD)" to usdValue.quarks * 1_000_000 + "balance quarks (in USD)" to balance?.quarks?.times(1_000_000) + "capped quarks (in USD)" to cappedValue.quarks * 1_000_000 + "supply of ${token.symbol}" to supply + "calculated quarks" to quarks + "units" to units + "fx" to fx + "sell estimate" to sellEstimate.formatted(rule = FormattingRule.Length(token.decimals)) + } + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt index 292b5885c..8fcc53014 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant /** * Manages and caches verified state information fetched from the OpenCodeProtocol, @@ -21,6 +24,8 @@ import javax.inject.Singleton @Singleton class VerifiedProtoManager @Inject constructor() { + private val TTL = 15.minutes + /** * A [MutableStateFlow] holding the latest cached exchange rate data. * The data is stored in a map where the key is the [CurrencyCode] (e.g., "USD", "EUR") @@ -58,13 +63,39 @@ class VerifiedProtoManager @Inject constructor() { return exchangeData.value[currencyCode] } + private fun getOrEvict(currencyCode: CurrencyCode): CurrencyService.VerifiedCoreMintFiatExchangeRate? { + val now = Clock.System.now() + val stored = get(currencyCode) ?: return null + val ts = Instant.fromEpochSeconds(stored.exchangeRate.timestamp.seconds, stored.exchangeRate.timestamp.nanos) + val expired = now - ts > TTL + if (expired) { + val updated = exchangeData.value.filterNot { it.key == currencyCode } + exchangeData.update { updated } + } + + return stored + } + private fun get(mint: Mint): CurrencyService.VerifiedLaunchpadCurrencyReserveState? { return reserveStates.value[mint] } + private fun getOrEvict(mint: Mint): CurrencyService.VerifiedLaunchpadCurrencyReserveState? { + val now = Clock.System.now() + val stored = get(mint) ?: return null + val ts = Instant.fromEpochSeconds(stored.reserveState.timestamp.seconds, stored.reserveState.timestamp.nanos) + val expired = now - ts > TTL + if (expired) { + val updated = reserveStates.value.filterNot { it.key == mint } + reserveStates.update { updated } + } + + return stored + } + fun getVerifiedStateFor(currencyCode: CurrencyCode, mint: Mint): VerifiedState? { - val exchangeRate = get(currencyCode) ?: return null - val reserveState = get(mint) + val exchangeRate = getOrEvict(currencyCode) ?: return null + val reserveState = getOrEvict(mint) return VerifiedState(exchangeRate, reserveState) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt index a2538b6e4..df2715d87 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt @@ -16,10 +16,8 @@ import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.core.errors.GetIntentMetadataError import com.getcode.opencode.model.core.errors.GetLimitsError -import com.getcode.opencode.model.core.errors.SendMessageError import com.getcode.opencode.model.core.errors.VoidGiftCardError import com.getcode.opencode.model.core.errors.WithdrawalAvailabilityError -import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt index 070b7c066..f62e6e11f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt @@ -1,12 +1,10 @@ package com.getcode.opencode.internal.transactors import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.controllers.MessagingController import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.internal.extensions.exchangeDataFor import com.getcode.opencode.internal.extensions.toPublicKey -import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.network.extensions.asProtobufMessage import com.getcode.opencode.model.accounts.AccountCluster @@ -15,7 +13,6 @@ import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.transactions.TransactionMetadata import com.getcode.opencode.utils.nonce -import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.utils.CodeServerError import com.getcode.utils.NotifiableError @@ -35,11 +32,9 @@ import kotlin.time.Duration * and clear state when the bill is dismissed or times out. */ internal class GiveBillTransactor( - private val currencyController: CurrencyController, private val messagingController: MessagingController, private val transactionController: TransactionController, private val scope: CoroutineScope, - private val verifiedProtoManager: VerifiedProtoManager, private val payloadFactory: PayloadFactory, ) : Transactor("Transactor::Give") { private var token: Token? = null @@ -124,7 +119,7 @@ internal class GiveBillTransactor( val sendingAmount = amount ?: return logAndFail(GiveTransactorError.Other(message = "No amount. Did you call with() first?")) - val verifiedState = resolveVerifiedState(sendingAmount, desiredToken) + val verifiedState = providedVerifiedState ?: return logAndFail(GiveTransactorError.Other("Failed to get verified state")) val exchangeData = verifiedState.exchangeDataFor( @@ -183,10 +178,7 @@ internal class GiveBillTransactor( receivingAccount = transferRequest.account - val transferVerifiedState = resolveVerifiedState(sendingAmount, desiredToken) - ?: return logAndFail(GiveTransactorError.Other("Failed to get verified state")) - - val transferExchangeData = transferVerifiedState.exchangeDataFor( + val transferExchangeData = verifiedState.exchangeDataFor( amount = sendingAmount, mint = desiredToken.address, billExchangeDataTimeout = exchangeDataTimeout @@ -226,54 +218,6 @@ internal class GiveBillTransactor( scope.cancel() } - /** - * Resolve the verified state for the given currency/token pair using a fallback chain: - * 1. Use the provided state directly if available - * 2. Otherwise, check the local proto store - * 3. If still missing, fetch live mint data (which internally persists to the store) and re-query - */ - private suspend fun resolveVerifiedState( - sendingAmount: LocalFiat, - desiredToken: Token - ): VerifiedState? { - val currency = sendingAmount.rate.currency - val mint = desiredToken.address - val label = "${currency}/${desiredToken.symbol}" - val needsReserveState = mint != Mint.usdf - - providedVerifiedState?.let { - trace(tag = tag, message = "Using provided verified state for $label") - return it - } - - verifiedProtoManager.getVerifiedStateFor(currency, mint)?.let { - if (!needsReserveState || it.reserveProto != null) { - trace(tag = tag, message = "Resolved verified state from proto store for $label") - return it - } - - trace(tag = tag, message = "Proto store hit but missing reserve state for $label — fetching live mint data") - } - - trace(tag = tag, message = "Proto store miss — fetching live mint data for ${desiredToken.symbol}") - - return currencyController.getLiveMintData(scope, mint) - .onFailure { cause -> - trace( - tag = tag, - message = "Live mint data fetch failed for ${desiredToken.symbol}", - type = TraceType.Error, - error = cause - ) - } - .map { verifiedProtoManager.getVerifiedStateFor(currency, mint) } - .getOrNull() - ?.also { - trace(tag = tag, message = "Resolved verified state after live mint fetch for $label") - } - } - - sealed class GiveTransactorError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/SendGiftCardTransactor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/SendGiftCardTransactor.kt index 633da2727..4719a2c02 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/SendGiftCardTransactor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/SendGiftCardTransactor.kt @@ -3,6 +3,7 @@ package com.getcode.opencode.internal.transactors import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.internal.extensions.toPublicKey +import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.network.api.intents.IntentRemoteSend import com.getcode.opencode.internal.transactors.GiveBillTransactor.GiveTransactorError import com.getcode.opencode.model.accounts.AccountCluster @@ -29,15 +30,17 @@ internal class SendGiftCardTransactor( private var token: Token? = null private var amount: LocalFiat? = null private var owner: AccountCluster? = null + private var verifiedState: VerifiedState? = null private var rendezvousKey: KeyPair? = null /** Configures this transactor for a new gift card send. Must be called before [start]. */ - fun with(giftCard: GiftCardAccount, amount: LocalFiat, token: Token, owner: AccountCluster) { + fun with(giftCard: GiftCardAccount, amount: LocalFiat, token: Token, owner: AccountCluster, verifiedState: VerifiedState) { this.giftCardAccount = giftCard this.token = token this.amount = amount this.owner = owner + this.verifiedState = verifiedState val payloadResult = payloadFactory.create( kind = PayloadKind.MultiMintCash, @@ -73,6 +76,9 @@ internal class SendGiftCardTransactor( val ownerKey = owner ?: return logAndFail(GiveTransactorError.Other(message = "No owner key. Did you call with() first?")) + val pinnedState = verifiedState + ?: return logAndFail(GiveTransactorError.Other(message = "No verified state. Did you call with() first?")) + val source = ownerKey.withTimelockForToken(desiredToken) return transactionController.remoteSend( @@ -82,6 +88,7 @@ internal class SendGiftCardTransactor( amount = amount!!, giftCard = giftCard, token = desiredToken, + verifiedState = pinnedState, ).map { it as IntentRemoteSend } .fold( onSuccess = { Result.success(it) }, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/managers/BillTransactionManager.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/managers/BillTransactionManager.kt index b53af438c..210648edc 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/managers/BillTransactionManager.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/managers/BillTransactionManager.kt @@ -1,11 +1,9 @@ package com.getcode.opencode.managers import com.getcode.opencode.controllers.AccountController -import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.controllers.MessagingController import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.internal.domain.mapping.MintMapper -import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.internal.transactors.AccountClusterFactory import com.getcode.opencode.internal.transactors.BillPresentationData @@ -50,13 +48,11 @@ import kotlin.time.Duration @Singleton class BillTransactionManager @Inject constructor( private val accountController: AccountController, - private val currencyController: CurrencyController, private val messagingController: MessagingController, private val transactionController: TransactionController, private val tokenProvider: TokenMetadataProvider, private val mnemonicManager: MnemonicManager, private val giftCardManager: GiftCardManager, - private val verifiedProtoManager: VerifiedProtoManager, private val payloadFactory: PayloadFactory, private val accountClusterFactory: AccountClusterFactory, ) { @@ -102,11 +98,9 @@ class BillTransactionManager @Inject constructor( val childScope = CoroutineScope(sharedScope.coroutineContext + Job()) val transactor = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = childScope, - verifiedProtoManager = verifiedProtoManager, payloadFactory = payloadFactory, ).apply { with(token, amount, owner, billExchangeDataTimeout, verifiedState, nonce) @@ -209,6 +203,7 @@ class BillTransactionManager @Inject constructor( amount: LocalFiat, owner: AccountCluster, token: Token, + verifiedState: VerifiedState, onFunded: suspend (LocalFiat) -> Unit, onError: (Throwable) -> Unit, ) { @@ -216,7 +211,7 @@ class BillTransactionManager @Inject constructor( sharedScope.launch { val transactor = SendGiftCardTransactor(transactionController, payloadFactory).apply { - with(giftCard, amount, token, owner) + with(giftCard, amount, token, owner, verifiedState) } giftTransactor = transactor diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt index f1418ff04..7e029ce8c 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt @@ -203,7 +203,8 @@ data class Fiat( fun tokenBalance( quarks: Long, - token: Token + token: Token, + supplyOverride: Long? = null, ): Fiat { if (token.address == Mint.usdf) { return Fiat(quarks, CurrencyCode.USD) @@ -214,7 +215,7 @@ data class Fiat( Estimator.sell( amountInQuarks = quarks, marketState = MarketState.FromSupply( - token.launchpadMetadata?.currentCirculatingSupplyQuarks ?: 0, + supplyOverride ?: token.launchpadMetadata?.currentCirculatingSupplyQuarks ?: 0, ), mintDecimals = token.decimals, outputDecimals = 6, // The desired value here is USDF which is 6 diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt index a4535e2d8..376db2849 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt @@ -1,17 +1,11 @@ package com.getcode.opencode.model.financial import android.os.Parcelable -import com.flipcash.libs.currency.math.Estimator -import com.flipcash.libs.currency.math.divideWithHighPrecision -import com.flipcash.libs.currency.math.units -import com.getcode.opencode.model.financial.Fiat.FormattingRule +import com.getcode.opencode.internal.extensions.fractionDigits import com.getcode.opencode.model.transactions.ExchangeData -import com.getcode.services.opencode.BuildConfig import com.getcode.solana.keys.Mint -import com.getcode.utils.trace import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -import java.math.BigDecimal import javax.annotation.concurrent.Immutable typealias Usd = Fiat @@ -105,128 +99,14 @@ data class LocalFiat( rate = rate ) } - - fun valueExchangeIn( - amount: Fiat, - token: Token, - balance: Fiat? = null, - rate: Rate, - debug: Boolean = BuildConfig.DEBUG, - trace: Boolean = true, - ): LocalFiat { - val usdValue = amount.convertingToUsdIfNeeded(rate) - // cap the entered amount as well, since our display rounds HALF_UP - // e,g entered 0.02 USD, but balance is 0.016 USD - val cappedValue = balance?.let { min(it, usdValue) } ?: usdValue - - if (token.address == Mint.usdf) { - // this doesn't need a calculated value exchange since we are USDC - return if (rate.currency != CurrencyCode.USD) { - LocalFiat( - usdf = cappedValue, - rate = rate, - mint = token.address, - ) - } else { - LocalFiat(usdf = cappedValue) - } - } - - val supply = token.launchpadMetadata?.currentCirculatingSupplyQuarks ?: 0 - - // determine quarks to exchange for the desired amount - val valuation = Estimator.valueExchangeAsQuarks( - valueInQuarks = cappedValue.quarks, - currentSupplyInQuarks = supply, - mintDecimals = 6, // usdf is 6 decimals - ).getOrThrow() - - val (quarks, _) = valuation - val units = quarks.units() - val underlyingTokenAmount = Fiat(quarks = quarks.toLong(), currencyCode = CurrencyCode.USD) - - val sellEstimate = Fiat.tokenBalance(quarks.toLong(), token).convertingTo(rate) - val fx = sellEstimate.decimalValue.toBigDecimal().divideWithHighPrecision(units).toDouble() - - logExchange( - debug = debug, - trace = trace, - rate = rate, - amount = amount, - usdValue = usdValue, - balance = balance, - cappedValue = cappedValue, - token = token, - supply = supply, - quarks = quarks, - units = units, - fx = fx, - sellEstimate = sellEstimate - ) - - return LocalFiat( - underlyingTokenAmount = underlyingTokenAmount, - // our native amount for the transfer is the valuation of the quarks from a sell - nativeAmount = sellEstimate, - mint = token.address, - rate = Rate(fx = fx, currency = rate.currency), - ) - } - - private fun logExchange( - debug: Boolean, - trace: Boolean, - rate: Rate, - amount: Fiat, - usdValue: Fiat, - balance: Fiat?, - cappedValue: Fiat, - token: Token, - supply: Long, - quarks: BigDecimal, - units: BigDecimal, - fx: Double, - sellEstimate: Fiat, - ) { - if (debug) { - println("############## EXCHANGE REPORT ###################") - println("requested currency: ${rate.currency.name}") - println("original currency fx: ${rate.fx}") - println("requested amount: ${amount.formatted()}") - println("requested quarks (in USD): ${usdValue.quarks * 1_000_000}") - println("balance quarks (in USD): ${balance?.quarks?.times(1_000_000)}") - println("capped quarks (in USD): ${cappedValue.quarks * 1_000_000}") - println("supply (of ${token.symbol}): $supply") - println("calculated quarks: $quarks") - println("units: $units") - println("fx: $fx") - println("sell estimate: ${sellEstimate.formatted(rule = FormattingRule.Length(token.decimals))}") - println("##################################################") - } - - if (trace) { - trace( - tag = "LocalFiat", - message = "currency exchange", - metadata = { - "requested currency" to rate.currency.name - "original currency fx" to rate.fx - "requested amount" to amount.formatted() - "requested quarks (in USD)" to usdValue.quarks * 1_000_000 - "balance quarks (in USD)" to balance?.quarks?.times(1_000_000) - "capped quarks (in USD)" to cappedValue.quarks * 1_000_000 - "supply of ${token.symbol}" to supply - "calculated quarks" to quarks - "units" to units - "fx" to fx - "sell estimate" to sellEstimate.formatted(rule = FormattingRule.Length(token.decimals)) - } - ) - } - } } } +fun LocalFiat.rounded(): LocalFiat = copy( + underlyingTokenAmount = underlyingTokenAmount.rounded(underlyingTokenAmount.currencyCode.fractionDigits), + nativeAmount = nativeAmount.rounded(nativeAmount.currencyCode.fractionDigits), +) + fun Iterable.sum(): LocalFiat { return this.fold(LocalFiat.Zero) { acc, localFiat -> val base = if (acc == LocalFiat.Zero) { @@ -261,4 +141,4 @@ operator fun LocalFiat.plus(other: LocalFiat): LocalFiat { underlyingTokenAmount = underlyingTokenAmount + other.underlyingTokenAmount, nativeAmount = nativeAmount + other.nativeAmount ) -} \ No newline at end of file +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt new file mode 100644 index 000000000..560f07e04 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt @@ -0,0 +1,384 @@ +package com.getcode.opencode.internal.exchange + +import com.codeinc.opencode.gen.common.v1.solanaAccountId +import com.codeinc.opencode.gen.currency.v1.coreMintFiatExchangeRate +import com.codeinc.opencode.gen.currency.v1.launchpadCurrencyReserveState +import com.codeinc.opencode.gen.currency.v1.verifiedCoreMintFiatExchangeRate +import com.codeinc.opencode.gen.currency.v1.verifiedLaunchpadCurrencyReserveState +import com.flipcash.libs.currency.math.CurveTestInitializer +import com.getcode.opencode.controllers.CurrencyController +import com.getcode.opencode.internal.manager.VerifiedProtoManager +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LaunchpadMetadata +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 +import com.getcode.solana.keys.PublicKey +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class RealVerifiedFiatCalculatorTest { + + companion object { + @JvmStatic + @BeforeClass + fun initCurve() { + CurveTestInitializer.initialize() + } + } + + private lateinit var verifiedStateManager: VerifiedProtoManager + private lateinit var currencyController: CurrencyController + private lateinit var calculator: RealVerifiedFiatCalculator + + private val testMint = Mint(List(32) { 1.toByte() }) + private val dummyKey = PublicKey.fromBase58("11111111111111111111111111111111") + + @Before + fun setUp() { + verifiedStateManager = mockk(relaxed = true) + currencyController = mockk(relaxed = true) + calculator = RealVerifiedFiatCalculator(verifiedStateManager, currencyController) + } + + // region USDF passthrough + + @Test + fun `USDF token returns simple LocalFiat without bonding curve`() = runTest { + val amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD) + val token = usdfToken() + + val result = calculator.compute( + amount = amount, + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertEquals(Mint.usdf, result.localFiat.mint) + assertEquals(amount.quarks, result.localFiat.underlyingTokenAmount.quarks) + } + + @Test + fun `USDF token with non-USD rate converts native amount`() = runTest { + val amount = Fiat(fiat = 10.0, currencyCode = CurrencyCode.CAD) + val rate = Rate(fx = 1.35, currency = CurrencyCode.CAD) + val token = usdfToken() + + val result = calculator.compute( + amount = amount, + token = token, + rate = rate, + trace = false, + ) + + assertEquals(Mint.usdf, result.localFiat.mint) + assertEquals(CurrencyCode.CAD, result.localFiat.nativeAmount.currencyCode) + } + + // endregion + + // region verified supply usage + + @Test + fun `uses verified supply when available`() = runTest { + val supply = 1_000_000_000_000L // 1M tokens + val token = bondingCurveToken(supply = supply) + + // Set up verified state with a DIFFERENT supply + val verifiedSupply = 2_000_000_000_000L + stubVerifiedState(CurrencyCode.USD, testMint, verifiedSupply) + + val result = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + // Compute what we'd get with the token's own supply + every { + verifiedStateManager.getVerifiedStateFor(any(), any()) + } returns null + + val resultWithTokenSupply = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + // Results should differ because different supplies were used + assertNotEquals( + result.localFiat.underlyingTokenAmount.quarks, + resultWithTokenSupply.localFiat.underlyingTokenAmount.quarks, + ) + } + + @Test + fun `falls back to token supply when no verified state`() = runTest { + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + + every { + verifiedStateManager.getVerifiedStateFor(any(), any()) + } returns null + + val result = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertTrue(result.localFiat.underlyingTokenAmount.quarks > 0) + assertEquals(testMint, result.localFiat.mint) + } + + @Test + fun `falls back to token supply when verified state has no reserve proto`() = runTest { + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + + val stateWithoutReserve = VerifiedState( + rateProto = verifiedCoreMintFiatExchangeRate { + exchangeRate = coreMintFiatExchangeRate { currencyCode = "USD" } + }, + reserveProto = null, + ) + every { + verifiedStateManager.getVerifiedStateFor(CurrencyCode.USD, testMint) + } returns stateWithoutReserve + + val result = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertTrue(result.localFiat.underlyingTokenAmount.quarks > 0) + } + + // endregion + + // region balance capping + + @Test + fun `caps amount to balance when balance is smaller`() = runTest { + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + every { verifiedStateManager.getVerifiedStateFor(any(), any()) } returns null + + val largeAmount = Fiat(fiat = 10.0, currencyCode = CurrencyCode.USD) + val smallBalance = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD) + + val cappedResult = calculator.compute( + amount = largeAmount, + token = token, + balance = smallBalance, + rate = Rate.oneToOne, + trace = false, + ) + + val directResult = calculator.compute( + amount = smallBalance, + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + // Capped result should match computing with the balance amount directly + assertEquals( + directResult.localFiat.underlyingTokenAmount.quarks, + cappedResult.localFiat.underlyingTokenAmount.quarks, + ) + } + + @Test + fun `does not cap when balance is larger than amount`() = runTest { + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + every { verifiedStateManager.getVerifiedStateFor(any(), any()) } returns null + + val amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD) + val largeBalance = Fiat(fiat = 10.0, currencyCode = CurrencyCode.USD) + + val withBalance = calculator.compute( + amount = amount, + token = token, + balance = largeBalance, + rate = Rate.oneToOne, + trace = false, + ) + + val withoutBalance = calculator.compute( + amount = amount, + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertEquals( + withoutBalance.localFiat.underlyingTokenAmount.quarks, + withBalance.localFiat.underlyingTokenAmount.quarks, + ) + } + + // endregion + + // region result consistency + + @Test + fun `result has correct mint`() = runTest { + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + every { verifiedStateManager.getVerifiedStateFor(any(), any()) } returns null + + val result = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertEquals(testMint, result.localFiat.mint) + } + + @Test + fun `result underlying amount is positive for positive input`() = runTest { + val supply = 1_000_000_000_000L + val token = bondingCurveToken(supply = supply) + every { verifiedStateManager.getVerifiedStateFor(any(), any()) } returns null + + val result = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertTrue(result.localFiat.underlyingTokenAmount.quarks > 0) + assertTrue(result.localFiat.nativeAmount.quarks > 0) + assertTrue(result.localFiat.rate.fx > 0) + } + + @Test + fun `same supply produces same result regardless of source`() = runTest { + val supply = 5_000_000_000_000L + val token = bondingCurveToken(supply = supply) + + // First: use verified supply matching the token supply + stubVerifiedState(CurrencyCode.USD, testMint, supply) + + val verifiedResult = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + // Second: no verified state, fall back to token supply + every { verifiedStateManager.getVerifiedStateFor(any(), any()) } returns null + + val fallbackResult = calculator.compute( + amount = Fiat(fiat = 1.0, currencyCode = CurrencyCode.USD), + token = token, + rate = Rate.oneToOne, + trace = false, + ) + + assertEquals( + verifiedResult.localFiat.underlyingTokenAmount.quarks, + fallbackResult.localFiat.underlyingTokenAmount.quarks, + ) + assertEquals( + verifiedResult.localFiat.nativeAmount.quarks, + fallbackResult.localFiat.nativeAmount.quarks, + ) + } + + // endregion + + // region helpers + + private fun usdfToken(): Token = MintMetadata( + address = Mint.usdf, + decimals = 6, + name = "USDF", + symbol = "USDF", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = dummyKey, + authority = dummyKey, + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun bondingCurveToken(supply: Long): Token = MintMetadata( + address = testMint, + decimals = 10, + name = "TestCoin", + symbol = "TEST", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = dummyKey, + authority = dummyKey, + lockDurationInDays = 21, + ), + launchpadMetadata = LaunchpadMetadata( + currencyConfig = dummyKey, + liquidityPool = dummyKey, + seed = dummyKey, + authority = dummyKey, + mintVault = dummyKey, + coreMintVault = dummyKey, + currentCirculatingSupplyQuarks = supply, + sellFeeBps = 100, + price = Fiat(fiat = 0.01), + marketCap = Fiat(fiat = 10000.0), + ), + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun stubVerifiedState(currency: CurrencyCode, mint: Mint, supply: Long) { + val reserveState = verifiedLaunchpadCurrencyReserveState { + this.reserveState = launchpadCurrencyReserveState { + this.mint = solanaAccountId { value = com.google.protobuf.ByteString.copyFrom(mint.bytes.toByteArray()) } + this.supplyFromBonding = supply + } + } + + val rateProto = verifiedCoreMintFiatExchangeRate { + exchangeRate = coreMintFiatExchangeRate { currencyCode = currency.name } + } + + every { + verifiedStateManager.getVerifiedStateFor(currency, mint) + } returns VerifiedState(rateProto, reserveState) + } + + // endregion +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt index c1526709c..d7d7a1b13 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactorTest.kt @@ -1,10 +1,8 @@ package com.getcode.opencode.internal.transactors -import com.getcode.opencode.controllers.CurrencyController import com.getcode.opencode.controllers.MessagingController import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.internal.extensions.exchangeDataFor -import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.CurrencyCode @@ -34,10 +32,8 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class GiveBillTransactorTest { - private val currencyController = mockk(relaxed = true) private val messagingController = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) - private val verifiedProtoManager = mockk(relaxed = true) private val payloadFactory = PayloadFactory { _, _, _ -> PayloadResult(rendezvous = mockk(relaxed = true), codeData = emptyList()) @@ -45,11 +41,9 @@ class GiveBillTransactorTest { private fun createTransactor(scope: TestScope): GiveBillTransactor { return GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = scope, - verifiedProtoManager = verifiedProtoManager, payloadFactory = payloadFactory, ) } @@ -66,21 +60,14 @@ class GiveBillTransactorTest { } @Test - fun `start fails when no verified state provided and none resolvable`() = runTest { + fun `start fails when no verified state provided`() = runTest { val transactor = createTransactor(this) - // Pass verifiedState = null, proto store returns null → resolveVerifiedState - // will call getLiveMintData which throws (MockK can't mock Result returns) - // → resolveVerifiedState returns null → start() returns failure + // Pass verifiedState = null → start() uses providedVerifiedState which is null → failure setupWith(transactor, verifiedState = null) - every { verifiedProtoManager.getVerifiedStateFor(any(), any()) } returns null - // getLiveMintData throws → runCatching in resolveVerifiedState catches it → returns null - coEvery { currencyController.getLiveMintData(any(), any(), any()) } throws RuntimeException("no data") - - // Use runCatching because the thrown exception may propagate - val result = runCatching { transactor.start() } + val result = transactor.start() - assertTrue(result.isFailure || result.getOrNull()?.isFailure == true) + assertTrue(result.isFailure) } @Test @@ -151,11 +138,9 @@ class GiveBillTransactorTest { } val transactor = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, payloadFactory = factory, ) @@ -183,11 +168,9 @@ class GiveBillTransactorTest { } val transactor = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, payloadFactory = factory, ) @@ -208,19 +191,15 @@ class GiveBillTransactorTest { } val transactor1 = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, payloadFactory = factory, ) val transactor2 = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, payloadFactory = factory, )