Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
29f5520
core: add the shared payment models
gemdev111 Aug 4, 2026
a731e2c
core: split WalletConnect request handling behind a feature
gemdev111 Aug 4, 2026
f15700e
core: add the payment crate with the WalletConnect Pay provider
gemdev111 Aug 4, 2026
4cd599a
core: expose payments through gemstone
gemdev111 Aug 4, 2026
00998b3
Localize the payment link strings
gemdev111 Aug 4, 2026
08992a0
iOS: map the payment models and shared rows
gemdev111 Aug 4, 2026
e72bf90
iOS: add the signing request and payment services
gemdev111 Aug 4, 2026
a9e013d
iOS: confirm a payment approval instead of broadcasting it
gemdev111 Aug 4, 2026
bae0efc
iOS: pay payment links
gemdev111 Aug 4, 2026
45d371d
iOS: move the signing sheets out of WalletConnector
gemdev111 Aug 4, 2026
0783be4
iOS: wire payments into the app
gemdev111 Aug 4, 2026
71a7194
Android: consume the payment models and reconcile payments
gemdev111 Aug 4, 2026
f72393c
Android: pay payment links
gemdev111 Aug 4, 2026
769e06f
Android: approve only what the payment asked for
gemdev111 Aug 5, 2026
1b6764e
core: keep the buyer's asset when a payment quote is requoted
gemdev111 Aug 5, 2026
e197bba
Android: match the iOS payment screens
gemdev111 Aug 5, 2026
0a48c62
core: validate where a payment collects data
gemdev111 Aug 5, 2026
6ef9645
core: format the payment models
gemdev111 Aug 5, 2026
e1fcf67
iOS: let the gateway confirm a payment approval
gemdev111 Aug 5, 2026
f333955
Stop identifying the wallet to the payment gateway
gemdev111 Aug 5, 2026
60c53f0
iOS: name the payment asset failure after its cause
gemdev111 Aug 5, 2026
0170f89
iOS: share one sheet presenter implementation
gemdev111 Aug 5, 2026
49d5171
iOS: share the signing request sheet between rails
gemdev111 Aug 5, 2026
91f65d9
iOS: read the presented sheet through the presenter
gemdev111 Aug 5, 2026
b4097ba
iOS: keep the navigation stack visible in the sheet views
gemdev111 Aug 5, 2026
4e15f8d
Android: reuse the shared wallet account lookup in payments
gemdev111 Aug 5, 2026
d1c08e9
Android: share the expiry countdown as a property item
gemdev111 Aug 5, 2026
f7044f8
Android: let a payment offer to acquire a missing asset
gemdev111 Aug 5, 2026
87c1b7b
iOS: move the sign message screen out of WalletConnector
gemdev111 Aug 5, 2026
4d87dfe
iOS: point the sign message test at its new module
gemdev111 Aug 5, 2026
8e0f594
iOS: move sheet presentation out of the signing service
gemdev111 Aug 5, 2026
a8bb41b
iOS: hold the payment manager by value
gemdev111 Aug 5, 2026
2ad9ad9
iOS: let a dismissed sheet report its own cancellation
gemdev111 Aug 5, 2026
647df01
iOS: format the payment and signing sources
gemdev111 Aug 5, 2026
e81bb56
iOS: approve on the chain the payment quote is priced in
gemdev111 Aug 5, 2026
bc258f7
iOS: keep the payment gateway with the feature services
gemdev111 Aug 5, 2026
675d24b
iOS: declare the dependencies the Gemstone package imports
gemdev111 Aug 5, 2026
2a76ac2
Core: typeshare the sign digest type
gemdev111 Aug 5, 2026
f134225
iOS: dissolve the signing request service
gemdev111 Aug 5, 2026
dbb2e7c
iOS: drop test plan entries for targets that do not exist
gemdev111 Aug 5, 2026
34031ad
core: tighten the payment service surface
gemdev111 Aug 5, 2026
363cf4a
core: reject an approval on a chain the quote is not priced in
gemdev111 Aug 5, 2026
653fee3
iOS: read the payment relay and status rules from core
gemdev111 Aug 5, 2026
6591828
iOS: execute payment actions behind a protocol
gemdev111 Aug 5, 2026
eef55e2
iOS: cover the payment mapping and tighten what the feature exposes
gemdev111 Aug 5, 2026
092b86e
Android: speak payment primitives and wrap the gateway
gemdev111 Aug 5, 2026
66506bd
Android: dispatch payment scene actions through one sealed type
gemdev111 Aug 5, 2026
4ee8ad9
Android: simulate a payment's message before asking to sign it
gemdev111 Aug 5, 2026
bfc07f3
Android: reconcile a payment through the shared transaction update
gemdev111 Aug 5, 2026
d0b000f
Android: name the payment entry a screen
gemdev111 Aug 5, 2026
f62bd6c
Merge remote-tracking branch 'origin/main' into feature/payments
gemdev111 Aug 6, 2026
45f2824
Payments: dev routing guard, cancellable tasks, error handling
gemdev111 Aug 6, 2026
efe49d6
Update PendingNavigationCoordinatorTest.kt
gemdev111 Aug 6, 2026
a93e4d3
Add Payments docs and repo references
gemdev111 Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ If a task spans multiple platforms, read every affected guide. Do not treat ever
Cross-platform subsystem references live in [docs/](docs). Read the relevant one before changing that area:

