Skip to content

Commit d2fe7d9

Browse files
authored
Merge pull request #656 from code-payments/feat/phantom-coinbase-swapper
feat(onramp): add CoinbaseStableSwapper support to external wallet USDC on-ramp
2 parents e09eca5 + 6a8494f commit d2fe7d9

12 files changed

Lines changed: 217 additions & 44 deletions

File tree

apps/flipcash/shared/onramp/deeplinks/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies {
1414
implementation(libs.bundles.kotlinx.serialization)
1515

1616
implementation(project(":apps:flipcash:shared:analytics"))
17+
implementation(project(":apps:flipcash:shared:userflags"))
1718
implementation(project(":libs:crypto:solana"))
1819
implementation(project(":libs:messaging"))
1920
}

apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/ExternalWalletOnRampController.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import com.flipcash.app.core.encryption.boxOpen
55
import com.flipcash.app.core.navigation.DeeplinkType
66
import com.flipcash.app.core.onramp.deeplinks.ExternalWalletConnection
77
import com.flipcash.app.core.onramp.deeplinks.ExternallySignedTransaction
8+
import com.flipcash.app.userflags.UserFlagsCoordinator
89
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
10+
import com.flipcash.services.internal.model.thirdparty.UsdcLiquidtyPool
911
import com.flipcash.services.user.UserManager
1012
import com.getcode.opencode.controllers.TransactionOperations
1113
import com.getcode.opencode.exchange.VerifiedFiat
1214
import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount
13-
import com.getcode.opencode.internal.solana.model.LiquidityPool
1415
import com.getcode.opencode.internal.solana.model.SwapId
1516
import com.getcode.opencode.model.financial.LocalFiat
1617
import com.getcode.opencode.model.financial.Token
18+
import com.getcode.opencode.model.transactions.FundSwapPool
19+
import com.getcode.opencode.model.transactions.LiquidityPool
1720
import com.getcode.opencode.model.transactions.SwapFundingSource
1821
import com.getcode.opencode.solana.SolanaTransaction
1922
import com.getcode.opencode.solana.TransactionBuilder
@@ -29,6 +32,7 @@ import com.getcode.solana.rpc.RpcException
2932
import com.getcode.solana.rpc.SolanaConnection
3033
import com.getcode.solana.rpc.doesAccountExist
3134
import com.getcode.solana.rpc.getBalance
35+
import com.getcode.solana.rpc.getAccountData
3236
import com.getcode.solana.rpc.getTokenAccountBalance
3337
import com.getcode.solana.rpc.sendTransaction
3438
import com.getcode.solana.rpc.simulateTransaction
@@ -52,13 +56,16 @@ import kotlinx.coroutines.flow.SharedFlow
5256
import kotlinx.coroutines.flow.StateFlow
5357
import kotlinx.coroutines.flow.asSharedFlow
5458
import kotlinx.coroutines.flow.asStateFlow
59+
import kotlinx.coroutines.flow.firstOrNull
60+
import kotlinx.coroutines.flow.map
5561
import kotlinx.coroutines.withContext
5662
import kotlinx.serialization.json.Json
5763
import javax.inject.Inject
5864

