From 3f73f3f7b5f9bde8f9558493a44a0bacc16e3507 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Apr 2026 07:41:08 -0400 Subject: [PATCH 1/5] chore(currency-math): add testFixtures for bonding curve initialization Enable AGP Kotlin testFixtures support and expose CurveTestInitializer and FileTableLoader so other modules can initialize the DiscreteBondingCurve in unit tests without an Android Context. Signed-off-by: Brandon McAnsh --- gradle.properties | 5 ++++- libs/currency-math/build.gradle.kts | 5 +++++ .../currency/math/DiscreteBondingCurveTests.kt | 18 ++++++++---------- .../libs/currency/math/EstimationTests.kt | 5 +---- .../math/EstimatorValueExchangeTests.kt | 9 +++------ .../libs/currency/math/CurveTestInitializer.kt | 11 +++++++++++ .../currency/math/loader/FileTableLoader.kt | 18 +++++++----------- 7 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 libs/currency-math/src/testFixtures/kotlin/com/flipcash/libs/currency/math/CurveTestInitializer.kt rename libs/currency-math/src/{test/java => testFixtures/kotlin}/com/flipcash/libs/currency/math/loader/FileTableLoader.kt (76%) 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 +} From 2ede53fd643edfc859279a45687906d8583bd2ce Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Apr 2026 07:48:18 -0400 Subject: [PATCH 2/5] feat(opencode): add VerifiedFiatCalculator for supply-consistent exchange Introduce VerifiedFiatCalculator that computes LocalFiat using the verified supply from VerifiedProtoManager, ensuring underlyingTokenAmount, nativeAmount, and fx are all consistent with the VerifiedState sent to the server. - Move valueExchangeIn logic from LocalFiat into RealVerifiedFiatCalculator - Add supplyOverride parameter to Fiat.tokenBalance - Bind via Hilt in OpenCodeModule - Remove unused imports from TransactionController Signed-off-by: Brandon McAnsh --- .../app/cash/internal/CashScreenViewModel.kt | 6 +- .../cash/internal/CashScreenViewModelTest.kt | 3 + .../app/withdrawal/WithdrawalViewModel.kt | 8 +- .../WithdrawalViewModelErrorTest.kt | 3 + .../flipcash/app/tokens/TokenCoordinator.kt | 5 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 6 +- .../app/tokens/ui/SwapViewModelErrorTest.kt | 3 + services/opencode/build.gradle.kts | 1 + .../controllers/TransactionController.kt | 3 - .../exchange/VerifiedFiatCalculator.kt | 17 + .../getcode/opencode/inject/OpenCodeModule.kt | 6 + .../exchange/RealVerifiedFiatCalculator.kt | 127 ++++++ .../getcode/opencode/model/financial/Fiat.kt | 5 +- .../opencode/model/financial/LocalFiat.kt | 128 +----- .../RealVerifiedFiatCalculatorTest.kt | 380 ++++++++++++++++++ 15 files changed, 560 insertions(+), 141 deletions(-) create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt 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..d7ab514f3 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,7 +142,7 @@ 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, @@ -302,7 +304,7 @@ internal class CashScreenViewModel @Inject constructor( val (token, balance) = stateFlow.value.token!! val rate = exchange.entryRate - val amountFiat = LocalFiat.valueExchangeIn( + val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = token, balance = balance.underlyingTokenAmount, 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/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..9cf1585a7 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,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 @@ -68,6 +69,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, @@ -267,7 +269,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 amountFiat = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = token, balance = stateFlow.value.token!!.balance, @@ -394,8 +396,8 @@ 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)!!, 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/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..d75df0794 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 { @@ -476,12 +478,11 @@ class TokenCoordinator @Inject constructor( state.balances[mint]?.let { balance -> val exchangedValue = runCatching { - LocalFiat.valueExchangeIn( + verifiedFiatCalculator.compute( amount = balance, token = updatedToken, balance = balance, rate = Rate.oneToOne, - debug = false, trace = false, ).underlyingTokenAmount }.getOrNull() 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..e2411c57e 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,7 @@ 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.VerifiedFiatCalculator import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.core.errors.SwapError import com.getcode.opencode.model.financial.Currency @@ -67,6 +68,7 @@ data class AmountEntryState( class SwapViewModel @Inject constructor( userManager: UserManager, private val exchange: Exchange, + private val verifiedFiatCalculator: VerifiedFiatCalculator, transactionController: TransactionOperations, resources: ResourceHelper, tokenCoordinator: TokenCoordinator, @@ -475,7 +477,7 @@ 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), @@ -511,7 +513,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, 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..9dbae104f 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,7 @@ 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.VerifiedFiatCalculator import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -47,6 +48,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 +87,7 @@ class SwapViewModelErrorTest { return SwapViewModel( userManager = userManager, exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, transactionController = transactionController, resources = resources, tokenCoordinator = tokenCoordinator, 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/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index f2c2f3318..5eb127152 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 @@ -12,7 +12,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 +45,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 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..4a5730eab --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt @@ -0,0 +1,17 @@ +package com.getcode.opencode.exchange + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.services.opencode.BuildConfig + +interface VerifiedFiatCalculator { + fun compute( + amount: Fiat, + token: Token, + balance: Fiat? = null, + rate: Rate, + trace: Boolean = true, + ): LocalFiat +} 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..e81d461a1 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt @@ -0,0 +1,127 @@ +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.exchange.VerifiedFiatCalculator +import com.getcode.opencode.internal.manager.VerifiedProtoManager +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.trace +import java.math.BigDecimal +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class RealVerifiedFiatCalculator @Inject constructor( + private val verifiedStateManager: VerifiedProtoManager, +) : VerifiedFiatCalculator { + override fun compute( + amount: Fiat, + token: Token, + balance: Fiat?, + rate: Rate, + trace: Boolean, + ): 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 verifiedSupply = verifiedStateManager + .getVerifiedStateFor(rate.currency, token.address) + ?.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 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( + 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/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..a64d4e17d 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,10 @@ 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.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,125 +98,6 @@ 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)) - } - ) - } - } } } @@ -261,4 +135,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..049d5c335 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt @@ -0,0 +1,380 @@ +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.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 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 calculator: RealVerifiedFiatCalculator + + private val testMint = Mint(List(32) { 1.toByte() }) + private val dummyKey = PublicKey.fromBase58("11111111111111111111111111111111") + + @Before + fun setUp() { + verifiedStateManager = mockk(relaxed = true) + calculator = RealVerifiedFiatCalculator(verifiedStateManager) + } + + // region USDF passthrough + + @Test + fun `USDF token returns simple LocalFiat without bonding curve`() { + 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.mint) + assertEquals(amount.quarks, result.underlyingTokenAmount.quarks) + } + + @Test + fun `USDF token with non-USD rate converts native amount`() { + 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.mint) + assertEquals(CurrencyCode.CAD, result.nativeAmount.currencyCode) + } + + // endregion + + // region verified supply usage + + @Test + fun `uses verified supply when available`() { + 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.underlyingTokenAmount.quarks, + resultWithTokenSupply.underlyingTokenAmount.quarks, + ) + } + + @Test + fun `falls back to token supply when no verified state`() { + 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.underlyingTokenAmount.quarks > 0) + assertEquals(testMint, result.mint) + } + + @Test + fun `falls back to token supply when verified state has no reserve proto`() { + 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.underlyingTokenAmount.quarks > 0) + } + + // endregion + + // region balance capping + + @Test + fun `caps amount to balance when balance is smaller`() { + 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.underlyingTokenAmount.quarks, + cappedResult.underlyingTokenAmount.quarks, + ) + } + + @Test + fun `does not cap when balance is larger than amount`() { + 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.underlyingTokenAmount.quarks, + withBalance.underlyingTokenAmount.quarks, + ) + } + + // endregion + + // region result consistency + + @Test + fun `result has correct mint`() { + 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.mint) + } + + @Test + fun `result underlying amount is positive for positive input`() { + 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.underlyingTokenAmount.quarks > 0) + assertTrue(result.nativeAmount.quarks > 0) + assertTrue(result.rate.fx > 0) + } + + @Test + fun `same supply produces same result regardless of source`() { + 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.underlyingTokenAmount.quarks, + fallbackResult.underlyingTokenAmount.quarks, + ) + assertEquals( + verifiedResult.nativeAmount.quarks, + fallbackResult.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 +} From 7823a86abfcecf05fe06d49fcf9143104891a6bc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Apr 2026 11:28:07 -0400 Subject: [PATCH 3/5] feat(opencode): pin VerifiedState from compute() through to intent submission Thread VerifiedFiat (LocalFiat + VerifiedState) atomically from VerifiedFiatCalculator.compute() through ViewModels to TransactionController, eliminating TOCTOU race where bonding curve supply could diverge between amount computation and intent submission. - Add VerifiedFiat wrapper type pairing LocalFiat with pinned VerifiedState - Update TransactionOperations buy/sell/withdraw to accept VerifiedFiat - Thread VerifiedFiat through SwapViewModel, WithdrawalViewModel, OnRampViewModel, CashScreenViewModel, CurrencyCreatorViewModel - Thread through ExternalWalletOnRampState and CoinbaseOnRampState pipelines - Add LocalFiat.rounded() and fix totalBalance penny rounding discrepancy Signed-off-by: Brandon McAnsh --- .../app/cash/internal/CashScreenViewModel.kt | 7 ++-- .../internal/CurrencyCreatorViewModel.kt | 22 ++++++++-- .../app/onramp/OnRampCustomAmountScreen.kt | 1 + .../app/onramp/internal/OnRampViewModel.kt | 21 ++++++---- .../app/withdrawal/WithdrawalViewModel.kt | 17 ++++---- .../WithdrawalConfirmationScreen.kt | 2 +- .../app/onramp/CoinbaseOnRampController.kt | 9 ++-- .../app/onramp/CoinbaseOnRampState.kt | 7 ++-- .../onramp/ExternalWalletOnRampController.kt | 12 +++--- .../app/onramp/ExternalWalletOnRampHandler.kt | 2 +- .../app/onramp/ExternalWalletOnRampState.kt | 5 ++- .../flipcash/app/tokens/TokenCoordinator.kt | 2 +- .../app/tokens/ui/SelectTokenViewModel.kt | 3 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 31 +++++++------- .../app/tokens/ui/SwapViewModelErrorTest.kt | 5 ++- .../controllers/TransactionController.kt | 30 ++++++------- .../controllers/TransactionOperations.kt | 7 ++-- .../exchange/VerifiedFiatCalculator.kt | 9 +++- .../exchange/RealVerifiedFiatCalculator.kt | 27 +++++++----- .../network/services/TransactionService.kt | 2 - .../opencode/model/financial/LocalFiat.kt | 6 +++ .../RealVerifiedFiatCalculatorTest.kt | 42 +++++++++---------- 22 files changed, 157 insertions(+), 112 deletions(-) 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 d7ab514f3..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 @@ -146,7 +146,7 @@ internal class CashScreenViewModel @Inject constructor( amount = Fiat(amount, rate.currency), token = token, rate = rate, - ) + ).localFiat val neededAmount = amountFiat.nativeAmount - tokenBalance println("entered amount ${amountFiat.nativeAmount}, tokenbalace=$tokenBalance, needed=$neededAmount") @@ -304,7 +304,7 @@ internal class CashScreenViewModel @Inject constructor( val (token, balance) = stateFlow.value.token!! val rate = exchange.entryRate - val amountFiat = verifiedFiatCalculator.compute( + val result = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = token, balance = balance.underlyingTokenAmount, @@ -313,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/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 9cf1585a7..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,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.VerifiedFiat import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.financial.Currency import com.getcode.opencode.model.financial.CurrencyCode @@ -56,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( @@ -123,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, @@ -269,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 = verifiedFiatCalculator.compute( + val amountVerified = verifiedFiatCalculator.compute( amount = Fiat(data.amountData.amount, rate.currency), token = token, balance = stateFlow.value.token!!.balance, @@ -277,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 @@ -341,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) { @@ -401,7 +402,7 @@ internal class WithdrawalViewModel @Inject constructor( token = token, balance = stateFlow.value.token!!.balance, rate = exchange.rateToUsd(CurrencyCode.USD)!!, - ).underlyingTokenAmount + ).localFiat.underlyingTokenAmount } transactionController.withdraw( @@ -417,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, ) @@ -430,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/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/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 d75df0794..d3403c2fa 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 @@ -484,7 +484,7 @@ class TokenCoordinator @Inject constructor( balance = balance, rate = Rate.oneToOne, trace = false, - ).underlyingTokenAmount + ).localFiat.underlyingTokenAmount }.getOrNull() if (exchangedValue != null) { 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 e2411c57e..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,7 @@ 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 @@ -61,7 +62,7 @@ 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 @@ -208,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 @@ -483,7 +484,7 @@ class SwapViewModel @Inject constructor( 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)) @@ -492,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( @@ -555,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)) @@ -596,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 9dbae104f..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,7 @@ 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 @@ -109,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)))) @@ -130,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/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index 5eb127152..a75065353 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,6 +2,7 @@ package com.getcode.opencode.controllers import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.events.Events +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.internal.network.api.intents.IntentDistribution import com.getcode.opencode.internal.network.api.intents.IntentRemoteReceive @@ -118,7 +119,7 @@ class TransactionController @Inject constructor( } override suspend fun withdraw( - amount: LocalFiat, + amount: VerifiedFiat, mint: Mint, owner: AccountCluster, destination: PublicKey, @@ -126,11 +127,12 @@ class TransactionController @Inject constructor( fee: Fiat?, scope: CoroutineScope, ): Result { - val verifiedState = verifiedStateManager.getVerifiedStateFor(amount.rate.currency, mint) + val verifiedState = amount.verifiedState + ?: verifiedStateManager.getVerifiedStateFor(amount.localFiat.rate.currency, mint) ?: 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, @@ -233,14 +235,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 @@ -262,9 +264,9 @@ 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 + ?: verifiedStateManager.getVerifiedStateFor(amount.localFiat.rate.currency, Mint.usdf) + ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) return accountResult.fold( onSuccess = { @@ -272,7 +274,7 @@ class TransactionController @Inject constructor( scope = scope, swapId = swapId, owner = owner, - amount = amount, + amount = amount.localFiat, feeAmount = feeAmount, of = of, source = source, @@ -294,17 +296,17 @@ 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 + ?: verifiedStateManager.getVerifiedStateFor(amount.localFiat.rate.currency, of.address) + ?: 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 index 4a5730eab..f9df79fc7 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt @@ -1,10 +1,15 @@ 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 -import com.getcode.services.opencode.BuildConfig + +data class VerifiedFiat( + val localFiat: LocalFiat, + val verifiedState: VerifiedState?, +) interface VerifiedFiatCalculator { fun compute( @@ -13,5 +18,5 @@ interface VerifiedFiatCalculator { balance: Fiat? = null, rate: Rate, trace: Boolean = true, - ): LocalFiat + ): VerifiedFiat } 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 index e81d461a1..4bdf5c004 100644 --- 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 @@ -3,6 +3,7 @@ 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.exchange.VerifiedFiat import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.internal.manager.VerifiedProtoManager import com.getcode.opencode.model.financial.CurrencyCode @@ -29,15 +30,17 @@ internal class RealVerifiedFiatCalculator @Inject constructor( balance: Fiat?, rate: Rate, trace: Boolean, - ): LocalFiat { + ): 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 = verifiedStateManager.getVerifiedStateFor(rate.currency, token.address) + if (token.address == Mint.usdf) { // this doesn't need a calculated value exchange since we are USDC - return if (rate.currency != CurrencyCode.USD) { + val localFiat = if (rate.currency != CurrencyCode.USD) { LocalFiat( usdf = cappedValue, rate = rate, @@ -46,11 +49,10 @@ internal class RealVerifiedFiatCalculator @Inject constructor( } else { LocalFiat(usdf = cappedValue) } + return VerifiedFiat(localFiat, verifiedState) } - val verifiedSupply = verifiedStateManager - .getVerifiedStateFor(rate.currency, token.address) - ?.reserveProto?.reserveState?.supplyFromBonding + val verifiedSupply = verifiedState?.reserveProto?.reserveState?.supplyFromBonding val supply = verifiedSupply ?: token.launchpadMetadata?.currentCirculatingSupplyQuarks ?: 0 @@ -84,12 +86,15 @@ internal class RealVerifiedFiatCalculator @Inject constructor( ) } - 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), + 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, ) } 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/model/financial/LocalFiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt index a64d4e17d..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,6 +1,7 @@ package com.getcode.opencode.model.financial import android.os.Parcelable +import com.getcode.opencode.internal.extensions.fractionDigits import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.solana.keys.Mint import kotlinx.parcelize.Parcelize @@ -101,6 +102,11 @@ data class LocalFiat( } } +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) { 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 index 049d5c335..65da236ed 100644 --- 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 @@ -63,8 +63,8 @@ class RealVerifiedFiatCalculatorTest { trace = false, ) - assertEquals(Mint.usdf, result.mint) - assertEquals(amount.quarks, result.underlyingTokenAmount.quarks) + assertEquals(Mint.usdf, result.localFiat.mint) + assertEquals(amount.quarks, result.localFiat.underlyingTokenAmount.quarks) } @Test @@ -80,8 +80,8 @@ class RealVerifiedFiatCalculatorTest { trace = false, ) - assertEquals(Mint.usdf, result.mint) - assertEquals(CurrencyCode.CAD, result.nativeAmount.currencyCode) + assertEquals(Mint.usdf, result.localFiat.mint) + assertEquals(CurrencyCode.CAD, result.localFiat.nativeAmount.currencyCode) } // endregion @@ -118,8 +118,8 @@ class RealVerifiedFiatCalculatorTest { // Results should differ because different supplies were used assertNotEquals( - result.underlyingTokenAmount.quarks, - resultWithTokenSupply.underlyingTokenAmount.quarks, + result.localFiat.underlyingTokenAmount.quarks, + resultWithTokenSupply.localFiat.underlyingTokenAmount.quarks, ) } @@ -139,8 +139,8 @@ class RealVerifiedFiatCalculatorTest { trace = false, ) - assertTrue(result.underlyingTokenAmount.quarks > 0) - assertEquals(testMint, result.mint) + assertTrue(result.localFiat.underlyingTokenAmount.quarks > 0) + assertEquals(testMint, result.localFiat.mint) } @Test @@ -165,7 +165,7 @@ class RealVerifiedFiatCalculatorTest { trace = false, ) - assertTrue(result.underlyingTokenAmount.quarks > 0) + assertTrue(result.localFiat.underlyingTokenAmount.quarks > 0) } // endregion @@ -198,8 +198,8 @@ class RealVerifiedFiatCalculatorTest { // Capped result should match computing with the balance amount directly assertEquals( - directResult.underlyingTokenAmount.quarks, - cappedResult.underlyingTokenAmount.quarks, + directResult.localFiat.underlyingTokenAmount.quarks, + cappedResult.localFiat.underlyingTokenAmount.quarks, ) } @@ -228,8 +228,8 @@ class RealVerifiedFiatCalculatorTest { ) assertEquals( - withoutBalance.underlyingTokenAmount.quarks, - withBalance.underlyingTokenAmount.quarks, + withoutBalance.localFiat.underlyingTokenAmount.quarks, + withBalance.localFiat.underlyingTokenAmount.quarks, ) } @@ -250,7 +250,7 @@ class RealVerifiedFiatCalculatorTest { trace = false, ) - assertEquals(testMint, result.mint) + assertEquals(testMint, result.localFiat.mint) } @Test @@ -266,9 +266,9 @@ class RealVerifiedFiatCalculatorTest { trace = false, ) - assertTrue(result.underlyingTokenAmount.quarks > 0) - assertTrue(result.nativeAmount.quarks > 0) - assertTrue(result.rate.fx > 0) + assertTrue(result.localFiat.underlyingTokenAmount.quarks > 0) + assertTrue(result.localFiat.nativeAmount.quarks > 0) + assertTrue(result.localFiat.rate.fx > 0) } @Test @@ -297,12 +297,12 @@ class RealVerifiedFiatCalculatorTest { ) assertEquals( - verifiedResult.underlyingTokenAmount.quarks, - fallbackResult.underlyingTokenAmount.quarks, + verifiedResult.localFiat.underlyingTokenAmount.quarks, + fallbackResult.localFiat.underlyingTokenAmount.quarks, ) assertEquals( - verifiedResult.nativeAmount.quarks, - fallbackResult.nativeAmount.quarks, + verifiedResult.localFiat.nativeAmount.quarks, + fallbackResult.localFiat.nativeAmount.quarks, ) } From 7e3bf0b0d40b9fb0fa8bc9d0259a3804f6de781c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Apr 2026 12:55:38 -0400 Subject: [PATCH 4/5] feat(opencode): pin VerifiedState through remoteSend gift card path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the pinned VerifiedState from Bill.Cash through the entire gift card funding chain: shareGiftCard → initiateGiftCardFunding → BillController → BillTransactionManager → SendGiftCardTransactor → TransactionController.remoteSend. This eliminates the cache lookup in remoteSend that could return stale exchange data, making it consistent with the give-bill and swap paths that already receive a pinned VerifiedState from compute(). Remove VerifiedProtoManager dependency from TransactionController since remoteSend was its only consumer. Co-Authored-By: Claude Opus 4.6 --- .../app/core/internal/bill/BillController.kt | 3 +- .../session/internal/RealSessionController.kt | 20 ++++- .../flipcash/app/tokens/TokenCoordinator.kt | 82 +++++++++---------- .../workers/internal/GiftCardFundingWorker.kt | 10 +++ .../internal/GiftCardFundingWorkerTest.kt | 3 + .../com/getcode/opencode/ControllerFactory.kt | 1 - .../controllers/TransactionController.kt | 10 +-- .../exchange/VerifiedFiatCalculator.kt | 2 +- .../exchange/RealVerifiedFiatCalculator.kt | 42 +++++++++- .../internal/manager/VerifiedProtoManager.kt | 35 +++++++- .../transactors/GiveBillTransactor.kt | 60 +------------- .../transactors/SendGiftCardTransactor.kt | 9 +- .../managers/BillTransactionManager.kt | 9 +- .../RealVerifiedFiatCalculatorTest.kt | 26 +++--- .../transactors/GiveBillTransactorTest.kt | 34 ++------ 15 files changed, 185 insertions(+), 161 deletions(-) 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/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 d3403c2fa..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 @@ -459,50 +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 - - response.reserveStates.forEach { update -> - val mint = update.reserveState.mint - val token = state.tokens[mint] ?: return@forEach - val launchpad = token.launchpadMetadata ?: return@forEach - - val updatedToken = token.copy( - launchpadMetadata = launchpad.copy( - currentCirculatingSupplyQuarks = update.reserveState.currentSupply - ) + val state = _state.value + var updatedTokens = state.tokens + var updatedBalances = state.balances + var updatedAppreciation = state.appreciation + + 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 ) - updatedTokens = updatedTokens + (mint to updatedToken) - - state.balances[mint]?.let { balance -> - 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) - } - } + ) + 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/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/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 a75065353..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 @@ -3,7 +3,7 @@ package com.getcode.opencode.controllers import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.events.Events import com.getcode.opencode.exchange.VerifiedFiat -import com.getcode.opencode.internal.manager.VerifiedProtoManager +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 @@ -63,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()) @@ -128,7 +127,6 @@ class TransactionController @Inject constructor( scope: CoroutineScope, ): Result { val verifiedState = amount.verifiedState - ?: verifiedStateManager.getVerifiedStateFor(amount.localFiat.rate.currency, mint) ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) val intent = IntentWithdraw.create( @@ -154,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, @@ -265,7 +261,6 @@ class TransactionController @Inject constructor( } val verifiedState = amount.verifiedState - ?: verifiedStateManager.getVerifiedStateFor(amount.localFiat.rate.currency, Mint.usdf) ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) return accountResult.fold( @@ -300,7 +295,6 @@ class TransactionController @Inject constructor( of: Token, ): Result { val verifiedState = amount.verifiedState - ?: verifiedStateManager.getVerifiedStateFor(amount.localFiat.rate.currency, of.address) ?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found"))) return repository.sell( 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 index f9df79fc7..23532dfa6 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/exchange/VerifiedFiatCalculator.kt @@ -12,7 +12,7 @@ data class VerifiedFiat( ) interface VerifiedFiatCalculator { - fun compute( + suspend fun compute( amount: Fiat, token: Token, balance: Fiat? = null, 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 index 4bdf5c004..2566549f4 100644 --- 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 @@ -3,9 +3,11 @@ 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 @@ -15,7 +17,11 @@ 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 @@ -23,8 +29,40 @@ import javax.inject.Singleton @Singleton internal class RealVerifiedFiatCalculator @Inject constructor( private val verifiedStateManager: VerifiedProtoManager, + private val currencyController: CurrencyController, ) : VerifiedFiatCalculator { - override fun compute( + + 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?, @@ -36,7 +74,7 @@ internal class RealVerifiedFiatCalculator @Inject constructor( // e,g entered 0.02 USD, but balance is 0.016 USD val cappedValue = balance?.let { min(it, usdValue) } ?: usdValue - val verifiedState = verifiedStateManager.getVerifiedStateFor(rate.currency, token.address) + val verifiedState = resolveVerifiedState(rate.currency, token.address) if (token.address == Mint.usdf) { // this doesn't need a calculated value exchange since we are USDC 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/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/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt index 65da236ed..560f07e04 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -20,6 +21,7 @@ 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 @@ -38,6 +40,7 @@ class RealVerifiedFiatCalculatorTest { } private lateinit var verifiedStateManager: VerifiedProtoManager + private lateinit var currencyController: CurrencyController private lateinit var calculator: RealVerifiedFiatCalculator private val testMint = Mint(List(32) { 1.toByte() }) @@ -46,13 +49,14 @@ class RealVerifiedFiatCalculatorTest { @Before fun setUp() { verifiedStateManager = mockk(relaxed = true) - calculator = RealVerifiedFiatCalculator(verifiedStateManager) + currencyController = mockk(relaxed = true) + calculator = RealVerifiedFiatCalculator(verifiedStateManager, currencyController) } // region USDF passthrough @Test - fun `USDF token returns simple LocalFiat without bonding curve`() { + fun `USDF token returns simple LocalFiat without bonding curve`() = runTest { val amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD) val token = usdfToken() @@ -68,7 +72,7 @@ class RealVerifiedFiatCalculatorTest { } @Test - fun `USDF token with non-USD rate converts native amount`() { + 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() @@ -89,7 +93,7 @@ class RealVerifiedFiatCalculatorTest { // region verified supply usage @Test - fun `uses verified supply when available`() { + fun `uses verified supply when available`() = runTest { val supply = 1_000_000_000_000L // 1M tokens val token = bondingCurveToken(supply = supply) @@ -124,7 +128,7 @@ class RealVerifiedFiatCalculatorTest { } @Test - fun `falls back to token supply when no verified state`() { + fun `falls back to token supply when no verified state`() = runTest { val supply = 1_000_000_000_000L val token = bondingCurveToken(supply = supply) @@ -144,7 +148,7 @@ class RealVerifiedFiatCalculatorTest { } @Test - fun `falls back to token supply when verified state has no reserve proto`() { + 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) @@ -173,7 +177,7 @@ class RealVerifiedFiatCalculatorTest { // region balance capping @Test - fun `caps amount to balance when balance is smaller`() { + 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 @@ -204,7 +208,7 @@ class RealVerifiedFiatCalculatorTest { } @Test - fun `does not cap when balance is larger than amount`() { + 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 @@ -238,7 +242,7 @@ class RealVerifiedFiatCalculatorTest { // region result consistency @Test - fun `result has correct mint`() { + 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 @@ -254,7 +258,7 @@ class RealVerifiedFiatCalculatorTest { } @Test - fun `result underlying amount is positive for positive input`() { + 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 @@ -272,7 +276,7 @@ class RealVerifiedFiatCalculatorTest { } @Test - fun `same supply produces same result regardless of source`() { + fun `same supply produces same result regardless of source`() = runTest { val supply = 5_000_000_000_000L val token = bondingCurveToken(supply = supply) 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..9b84ac2ad 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,10 @@ class GiveBillTransactorTest { private fun createTransactor(scope: TestScope): GiveBillTransactor { return GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = scope, - verifiedProtoManager = verifiedProtoManager, + verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = payloadFactory, ) } @@ -66,21 +61,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 +139,10 @@ class GiveBillTransactorTest { } val transactor = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, + verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) @@ -183,11 +170,10 @@ class GiveBillTransactorTest { } val transactor = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, + verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) @@ -208,19 +194,17 @@ class GiveBillTransactorTest { } val transactor1 = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, + verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) val transactor2 = GiveBillTransactor( - currencyController = currencyController, messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedProtoManager = verifiedProtoManager, + verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) From 009282aad2670dcd55160a741c2dc303b6527d1b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 24 Apr 2026 15:17:51 -0400 Subject: [PATCH 5/5] fix(services/opencode): correct params for GiveBillTransactor in tests Signed-off-by: Brandon McAnsh --- .../opencode/internal/transactors/GiveBillTransactorTest.kt | 5 ----- 1 file changed, 5 deletions(-) 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 9b84ac2ad..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 @@ -44,7 +44,6 @@ class GiveBillTransactorTest { messagingController = messagingController, transactionController = transactionController, scope = scope, - verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = payloadFactory, ) } @@ -142,7 +141,6 @@ class GiveBillTransactorTest { messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) @@ -173,7 +171,6 @@ class GiveBillTransactorTest { messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) @@ -197,14 +194,12 @@ class GiveBillTransactorTest { messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, ) val transactor2 = GiveBillTransactor( messagingController = messagingController, transactionController = transactionController, scope = this, - verifiedFiatCalculator = verifiedFiatCalculator, payloadFactory = factory, )