Skip to content

Commit 9496601

Browse files
committed
feat(flipcash): support onramp via phantom deep links end-to-end on Android
* Add on ramp flow to menu, cash and pool bid NSF Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent e9c99c1 commit 9496601

52 files changed

Lines changed: 1613 additions & 515 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/flipcash/app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ dependencies {
148148
implementation(project(":apps:flipcash:shared:currency-selection:ui"))
149149
implementation(project(":apps:flipcash:shared:notifications"))
150150
implementation(project(":apps:flipcash:shared:coinbase-onramp"))
151+
implementation(project(":apps:flipcash:shared:phantom-onramp"))
151152
implementation(project(":apps:flipcash:shared:payments"))
152153
implementation(project(":apps:flipcash:shared:permissions"))
153154
implementation(project(":apps:flipcash:shared:shareable"))
@@ -171,6 +172,7 @@ dependencies {
171172
implementation(project(":apps:flipcash:features:payments"))
172173
implementation(project(":apps:flipcash:features:onramp"))
173174

175+
implementation(project(":libs:crypto:solana"))
174176
implementation(project(":libs:datetime"))
175177
implementation(project(":libs:locale:bindings"))
176178
implementation(project(":libs:logging"))

apps/flipcash/app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@
9090
android:scheme="https" />
9191
</intent-filter>
9292

93+
<intent-filter android:autoVerify="true">
94+
<action android:name="android.intent.action.VIEW" />
95+
96+
<category android:name="android.intent.category.DEFAULT" />
97+
<category android:name="android.intent.category.BROWSABLE" />
98+
99+
<data
100+
android:host="app.flipcash.com"
101+
android:pathPattern="/phantom/signed"
102+
android:scheme="https" />
103+
</intent-filter>
104+
93105
<intent-filter android:autoVerify="true">
94106
<action android:name="android.intent.action.VIEW" />
95107

apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.flipcash.services.user.UserManager
3131
import com.getcode.libs.analytics.LocalAnalytics
3232
import com.getcode.opencode.compose.LocalExchange
3333
import com.getcode.opencode.exchange.Exchange
34+
import com.getcode.solana.rpc.RpcConfig
3435
import com.getcode.util.permissions.LocalPermissionChecker
3536
import com.getcode.util.permissions.PermissionChecker
3637
import com.getcode.util.resources.LocalResources
@@ -104,6 +105,9 @@ class MainActivity : FragmentActivity() {
104105
@Inject
105106
lateinit var analytics: FlipcashAnalyticsService
106107

108+
@Inject
109+
lateinit var solanaRpcConfig: RpcConfig
110+
107111
override fun onCreate(savedInstanceState: Bundle?) {
108112
super.onCreate(savedInstanceState)
109113
handleUncaughtException()
@@ -129,7 +133,10 @@ class MainActivity : FragmentActivity() {
129133
LocalPaymentController provides paymentController,
130134
) {
131135
Rinku {
132-
App(tipsEngine = tipsEngine)
136+
App(
137+
tipsEngine = tipsEngine,
138+
solanaRpcConfig = solanaRpcConfig,
139+
)
133140
}
134141
}
135142
}

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt

Lines changed: 116 additions & 118 deletions
Large diffs are not rendered by default.

apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import com.flipcash.app.myaccount.MyAccountScreen
2121
import com.flipcash.app.onramp.OnRampAmountScreen
2222
import com.flipcash.app.onramp.OnRampProviderListScreen
2323
import com.flipcash.app.onramp.OnRampFlowTracker
24-
import com.flipcash.app.onramp.internal.data.LocalPhantomDepositState
24+
import com.flipcash.app.onramp.OnRampSuccessScreen
2525
import com.flipcash.app.permissions.CameraPermissionScreen
2626
import com.flipcash.app.permissions.NotificationPermissionScreen
2727
import com.flipcash.app.pools.PoolBettingScreen
@@ -131,6 +131,10 @@ internal fun AppScreenContent(content: @Composable () -> Unit) {
131131
OnRampAmountScreen()
132132
}
133133

134+
register<NavScreenProvider.HomeScreen.OnRamp.Success> {
135+
OnRampSuccessScreen()
136+
}
137+
134138
register<NavScreenProvider.HomeScreen.Menu.Root> {
135139
MenuScreen()
136140
}

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/NavScreenProvider.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ sealed class NavScreenProvider : ScreenProvider {
5858
sealed class OnRamp {
5959
data class ProviderList(val from: NavScreenProvider? = null): NavScreenProvider()
6060
data object Amount : NavScreenProvider()
61+
data object Success: NavScreenProvider()
6162
}
6263

6364
sealed class Menu {
Lines changed: 7 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,11 @@
11
package com.flipcash.app.core.encryption
22

3-
import android.util.Base64
4-
import com.getcode.ed25519.Ed25519.KeyPair
53
import com.getcode.solana.keys.PrivateKey
64
import com.getcode.solana.keys.PublicKey
75
import com.ionspin.kotlin.crypto.box.Box
86
import com.ionspin.kotlin.crypto.secretbox.SecretBox
9-
import com.ionspin.kotlin.crypto.signature.Signature
10-
11-
/** Some cryptographic function require the private
12-
* key to be formatted this way to work correctly.
13-
* A good example of this would Sodium and the box
14-
* `seal` and `open` functions.
15-
*/
16-
val KeyPair.encryptionPrivateKey: PrivateKey?
17-
get() {
18-
if (seed.isNullOrEmpty()) return null
19-
20-
val bytes = Base64.decode(seed, Base64.DEFAULT) + publicKeyBytes
21-
return PrivateKey(bytes.toList())
22-
}
23-
24-
val PrivateKey.curvePrivate: Result<PrivateKey>
25-
get() {
26-
val localBytes = bytes
7+
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
278

28-
val result = runCatching { Signature.ed25519SkToCurve25519(localBytes.map { it.toUByte() }.toUByteArray()) }
29-
30-
if (result.isFailure) {
31-
return Result.failure(SodiumError.ConversionToCurveFailed(result.exceptionOrNull()))
32-
}
33-
return Result.success(result.getOrNull()!!.toPrivateKey())
34-
}
35-
36-
val PublicKey.curvePublic: Result<PublicKey>
37-
get() {
38-
val localBytes = bytes
39-
40-
val result = runCatching { Signature.ed25519PkToCurve25519(localBytes.map { it.toUByte() }.toUByteArray()) }
41-
if (result.isFailure) {
42-
return Result.failure(SodiumError.ConversionToCurveFailed(result.exceptionOrNull()))
43-
}
44-
return Result.success(result.getOrNull()!!.toPublicKey())
45-
}
469

4710
fun PublicKey.Companion.shared(publicKey: PublicKey, privateKey: PrivateKey): Result<PublicKey> {
4811
val pubKeyBytes = publicKey.bytes.map { it.toUByte() }.toUByteArray()
@@ -59,19 +22,16 @@ fun PublicKey.Companion.fromUbytes(bytes: List<UByte>): PublicKey {
5922
return PublicKey(bytes.map { it.toByte() })
6023
}
6124

62-
fun String.boxSeal(privateKey: PrivateKey, publicKey: PublicKey, nonce: List<Byte>): Result<List<Byte>> {
63-
val publicCurve = publicKey.curvePublic.getOrThrow()
64-
val privateCurve = privateKey.curvePrivate.getOrThrow()
65-
66-
val sharedKey = PublicKey.shared(publicCurve, privateCurve).getOrThrow()
25+
fun String.boxSeal(privateKey: List<Byte>, publicKey: List<Byte>, nonce: List<Byte>): Result<List<Byte>> {
6726
val nonceU = nonce.map { it.toUByte() }.toUByteArray()
68-
val message = toByteArray().map { it.toUByte() }.toUByteArray()
27+
val message = encodeToUByteArray()
6928

7029
val encrypted = runCatching {
71-
SecretBox.easy(
72-
key = sharedKey.bytes.map { it.toUByte() }.toUByteArray(),
30+
Box.easy(
7331
message = message,
74-
nonce = nonceU
32+
nonce = nonceU,
33+
recipientsPublicKey = publicKey.toByteArray().toUByteArray(),
34+
sendersSecretKey = privateKey.toByteArray().toUByteArray(),
7535
)
7636
}
7737

@@ -82,25 +42,6 @@ fun String.boxSeal(privateKey: PrivateKey, publicKey: PublicKey, nonce: List<Byt
8242
return Result.success(encrypted.getOrNull()!!.map { it.toByte() })
8343
}
8444

85-
fun List<Byte>.boxOpen(privateKey: PrivateKey, publicKey: PublicKey, nonce: List<Byte>): Result<List<Byte>> {
86-
val publicCurve = publicKey.curvePublic.getOrThrow()
87-
val privateCurve = privateKey.curvePrivate.getOrThrow()
88-
89-
val sharedKey = PublicKey.shared(publicCurve, privateCurve).getOrThrow()
90-
.bytes.map { it.toUByte() }.toUByteArray()
91-
92-
val nonceU = nonce.map { it.toUByte() }.toUByteArray()
93-
val cipher = map { it.toUByte() }.toUByteArray()
94-
95-
val decrypted = runCatching { SecretBox.openEasy(cipher, nonceU, sharedKey) }
96-
97-
if (decrypted.isFailure) {
98-
return Result.failure(SodiumError.DecryptionFailed(decrypted.exceptionOrNull()))
99-
}
100-
101-
return Result.success(decrypted.getOrNull()!!.map { it.toByte() })
102-
}
103-
10445
fun List<Byte>.boxOpen(privateKey: List<Byte>, publicKey: List<Byte>, nonce: List<Byte>): Result<List<Byte>> {
10546
val sharedKey = PublicKey.shared(PublicKey(publicKey), PrivateKey(privateKey)).getOrThrow()
10647
.bytes.map { it.toUByte() }.toUByteArray()
@@ -118,20 +59,11 @@ fun List<Byte>.boxOpen(privateKey: List<Byte>, publicKey: List<Byte>, nonce: Lis
11859
}
11960

12061
sealed class SodiumError {
121-
data class ConversionToCurveFailed(val root: Throwable? = null): Throwable(cause = root)
12262
data class SharedKeyFailed(val root: Throwable? = null): Throwable(cause = root)
12363
data class EncryptionFailed(val root: Throwable? = null): Throwable(cause = root)
12464
data class DecryptionFailed(val root: Throwable? = null): Throwable(cause = root)
12565
}
12666

127-
internal fun ByteArray.toPublicKey(): PublicKey {
128-
return PublicKey(this.toList())
129-
}
130-
13167
fun UByteArray.toPublicKey(): PublicKey {
13268
return PublicKey.fromUbytes(this.toList())
133-
}
134-
135-
internal fun UByteArray.toPrivateKey(): PrivateKey {
136-
return PrivateKey(this.toList().map { it.toByte() })
13769
}

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import android.net.Uri
44
import android.os.Parcelable
55
import com.flipcash.app.core.NavScreenProvider
66
import com.flipcash.app.core.phantom.PhantomConnectionResult
7+
import com.flipcash.app.core.phantom.PhantomDeeplinkError
78
import com.flipcash.app.core.phantom.PhantomDeeplinkOrigin
9+
import com.flipcash.app.core.phantom.PhantomSigningResult
810
import com.getcode.ed25519.Ed25519
911
import com.getcode.opencode.model.core.ID
1012
import com.getcode.vendor.Base58
@@ -21,7 +23,14 @@ sealed interface DeeplinkType: Parcelable {
2123

2224
data class PhantomConnection(
2325
val origin: PhantomDeeplinkOrigin,
24-
val result: PhantomConnectionResult,
26+
val result: PhantomConnectionResult?,
27+
val error: PhantomDeeplinkError? = null
28+
): DeeplinkType
29+
30+
data class PhantomSignedTransaction(
31+
val origin: PhantomDeeplinkOrigin,
32+
val result: PhantomSigningResult?,
33+
val error: PhantomDeeplinkError? = null
2534
): DeeplinkType
2635
}
2736

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/phantom/PhantomConnectionResult.kt

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,59 @@ import android.os.Parcelable
44
import com.flipcash.app.core.NavScreenProvider
55
import com.getcode.ed25519.Ed25519
66
import com.getcode.opencode.model.core.ID
7+
import com.getcode.opencode.utils.base64
78
import com.getcode.solana.keys.PublicKey
9+
import com.getcode.utils.base58
10+
import com.getcode.utils.decodeBase58
11+
import com.getcode.utils.decodeBase64
12+
import kotlinx.parcelize.IgnoredOnParcel
813
import kotlinx.parcelize.Parcelize
914
import kotlinx.serialization.SerialName
1015
import kotlinx.serialization.Serializable
1116

1217
@Serializable
1318
@Parcelize
14-
data class PhantomEncryptedData(
19+
data class PhantomWalletConnection(
1520
@SerialName("public_key")
1621
val publicKey: PublicKey,
1722
val session: String,
1823
): Parcelable
1924

25+
@Serializable
26+
@Parcelize
27+
data class PhantomSignedTransaction(
28+
@SerialName("transaction")
29+
val serializedTransaction: String,
30+
): Parcelable
31+
2032
@Parcelize
21-
sealed interface PhantomDeeplinkOrigin: Parcelable {
33+
sealed class PhantomDeeplinkOrigin: Parcelable {
34+
@Parcelize
35+
data object Menu : PhantomDeeplinkOrigin()
36+
2237
@Parcelize
23-
data object Menu: PhantomDeeplinkOrigin
38+
data object Cash: PhantomDeeplinkOrigin()
39+
2440
@Parcelize
25-
data class PoolWithId(val id: ID): PhantomDeeplinkOrigin
41+
data class PoolWithId(val id: ID) : PhantomDeeplinkOrigin()
42+
2643
@Parcelize
27-
data class PoolWithRendezvous(val keyPair: Ed25519.KeyPair): PhantomDeeplinkOrigin
44+
data class PoolWithRendezvous(val keyPair: Ed25519.KeyPair) : PhantomDeeplinkOrigin()
45+
46+
fun forUri(): String {
47+
return when(this) {
48+
is PoolWithId -> "pool-id_${id.base58}"
49+
is PoolWithRendezvous -> "pool-seed_${keyPair.seed.base64}"
50+
Menu -> "menu"
51+
Cash -> "cash"
52+
}.lowercase()
53+
}
2854

2955
companion object {
3056
fun fromScreenProvider(provider: NavScreenProvider?): PhantomDeeplinkOrigin? {
3157
return when (provider) {
3258
is NavScreenProvider.HomeScreen.Menu.Root -> Menu
59+
is NavScreenProvider.HomeScreen.Cash -> Cash
3360
is NavScreenProvider.HomeScreen.Pools.ChoiceSelection -> {
3461
provider.rendezvous?.let { keyPair -> PoolWithRendezvous(keyPair) }
3562
provider.poolId?.let { id -> PoolWithId(id) }
@@ -38,6 +65,25 @@ sealed interface PhantomDeeplinkOrigin: Parcelable {
3865
else -> null
3966
}
4067
}
68+
69+
fun fromString(value: String?): PhantomDeeplinkOrigin? {
70+
return when {
71+
value == "menu" -> Menu
72+
value == "cash" -> Cash
73+
value?.startsWith("pool-") == true -> {
74+
val idStringWithPrefix = value.removePrefix("pool-")
75+
val splits = idStringWithPrefix.split("_")
76+
val prefix = splits.getOrNull(0) ?: return null
77+
when (prefix) {
78+
"seed" -> PoolWithRendezvous(Ed25519.createKeyPair(splits[1].decodeBase64()))
79+
"id" -> PoolWithId(splits[1].decodeBase58().toList())
80+
else -> return null
81+
}
82+
}
83+
84+
else -> return null
85+
}
86+
}
4187
}
4288
}
4389

@@ -46,4 +92,17 @@ data class PhantomConnectionResult(
4692
val encryptionPublicKey: List<Byte>,
4793
val nonce: List<Byte>,
4894
val encryptedData: List<Byte>
95+
): Parcelable
96+
97+
@Parcelize
98+
data class PhantomSigningResult(
99+
val nonce: List<Byte>,
100+
val encryptedData: List<Byte>,
101+
): Parcelable
102+
103+
@Serializable
104+
@Parcelize
105+
data class PhantomDeeplinkError(
106+
val errorCode: String,
107+
val errorMessage: String,
49108
): Parcelable

apps/flipcash/shared/transfers/src/main/res/drawable/ic_transfer_deposit.xml renamed to apps/flipcash/core/src/main/res/drawable/ic_transfer_deposit.xml

File renamed without changes.

0 commit comments

Comments
 (0)