Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flipcash/shared/onramp/deeplinks/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies {
implementation(libs.bundles.kotlinx.serialization)

implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:userflags"))
implementation(project(":libs:crypto:solana"))
implementation(project(":libs:messaging"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import com.flipcash.app.core.encryption.boxOpen
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.app.userflags.UserFlagsCoordinator
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
import com.flipcash.services.internal.model.thirdparty.UsdcLiquidtyPool
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
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.transactions.FundSwapPool
import com.getcode.opencode.model.transactions.LiquidityPool
import com.getcode.opencode.model.transactions.SwapFundingSource
import com.getcode.opencode.solana.SolanaTransaction
import com.getcode.opencode.solana.TransactionBuilder
Expand All @@ -29,6 +32,7 @@ import com.getcode.solana.rpc.RpcException
import com.getcode.solana.rpc.SolanaConnection
import com.getcode.solana.rpc.doesAccountExist
import com.getcode.solana.rpc.getBalance
import com.getcode.solana.rpc.getAccountData
import com.getcode.solana.rpc.getTokenAccountBalance
import com.getcode.solana.rpc.sendTransaction
import com.getcode.solana.rpc.simulateTransaction
Expand All @@ -52,13 +56,16 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import javax.inject.Inject

@ActivityRetainedScoped
class ExternalWalletOnRampController @Inject constructor(
private val userManager: UserManager,
private val userFlags: UserFlagsCoordinator,
private val transactionController: TransactionOperations,
private val rpcConfig: RpcConfig,
) {
Expand Down Expand Up @@ -445,11 +452,25 @@ class ExternalWalletOnRampController @Inject constructor(

val swapId = SwapId.generate()
val recentBlockhash = connection.getLatestBlockhash()

val liquidityPool = userFlags.resolvedFlags.value.usdcOnRampLiquidityPool.effectiveValue
val swapPool = when (liquidityPool) {
UsdcLiquidtyPool.CoinbaseStableSwapper -> {
val poolAddress = FundSwapPool.CoinbaseStableSwapper.poolAddress
val poolData = driver.getAccountData(poolAddress).getOrThrow()
FundSwapPool.CoinbaseStableSwapper.fromAccountData(poolData)
}
UsdcLiquidtyPool.Unknown,
UsdcLiquidtyPool.Flipcash -> FundSwapPool.Usdf(LiquidityPool.usdf)
}

trace("Building USDC -> USDF fund swap using $liquidityPool LP")

val transaction = TransactionBuilder.usdcFundSwap(
owner = owner.authorityPublicKey,
sender = sender,
amount = amount.underlyingTokenAmount.quarks,
pool = LiquidityPool.usdf,
pool = swapPool,
blockhash = Hash(recentBlockhash),
swapId = swapId,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.flipcash.app.core.onramp.deeplinks.ExternalWalletConnection
import com.flipcash.app.onramp.DeeplinkOnRampError
import com.flipcash.app.onramp.ExternalWalletOnRampController
import com.flipcash.app.onramp.ExternalWalletOnRampState
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
import com.flipcash.services.user.UserManager
import com.getcode.opencode.controllers.TransactionOperations
Expand All @@ -31,6 +32,7 @@ import kotlin.test.assertTrue
class ExternalWalletBalanceCheckTest {

private val userManager = mockk<UserManager>(relaxed = true)
private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)
private val transactionController = mockk<TransactionOperations>(relaxed = true)
private val networkDriver = mockk<HttpNetworkDriver>()
private val rpcConfig = RpcConfig(networkDriver = networkDriver, rpcUrl = "https://localhost")
Expand All @@ -45,6 +47,7 @@ class ExternalWalletBalanceCheckTest {
every { Box.keypair() } returns mockk<BoxKeyPair>(relaxed = true)
controller = ExternalWalletOnRampController(
userManager = userManager,
userFlags = userFlags,
transactionController = transactionController,
rpcConfig = rpcConfig,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.app.onramp.DeeplinkError
import com.flipcash.app.onramp.DeeplinkOnRampError
import com.flipcash.app.onramp.ExternalWalletOnRampController
import com.flipcash.app.onramp.ExternalWalletOnRampState
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.services.user.UserManager
import com.getcode.opencode.controllers.TransactionOperations
import com.getcode.solana.rpc.RpcConfig
Expand All @@ -30,6 +31,8 @@ class ExternalWalletDeeplinkStateErrorTest {
private val networkDriver = mockk<HttpNetworkDriver>(relaxed = true)
private val rpcConfig = RpcConfig(networkDriver = networkDriver, rpcUrl = "https://localhost")

private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)

private lateinit var controller: ExternalWalletOnRampController

@Before
Expand All @@ -38,6 +41,7 @@ class ExternalWalletDeeplinkStateErrorTest {
every { Box.keypair() } returns mockk<BoxKeyPair>(relaxed = true)
controller = ExternalWalletOnRampController(
userManager = userManager,
userFlags = userFlags,
transactionController = transactionController,
rpcConfig = rpcConfig,
)
Expand Down
26 changes: 26 additions & 0 deletions libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import com.getcode.solana.keys.PublicKey
import com.solana.networking.Rpc20Driver
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import org.sol4k.Connection
import android.util.Base64

class SolanaConnection(rpcUrl: String,) {
private val connection = Connection(rpcUrl)
Expand Down Expand Up @@ -92,6 +94,30 @@ suspend fun Rpc20Driver.doesAccountExist(publicKey: PublicKey): Result<Unit> {
return Result.success(Unit)
}

/**
* Returns the raw account data for the given public key, base64-decoded.
*/
suspend fun Rpc20Driver.getAccountData(publicKey: PublicKey): Result<ByteArray> {
val response = makeRequest(
request = GetAccountInfo(publicKey),
resultSerializer = JsonElement.serializer()
)
val error = response.error
if (error != null) {
return Result.failure(RpcException(error.code, error.message))
}

val value = response.result?.jsonObject?.get("value")?.takeIf { it !is JsonNull }
?: return Result.failure(Throwable("Account not found"))

val dataArray = value.jsonObject["data"]?.jsonArray
?: return Result.failure(Throwable("Missing account data"))

val base64String = dataArray[0].jsonPrimitive.content
val decoded = Base64.decode(base64String, Base64.NO_WRAP)
return Result.success(decoded)
}

/**
* Sends a transaction to the Solana blockchain.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.getcode.opencode.internal.solana.model

import com.getcode.solana.keys.PublicKey

/**
* Represents the on-chain CoinbaseStableSwapper liquidity pool account.
*
* Layout:
* [8 discriminator][32 operations_authority][32 pause_authority][32 fee_recipient]...
*/
internal data class CoinbaseStablecoinPoolAccount(
val feeRecipient: PublicKey,
) {
companion object {
private const val FEE_RECIPIENT_OFFSET = 8 + 32 + 32 // discriminator + ops_authority + pause_authority

fun fromAccountData(data: ByteArray): CoinbaseStablecoinPoolAccount {
require(data.size >= FEE_RECIPIENT_OFFSET + 32) {
"Account data too short: expected at least ${FEE_RECIPIENT_OFFSET + 32} bytes, got ${data.size}"
}
val feeRecipientBytes = data.sliceArray(FEE_RECIPIENT_OFFSET until FEE_RECIPIENT_OFFSET + 32)
return CoinbaseStablecoinPoolAccount(
feeRecipient = PublicKey(feeRecipientBytes.toList()),
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.getcode.opencode.internal.solana.model

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.solana.keys.PublicKey

internal data class CoinbaseSwapAccounts(
val pool: PublicKey,
val inVault: PublicKey,
val outVault: PublicKey,
val inVaultTokenAccount: PublicKey,
val outVaultTokenAccount: PublicKey,
val whitelist: PublicKey,
) {
fun feeRecipientTokenAccount(feeRecipient: PublicKey, fromMint: PublicKey): PublicKey {
return PublicKey.deriveAssociatedAccount(
owner = feeRecipient,
mint = fromMint,
).publicKey
}

companion object {
fun derive(fromMint: PublicKey, toMint: PublicKey): CoinbaseSwapAccounts {
val pool = PublicKey.deriveCoinbasePoolAddress().publicKey
val inVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, fromMint).publicKey
val outVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMint).publicKey
val inVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(inVault).publicKey
val outVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(outVault).publicKey
val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey

return CoinbaseSwapAccounts(
pool = pool,
inVault = inVault,
outVault = outVault,
inVaultTokenAccount = inVaultTokenAccount,
outVaultTokenAccount = outVaultTokenAccount,
whitelist = whitelist,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.getcode.opencode.model.transactions

import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress
import com.getcode.opencode.internal.solana.model.CoinbaseStablecoinPoolAccount
import com.getcode.solana.keys.PublicKey

sealed interface FundSwapPool {
data class Usdf(val pool: LiquidityPool) : FundSwapPool
data class CoinbaseStableSwapper(val feeRecipient: PublicKey) : FundSwapPool {
companion object {
/** The on-chain address of the Coinbase liquidity pool account. */
val poolAddress: PublicKey
get() = PublicKey.deriveCoinbasePoolAddress().publicKey

/**
* Parses the on-chain pool account data and returns a [CoinbaseStableSwapper]
* with the resolved fee recipient.
*/
fun fromAccountData(data: ByteArray): CoinbaseStableSwapper {
val poolAccount = CoinbaseStablecoinPoolAccount.fromAccountData(data)
return CoinbaseStableSwapper(feeRecipient = poolAccount.feeRecipient)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package com.getcode.opencode.internal.solana.model
package com.getcode.opencode.model.transactions

import com.getcode.opencode.internal.solana.model.Vault
import com.getcode.opencode.internal.solana.vmAuthority
import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.PublicKey
import com.getcode.utils.serializer.PublicKeyAsStringSerializer
import com.getcode.vendor.Base58
import kotlinx.serialization.Serializable


@Serializable(with = PublicKeyAsStringSerializer::class)
class LiquidityPool(
val address: PublicKey,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.getcode.opencode.solana

import com.getcode.opencode.internal.solana.model.LiquidityPool
import com.getcode.opencode.internal.solana.model.SwapId
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.usdf
import com.getcode.opencode.model.transactions.SwapDirection
import com.getcode.opencode.model.transactions.SwapResponseServerParameters
import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.transactions.FundSwapPool
import com.getcode.opencode.solana.swap.buildExistingCurrencyBuyInstructions
import com.getcode.opencode.solana.swap.buildNewCurrencyBuyInstructions
import com.getcode.opencode.solana.swap.buildSellInstructions
Expand Down Expand Up @@ -179,7 +179,7 @@ object TransactionBuilder {
* @param owner The public key of the wallet owner whose USDF swap PDA will receive the funds.
* @param sender The public key of the account paying for and signing the transaction.
* @param amount The amount of USDC to swap into USDF (in quarks).
* @param pool The USDF liquidity pool containing vault addresses for the swap.
* @param pool The pool to use for the swap — either USDF liquidity pool or CoinbaseStableSwapper.
* @param swapId A unique identifier for this swap, included as a memo in the transaction.
* @param blockhash A recent blockhash for the transaction, or null.
* @return A constructed [SolanaTransaction] (V0) ready to be signed and submitted to the network.
Expand All @@ -188,7 +188,7 @@ object TransactionBuilder {
owner: PublicKey,
sender: PublicKey,
amount: Long,
pool: LiquidityPool,
pool: FundSwapPool,
swapId: SwapId,
blockhash: Hash?,
): SolanaTransaction {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
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.timelockSwapAccounts
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
Expand Down Expand Up @@ -63,17 +59,12 @@ internal fun buildStablecoinSwapperInstructions(
val fromTimelockAccounts = fromMintMetadata.timelockSwapAccounts(authority)

// Derive CoinbaseStableSwapper PDAs
val pool = PublicKey.deriveCoinbasePoolAddress().publicKey
val inVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, fromMintMetadata.address).publicKey
val outVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMintMetadata.address).publicKey
val inVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(inVault).publicKey
val outVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(outVault).publicKey
val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey
val swapAccounts = CoinbaseSwapAccounts.derive(fromMintMetadata.address, toMintMetadata.address)

val feeRecipientFromMintAta = PublicKey.deriveAssociatedAccount(
owner = serverParameters.poolFeeRecipient,
mint = fromMintMetadata.address,
).publicKey
val feeRecipientFromMintAta = swapAccounts.feeRecipientTokenAccount(
feeRecipient = serverParameters.poolFeeRecipient,
fromMint = fromMintMetadata.address,
)

// 5. AssociatedTokenAccount::CreateIdempotent (open swap authority's from_mint ATA)
val createSwapAuthorityFromMintAta = AssociatedTokenProgram_CreateIdempotent(
Expand Down Expand Up @@ -127,19 +118,19 @@ internal fun buildStablecoinSwapperInstructions(
// 8. CoinbaseStableSwapper::Swap (from_mint swap authority ATA -> to_mint destination owner ATA)
add(
CoinbaseStableSwapperProgram_Swap(
pool = pool,
inVault = inVault,
outVault = outVault,
inVaultTokenAccount = inVaultTokenAccount,
outVaultTokenAccount = outVaultTokenAccount,
pool = swapAccounts.pool,
inVault = swapAccounts.inVault,
outVault = swapAccounts.outVault,
inVaultTokenAccount = swapAccounts.inVaultTokenAccount,
outVaultTokenAccount = swapAccounts.outVaultTokenAccount,
userFromTokenAccount = createSwapAuthorityFromMintAta.address,
toTokenAccount = createDestinationOwnerToMintAta.address,
feeRecipientTokenAccount = feeRecipientFromMintAta,
feeRecipient = serverParameters.poolFeeRecipient,
fromMint = fromMintMetadata.address,
toMint = toMintMetadata.address,
user = swapAuthority,
whitelist = whitelist,
whitelist = swapAccounts.whitelist,
amountIn = amount,
minAmountOut = minOutput,
).instruction()
Expand Down
Loading
Loading