From f62e4930ca5ca6ac2885207e179f8993533f25ec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 19 May 2026 19:43:11 -0400 Subject: [PATCH 1/6] feat(deposit): auto-sweep USDC deposits to USDF on app foreground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end USDC → USDF stateless swap triggered on every app foreground event. When a user receives USDC (e.g. from Coinbase on-ramp), the app automatically converts it to USDF via the StatelessSwap RPC. Key changes: - Add UsdcDepositSweep executor with job-based cancellation guard - Wire sweep into RealSessionController foreground/background lifecycle - Build stateless swap transaction (CoinbaseStableSwapper instructions) - Implement IntentStatelessSwap.transaction() for signing - Add swapUsdc to TransactionOperations/Repository/Controller - Add usdcDepositAddress on AccountCluster using standard ATA derivation - Fix DepositViewModel to show USDC ATA for direct deposit - Fix DepositFlowScreen feature flag race with key(directDeposit) Signed-off-by: Brandon McAnsh --- .../flipcash/app/deposit/DepositFlowScreen.kt | 61 +++++---- .../app/deposit/internal/DepositViewModel.kt | 7 +- .../session/internal/RealSessionController.kt | 16 +++ .../flipcash/app/tokens/UsdcDepositSweep.kt | 74 +++++++++++ .../app/tokens/ui/SwapViewModelErrorTest.kt | 3 + .../controllers/TransactionController.kt | 9 +- .../controllers/TransactionOperations.kt | 5 + .../InternalTransactionRepository.kt | 10 ++ .../api/intents/IntentStatelessSwap.kt | 12 +- .../network/services/TransactionService.kt | 29 ++++- .../opencode/model/accounts/AccountCluster.kt | 3 + .../repositories/TransactionRepository.kt | 6 + .../opencode/solana/TransactionBuilder.kt | 39 ++++++ .../swap/UsdcDepositSweepInstructions.kt | 120 ++++++++++++++++++ 14 files changed, 359 insertions(+), 35 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt create mode 100644 services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt index ec3c6742d..fab8e157d 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/DepositFlowScreen.kt @@ -2,8 +2,12 @@ package com.flipcash.app.deposit import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -22,7 +26,6 @@ import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.LocalFlowNavigator import com.getcode.navigation.flow.PreviewFlowNavigator import com.getcode.navigation.flow.deliverFlowResult -import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.solana.keys.Mint @@ -35,8 +38,14 @@ fun DepositFlowScreen( val outerNavigator = LocalCodeNavigator.current val featureFlags = LocalFeatureFlags.current - val initialStack = route.rememberInitialStack { steps -> - val directDeposit = featureFlags.observe(FeatureFlag.DepositUsdc).value + val directDeposit by featureFlags + .observe(FeatureFlag.DepositUsdc) + .collectAsStateWithLifecycle() + + val initialStack = remember(route, directDeposit) { + @Suppress("UNCHECKED_CAST") + val steps = route.initialStack as List + println("direct deposit = $directDeposit, isUsdf=${route.mint == Mint.usdf}") if (!directDeposit && route.mint == Mint.usdf) { listOf(DepositStep.Destination(route.mint)) } else { @@ -44,30 +53,32 @@ fun DepositFlowScreen( } } - FlowHost( - initialStack = initialStack, - resultStateRegistry = resultStateRegistry, - onExit = { reason -> - val result: DepositResult = when (reason) { - is FlowExitReason.Completed -> reason.result - FlowExitReason.Canceled, - FlowExitReason.BackedOutOfRoot -> DepositResult.Canceled - } - outerNavigator.deliverFlowResult( - route = route, - value = NavResultOrCanceled.ReturnValue(result), - ) - when (result) { - DepositResult.Success -> { - outerNavigator.popUntil { it == AppRoute.Sheets.Menu } + key(directDeposit) { + FlowHost( + initialStack = initialStack, + resultStateRegistry = resultStateRegistry, + onExit = { reason -> + val result: DepositResult = when (reason) { + is FlowExitReason.Completed -> reason.result + FlowExitReason.Canceled, + FlowExitReason.BackedOutOfRoot -> DepositResult.Canceled } - DepositResult.Canceled -> { - outerNavigator.pop() + outerNavigator.deliverFlowResult( + route = route, + value = NavResultOrCanceled.ReturnValue(result), + ) + when (result) { + DepositResult.Success -> { + outerNavigator.popUntil { it == AppRoute.Sheets.Menu } + } + DepositResult.Canceled -> { + outerNavigator.pop() + } } - } - }, - entryProvider = depositEntryProvider(route.mint), - ) + }, + entryProvider = depositEntryProvider(route.mint), + ) + } } private fun depositEntryProvider( diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt index ba2fb8280..5e4b74dff 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt @@ -12,10 +12,8 @@ import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 import com.getcode.util.resources.ResourceHelper import com.flipcash.libs.coroutines.DispatcherProvider -import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf import com.getcode.view.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -65,10 +63,7 @@ internal class DepositViewModel @Inject constructor( viewModelScope.launch { val directDeposit = featureFlags.get(FeatureFlag.DepositUsdc) val address = if (result.token.address == Mint.usdf && directDeposit) { - val usdfSwapAccounts = userManager.accountCluster?.let { - Token.usdf.timelockSwapAccounts(it.authorityPublicKey) - } - usdfSwapAccounts?.ata?.publicKey?.base58() + userManager.accountCluster?.authorityPublicKey?.base58() } else { userManager.accountCluster?.depositAddressFor(result.token)?.base58() } 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 4412e9fc4..1b2a52e0a 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 @@ -29,6 +29,7 @@ import com.flipcash.app.shareable.Shareable import com.flipcash.app.shareable.ShareableConfirmationController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.TokenUpdater +import com.flipcash.app.tokens.UsdcDepositSweep import com.flipcash.core.R import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.SettingsController @@ -122,6 +123,7 @@ class RealSessionController @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val featureFlagController: FeatureFlagController, private val analytics: FlipcashAnalyticsService, + private val usdcSweep: UsdcDepositSweep, appSettingsCoordinator: AppSettingsCoordinator, ) : SessionController { @@ -147,6 +149,7 @@ class RealSessionController @Inject constructor( when { authState is AuthState.LoggedOut -> { stopPolling() + cancelUpdates() _state.update { SessionState() } } authState.isAtLeastRegistered -> { @@ -218,6 +221,7 @@ class RealSessionController @Inject constructor( type = TraceType.Process, ) startPolling() + swapUsdcIfNeeded() updateUserFlags() updateSettings() checkPendingItemsInFeed() @@ -240,6 +244,7 @@ class RealSessionController @Inject constructor( */ override fun onAppInBackground() { stopPolling() + cancelUpdates() billingClient.disconnect() toastController.clear() @@ -260,12 +265,23 @@ class RealSessionController @Inject constructor( } } + private fun swapUsdcIfNeeded() { + val owner = userManager.accountCluster ?: return + if (userManager.authState.canAccessAuthenticatedApis) { + usdcSweep.execute(owner) + } + } + private fun stopPolling() { tokenUpdater.stop() activityFeedUpdater.stop() profileUpdater.stop() } + private fun cancelUpdates() { + usdcSweep.cancel() + } + private fun updateUserFlags() { if (userManager.authState.isAtLeastRegistered) { scope.launch { diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt new file mode 100644 index 000000000..816c2613c --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt @@ -0,0 +1,74 @@ +package com.flipcash.app.tokens + +import com.getcode.opencode.controllers.AccountController +import com.getcode.opencode.controllers.TransactionOperations +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.AccountFilter +import com.getcode.opencode.model.accounts.AccountType +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +class UsdcDepositSweep @Inject constructor( + private val transactionOperations: TransactionOperations, + private val accountController: AccountController, + private val tokenCoordinator: TokenCoordinator, +) { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var activeJob: Job? = null + + fun execute(owner: AccountCluster) { + if (activeJob?.isActive == true) return + activeJob = scope.launch { + val usdcAccount = accountController.getAccount( + accountOwner = owner, + requestingOwner = owner, + filter = AccountFilter.MintAddress(Mint.usdc), + ).getOrNull()?.takeIf { account -> + account.accountType == AccountType.AssociatedToken + } + + usdcAccount?.let { + trace(tag = TAG, message = "USDC ATA found. => ${it.address.base58()}") + } ?: trace(tag = TAG, message = "USDC ATA not found") + + val amount = usdcAccount?.balance ?: 0L + if (amount <= 0L) { + trace(tag = TAG, message = "USDC balance <= 0. nothing to sweep") + return@launch + } + + coroutineContext.ensureActive() + + trace(tag = TAG, message = "Swapping $amount USDC quarks to USDF", type = TraceType.Process) + + transactionOperations.swapUsdc( + owner = owner, + amount = amount, + ).onSuccess { + trace(tag = TAG, message = "USDC→USDF sweep completed") + tokenCoordinator.update() + }.onFailure { error -> + trace(tag = TAG, message = "USDC→USDF sweep failed: ${error.message}", error = error) + } + } + } + + fun cancel() { + activeJob?.cancel() + activeJob = null + } + + companion object { + private const val TAG = "UsdcDepositSweep" + } +} 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 cca327e4c..6275c5e46 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 @@ -24,6 +24,7 @@ import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.onramp.PhantomWalletController import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -62,6 +63,7 @@ class SwapViewModelErrorTest { private val analytics = mockk(relaxed = true) private val purchaseMethodController = mockk(relaxed = true) private val coinbaseOnRampController = mockk(relaxed = true) + private val phantomWalletController = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -102,6 +104,7 @@ class SwapViewModelErrorTest { analytics = analytics, purchaseMethodController = purchaseMethodController, coinbaseOnRampController = coinbaseOnRampController, + phantomWalletController = phantomWalletController, dispatchers = dispatchers, ) } 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 a04207265..1fd7bbdd7 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 @@ -1,7 +1,6 @@ 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.VerifiedState import com.getcode.opencode.internal.network.api.intents.IntentDistribution @@ -438,6 +437,14 @@ class TransactionController @Inject constructor( ?: Result.failure(GetIntentMetadataError.Timeout()) } + override suspend fun swapUsdc( + owner: AccountCluster, + amount: Long, + ): Result { + if (amount <= 0L) return Result.success(Unit) + return repository.sweepUsdc(scope, owner, amount) + } + override suspend fun checkWithdrawalAvailability( address: String, mint: Mint, 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 97286e40d..2fb1eed8f 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 @@ -79,4 +79,9 @@ interface TransactionOperations { address: String, mint: Mint, ): Result + + suspend fun swapUsdc( + owner: AccountCluster, + amount: Long, + ): Result } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt index 084404628..3217a2282 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt @@ -8,6 +8,7 @@ import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.transactions.StatelessSwapRequest import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.transactions.StatefulSwapRequest import com.getcode.opencode.model.transactions.TransactionMetadata @@ -115,4 +116,13 @@ internal class InternalTransactionRepository @Inject constructor( destinationOwner = destinationOwner, verifiedState = verifiedState, ).onFailure { ErrorUtils.handleError(it) } + + override suspend fun sweepUsdc( + scope: CoroutineScope, + owner: AccountCluster, + amount: Long, + ): Result { + return service.sweepUsdc(scope, owner, amount) + .onFailure { ErrorUtils.handleError(it) } + } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt index 5d4da8cdc..114fd56b0 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatelessSwap.kt @@ -4,9 +4,13 @@ import com.codeinc.opencode.gen.transaction.v1.TransactionService import com.getcode.opencode.internal.network.extensions.asSignature import com.getcode.opencode.internal.network.extensions.asSolanaAccountId import com.getcode.opencode.internal.network.extensions.sign +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.usdc +import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.transactions.StatelessSwapRequest import com.getcode.opencode.model.transactions.StatelessSwapServerParameters import com.getcode.opencode.solana.SolanaTransaction +import com.getcode.opencode.solana.TransactionBuilder import com.getcode.solana.keys.Signature internal class IntentStatelessSwap( @@ -20,7 +24,13 @@ internal class IntentStatelessSwap( } fun transaction(parameters: StatelessSwapServerParameters): SolanaTransaction { - TODO("Stateless swap transaction building not yet implemented") + return TransactionBuilder.statelessSwap( + response = parameters, + owner = request.owner.authorityPublicKey, + fromMint = MintMetadata.usdc, + toMint = MintMetadata.usdf, + amount = request.amount, + ) } fun initiate(): TransactionService.StatelessSwapRequest { 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 ab5ec9371..ccbebacde 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 @@ -271,12 +271,37 @@ internal class TransactionService @Inject constructor( return statefulSwap(scope, request, owner) } + suspend fun sweepUsdc( + scope: CoroutineScope, + owner: AccountCluster, + amount: Long, + ): Result { + val request = StatelessSwapRequest( + owner = owner, + fromMint = Mint.usdc, + toMint = Mint.usdf, + amount = amount, + ) + + return statelessSwap(scope, request) + } + private suspend fun statelessSwap( scope: CoroutineScope, - request: StatelessSwapRequest, - ): StatelessSwapResult { + request: StatelessSwapRequest, + ): Result { val executor = StatelessSwapExecutor(api) + return executor.execute(scope, request) + .fold( + onSuccess = { + trace("Sweep submitted") + Result.success(Unit) + }, + onFailure = { + Result.failure(it) + } + ) } private suspend fun statefulSwap( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/accounts/AccountCluster.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/accounts/AccountCluster.kt index 0f395b114..465d7799e 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/accounts/AccountCluster.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/accounts/AccountCluster.kt @@ -4,10 +4,13 @@ import com.getcode.crypt.DerivedKey import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.solana.extensions.newInstance import com.getcode.opencode.internal.extensions.toPublicKey +import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.usdc import com.getcode.opencode.model.financial.usdf +import com.getcode.solana.keys.Mint import com.getcode.opencode.solana.keys.TimelockDerivedAccounts import com.getcode.solana.keys.PublicKey diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt index d98e841ad..a7136a141 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt @@ -73,4 +73,10 @@ interface TransactionRepository { destinationOwner: PublicKey, verifiedState: VerifiedState, ): Result + + suspend fun sweepUsdc( + scope: CoroutineScope, + owner: AccountCluster, + amount: Long, + ): Result } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index 2918da190..5c0e3f4f1 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt @@ -11,7 +11,9 @@ import com.getcode.opencode.solana.swap.buildExistingCurrencyBuyInstructions import com.getcode.opencode.solana.swap.buildNewCurrencyBuyInstructions import com.getcode.opencode.solana.swap.buildSellInstructions import com.getcode.opencode.solana.swap.buildStablecoinSwapperInstructions +import com.getcode.opencode.solana.swap.buildStatelessSwapInstructions import com.getcode.opencode.solana.swap.buildUsdcToUsdfSwapInstructions +import com.getcode.opencode.model.transactions.StatelessSwapServerParameters import com.getcode.solana.keys.Hash import com.getcode.solana.keys.PublicKey @@ -207,4 +209,41 @@ object TransactionBuilder { instructions = instructions, ) } + + /** + * Constructs a Solana transaction for a stateless USDC deposit sweep via the + * Coinbase Stable Swapper program. + * + * Swaps USDC from the owner's ATA into USDF, depositing the result into + * the owner's USDF VM Deposit ATA (monitored by Geyser). + * + * @param response Server parameters for the stateless swap. + * @param owner The public key of the wallet owner. + * @param fromMint Metadata for the source mint (USDC). + * @param toMint Metadata for the destination mint (USDF). + * @param amount The amount to swap (in quarks). + * @return A constructed [SolanaTransaction] (V0) ready to be signed. + */ + fun statelessSwap( + response: StatelessSwapServerParameters, + owner: PublicKey, + fromMint: MintMetadata, + toMint: MintMetadata, + amount: Long, + ): SolanaTransaction { + val instructions = buildStatelessSwapInstructions( + serverParameters = response, + owner = owner, + fromMint = fromMint, + toMint = toMint, + amount = amount, + ) + + return SolanaTransaction.newV0Instance( + payer = response.payer, + recentBlockhash = response.blockhash, + addressLookupTables = response.alts, + instructions = instructions, + ) + } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt new file mode 100644 index 000000000..6dcff213e --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/UsdcDepositSweepInstructions.kt @@ -0,0 +1,120 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount +import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount +import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount +import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts +import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitPrice +import com.getcode.opencode.internal.solana.programs.MemoProgram_Memo +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.transactions.StatelessSwapServerParameters +import com.getcode.opencode.solana.Instruction +import com.getcode.solana.keys.PublicKey + +/** + * Builds the instruction list for a stateless swap against the Coinbase Stable + * Swapper program. + * + * Instruction layout: + * 1. [Optional] ComputeBudget::SetComputeUnitLimit + * 2. [Optional] ComputeBudget::SetComputeUnitPrice + * 3. [Optional] Memo::Memo + * 4. AssociatedTokenAccount::CreateIdempotent (open owner's to_mint VM Deposit ATA) + * 5. CoinbaseStableSwapper::Swap (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) + * + * Used by the on-app-open USDC -> USDF sweep: source is the owner's plain + * USDC ATA, destination is the owner's USDF VM Deposit ATA (Geyser-monitored + * — once funds land there, the server-side watcher transfers them into the USDF VM). + */ +internal fun buildStatelessSwapInstructions( + serverParameters: StatelessSwapServerParameters, + owner: PublicKey, + fromMint: MintMetadata, + toMint: MintMetadata, + amount: Long, +): List { + val toVm = requireNotNull(toMint.vmMetadata) { + "Destination mint missing VM metadata: ${toMint.symbol}" + } + + // Owner's source-mint ATA — where external USDC deposits land. + val ownerFromAta = PublicKey.deriveAssociatedAccount( + owner = owner, + mint = fromMint.address, + ) + + // Owner's destination-mint VM Deposit PDA. + val vmAccount = PublicKey.deriveVirtualMachineAccount( + mint = toMint.address, + authority = toVm.authority, + lockout = toVm.lockDurationInDays.toUByte(), + ) + val ownerToVmDepositPda = PublicKey.deriveDepositAccount( + vm = vmAccount.publicKey, + depositor = owner, + ) + + // Owner's destination-mint VM Deposit ATA — the address Geyser watches. + val ownerToVmDepositAta = PublicKey.deriveAssociatedAccount( + owner = ownerToVmDepositPda.publicKey, + mint = toMint.address, + ) + + // Coinbase Stable Swapper PDAs. + val swapAccounts = CoinbaseSwapAccounts.derive(fromMint.address, toMint.address) + + val feeRecipientFromMintAta = swapAccounts.feeRecipientTokenAccount( + feeRecipient = serverParameters.poolFeeRecipient, + fromMint = fromMint.address, + ) + + return buildList { + // 1. [Optional] ComputeBudget::SetComputeUnitLimit + if (serverParameters.computeUnitLimit != 0) { + add(ComputeBudgetProgram_SetComputeUnitLimit(units = serverParameters.computeUnitLimit).instruction()) + } + + // 2. [Optional] ComputeBudget::SetComputeUnitPrice + if (serverParameters.computeUnitPrice != 0L) { + add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = serverParameters.computeUnitPrice).instruction()) + } + + // 3. [Optional] Memo::Memo + if (serverParameters.memoValue.isNotEmpty()) { + add(MemoProgram_Memo(message = serverParameters.memoValue).instruction()) + } + + // 4. AssociatedTokenAccount::CreateIdempotent (open owner's to_mint VM Deposit ATA) + add( + AssociatedTokenProgram_CreateIdempotent( + subsidizer = serverParameters.payer, + owner = ownerToVmDepositPda.publicKey, + mint = toMint.address, + ).instruction() + ) + + // 5. CoinbaseStableSwapper::Swap (owner's from_mint ATA -> owner's to_mint VM Deposit ATA) + add( + CoinbaseStableSwapperProgram_Swap( + pool = swapAccounts.pool, + inVault = swapAccounts.inVault, + outVault = swapAccounts.outVault, + inVaultTokenAccount = swapAccounts.inVaultTokenAccount, + outVaultTokenAccount = swapAccounts.outVaultTokenAccount, + userFromTokenAccount = ownerFromAta.publicKey, + toTokenAccount = ownerToVmDepositAta.publicKey, + feeRecipientTokenAccount = feeRecipientFromMintAta, + feeRecipient = serverParameters.poolFeeRecipient, + fromMint = fromMint.address, + toMint = toMint.address, + user = owner, + whitelist = swapAccounts.whitelist, + amountIn = amount, + minAmountOut = amount, // 1:1 stable pair + ).instruction() + ) + } +} From 3dd5e0599f0d4fcc931e75003e0c2ae53739a02b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 19 May 2026 19:57:26 -0400 Subject: [PATCH 2/6] test(deposit): add unit tests for USDC deposit sweep Signed-off-by: Brandon McAnsh --- .../SessionControllerGiftCardErrorTest.kt | 1 + .../app/tokens/UsdcDepositSweepTest.kt | 206 ++++++++++++ .../swap/StatelessSwapInstructionsTest.kt | 318 ++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index afcb1af03..3d37eefb1 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -102,6 +102,7 @@ class SessionControllerGiftCardErrorTest { featureFlagController = mockk(relaxed = true), analytics = analytics, appSettingsCoordinator = mockk(relaxed = true), + usdcSweep = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt new file mode 100644 index 000000000..180ef1c7b --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt @@ -0,0 +1,206 @@ +package com.flipcash.app.tokens + +import com.getcode.opencode.controllers.AccountController +import com.getcode.opencode.controllers.TransactionOperations +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.AccountInfo +import com.getcode.opencode.model.accounts.AccountType +import com.getcode.solana.keys.PublicKey +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UsdcDepositSweepTest { + + private val transactionOperations: TransactionOperations = mockk(relaxed = true) + private val accountController: AccountController = mockk(relaxed = true) + private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) + + private val owner: AccountCluster = mockk(relaxed = true) + + private lateinit var sweep: UsdcDepositSweep + + @Before + fun setUp() { + sweep = UsdcDepositSweep( + transactionOperations = transactionOperations, + accountController = accountController, + tokenCoordinator = tokenCoordinator, + ) + } + + @After + fun tearDown() { + sweep.cancel() + } + + private fun stubUsdcAccount(balance: Long, type: AccountType = AccountType.AssociatedToken) { + val accountInfo = mockk { + every { accountType } returns type + every { this@mockk.balance } returns balance + every { address } returns mockk(relaxed = true) + } + coEvery { + accountController.getAccount(any(), any(), any()) + } returns Result.success(accountInfo) + } + + private fun stubNoUsdcAccount() { + coEvery { + accountController.getAccount(any(), any(), any()) + } returns Result.failure(RuntimeException("not found")) + } + + @Test + fun `skips swap when USDC account is not found`() = runTest { + stubNoUsdcAccount() + + sweep.execute(owner) + advanceUntilIdle() + + // Give the internal scope time to complete + Thread.sleep(100) + + coVerify(exactly = 0) { + transactionOperations.swapUsdc(any(), any()) + } + } + + @Test + fun `skips swap when USDC account type is not AssociatedToken`() = runTest { + stubUsdcAccount(balance = 1_000_000L, type = AccountType.Primary) + + sweep.execute(owner) + advanceUntilIdle() + Thread.sleep(100) + + coVerify(exactly = 0) { + transactionOperations.swapUsdc(any(), any()) + } + } + + @Test + fun `skips swap when USDC balance is zero`() = runTest { + stubUsdcAccount(balance = 0L) + + sweep.execute(owner) + advanceUntilIdle() + Thread.sleep(100) + + coVerify(exactly = 0) { + transactionOperations.swapUsdc(any(), any()) + } + } + + @Test + fun `skips swap when USDC balance is negative`() = runTest { + stubUsdcAccount(balance = -1L) + + sweep.execute(owner) + advanceUntilIdle() + Thread.sleep(100) + + coVerify(exactly = 0) { + transactionOperations.swapUsdc(any(), any()) + } + } + + @Test + fun `calls swapUsdc with correct amount when balance is positive`() = runTest { + val amount = 5_000_000L + stubUsdcAccount(balance = amount) + coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) + + sweep.execute(owner) + advanceUntilIdle() + Thread.sleep(200) + + coVerify { + transactionOperations.swapUsdc(owner, amount) + } + } + + @Test + fun `calls tokenCoordinator update on successful swap`() = runTest { + stubUsdcAccount(balance = 1_000_000L) + coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) + + sweep.execute(owner) + advanceUntilIdle() + Thread.sleep(200) + + coVerify { + tokenCoordinator.update() + } + } + + @Test + fun `does not call tokenCoordinator update on failed swap`() = runTest { + stubUsdcAccount(balance = 1_000_000L) + coEvery { + transactionOperations.swapUsdc(any(), any()) + } returns Result.failure(RuntimeException("swap failed")) + + sweep.execute(owner) + advanceUntilIdle() + Thread.sleep(200) + + coVerify(exactly = 0) { + tokenCoordinator.update() + } + } + + @Test + fun `does not execute concurrently when job is active`() = runTest { + stubUsdcAccount(balance = 1_000_000L) + coEvery { transactionOperations.swapUsdc(any(), any()) } coAnswers { + kotlinx.coroutines.delay(500) + Result.success(Unit) + } + + // First call starts the job + sweep.execute(owner) + // Second call should be ignored since the first is still active + sweep.execute(owner) + + Thread.sleep(700) + + coVerify(exactly = 1) { + transactionOperations.swapUsdc(any(), any()) + } + } + + @Test + fun `cancel stops active job`() = runTest { + // Make getAccount slow so we can cancel before swapUsdc is reached + coEvery { + accountController.getAccount(any(), any(), any()) + } coAnswers { + kotlinx.coroutines.delay(5000) + val accountInfo = mockk { + every { accountType } returns AccountType.AssociatedToken + every { balance } returns 1_000_000L + every { address } returns mockk(relaxed = true) + } + Result.success(accountInfo) + } + coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) + + sweep.execute(owner) + Thread.sleep(50) // Let the coroutine start + sweep.cancel() + Thread.sleep(100) + + coVerify(exactly = 0) { + transactionOperations.swapUsdc(any(), any()) + } + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt new file mode 100644 index 000000000..37cb7acfe --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/swap/StatelessSwapInstructionsTest.kt @@ -0,0 +1,318 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount +import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress +import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseTokenVaultAddress +import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseVaultTokenAccountAddress +import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseWhitelistAddress +import com.getcode.opencode.internal.solana.extensions.deriveDepositAccount +import com.getcode.opencode.internal.solana.extensions.deriveVirtualMachineAccount +import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram +import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram +import com.getcode.opencode.internal.solana.programs.MemoProgram +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.opencode.model.transactions.StatelessSwapServerParameters +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class StatelessSwapInstructionsTest { + + private fun testKey(seed: Int): PublicKey = + PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + private fun testMint(seed: Int): Mint = + Mint(ByteArray(32) { seed.toByte() }.toList()) + + private fun mintMetadata( + address: Mint, + vmAuthority: PublicKey, + hasVm: Boolean = true, + ): MintMetadata = MintMetadata( + address = address, + decimals = 6, + name = "Test", + symbol = "TST", + description = "", + createdAt = Instant.parse("2024-01-01T00:00:00Z"), + imageUrl = "", + vmMetadata = if (hasVm) { + val vm = PublicKey.deriveVirtualMachineAccount( + mint = address, + authority = vmAuthority, + lockout = 21u, + ) + VmMetadata( + vm = vm.publicKey, + authority = vmAuthority, + lockDurationInDays = 21, + ) + } else { + VmMetadata( + vm = testKey(99), + authority = vmAuthority, + lockDurationInDays = 21, + ) + }, + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private val payer = testKey(1) + private val blockhash = testKey(3) + private val owner = testKey(4) + private val poolFeeRecipient = testKey(8) + + private val fromMintAddress = testMint(10) + private val fromVmAuthority = testKey(11) + private val toMintAddress = testMint(20) + private val toVmAuthority = testKey(21) + + private val fromMintMetadata = mintMetadata(fromMintAddress, fromVmAuthority, hasVm = false) + private val toMintMetadata = mintMetadata(toMintAddress, toVmAuthority, hasVm = true) + + private val serverParameters = StatelessSwapServerParameters( + payer = payer, + blockhash = blockhash, + alts = emptyList(), + computeUnitLimit = 150_000, + computeUnitPrice = 10_000, + memoValue = "stateless_swap_v0", + poolFeeRecipient = poolFeeRecipient, + ) + + private fun buildInstructions( + amount: Long = 1_000_000L, + params: StatelessSwapServerParameters = serverParameters, + ) = buildStatelessSwapInstructions( + serverParameters = params, + owner = owner, + fromMint = fromMintMetadata, + toMint = toMintMetadata, + amount = amount, + ) + + // --- Instruction count --- + + @Test + fun `produces exactly 5 instructions with all optional fields present`() { + val instructions = buildInstructions() + assertEquals(5, instructions.size) + } + + @Test + fun `omits ComputeUnitLimit when value is 0`() { + val params = serverParameters.copy(computeUnitLimit = 0) + val instructions = buildInstructions(params = params) + // Should be 4: no SetComputeUnitLimit + assertEquals(4, instructions.size) + // First instruction should be ComputeUnitPrice (not ComputeUnitLimit) + assertEquals(ComputeBudgetProgram.address, instructions[0].program) + } + + @Test + fun `omits ComputeUnitPrice when value is 0`() { + val params = serverParameters.copy(computeUnitPrice = 0) + val instructions = buildInstructions(params = params) + assertEquals(4, instructions.size) + } + + @Test + fun `omits Memo when value is empty`() { + val params = serverParameters.copy(memoValue = "") + val instructions = buildInstructions(params = params) + assertEquals(4, instructions.size) + } + + @Test + fun `omits all optional instructions when all values are zero or empty`() { + val params = serverParameters.copy( + computeUnitLimit = 0, + computeUnitPrice = 0, + memoValue = "", + ) + val instructions = buildInstructions(params = params) + // Only CreateIdempotent + Swap + assertEquals(2, instructions.size) + assertEquals(AssociatedTokenProgram.address, instructions[0].program) + assertEquals(CoinbaseStableSwapperProgram.address, instructions[1].program) + } + + // --- Instruction order and programs --- + + @Test + fun `instruction 0 is ComputeBudget SetComputeUnitLimit`() { + val ix = buildInstructions()[0] + assertEquals(ComputeBudgetProgram.address, ix.program) + } + + @Test + fun `instruction 1 is ComputeBudget SetComputeUnitPrice`() { + val ix = buildInstructions()[1] + assertEquals(ComputeBudgetProgram.address, ix.program) + } + + @Test + fun `instruction 2 is Memo`() { + val ix = buildInstructions()[2] + assertEquals(MemoProgram.address, ix.program) + } + + @Test + fun `instruction 3 is AssociatedTokenProgram CreateIdempotent`() { + val ix = buildInstructions()[3] + assertEquals(AssociatedTokenProgram.address, ix.program) + } + + @Test + fun `instruction 4 is CoinbaseStableSwapper Swap`() { + val ix = buildInstructions()[4] + assertEquals(CoinbaseStableSwapperProgram.address, ix.program) + } + + // --- CreateIdempotent account verification --- + + @Test + fun `CreateIdempotent payer is server payer`() { + val ix = buildInstructions()[3] + assertEquals(payer, ix.accounts[0].publicKey) + } + + @Test + fun `CreateIdempotent owner is owner's to_mint VM Deposit PDA`() { + val ix = buildInstructions()[3] + val toVm = toMintMetadata.vmMetadata + val vmAccount = PublicKey.deriveVirtualMachineAccount( + mint = toMintAddress, + authority = toVm.authority, + lockout = toVm.lockDurationInDays.toUByte(), + ) + val expectedDepositPda = PublicKey.deriveDepositAccount( + vm = vmAccount.publicKey, + depositor = owner, + ) + assertEquals(expectedDepositPda.publicKey, ix.accounts[2].publicKey) + } + + @Test + fun `CreateIdempotent mint is to_mint`() { + val ix = buildInstructions()[3] + assertEquals(toMintAddress, ix.accounts[3].publicKey) + } + + // --- CoinbaseStableSwapper::Swap account verification --- + + @Test + fun `swap instruction has correct pool PDA`() { + val ix = buildInstructions()[4] + val expectedPool = PublicKey.deriveCoinbasePoolAddress().publicKey + assertEquals(expectedPool, ix.accounts[0].publicKey) + } + + @Test + fun `swap instruction has correct in and out vaults`() { + val ix = buildInstructions()[4] + val pool = PublicKey.deriveCoinbasePoolAddress().publicKey + val expectedInVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, fromMintAddress).publicKey + val expectedOutVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMintAddress).publicKey + assertEquals(expectedInVault, ix.accounts[1].publicKey) + assertEquals(expectedOutVault, ix.accounts[2].publicKey) + } + + @Test + fun `swap instruction has correct vault token accounts`() { + val ix = buildInstructions()[4] + val pool = PublicKey.deriveCoinbasePoolAddress().publicKey + val inVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, fromMintAddress).publicKey + val outVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMintAddress).publicKey + val expectedInVaultTA = PublicKey.deriveCoinbaseVaultTokenAccountAddress(inVault).publicKey + val expectedOutVaultTA = PublicKey.deriveCoinbaseVaultTokenAccountAddress(outVault).publicKey + assertEquals(expectedInVaultTA, ix.accounts[3].publicKey) + assertEquals(expectedOutVaultTA, ix.accounts[4].publicKey) + } + + @Test + fun `swap instruction userFromTokenAccount is owner's from_mint ATA`() { + val ix = buildInstructions()[4] + val expected = PublicKey.deriveAssociatedAccount(owner = owner, mint = fromMintAddress).publicKey + assertEquals(expected, ix.accounts[5].publicKey) + } + + @Test + fun `swap instruction toTokenAccount is owner's to_mint VM Deposit ATA`() { + val ix = buildInstructions()[4] + val toVm = toMintMetadata.vmMetadata + val vmAccount = PublicKey.deriveVirtualMachineAccount( + mint = toMintAddress, + authority = toVm.authority, + lockout = toVm.lockDurationInDays.toUByte(), + ) + val depositPda = PublicKey.deriveDepositAccount( + vm = vmAccount.publicKey, + depositor = owner, + ) + val expected = PublicKey.deriveAssociatedAccount( + owner = depositPda.publicKey, + mint = toMintAddress, + ).publicKey + assertEquals(expected, ix.accounts[6].publicKey) + } + + @Test + fun `swap instruction feeRecipientTokenAccount is poolFeeRecipient's from_mint ATA`() { + val ix = buildInstructions()[4] + val expected = PublicKey.deriveAssociatedAccount( + owner = poolFeeRecipient, + mint = fromMintAddress, + ).publicKey + assertEquals(expected, ix.accounts[7].publicKey) + } + + @Test + fun `swap instruction feeRecipient matches server parameter`() { + val ix = buildInstructions()[4] + assertEquals(poolFeeRecipient, ix.accounts[8].publicKey) + } + + @Test + fun `swap instruction has correct mints`() { + val ix = buildInstructions()[4] + assertEquals(fromMintAddress, ix.accounts[9].publicKey) + assertEquals(toMintAddress, ix.accounts[10].publicKey) + } + + @Test + fun `swap instruction user is owner and is signer`() { + val ix = buildInstructions()[4] + assertEquals(owner, ix.accounts[11].publicKey) + assertTrue(ix.accounts[11].isSigner) + } + + @Test + fun `swap instruction has correct whitelist PDA`() { + val ix = buildInstructions()[4] + val expectedWhitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey + assertEquals(expectedWhitelist, ix.accounts[12].publicKey) + } + + // --- CreateIdempotent and Swap destination linkage --- + + @Test + fun `CreateIdempotent ATA matches swap instruction toTokenAccount`() { + val instructions = buildInstructions() + val createIx = instructions[3] + val swapIx = instructions[4] + // CreateIdempotent creates ATA for (owner=depositPda, mint=toMint) + // Swap toTokenAccount should be that same ATA + // CreateIdempotent account[1] is the ATA address + assertEquals(createIx.accounts[1].publicKey, swapIx.accounts[6].publicKey) + } +} From 7992a3b292cea0275d588bcf2fe3f934e9ebffe4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 19 May 2026 20:09:02 -0400 Subject: [PATCH 3/6] chore(services/ocp): wait for finalization on stateless swaps Signed-off-by: Brandon McAnsh --- .../getcode/opencode/model/transactions/StatelessSwapRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatelessSwapRequest.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatelessSwapRequest.kt index e76c5149e..714f0aa3a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatelessSwapRequest.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatelessSwapRequest.kt @@ -8,5 +8,5 @@ data class StatelessSwapRequest( val fromMint: Mint, val toMint: Mint, val amount: Long, - val waitForFinalization: Boolean = false, + val waitForFinalization: Boolean = true, ) From 304a012aa868749cb895bb9b938d6eb28cc2d6fc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 19 May 2026 20:28:56 -0400 Subject: [PATCH 4/6] feat: retry usdc sweep to allow funds to land post deposit Signed-off-by: Brandon McAnsh --- .../flipcash/app/tokens/UsdcDepositSweep.kt | 64 +++++++++++++------ .../app/tokens/UsdcDepositSweepTest.kt | 46 ++++++++----- .../kotlin/com/getcode/utils/network/Retry.kt | 16 ++--- .../com/getcode/utils/network/RetryTest.kt | 32 ++++++++++ 4 files changed, 112 insertions(+), 46 deletions(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt index 816c2613c..13a2fa6d4 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt @@ -7,49 +7,68 @@ import com.getcode.opencode.model.accounts.AccountFilter import com.getcode.opencode.model.accounts.AccountType import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 -import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType +import com.getcode.utils.network.retryable import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds -class UsdcDepositSweep @Inject constructor( +class UsdcDepositSweep( private val transactionOperations: TransactionOperations, private val accountController: AccountController, private val tokenCoordinator: TokenCoordinator, + private val maxRetries: Int = MAX_RETRIES, + private val initialDelay: Duration = INITIAL_DELAY, + private val backoffFactor: Double = BACKOFF_FACTOR, ) { + @Inject constructor( + transactionOperations: TransactionOperations, + accountController: AccountController, + tokenCoordinator: TokenCoordinator, + ) : this( + transactionOperations = transactionOperations, + accountController = accountController, + tokenCoordinator = tokenCoordinator, + maxRetries = MAX_RETRIES, + initialDelay = INITIAL_DELAY, + backoffFactor = BACKOFF_FACTOR + ) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var activeJob: Job? = null fun execute(owner: AccountCluster) { if (activeJob?.isActive == true) return activeJob = scope.launch { - val usdcAccount = accountController.getAccount( - accountOwner = owner, - requestingOwner = owner, - filter = AccountFilter.MintAddress(Mint.usdc), - ).getOrNull()?.takeIf { account -> - account.accountType == AccountType.AssociatedToken - } + val amount = retryable( + maxRetries = maxRetries, + delayDuration = initialDelay, + backoffFactor = backoffFactor, + ) { + val usdcAccount = accountController.getAccount( + accountOwner = owner, + requestingOwner = owner, + filter = AccountFilter.MintAddress(Mint.usdc), + ).getOrNull()?.takeIf { account -> + account.accountType == AccountType.AssociatedToken + } - usdcAccount?.let { - trace(tag = TAG, message = "USDC ATA found. => ${it.address.base58()}") - } ?: trace(tag = TAG, message = "USDC ATA not found") - - val amount = usdcAccount?.balance ?: 0L - if (amount <= 0L) { - trace(tag = TAG, message = "USDC balance <= 0. nothing to sweep") - return@launch - } + usdcAccount?.let { + trace(tag = TAG, message = "USDC ATA found. => ${it.address.base58()}") + } ?: trace(tag = TAG, message = "USDC ATA not found") - coroutineContext.ensureActive() + val balance = usdcAccount?.balance ?: 0L + check(balance > 0L) { "USDC balance <= 0. nothing to sweep" } + balance + } ?: return@launch - trace(tag = TAG, message = "Swapping $amount USDC quarks to USDF", type = TraceType.Process) + trace(tag = TAG, message = "Swapping $amount USDC to USDF", type = TraceType.Process) transactionOperations.swapUsdc( owner = owner, @@ -70,5 +89,8 @@ class UsdcDepositSweep @Inject constructor( companion object { private const val TAG = "UsdcDepositSweep" + private const val MAX_RETRIES = 5 + private val INITIAL_DELAY = 5.seconds + private const val BACKOFF_FACTOR = 2.0 } } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt index 180ef1c7b..d5179e265 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) class UsdcDepositSweepTest { @@ -34,6 +35,9 @@ class UsdcDepositSweepTest { transactionOperations = transactionOperations, accountController = accountController, tokenCoordinator = tokenCoordinator, + maxRetries = 3, + initialDelay = 10.milliseconds, + backoffFactor = 1.0, ) } @@ -60,14 +64,12 @@ class UsdcDepositSweepTest { } @Test - fun `skips swap when USDC account is not found`() = runTest { + fun `gives up after max retries when USDC account is not found`() = runTest { stubNoUsdcAccount() sweep.execute(owner) advanceUntilIdle() - - // Give the internal scope time to complete - Thread.sleep(100) + Thread.sleep(200) coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) @@ -75,12 +77,12 @@ class UsdcDepositSweepTest { } @Test - fun `skips swap when USDC account type is not AssociatedToken`() = runTest { + fun `gives up after max retries when account type is not AssociatedToken`() = runTest { stubUsdcAccount(balance = 1_000_000L, type = AccountType.Primary) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(100) + Thread.sleep(200) coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) @@ -88,12 +90,12 @@ class UsdcDepositSweepTest { } @Test - fun `skips swap when USDC balance is zero`() = runTest { + fun `gives up after max retries when USDC balance stays zero`() = runTest { stubUsdcAccount(balance = 0L) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(100) + Thread.sleep(200) coVerify(exactly = 0) { transactionOperations.swapUsdc(any(), any()) @@ -101,15 +103,28 @@ class UsdcDepositSweepTest { } @Test - fun `skips swap when USDC balance is negative`() = runTest { - stubUsdcAccount(balance = -1L) + fun `retries until balance appears then sweeps`() = runTest { + var callCount = 0 + coEvery { + accountController.getAccount(any(), any(), any()) + } coAnswers { + callCount++ + val balance = if (callCount >= 3) 2_000_000L else 0L + val accountInfo = mockk { + every { accountType } returns AccountType.AssociatedToken + every { this@mockk.balance } returns balance + every { address } returns mockk(relaxed = true) + } + Result.success(accountInfo) + } + coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) sweep.execute(owner) advanceUntilIdle() - Thread.sleep(100) + Thread.sleep(300) - coVerify(exactly = 0) { - transactionOperations.swapUsdc(any(), any()) + coVerify { + transactionOperations.swapUsdc(owner, 2_000_000L) } } @@ -166,9 +181,7 @@ class UsdcDepositSweepTest { Result.success(Unit) } - // First call starts the job sweep.execute(owner) - // Second call should be ignored since the first is still active sweep.execute(owner) Thread.sleep(700) @@ -180,7 +193,6 @@ class UsdcDepositSweepTest { @Test fun `cancel stops active job`() = runTest { - // Make getAccount slow so we can cancel before swapUsdc is reached coEvery { accountController.getAccount(any(), any(), any()) } coAnswers { @@ -195,7 +207,7 @@ class UsdcDepositSweepTest { coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit) sweep.execute(owner) - Thread.sleep(50) // Let the coroutine start + Thread.sleep(50) sweep.cancel() Thread.sleep(100) diff --git a/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt b/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt index 529ae4732..4e9b20ef0 100644 --- a/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt +++ b/libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt @@ -3,13 +3,15 @@ package com.getcode.utils.network import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.delay +import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource -suspend fun retryable( +suspend inline fun retryable( maxRetries: Int = 3, delayDuration: Duration = 2.seconds, + backoffFactor: Double = 1.0, retryIf: (Throwable) -> Boolean = { true }, onRetry: (Int) -> Unit = { currentAttempt -> trace( @@ -36,11 +38,6 @@ suspend fun retryable( call() } catch (e: Throwable) { if (!retryIf(e)) throw e - trace( - message = "Attempt $currentAttempt failed with exception: ${e.message}", - error = e, - type = TraceType.Error - ) null } @@ -50,7 +47,8 @@ suspend fun retryable( currentAttempt++ if (currentAttempt < maxRetries) { onRetry(currentAttempt) - delay(delayDuration.inWholeMilliseconds) + val actualDelay = delayDuration * backoffFactor.pow(currentAttempt - 1) + delay(actualDelay.inWholeMilliseconds) } } } @@ -66,6 +64,7 @@ suspend fun retryable( suspend fun retryableOrThrow( maxRetries: Int = 3, delayDuration: Duration = 2.seconds, + backoffFactor: Double = 1.0, retryIf: (Throwable) -> Boolean = { true }, onRetry: (Int) -> Unit = { currentAttempt -> trace( @@ -102,7 +101,8 @@ suspend fun retryableOrThrow( currentAttempt++ if (currentAttempt < maxRetries) { onRetry(currentAttempt) - delay(delayDuration.inWholeMilliseconds) + val actualDelay = delayDuration * backoffFactor.pow(currentAttempt - 1) + delay(actualDelay.inWholeMilliseconds) } } } diff --git a/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt index 3b8402eac..5d04a5cae 100644 --- a/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt +++ b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt @@ -73,6 +73,38 @@ class RetryTest { // endregion + @Test + fun `retryable with backoffFactor retries and succeeds`() = runTest { + var attempts = 0 + val result = retryable( + maxRetries = 4, + delayDuration = 100.milliseconds, + backoffFactor = 2.0, + ) { + attempts++ + if (attempts < 4) throw RuntimeException("retry") + "ok" + } + assertEquals("ok", result) + assertEquals(4, attempts) + } + + @Test + fun `retryable with backoffFactor 1 behaves like fixed delay`() = runTest { + var attempts = 0 + val result = retryable( + maxRetries = 3, + delayDuration = 1.milliseconds, + backoffFactor = 1.0, + ) { + attempts++ + if (attempts < 3) throw RuntimeException("retry") + "ok" + } + assertEquals("ok", result) + assertEquals(3, attempts) + } + // region retryableOrThrow @Test From 097bccda7b05b0eecc210c17aa14d0bb847a0de1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 19 May 2026 20:31:47 -0400 Subject: [PATCH 5/6] feat: enable USDC deposits Signed-off-by: Brandon McAnsh --- .../main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index d9a8831b4..ae16a268f 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -152,8 +152,8 @@ sealed interface FeatureFlag { @FeatureFlagMarker data object DepositUsdc: FeatureFlag { override val key: String = "deposit_usdc_enabled" - override val default: Boolean = false - override val launched: Boolean = false + override val default: Boolean = true + override val launched: Boolean = true override val visible: Boolean = true override val persistLogOut: Boolean = false } From 6ffe68b3952bd35dc9deda98c8b044642cf167d2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 19 May 2026 20:38:19 -0400 Subject: [PATCH 6/6] chore: swap USDC on network state change as well Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/session/internal/RealSessionController.kt | 1 + 1 file changed, 1 insertion(+) 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 1b2a52e0a..7ffc1c384 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 @@ -199,6 +199,7 @@ class RealSessionController @Inject constructor( .onEach { if (userManager.authState.isAtLeastRegistered) { updateUserFlags() + swapUsdcIfNeeded() } }.launchIn(scope) }