- [Device and subscriptions](docs/DEVICE_SUBSCRIPTIONS.md) — device registration, subscription sync, and the iOS/Android contract
- [Payments](docs/PAYMENTS.md) — payment link decoding, the action list both apps sign, and provider coverage
- [Swapper](docs/SWAPPER.md) — quote flow, route preloading, and the shared route cache

Core-owned subsystems (keystore, device and wallet authentication, WebSockets, provider coverage) are documented in [core/docs/](core/docs).
Expand Down
2 changes: 2 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ dependencies {
implementation(project(":features:wallet-details:presents"))
implementation(project(":features:bridge:presents"))
implementation(project(":features:bridge:viewmodels"))
implementation(project(":features:payment:presents"))
implementation(project(":features:payment:viewmodels"))
implementation(project(":features:assets:presents"))
implementation(project(":features:assets:viewmodels"))
implementation(project(":features:perpetual:presents"))
Expand Down
18 changes: 18 additions & 0 deletions android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<activity
android:name="com.gemwallet.android.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data android:scheme="https" android:host="pay.walletconnect.com" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import com.gemwallet.android.data.repositories.config.UserConfig
import com.gemwallet.android.ext.toPrimitives
import com.gemwallet.android.ui.navigation.routes.PaymentRoute
import uniffi.gemstone.UrlAction
import uniffi.gemstone.WalletConnectLink
import uniffi.gemstone.urlAction
Expand All @@ -21,6 +24,7 @@ internal sealed interface PendingNavigation {

class PendingNavigationCoordinator @Inject constructor(
private val notificationNavigation: NotificationNavigation,
private val userConfig: UserConfig,
) {

private val _pendingNavigation = MutableStateFlow<PendingNavigation?>(null)
Expand Down Expand Up @@ -56,6 +60,15 @@ class PendingNavigationCoordinator @Inject constructor(
return
}
}
is UrlAction.Payment -> {
val route = if (userConfig.developEnabled()) {
PendingNavigation.Route(PaymentRoute(action.link.provider.toPrimitives(), action.link.id))
} else {
null
}
replace(pendingIntent, route)
return
}
null -> Unit
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import com.gemwallet.android.Constants
import com.gemwallet.android.NodeAuthInterceptor
import com.gemwallet.android.NodeAuthTokenService
import com.gemwallet.android.blockchain.services.PaymentService
import com.gemwallet.android.blockchain.services.ServiceStatusService
import com.gemwallet.android.cases.device.IsDeviceRegistered
import com.gemwallet.android.cases.nodes.GetNodeUrlCase
Expand All @@ -22,12 +23,16 @@ import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import uniffi.gemstone.AlienProvider
import uniffi.gemstone.GemGateway
import uniffi.gemstone.GemPaymentConfig
import uniffi.gemstone.GemPreferences
import uniffi.gemstone.GemServiceStatus
import uniffi.gemstone.GemWalletConnectPayAuth
import uniffi.gemstone.serviceStatusTimeoutSeconds
import uniffi.gemstone.WalletConnectSimulationClient
import uniffi.gemstone.GemPaymentService
import uniffi.gemstone.WalletConnectSimulationClientInterface
import javax.inject.Singleton
import java.util.UUID

@InstallIn(SingletonComponent::class)
@Module
Expand Down Expand Up @@ -105,6 +110,22 @@ object GatewayModule {
return ServiceStatusService(GemServiceStatus(provider))
}

@Provides
@Singleton
fun providePaymentService(
alienProvider: AlienProvider,
): PaymentService = PaymentService(
GemPaymentService(
provider = alienProvider,
config = GemPaymentConfig(
walletConnectPay = GemWalletConnectPayAuth(
appId = Constants.WALLET_CONNECT_PROJECT_ID,
clientId = UUID.randomUUID().toString(),
),
),
),
)

@Provides
@Singleton
fun provideWalletConnectSimulationService(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ import com.gemwallet.android.ui.navigation.routes.settingsRoute
import com.gemwallet.android.ui.navigation.routes.transactionsRoute
import com.gemwallet.android.ui.theme.alpha10
import kotlinx.coroutines.launch
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.gemwallet.android.ext.toPrimitives
import com.gemwallet.android.ui.components.QrCodeScannerModal
import uniffi.gemstone.UrlAction
import uniffi.gemstone.urlAction

@Composable
fun MainScreen(
Expand All @@ -66,6 +72,18 @@ fun MainScreen(
val pendingCount by viewModel.pendingTxCount.collectAsStateWithLifecycle()
val assetsViewModel: AssetsViewModel = hiltViewModel()
val isRootRouteActive = navigator.backStack.lastOrNull() == WalletRootRoute
var isPresentingScanner by remember { mutableStateOf(false) }

QrCodeScannerModal(
isVisible = isPresentingScanner,
onDismissRequest = { isPresentingScanner = false },
onResult = { scanned ->
isPresentingScanner = false
(runCatching { urlAction(scanned) }.getOrNull() as? UrlAction.Payment)?.let {
navigator.openPayment(it.link.toPrimitives())
}
},
)

BackHandler(isRootRouteActive && currentTab.value != assetsRoute) {
currentTab.value = assetsRoute
Expand Down Expand Up @@ -185,6 +203,7 @@ fun MainScreen(
AssetsAction.ShowWallets -> navigator.openWallets()
AssetsAction.Manage -> navigator.openAssetsManage()
AssetsAction.Search -> navigator.openAssetsSearch()
AssetsAction.Scan -> isPresentingScanner = true
AssetsAction.Send -> navigator.openRecipient()
AssetsAction.Receive -> navigator.openReceive()
AssetsAction.Buy -> navigator.openBuy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import com.gemwallet.android.ui.navigation.routes.BridgeConnectionDetailsRoute
import com.gemwallet.android.ui.navigation.routes.BridgeConnectionsRoute
import com.gemwallet.android.ui.navigation.routes.AddContactRoute
import com.gemwallet.android.ui.navigation.routes.ConfirmRoute
import com.gemwallet.android.ui.navigation.routes.PaymentRoute
import com.wallet.core.primitives.PaymentLink
import com.gemwallet.android.ui.navigation.routes.ContactsRoute
import com.gemwallet.android.ui.navigation.routes.CurrenciesRoute
import com.gemwallet.android.ui.navigation.routes.EditContactRoute
Expand Down Expand Up @@ -279,6 +281,7 @@ class WalletNavigator(
val pack = params.pack() ?: return
push(ConfirmRoute(pack))
}
fun openPayment(link: PaymentLink) = push(PaymentRoute(link.provider, link.id))
fun openNftList() = push(NftListRoute)
fun openNftCollection(nftCollectionId: String) = push(NftCollectionRoute(nftCollectionId))
fun openNftUnverifiedCollections() = push(NftUnverifiedCollectionsRoute)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.gemwallet.android.ui.navigation.routes.assetScreen
import com.gemwallet.android.ui.navigation.routes.networkAssetsScreen
import com.gemwallet.android.ui.navigation.routes.bridgesScreen
import com.gemwallet.android.ui.navigation.routes.confirm
import com.gemwallet.android.ui.navigation.routes.payment
import com.gemwallet.android.ui.navigation.routes.fiatScreen
import com.gemwallet.android.ui.navigation.routes.nftCollection
import com.gemwallet.android.ui.navigation.routes.perpetualScreen
Expand Down Expand Up @@ -172,6 +173,11 @@ fun WalletNavGraph(
cancelAction = onCancel,
)

payment(
onAcquireAsset = navigator::openAcquireAsset,
cancelAction = onCancel,
)

nftCollection(
cancelAction = onCancel,
collectionIdAction = navigator::openNftCollection,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.gemwallet.android.ui.navigation.routes

import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.gemwallet.android.features.confirm.presents.AcquireAssetAction
import com.gemwallet.android.features.payment.presents.PaymentScreen
import com.gemwallet.android.ui.models.actions.CancelAction
import com.wallet.core.primitives.AssetId
import com.wallet.core.primitives.PaymentProviderName
import kotlinx.serialization.Serializable

@Serializable
data class PaymentRoute(val provider: PaymentProviderName, val paymentId: String) : NavKey

fun EntryProviderScope<NavKey>.payment(
onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit,
cancelAction: CancelAction,
) {
entry<PaymentRoute> { key ->
PaymentScreen(
provider = key.provider,
paymentId = key.paymentId,
onAcquireAsset = onAcquireAsset,
onCancel = { cancelAction() },
)
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.gemwallet.android

import android.content.Intent
import com.gemwallet.android.data.repositories.config.UserConfig
import com.gemwallet.android.model.PushNotificationField
import com.gemwallet.android.ui.navigation.routes.PaymentRoute
import com.gemwallet.android.ui.navigation.routes.ReferralRoute
import com.wallet.core.primitives.PaymentProviderName
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
Expand All @@ -16,14 +19,17 @@ import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import uniffi.gemstone.Deeplink
import uniffi.gemstone.GemPaymentLink
import uniffi.gemstone.GemPaymentProviderName
import uniffi.gemstone.UrlAction
import uniffi.gemstone.WalletConnectLink
import uniffi.gemstone.urlAction

class PendingNavigationCoordinatorTest {

private val notificationNavigation = mockk<NotificationNavigation>(relaxed = true)
private val coordinator = PendingNavigationCoordinator(notificationNavigation)
private val userConfig = mockk<UserConfig>()
private val coordinator = PendingNavigationCoordinator(notificationNavigation, userConfig)

@Before
fun setUp() = mockkStatic("uniffi.gemstone.GemstoneKt")
Expand Down Expand Up @@ -76,6 +82,26 @@ class PendingNavigationCoordinatorTest {
assertEquals(listOf(ReferralRoute(code = "gemcoder")), routes)
}

@Test
fun resolve_paymentLink_storesRouteOnlyInDeveloperMode() = runTest {
val uri = "https://pay.walletconnect.com/pay_1"
every { urlAction(uri) } returns UrlAction.Payment(GemPaymentLink(GemPaymentProviderName.WALLET_CONNECT_PAY, "pay_1"))
every { userConfig.developEnabled() } returns true
coordinator.setPendingIntentForTest(intent(uri = uri))

coordinator.resolve(NoOpWalletConnect)

val routes = (coordinator.pendingNavigation.value as PendingNavigation.Route).routes
assertEquals(listOf(PaymentRoute(PaymentProviderName.WalletConnectPay, "pay_1")), routes)

every { userConfig.developEnabled() } returns false
coordinator.setPendingIntentForTest(intent(uri = uri))

coordinator.resolve(NoOpWalletConnect)

assertNull("payment links must not navigate outside developer mode", coordinator.pendingNavigation.value)
}

@Test
fun resolve_unknownIntentWithoutNotificationPayload_clears() = runTest {
val uri = "https://example.com/unknown"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal fun ConfirmParams.toScanTransactionPayload(destination: String): ScanTr
assetId = if (this is ConfirmParams.SwapParams) toAsset.id else assetId,
address = destination,
),
website = (this as? ConfirmParams.TransferParams.Generic)?.url,
website = (this as? ConfirmParams.TransferParams.Generic)?.appMetadata?.url,
type = getTransactionType(),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.gemwallet.android.blockchain.services

import com.gemwallet.android.ext.gemChainAddresses
import com.gemwallet.android.ext.toGem
import com.gemwallet.android.ext.toPrimitives
import com.gemwallet.android.model.PreparedPayment
import com.wallet.core.primitives.PaymentLink
import com.wallet.core.primitives.PaymentOptions
import com.wallet.core.primitives.PaymentOutcome
import com.wallet.core.primitives.PaymentProviderName
import com.wallet.core.primitives.PaymentQuote
import com.wallet.core.primitives.PaymentQuotes
import com.wallet.core.primitives.Wallet
import uniffi.gemstone.GemPaymentServiceInterface
import uniffi.gemstone.paymentProviderHasStatus

class PaymentService(
private val client: GemPaymentServiceInterface,
) {

fun hasStatus(provider: PaymentProviderName): Boolean = paymentProviderHasStatus(provider.toGem())

suspend fun getPaymentOptions(link: PaymentLink, wallet: Wallet): PaymentOptions =
client.getPaymentOptions(link.toGem(), wallet.gemChainAddresses()).toPrimitives()

suspend fun getPreparedPayment(
provider: PaymentProviderName,
quotes: PaymentQuotes,
quote: PaymentQuote,
wallet: Wallet,
): PreparedPayment {
val payment = client.getPreparedPayment(
provider.toGem(),
quotes.toGem(),
quote.toGem(),
wallet.gemChainAddresses(),
)
return PreparedPayment(
quotes = payment.quotes.toPrimitives(),
quote = payment.quote.toPrimitives(),
actions = payment.actions,
isRelayed = payment.isRelayed,
)
}

suspend fun confirmPayment(
provider: PaymentProviderName,
quote: PaymentQuote,
actionResults: List<String>,
): PaymentOutcome = client.confirmPayment(provider.toGem(), quote.toGem(), actionResults).toPrimitives()

suspend fun getPaymentStatus(provider: PaymentProviderName, paymentId: String): PaymentOutcome =
client.getPaymentStatus(provider.toGem(), paymentId).toPrimitives()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@ package com.gemwallet.android.blockchain.services
import com.gemwallet.android.blockchain.gemstone.toPrimitives
import com.wallet.core.primitives.SimulationResult
import uniffi.gemstone.SignDigestType
import uniffi.gemstone.SignMessage
import uniffi.gemstone.WalletConnectSimulationClientInterface
import uniffi.gemstone.WalletConnectTransactionType
import uniffi.gemstone.SignableTransactionType

class WalletConnectSimulationService(
private val client: WalletConnectSimulationClientInterface,
) {
suspend fun simulateSignMessage(chain: String, signType: SignDigestType, data: String, sessionDomain: String): SimulationResult =
client.simulateSignMessage(chain = chain, signType = signType, data = data, sessionDomain = sessionDomain).toPrimitives()

suspend fun simulateSendTransaction(chain: String, transactionType: WalletConnectTransactionType, data: String): SimulationResult =
suspend fun simulateSignMessage(message: SignMessage, sessionDomain: String): SimulationResult =
simulateSignMessage(
chain = message.chain,
signType = message.signType,
data = String(message.data, Charsets.UTF_8),
sessionDomain = sessionDomain,
)

suspend fun simulateSendTransaction(chain: String, transactionType: SignableTransactionType, data: String): SimulationResult =
client.simulateSendTransaction(chain = chain, transactionType = transactionType, data = data).toPrimitives()
}
Loading
Loading