Skip to content

Commit 76e5d59

Browse files
committed
feat(onramp): add CoinbaseStableSwapper support to external wallet USDC on-ramp
Extend the USDC→USDF fund swap to support both the Flipcash USDF liquidity pool and the Coinbase Stable Swapper program, selected via the usdcOnRampLiquidityPool user flag. - Add getAccountData RPC extension for fetching on-chain account data - Add CoinbaseStablecoinPoolAccount to parse pool fee recipient at offset 72 - Extract CoinbaseSwapAccounts PDA helper shared by both swap flows - Introduce FundSwapPool sealed interface (Usdf | CoinbaseStableSwapper) - Branch buildUsdcToUsdfSwapInstructions step 7 on pool type - Resolve pool type in ExternalWalletOnRampController via public companion helpers Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 81cbd0c commit 76e5d59

11 files changed

Lines changed: 214 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/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 {

services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CoinbaseStablecoinSwapperInstructions.kt

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

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
83
import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts
4+
import com.getcode.opencode.internal.solana.model.CoinbaseSwapAccounts
95
import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent
106
import com.getcode.opencode.internal.solana.programs.CoinbaseStableSwapperProgram_Swap
117
import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit
@@ -63,17 +59,12 @@ internal fun buildStablecoinSwapperInstructions(
6359
val fromTimelockAccounts = fromMintMetadata.timelockSwapAccounts(authority)
6460

6561
// Derive CoinbaseStableSwapper PDAs
66-
val pool = PublicKey.deriveCoinbasePoolAddress().publicKey
67-
val inVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, fromMintMetadata.address).publicKey
68-
val outVault = PublicKey.deriveCoinbaseTokenVaultAddress(pool, toMintMetadata.address).publicKey
69-
val inVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(inVault).publicKey
70-
val outVaultTokenAccount = PublicKey.deriveCoinbaseVaultTokenAccountAddress(outVault).publicKey
71-
val whitelist = PublicKey.deriveCoinbaseWhitelistAddress().publicKey
62+
val swapAccounts = CoinbaseSwapAccounts.derive(fromMintMetadata.address, toMintMetadata.address)
7263

73-
val feeRecipientFromMintAta = PublicKey.deriveAssociatedAccount(
74-
owner = serverParameters.poolFeeRecipient,
75-
mint = fromMintMetadata.address,
76-
).publicKey
64+
val feeRecipientFromMintAta = swapAccounts.feeRecipientTokenAccount(
65+
feeRecipient = serverParameters.poolFeeRecipient,
66+
fromMint = fromMintMetadata.address,
67+
)
7768

7869
// 5. AssociatedTokenAccount::CreateIdempotent (open swap authority's from_mint ATA)
7970
val createSwapAuthorityFromMintAta = AssociatedTokenProgram_CreateIdempotent(
@@ -127,19 +118,19 @@ internal fun buildStablecoinSwapperInstructions(
127118
// 8. CoinbaseStableSwapper::Swap (from_mint swap authority ATA -> to_mint destination owner ATA)
128119
add(
129120
CoinbaseStableSwapperProgram_Swap(
130-
pool = pool,
131-
inVault = inVault,
132-
outVault = outVault,
133-
inVaultTokenAccount = inVaultTokenAccount,
134-
outVaultTokenAccount = outVaultTokenAccount,
121+
pool = swapAccounts.pool,
122+
inVault = swapAccounts.inVault,
123+
outVault = swapAccounts.outVault,
124+
inVaultTokenAccount = swapAccounts.inVaultTokenAccount,
125+
outVaultTokenAccount = swapAccounts.outVaultTokenAccount,
135126
userFromTokenAccount = createSwapAuthorityFromMintAta.address,
136127
toTokenAccount = createDestinationOwnerToMintAta.address,
137128
feeRecipientTokenAccount = feeRecipientFromMintAta,
138129
feeRecipient = serverParameters.poolFeeRecipient,
139130
fromMint = fromMintMetadata.address,
140131
toMint = toMintMetadata.address,
141132
user = swapAuthority,
142-
whitelist = whitelist,
133+
whitelist = swapAccounts.whitelist,
143134
amountIn = amount,
144135
minAmountOut = minOutput,
145136
).instruction()

0 commit comments

Comments
 (0)