5965
@ActivityRetainedScoped
6066
class ExternalWalletOnRampController @Inject constructor(
6167
private val userManager: UserManager,
68+
private val userFlags: UserFlagsCoordinator,
6269
private val transactionController: TransactionOperations,
6370
private val rpcConfig: RpcConfig,
6471
) {
@@ -445,11 +452,25 @@ class ExternalWalletOnRampController @Inject constructor(
445452

446453
val swapId = SwapId.generate()
447454
val recentBlockhash = connection.getLatestBlockhash()
455+
456+
val liquidityPool = userFlags.resolvedFlags.value.usdcOnRampLiquidityPool.effectiveValue
457+
val swapPool = when (liquidityPool) {
458+
UsdcLiquidtyPool.CoinbaseStableSwapper -> {
459+
val poolAddress = FundSwapPool.CoinbaseStableSwapper.poolAddress
460+
val poolData = driver.getAccountData(poolAddress).getOrThrow()
461+
FundSwapPool.CoinbaseStableSwapper.fromAccountData(poolData)
462+
}
463+
UsdcLiquidtyPool.Unknown,
464+
UsdcLiquidtyPool.Flipcash -> FundSwapPool.Usdf(LiquidityPool.usdf)
465+
}
466+
467+
trace("Building USDC -> USDF fund swap using $liquidityPool LP")
468+
448469
val transaction = TransactionBuilder.usdcFundSwap(
449470
owner = owner.authorityPublicKey,
450471
sender = sender,
451472
amount = amount.underlyingTokenAmount.quarks,
452-
pool = LiquidityPool.usdf,
473+
pool = swapPool,
453474
blockhash = Hash(recentBlockhash),
454475
swapId = swapId,
455476
)

apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/internal/ExternalWalletBalanceCheckTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.flipcash.app.core.onramp.deeplinks.ExternalWalletConnection
55
import com.flipcash.app.onramp.DeeplinkOnRampError
66
import com.flipcash.app.onramp.ExternalWalletOnRampController
77
import com.flipcash.app.onramp.ExternalWalletOnRampState
8+
import com.flipcash.app.userflags.UserFlagsCoordinator
89
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
910
import com.flipcash.services.user.UserManager
1011
import com.getcode.opencode.controllers.TransactionOperations
@@ -31,6 +32,7 @@ import kotlin.test.assertTrue
3132
class ExternalWalletBalanceCheckTest {
3233

3334
private val userManager = mockk<UserManager>(relaxed = true)
35+
private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)
3436
private val transactionController = mockk<TransactionOperations>(relaxed = true)
3537
private val networkDriver = mockk<HttpNetworkDriver>()
3638
private val rpcConfig = RpcConfig(networkDriver = networkDriver, rpcUrl = "https://localhost")
@@ -45,6 +47,7 @@ class ExternalWalletBalanceCheckTest {
4547
every { Box.keypair() } returns mockk<BoxKeyPair>(relaxed = true)
4648
controller = ExternalWalletOnRampController(
4749
userManager = userManager,
50+
userFlags = userFlags,
4851
transactionController = transactionController,
4952
rpcConfig = rpcConfig,
5053
)

apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkStateErrorTest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.flipcash.app.onramp.DeeplinkError
77
import com.flipcash.app.onramp.DeeplinkOnRampError
88
import com.flipcash.app.onramp.ExternalWalletOnRampController
99
import com.flipcash.app.onramp.ExternalWalletOnRampState
10+
import com.flipcash.app.userflags.UserFlagsCoordinator
1011
import com.flipcash.services.user.UserManager
1112
import com.getcode.opencode.controllers.TransactionOperations
1213
import com.getcode.solana.rpc.RpcConfig
@@ -30,6 +31,8 @@ class ExternalWalletDeeplinkStateErrorTest {
3031
private val networkDriver = mockk<HttpNetworkDriver>(relaxed = true)
3132
private val rpcConfig = RpcConfig(networkDriver = networkDriver, rpcUrl = "https://localhost")
3233

34+
private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)
35+
3336
private lateinit var controller: ExternalWalletOnRampController
3437

3538
@Before
@@ -38,6 +41,7 @@ class ExternalWalletDeeplinkStateErrorTest {
3841
every { Box.keypair() } returns mockk<BoxKeyPair>(relaxed = true)
3942
controller = ExternalWalletOnRampController(
4043
userManager = userManager,
44+
userFlags = userFlags,
4145
transactionController = transactionController,
4246
rpcConfig = rpcConfig,
4347
)

libs/crypto/solana/src/main/kotlin/com/getcode/solana/rpc/Calls.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import com.getcode.solana.keys.PublicKey
44
import com.solana.networking.Rpc20Driver
55
import kotlinx.serialization.json.JsonElement
66
import kotlinx.serialization.json.JsonNull
7+
import kotlinx.serialization.json.jsonArray
78
import kotlinx.serialization.json.jsonObject
89
import kotlinx.serialization.json.jsonPrimitive
910
import kotlinx.serialization.json.long
1011
import org.sol4k.Connection
12+
import android.util.Base64
1113

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

97+
/**
98+
* Returns the raw account data for the given public key, base64-decoded.
99+
*/
100+
suspend fun Rpc20Driver.getAccountData(publicKey: PublicKey): Result<ByteArray> {
101+
val response = makeRequest(
102+
request = GetAccountInfo(publicKey),
103+
resultSerializer = JsonElement.serializer()
104+
)
105+
val error = response.error
106+
if (error != null) {
107+
return Result.failure(RpcException(error.code, error.message))
108+
}
109+
110+
val value = response.result?.jsonObject?.get("value")?.takeIf { it !is JsonNull }
111+
?: return Result.failure(Throwable("Account not found"))
112+
113+
val dataArray = value.jsonObject["data"]?.jsonArray
114+
?: return Result.failure(Throwable("Missing account data"))
115+
116+
val base64String = dataArray[0].jsonPrimitive.content
117+
val decoded = Base64.decode(base64String, Base64.NO_WRAP)
118+
return Result.success(decoded)
119+
}
120+
95121
/**
96122
* Sends a transaction to the Solana blockchain.
97123
*
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.getcode.opencode.internal.solana.model
2+
3+
import com.getcode.solana.keys.PublicKey
4+
5+
/**
6+
* Represents the on-chain CoinbaseStableSwapper liquidity pool account.
7+
*
8+
* Layout:
9+
* [8 discriminator][32 operations_authority][32 pause_authority][32 fee_recipient]...
10+
*/
11+
internal data class CoinbaseStablecoinPoolAccount(
12+
val feeRecipient: PublicKey,
13+
) {
14+
companion object {
15+
private const val FEE_RECIPIENT_OFFSET = 8 + 32 + 32 // discriminator + ops_authority + pause_authority
16+
17+
fun fromAccountData(data: ByteArray): CoinbaseStablecoinPoolAccount {
18+
require(data.size >= FEE_RECIPIENT_OFFSET + 32) {
19+
"Account data too short: expected at least ${FEE_RECIPIENT_OFFSET + 32} bytes, got ${data.size}"
20+
}
21+
val feeRecipientBytes = data.sliceArray(FEE_RECIPIENT_OFFSET until FEE_RECIPIENT_OFFSET + 32)
22+
return CoinbaseStablecoinPoolAccount(
23+
feeRecipient = PublicKey(feeRecipientBytes.toList()),
24+
)
25+
}
26+
}
27+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.getcode.opencode.internal.solana.model
2+
3+
import com.getcode.opencode.internal.solana.extensions.deriveAssociatedAccount
4+
import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress
5+
import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseTokenVaultAddress
6+
import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseVaultTokenAccountAddress
7+
import com.getcode.opencode.internal.solana.extensions.deriveCoinbaseWhitelistAddress
8+
import com.getcode.solana.keys.PublicKey
9+
10+
internal data class CoinbaseSwapAccounts(
11+
val pool: PublicKey,
12+
val inVault: PublicKey,
13+
val outVault: PublicKey,
14+
val inVaultTokenAccount: PublicKey,
15+
val outVaultTokenAccount: PublicKey,
16+
val whitelist: PublicKey,
17+
) {
18+
fun feeRecipientTokenAccount(feeRecipient: PublicKey, fromMint: PublicKey): PublicKey {
19+
return PublicKey.deriveAssociatedAccount(
20+
owner = feeRecipient,
21+
mint = fromMint,
22+
).publicKey
23+
}
24+
25+
companion object {
26+
fun derive(fromMint: PublicKey, toMint: PublicKey): CoinbaseSwapAccounts {
27+
val pool = PublicKey.deriveCoinbasePoolAddress().publicKey
28+
val inVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, fromMint).publicKey
29+
val outVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMint).publicKey
30+
val inVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(inVault).publicKey
31+
val outVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(outVault).publicKey
32+
val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey
33+
34+
return CoinbaseSwapAccounts(
35+
pool = pool,
36+
inVault = inVault,
37+
outVault = outVault,
38+
inVaultTokenAccount = inVaultTokenAccount,
39+
outVaultTokenAccount = outVaultTokenAccount,
40+
whitelist = whitelist,
41+
)
42+
}
43+
}
44+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.getcode.opencode.model.transactions
2+
3+
import com.getcode.opencode.internal.solana.extensions.deriveCoinbasePoolAddress
4+
import com.getcode.opencode.internal.solana.model.CoinbaseStablecoinPoolAccount
5+
import com.getcode.solana.keys.PublicKey
6+
7+
sealed interface FundSwapPool {
8+
data class Usdf(val pool: LiquidityPool) : FundSwapPool
9+
data class CoinbaseStableSwapper(val feeRecipient: PublicKey) : FundSwapPool {
10+
companion object {
11+
/** The on-chain address of the Coinbase liquidity pool account. */
12+
val poolAddress: PublicKey
13+
get() = PublicKey.deriveCoinbasePoolAddress().publicKey
14+
15+
/**
16+
* Parses the on-chain pool account data and returns a [CoinbaseStableSwapper]
17+
* with the resolved fee recipient.
18+
*/
19+
fun fromAccountData(data: ByteArray): CoinbaseStableSwapper {
20+
val poolAccount = CoinbaseStablecoinPoolAccount.fromAccountData(data)
21+
return CoinbaseStableSwapper(feeRecipient = poolAccount.feeRecipient)
22+
}
23+
}
24+
}
25+
}

services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/model/LiquidityPool.kt renamed to services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/LiquidityPool.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
package com.getcode.opencode.internal.solana.model
1+
package com.getcode.opencode.model.transactions
22

3+
import com.getcode.opencode.internal.solana.model.Vault
34
import com.getcode.opencode.internal.solana.vmAuthority
45
import com.getcode.solana.keys.Mint
56
import com.getcode.solana.keys.PublicKey
67
import com.getcode.utils.serializer.PublicKeyAsStringSerializer
7-
import com.getcode.vendor.Base58
88
import kotlinx.serialization.Serializable
99

10-
1110
@Serializable(with = PublicKeyAsStringSerializer::class)
1211
class LiquidityPool(
1312
val address: PublicKey,

services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package com.getcode.opencode.solana
22

3-
import com.getcode.opencode.internal.solana.model.LiquidityPool
43
import com.getcode.opencode.internal.solana.model.SwapId
54
import com.getcode.opencode.model.financial.Token
65
import com.getcode.opencode.model.financial.usdf
76
import com.getcode.opencode.model.transactions.SwapDirection
87
import com.getcode.opencode.model.transactions.SwapResponseServerParameters
98
import com.getcode.opencode.model.financial.MintMetadata
9+
import com.getcode.opencode.model.transactions.FundSwapPool
1010
import com.getcode.opencode.solana.swap.buildExistingCurrencyBuyInstructions
1111
import com.getcode.opencode.solana.swap.buildNewCurrencyBuyInstructions
1212
import com.getcode.opencode.solana.swap.buildSellInstructions
@@ -179,7 +179,7 @@ object TransactionBuilder {
179179
* @param owner The public key of the wallet owner whose USDF swap PDA will receive the funds.
180180
* @param sender The public key of the account paying for and signing the transaction.
181181
* @param amount The amount of USDC to swap into USDF (in quarks).
182-
* @param pool The USDF liquidity pool containing vault addresses for the swap.
182+
* @param pool The pool to use for the swap — either USDF liquidity pool or CoinbaseStableSwapper.
183183
* @param swapId A unique identifier for this swap, included as a memo in the transaction.
184184
* @param blockhash A recent blockhash for the transaction, or null.
185185
* @return A constructed [SolanaTransaction] (V0) ready to be signed and submitted to the network.
@@ -188,7 +188,7 @@ object TransactionBuilder {
188188
owner: PublicKey,
189189
sender: PublicKey,
190190
amount: Long,
191-
pool: LiquidityPool,
191+
pool: FundSwapPool,
192192
swapId: SwapId,
193193
blockhash: Hash?,
194194
): SolanaTransaction {

0 commit comments

Comments
 (0)