diff --git a/AGENTS.md b/AGENTS.md
index 234a84fdd7..05e28df138 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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).
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index a68fde4469..1e39755b31 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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"))
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000000..db5d48d386
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt
index 8416866fab..408bcd298a 100644
--- a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt
+++ b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt
@@ -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
@@ -21,6 +24,7 @@ internal sealed interface PendingNavigation {
class PendingNavigationCoordinator @Inject constructor(
private val notificationNavigation: NotificationNavigation,
+ private val userConfig: UserConfig,
) {
private val _pendingNavigation = MutableStateFlow(null)
@@ -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
}
diff --git a/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt b/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt
index c4da7dfac1..6652e6a16f 100644
--- a/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt
+++ b/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt
@@ -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
@@ -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
@@ -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(
diff --git a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt
index 68b80a23a1..1d4db85ab7 100644
--- a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt
+++ b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt
@@ -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(
@@ -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
@@ -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()
diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt
index 9ee510bcbe..cd8ec740b5 100644
--- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt
+++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt
@@ -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
@@ -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)
diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt
index b7541bab2c..5dc105ca8c 100644
--- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt
+++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt
@@ -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
@@ -172,6 +173,11 @@ fun WalletNavGraph(
cancelAction = onCancel,
)
+ payment(
+ onAcquireAsset = navigator::openAcquireAsset,
+ cancelAction = onCancel,
+ )
+
nftCollection(
cancelAction = onCancel,
collectionIdAction = navigator::openNftCollection,
diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt
new file mode 100644
index 0000000000..c79bfb33c7
--- /dev/null
+++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt
@@ -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.payment(
+ onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit,
+ cancelAction: CancelAction,
+) {
+ entry { key ->
+ PaymentScreen(
+ provider = key.provider,
+ paymentId = key.paymentId,
+ onAcquireAsset = onAcquireAsset,
+ onCancel = { cancelAction() },
+ )
+ }
+}
diff --git a/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt b/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt
index c66b28de55..bacf7e8e8a 100644
--- a/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt
+++ b/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt
@@ -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
@@ -16,6 +19,8 @@ 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
@@ -23,7 +28,8 @@ import uniffi.gemstone.urlAction
class PendingNavigationCoordinatorTest {
private val notificationNavigation = mockk(relaxed = true)
- private val coordinator = PendingNavigationCoordinator(notificationNavigation)
+ private val userConfig = mockk()
+ private val coordinator = PendingNavigationCoordinator(notificationNavigation, userConfig)
@Before
fun setUp() = mockkStatic("uniffi.gemstone.GemstoneKt")
@@ -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"
diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt
index 5d5b9e79fb..396d2820c9 100644
--- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt
+++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt
@@ -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(),
)
diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt
new file mode 100644
index 0000000000..3aaf40ffb2
--- /dev/null
+++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt
@@ -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,
+ ): PaymentOutcome = client.confirmPayment(provider.toGem(), quote.toGem(), actionResults).toPrimitives()
+
+ suspend fun getPaymentStatus(provider: PaymentProviderName, paymentId: String): PaymentOutcome =
+ client.getPaymentStatus(provider.toGem(), paymentId).toPrimitives()
+}
diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationService.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationService.kt
index 4560ecdb5c..5714170494 100644
--- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationService.kt
+++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationService.kt
@@ -3,8 +3,9 @@ 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,
@@ -12,6 +13,14 @@ class WalletConnectSimulationService(
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()
}
diff --git a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt
new file mode 100644
index 0000000000..f46f329bcd
--- /dev/null
+++ b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt
@@ -0,0 +1,83 @@
+package com.gemwallet.android.blockchain.services
+
+import com.gemwallet.android.testkit.mockWallet
+import com.wallet.core.primitives.Chain
+import com.wallet.core.primitives.PaymentLink
+import com.wallet.core.primitives.PaymentOptions
+import com.wallet.core.primitives.PaymentProviderName
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import uniffi.gemstone.GemPaymentAmount
+import uniffi.gemstone.GemPaymentMerchant
+import uniffi.gemstone.GemPaymentOptions
+import uniffi.gemstone.GemPaymentQuote
+import uniffi.gemstone.GemPaymentQuotes
+import uniffi.gemstone.GemPaymentServiceInterface
+import uniffi.gemstone.GemPreparedPayment
+
+private const val EXPIRES_AT_SECONDS = 1_700_000_000L
+
+class PaymentServiceTest {
+
+ private val client = mockk()
+ private val service = PaymentService(client)
+
+ private fun gemQuote(assetId: String = "ethereum_0xtoken") = GemPaymentQuote(
+ id = "option_1",
+ paymentId = "pay_1",
+ amount = GemPaymentAmount(assetId = assetId, value = "10", symbol = "USDT", decimals = 6),
+ expiresAt = EXPIRES_AT_SECONDS,
+ collectDataUrl = null,
+ providerData = "{\"opaque\":true}",
+ )
+
+ private fun gemQuotes() = GemPaymentQuotes(
+ merchant = GemPaymentMerchant(name = "Merchant", iconUrl = null),
+ price = null,
+ expiresAt = EXPIRES_AT_SECONDS,
+ quotes = listOf(gemQuote()),
+ )
+
+ @Test
+ fun getPaymentOptions_readsGatewaySecondsAsMillis() = runBlocking {
+ coEvery { client.getPaymentOptions(any(), any()) } returns GemPaymentOptions.Quotes(gemQuotes())
+
+ val options = service.getPaymentOptions(PaymentLink(PaymentProviderName.WalletConnectPay, "pay_1"), mockWallet())
+
+ val quotes = (options as PaymentOptions.Quotes).content
+ assertEquals(EXPIRES_AT_SECONDS * 1000, quotes.expiresAt)
+ assertEquals(EXPIRES_AT_SECONDS * 1000, quotes.quotes.first().expiresAt)
+ assertEquals(Chain.Ethereum, quotes.quotes.first().amount.assetId.chain)
+ assertEquals("0xtoken", quotes.quotes.first().amount.assetId.tokenId)
+ }
+
+ @Test
+ fun getPreparedPayment_returnsTheQuoteToTheGatewayUnchanged() = runBlocking {
+ coEvery { client.getPaymentOptions(any(), any()) } returns GemPaymentOptions.Quotes(gemQuotes())
+ val options = service.getPaymentOptions(PaymentLink(PaymentProviderName.WalletConnectPay, "pay_1"), mockWallet())
+ val quotes = (options as PaymentOptions.Quotes).content
+
+ val sentQuote = slot()
+ coEvery { client.getPreparedPayment(any(), any(), capture(sentQuote), any()) } returns GemPreparedPayment(
+ quotes = gemQuotes(),
+ quote = gemQuote(),
+ actions = emptyList(),
+ isRelayed = true,
+ )
+
+ val prepared = service.getPreparedPayment(
+ PaymentProviderName.WalletConnectPay,
+ quotes,
+ quotes.quotes.first(),
+ mockWallet(),
+ )
+
+ assertEquals(gemQuote(), sentQuote.captured)
+ assertTrue(prepared.isRelayed)
+ }
+}
diff --git a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt
new file mode 100644
index 0000000000..d1ea814ea1
--- /dev/null
+++ b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt
@@ -0,0 +1,38 @@
+package com.gemwallet.android.blockchain.services
+
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import uniffi.gemstone.SignDigestType
+import uniffi.gemstone.SignMessage
+import uniffi.gemstone.SimulationResult
+import uniffi.gemstone.WalletConnectSimulationClientInterface
+
+private const val TYPED_DATA = """{"domain":{"name":"Permit2"},"message":{"spender":"0xspender"}}"""
+
+class WalletConnectSimulationServiceTest {
+
+ private val client = mockk()
+ private val service = WalletConnectSimulationService(client)
+
+ @Test
+ fun simulateSignMessage_sendsTypedDataAsTextTheSimulatorCanParse() = runBlocking {
+ val sent = slot()
+ coEvery { client.simulateSignMessage(any(), any(), capture(sent), any()) } returns SimulationResult(
+ warnings = emptyList(),
+ balanceChanges = emptyList(),
+ payload = emptyList(),
+ header = null,
+ )
+
+ service.simulateSignMessage(
+ SignMessage(chain = "ethereum", signType = SignDigestType.EIP712, data = TYPED_DATA.toByteArray()),
+ sessionDomain = "https://pay.walletconnect.com",
+ )
+
+ assertEquals(TYPED_DATA, sent.captured)
+ }
+}
diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionDetailsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionDetailsImpl.kt
index eb6d679a1a..31d887f7c7 100644
--- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionDetailsImpl.kt
+++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionDetailsImpl.kt
@@ -50,6 +50,7 @@ import uniffi.gemstone.SwapperProviderMode
import uniffi.gemstone.SwapperProviderType
import uniffi.gemstone.swapperProviderConfig
import uniffi.gemstone.swapperProviderFromStr
+import com.gemwallet.android.ext.getPaymentMetadata
@OptIn(ExperimentalCoroutinesApi::class)
class GetTransactionDetailsImpl(
@@ -259,7 +260,9 @@ class TransactionDetailsAggregateImpl(
TransactionType.Transfer,
TransactionType.TransferNFT -> when (data.transaction.direction) {
TransactionDirection.SelfTransfer,
- TransactionDirection.Outgoing -> TransactionDetailsValue.Destination.Recipient(
+ TransactionDirection.Outgoing -> data.transaction.getPaymentMetadata()?.merchant?.name?.let {
+ TransactionDetailsValue.Destination.Merchant(it)
+ } ?: TransactionDetailsValue.Destination.Recipient(
data = data.transaction.to,
chain = data.asset.chain,
name = data.toAddress?.name,
diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionsImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionsImpl.kt
index 6190cb57de..449a88ce1a 100644
--- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionsImpl.kt
+++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/transaction/GetTransactionsImpl.kt
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.math.BigInteger
+import com.gemwallet.android.ext.getPaymentMetadata
class GetTransactionsImpl(
private val transactionsRepository: TransactionRepository,
@@ -66,7 +67,7 @@ class TransactionDataAggregateImpl(
override val asset: Asset = data.asset
- override val addressName: String? = when (data.transaction.type) {
+ override val addressName: String? = data.transaction.getPaymentMetadata()?.merchant?.name ?: when (data.transaction.type) {
TransactionType.StakeDelegate,
TransactionType.StakeUndelegate,
TransactionType.StakeRedelegate,
diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TransactionsModule.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TransactionsModule.kt
index 51c9a0adb0..87263f5372 100644
--- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TransactionsModule.kt
+++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TransactionsModule.kt
@@ -2,6 +2,7 @@ package com.gemwallet.android.data.repositories.di
import com.gemwallet.android.application.transactions.coordinators.GetChangedTransactions
import com.gemwallet.android.application.transactions.coordinators.GetPendingTransactionsCount
+import com.gemwallet.android.blockchain.services.PaymentService
import com.gemwallet.android.blockchain.services.TransactionStatusService
import com.gemwallet.android.cases.transactions.ClearPendingTransactions
import com.gemwallet.android.cases.transactions.CreateTransaction
@@ -27,12 +28,14 @@ object TransactionsModule {
sessionRepository: SessionRepository,
transactionsDao: TransactionsDao,
gateway: GemGateway,
+ paymentService: PaymentService,
): TransactionsRepositoryImpl = TransactionsRepositoryImpl(
sessionRepository = sessionRepository,
transactionsDao = transactionsDao,
transactionStatusService = TransactionStatusService(
gateway = gateway,
),
+ paymentService = paymentService,
)
@Singleton
diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt
index b0f84c0a30..d75f398c4a 100644
--- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt
+++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt
@@ -6,6 +6,7 @@ import com.gemwallet.android.application.transactions.coordinators.GetChangedTra
import com.gemwallet.android.application.transactions.coordinators.GetPendingTransactionsCount
import com.gemwallet.android.application.transactions.coordinators.TransactionsRequestFilter
import com.gemwallet.android.blockchain.model.ServiceUnavailable
+import com.gemwallet.android.blockchain.services.PaymentService
import com.gemwallet.android.blockchain.services.TransactionStatusService
import com.gemwallet.android.cases.transactions.ClearPendingTransactions
import com.gemwallet.android.cases.transactions.CreateTransaction
@@ -17,6 +18,7 @@ import com.gemwallet.android.data.service.store.database.entities.DbTransactionE
import com.gemwallet.android.data.service.store.database.entities.DbTxSwapMetadata
import com.gemwallet.android.data.service.store.database.entities.toDTO
import com.gemwallet.android.data.service.store.database.entities.toRecord
+import com.gemwallet.android.ext.getTransactionPaymentMetadata
import com.gemwallet.android.ext.getTransactionSwapMetadata
import com.gemwallet.android.ext.isCompleted
import com.gemwallet.android.ext.toIdentifier
@@ -26,9 +28,13 @@ import com.gemwallet.android.model.TransactionExtended
import com.gemwallet.android.serializer.jsonEncoder
import com.wallet.core.primitives.Account
import com.wallet.core.primitives.AssetId
+import com.wallet.core.primitives.PaymentStatus
import com.wallet.core.primitives.Transaction
import com.wallet.core.primitives.TransactionDirection
import com.wallet.core.primitives.TransactionId
+import com.gemwallet.android.model.HashChanges
+import com.gemwallet.android.model.TransactionChanges
+import com.wallet.core.primitives.TransactionPaymentMetadata
import com.wallet.core.primitives.TransactionState
import com.wallet.core.primitives.TransactionStateRequest
import com.wallet.core.primitives.TransactionSwapMetadata
@@ -44,6 +50,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -63,6 +70,7 @@ class TransactionsRepositoryImpl(
private val sessionRepository: SessionRepository,
private val transactionsDao: TransactionsDao,
private val transactionStatusService: TransactionStatusService,
+ private val paymentService: PaymentService,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
) : TransactionRepository,
GetChangedTransactions,
@@ -258,9 +266,36 @@ class TransactionsRepositoryImpl(
return ((sourceTimeout + destinationTimeout) * 3).coerceAtLeast(DateUtils.DAY_IN_MILLIS)
}
+ private suspend fun paymentStateChanges(record: DbTransaction, metadata: TransactionPaymentMetadata): TransactionChanges {
+ val outcome = try {
+ paymentService.getPaymentStatus(metadata.provider, metadata.paymentId)
+ } catch (_: Throwable) {
+ throw ServiceUnavailable
+ }
+ return when (outcome.status) {
+ PaymentStatus.Succeeded -> {
+ val settledHash = outcome.transactionId
+ if (settledHash == null || record.hash != metadata.paymentId) {
+ TransactionChanges(state = TransactionState.Confirmed)
+ } else {
+ TransactionChanges(
+ state = TransactionState.Confirmed,
+ hashChanges = HashChanges(old = record.hash, new = settledHash),
+ )
+ }
+ }
+ PaymentStatus.Failed, PaymentStatus.Expired, PaymentStatus.Cancelled -> TransactionChanges(state = TransactionState.Failed)
+ PaymentStatus.Processing, PaymentStatus.RequiresAction -> TransactionChanges(state = record.state)
+ }
+ }
+
private suspend fun checkTransaction(transaction: DbTransactionExtended): DbTransactionExtended? {
val transactionRecord = transaction.transaction
val chain = transactionRecord.assetId.chain
+ val paymentMetadata = getTransactionPaymentMetadata(transactionRecord.type, transactionRecord.metadata)
+ if (paymentMetadata != null && !paymentService.hasStatus(paymentMetadata.provider)) {
+ return null
+ }
val swapMetadata = getTransactionSwapMetadata(transactionRecord.type, transactionRecord.metadata)
val swapProvider = swapMetadata?.provider?.toSwapProvider()
if (transactionRecord.type == TransactionType.Swap && transactionRecord.state == TransactionState.InTransit && swapProvider == null) {
@@ -273,7 +308,9 @@ class TransactionsRepositoryImpl(
blockNumber = transactionRecord.blockNumber.toLongOrNull() ?: 0L,
)
val stateChanges = try {
- if (swapMetadata != null && swapProvider != null) {
+ if (paymentMetadata != null) {
+ paymentStateChanges(transactionRecord, paymentMetadata)
+ } else if (swapMetadata != null && swapProvider != null) {
transactionStatusService.getSwapStatus(
chain,
TransactionSwapStateRequest(
diff --git a/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt
index b3cf24af8e..cb72175757 100644
--- a/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt
+++ b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt
@@ -3,6 +3,7 @@ package com.gemwallet.android.data.service.walletconnect.reown
import android.app.Application
import android.content.Context
import android.util.Log
+import com.gemwallet.android.Constants
import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthObject
import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthPayloadParams
import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthenticationRequest
@@ -50,7 +51,7 @@ class ReownWalletConnectClient @Inject constructor(
override fun initialize(onSuccess: () -> Unit, onError: (String) -> Unit) {
CoreClient.initialize(
application = context as Application,
- projectId = PROJECT_ID,
+ projectId = Constants.WALLET_CONNECT_PROJECT_ID,
metaData = Core.Model.AppMetaData(
name = "Gem Wallet",
description = "Gem Web3 Wallet",
@@ -286,7 +287,6 @@ class ReownWalletConnectClient @Inject constructor(
private companion object {
const val TAG = "WalletConnect"
- const val PROJECT_ID = "3bc07cd7179d11ea65335fb9377702b6"
}
}
diff --git a/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt b/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt
index c10c4ebf9d..86e7510bfd 100644
--- a/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt
+++ b/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt
@@ -35,5 +35,10 @@ fun DestinationPropertyItem(property: TransactionDetailsValue.Destination, listP
data = { PropertyDataText(text = property.data) },
listPosition = listPosition,
)
+ is TransactionDetailsValue.Destination.Merchant -> PropertyItem(
+ title = { PropertyTitleText(R.string.transaction_recipient) },
+ data = { PropertyDataText(text = property.data) },
+ listPosition = listPosition,
+ )
}
}
diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt
index 8f10b90b2e..0ffe139c56 100644
--- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt
+++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt
@@ -7,6 +7,7 @@ sealed interface AssetsAction {
data object ShowWallets : AssetsAction
data object Manage : AssetsAction
data object Search : AssetsAction
+ data object Scan : AssetsAction
data object Send : AssetsAction
data object Receive : AssetsAction
data object Buy : AssetsAction
diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt
index d7211afa37..6e302997a6 100644
--- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt
+++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt
@@ -110,7 +110,14 @@ fun AssetsScreen(
Scaffold(
modifier = Modifier.fillMaxSize(),
- topBar = { AssetsTopBar(walletSummary, { onAction(AssetsAction.ShowWallets) }, { onAction(AssetsAction.Search) }) },
+ topBar = {
+ AssetsTopBar(
+ walletSummary,
+ { onAction(AssetsAction.ShowWallets) },
+ { onAction(AssetsAction.Search) },
+ { onAction(AssetsAction.Scan) }.takeIf { viewModel.showScanner },
+ )
+ },
snackbarHost = { SnackbarHost(snackbar) },
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = MaterialTheme.colorScheme.surface,
diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt
index 5b443caf3d..f6b4e5e3a1 100644
--- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt
+++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt
@@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.gemwallet.android.domains.wallet.aggregates.WalletSummaryAggregate
import com.gemwallet.android.ui.R
@@ -31,6 +32,7 @@ internal fun AssetsTopBar(
walletSummary: WalletSummaryAggregate?,
onShowWallets: () -> Unit,
onSearch: () -> Unit,
+ onScan: (() -> Unit)?,
) {
val walletIcon = walletImageModel(LocalContext.current, walletSummary?.walletIcon?.imageUrl)
?: walletSummary?.walletIcon?.placeholder
@@ -62,6 +64,20 @@ internal fun AssetsTopBar(
}
}
},
+ navigationIcon = {
+ if (onScan != null) {
+ IconButton(
+ onClick = onScan,
+ Modifier.testTag("assetsScanAction")
+ ) {
+ Icon(
+ imageVector = AppIcons.QrCodeScanner,
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = stringResource(R.string.wallet_scan_qr_code),
+ )
+ }
+ }
+ },
actions = {
IconButton(
onClick = onSearch,
diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt
index fa664b379c..eb401608e3 100644
--- a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt
+++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt
@@ -18,6 +18,7 @@ import com.gemwallet.android.ui.models.AssetToast
import com.gemwallet.android.ui.models.AssetToastEmitter
import com.gemwallet.android.ui.models.AssetToastEmitterImpl
import com.gemwallet.android.ext.isNftSupported
+import com.gemwallet.android.data.repositories.config.UserConfig
import com.wallet.core.primitives.AssetId
import com.wallet.core.primitives.Wallet
import com.wallet.core.primitives.WalletType
@@ -44,8 +45,11 @@ class AssetsViewModel @Inject constructor(
getHideBalancesState: GetHideBalancesState,
getShowWelcomeBanner: GetShowWelcomeBanner,
getSession: GetSession,
+ private val userConfig: UserConfig,
) : ViewModel(), AssetToastEmitter by AssetToastEmitterImpl() {
+ val showScanner: Boolean get() = userConfig.developEnabled()
+
val currentWalletId = getSession()
.map { it?.wallet?.id }
.distinctUntilChanged()
diff --git a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt
index ab0496f2d5..fe46a4b622 100644
--- a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt
+++ b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt
@@ -22,6 +22,9 @@ import com.gemwallet.android.model.AuthRequest
import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthenticationRequest
import com.gemwallet.android.data.repositories.bridge.WalletConnectVerifyContext
import com.gemwallet.android.ui.R
+import com.gemwallet.android.ui.components.message.SignMessageFullMessageSheet
+import com.gemwallet.android.ui.components.message.SignMessagePayloadDetailsSheet
+import com.gemwallet.android.ui.components.message.signMessageText
import com.gemwallet.android.ui.components.buttons.MainActionButton
import com.gemwallet.android.ui.models.ButtonState
import com.gemwallet.android.ui.components.list_head.CenteredListHead
@@ -163,14 +166,14 @@ private fun AuthRequestContent(
onDetailsClick = { sheetType = AuthRequestSheetType.Details },
)
} else {
- walletConnectTextMessage(state.approval.message)
+ signMessageText(state.approval.message)
}
}
}
when (sheetType) {
AuthRequestSheetType.Details -> {
- WalletConnectPayloadDetailsSheet(
+ SignMessagePayloadDetailsSheet(
primaryFields = state.approval.primaryPayloadFields,
secondaryFields = state.approval.secondaryPayloadFields,
onViewFullMessage = { sheetType = AuthRequestSheetType.FullMessage },
@@ -178,7 +181,7 @@ private fun AuthRequestContent(
)
}
AuthRequestSheetType.FullMessage -> {
- WalletConnectFullMessageSheet(
+ SignMessageFullMessageSheet(
message = state.approval.message,
onDismissRequest = { sheetType = null },
)
diff --git a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt
index b7fbd4fec7..c7addb809e 100644
--- a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt
+++ b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt
@@ -32,6 +32,10 @@ import com.gemwallet.android.ui.components.list_item.property.PropertyItem
import com.gemwallet.android.ui.components.list_item.property.PropertyNetworkItem
import com.gemwallet.android.ui.components.screen.LoadingScene
import com.gemwallet.android.ui.components.screen.Scene
+import com.gemwallet.android.ui.components.message.SignMessageFullMessageSheet
+import com.gemwallet.android.ui.components.message.SignMessageSheetType
+import com.gemwallet.android.ui.components.message.SignMessagePayloadDetailsSheet
+import com.gemwallet.android.ui.components.message.signMessageText
import com.gemwallet.android.ui.components.simulation.simulationPayloadFieldsContent
import com.gemwallet.android.ui.components.simulation.simulationWarningsContent
import com.gemwallet.android.ui.models.ListPosition
@@ -155,13 +159,13 @@ private fun SignMessageScene(
}
if (!request.hasPayload) {
- walletConnectTextMessage(request.plainMessage)
+ signMessageText(request.plainMessage)
}
}
when (sheetType) {
SignMessageSheetType.Details -> {
- WalletConnectPayloadDetailsSheet(
+ SignMessagePayloadDetailsSheet(
primaryFields = request.primaryPayloadFields,
secondaryFields = request.secondaryPayloadFields,
onViewFullMessage = { sheetType = SignMessageSheetType.FullMessage },
@@ -170,7 +174,7 @@ private fun SignMessageScene(
}
SignMessageSheetType.FullMessage -> {
- WalletConnectFullMessageSheet(
+ SignMessageFullMessageSheet(
message = request.plainMessage,
onDismissRequest = { sheetType = null },
)
@@ -181,7 +185,3 @@ private fun SignMessageScene(
}
}
-private enum class SignMessageSheetType {
- Details,
- FullMessage,
-}
diff --git a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt
index ba82566c75..1baee2d114 100644
--- a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt
+++ b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt
@@ -29,70 +29,6 @@ import com.gemwallet.android.ui.theme.paddingDefault
import com.wallet.core.primitives.Wallet
import com.wallet.core.primitives.WalletId
-internal fun LazyListScope.walletConnectTextMessage(message: String) {
- item {
- SubheaderItem(R.string.sign_message_message)
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .listItem()
- .padding(paddingDefault),
- text = message,
- )
- }
-}
-
-@Composable
-internal fun WalletConnectPayloadDetailsSheet(
- primaryFields: List,
- secondaryFields: List,
- onViewFullMessage: () -> Unit,
- onDismissRequest: () -> Unit,
-) {
- ModalBottomSheet(
- onDismissRequest = onDismissRequest,
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
- title = stringResource(R.string.common_details),
- ) {
- LazyColumn {
- simulationPayloadDetailsContent(
- primaryFields = primaryFields,
- secondaryFields = secondaryFields,
- )
- item {
- PropertyItem(
- action = R.string.sign_message_view_full_message,
- listPosition = ListPosition.Single,
- onClick = onViewFullMessage,
- )
- }
- }
- }
-}
-
-@Composable
-internal fun WalletConnectFullMessageSheet(
- message: String,
- onDismissRequest: () -> Unit,
-) {
- ModalBottomSheet(
- onDismissRequest = onDismissRequest,
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
- title = stringResource(R.string.sign_message_view_full_message),
- ) {
- LazyColumn(
- contentPadding = PaddingValues(paddingDefault),
- ) {
- item {
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = message,
- )
- }
- }
- }
-}
-
@Composable
internal fun WalletSelectionSheet(
isVisible: Boolean,
diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt
index a2833f4db9..515f9b351a 100644
--- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt
+++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt
@@ -4,6 +4,7 @@ import com.gemwallet.android.ext.asset
import com.gemwallet.android.ext.getShortUrl
import com.gemwallet.android.ext.shortName
import com.gemwallet.android.ext.toPrimitives
+import com.gemwallet.android.ext.toConfirmParams
import com.gemwallet.android.math.hexToBigInteger
import com.gemwallet.android.model.ConfirmParams
import com.gemwallet.android.model.ConfirmParams.TransferParams.Generic
@@ -19,12 +20,13 @@ import com.gemwallet.android.blockchain.services.GemSignMessageOperator
import com.gemwallet.android.blockchain.gemstone.toGem
import com.gemwallet.android.blockchain.gemstone.toPrimitives
import com.wallet.core.primitives.SimulationResult
+import com.wallet.core.primitives.TransactionAppMetadata
import uniffi.gemstone.TransferDataOutputType
import uniffi.gemstone.WalletConnect
import uniffi.gemstone.WalletConnectAction
import uniffi.gemstone.WalletConnectResponseType
-import uniffi.gemstone.WalletConnectTransaction
-import uniffi.gemstone.WalletConnectTransactionType
+import uniffi.gemstone.SignableTransaction
+import uniffi.gemstone.SignableTransactionType
import java.math.BigInteger
sealed class WCRequest(
@@ -103,7 +105,7 @@ sealed class WCRequest(
appMetadata: WalletConnectionSessionAppMetadata,
val isSendable: Boolean,
val inputType: ConfirmParams.TransferParams.InputType,
- val transactionType: WalletConnectTransactionType,
+ val transactionType: SignableTransactionType,
val data: String,
val simulation: SimulationResult,
) : WCRequest(sessionRequest, account, appMetadata) {
@@ -117,7 +119,7 @@ sealed class WCRequest(
sessionRequest: WalletConnectSessionRequest,
account: Account,
appMetadata: WalletConnectionSessionAppMetadata,
- transactionType: WalletConnectTransactionType,
+ transactionType: SignableTransactionType,
data: String,
simulation: SimulationResult,
) : Transaction(
@@ -154,7 +156,7 @@ sealed class WCRequest(
sessionRequest: WalletConnectSessionRequest,
account: Account,
appMetadata: WalletConnectionSessionAppMetadata,
- transactionType: WalletConnectTransactionType,
+ transactionType: SignableTransactionType,
data: String,
simulation: SimulationResult,
) : Signing(
@@ -198,99 +200,18 @@ internal fun WalletConnectResponseType.payload(): String = when (this) {
is WalletConnectResponseType.String -> value
}
-private fun WalletConnectTransaction.map(
+private fun SignableTransaction.map(
request: WCRequest.Transaction,
isSendable: Boolean,
-): Generic {
- val asset = request.chain.asset()
- return when (this) {
- is WalletConnectTransaction.Ethereum -> Generic(
- requestId = request.requestId.toString(),
- asset = asset,
- from = request.account,
- memo = data.data,
- name = request.name,
- description = request.description,
- url = request.url,
- icon = request.icon,
- gasLimit = data.gasLimit,
- inputType = request.inputType,
- destination = DestinationAddress(data.to),
- amount = data.value?.hexToBigInteger() ?: BigInteger.ZERO,
- isSendable = isSendable,
- decodedTransactionType = transactionType.toPrimitives(),
- )
- is WalletConnectTransaction.Solana -> Generic(
- requestId = request.requestId.toString(),
- asset = asset,
- from = request.account,
- memo = data.transaction,
- name = request.name,
- description = request.description,
- url = request.url,
- icon = request.icon,
- gasLimit = "",
- inputType = when (outputType) {
- TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction
- TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature
- },
- destination = DestinationAddress(""),
- amount = BigInteger.ZERO,
- isSendable = isSendable,
- )
- is WalletConnectTransaction.Sui -> Generic(
- requestId = request.requestId.toString(),
- asset = asset,
- from = request.account,
- memo = data.transaction,
- name = request.name,
- description = request.description,
- url = request.url,
- icon = request.icon,
- gasLimit = "",
- inputType = when (outputType) {
- TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction
- TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature
- },
- destination = DestinationAddress(""),
- amount = BigInteger.ZERO,
- isSendable = isSendable,
- )
- is WalletConnectTransaction.Ton -> Generic(
- requestId = request.requestId.toString(),
- asset = asset,
- from = request.account,
- memo = data,
- name = request.name,
- description = request.description,
- url = request.url,
- icon = request.icon,
- gasLimit = "",
- inputType = when (outputType) {
- TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction
- TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature
- },
- destination = DestinationAddress(""),
- amount = BigInteger.ZERO,
- isSendable = isSendable,
- )
- is WalletConnectTransaction.Tron -> Generic(
- requestId = request.requestId.toString(),
- asset = asset,
- memo = data,
- from = request.account,
- name = request.name,
- description = request.description,
- url = request.url,
- icon = request.icon,
- gasLimit = "",
- inputType = when (outputType) {
- TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction
- TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature
- },
- destination = DestinationAddress(""),
- amount = BigInteger.ZERO,
- isSendable = isSendable,
- )
- }
-}
+): Generic = toConfirmParams(
+ requestId = request.requestId.toString(),
+ account = request.account,
+ appMetadata = TransactionAppMetadata(
+ name = request.name,
+ description = request.description,
+ url = request.url,
+ icon = request.icon,
+ ),
+ isSendable = isSendable,
+ inputType = request.inputType,
+)
diff --git a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt
index d98b67076e..5305260610 100644
--- a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt
+++ b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt
@@ -102,7 +102,8 @@ fun ConfirmScreen(
val detailElements by viewModel.detailElements.collectAsStateWithLifecycle()
val payloadAddressNames by viewModel.payloadAddressNames.collectAsStateWithLifecycle()
val buttonState by viewModel.buttonState.collectAsStateWithLifecycle()
- val isWalletConnect = params is ConfirmParams.TransferParams.Generic
+ val isPayment = (params as? ConfirmParams.TransferParams.Generic)?.payment != null
+ val isWalletConnect = params is ConfirmParams.TransferParams.Generic && !isPayment
val displayTxProperties = if (isWalletConnect) txProperties.reorderWalletConnectProperties() else txProperties
var showSelectTxSpeed by remember { mutableStateOf(false) }
@@ -129,7 +130,7 @@ fun ConfirmScreen(
val perpetualType by viewModel.perpetualType.collectAsStateWithLifecycle()
Scene(
- title = confirmTitle(isWalletConnect, amountModel?.transactionType, perpetualType),
+ title = confirmTitle(params, amountModel?.transactionType, perpetualType),
closeIcon = isWalletConnect,
onClose = { cancelAction() },
mainAction = {
@@ -403,11 +404,12 @@ fun ConfirmError.toLabel() = when (this) {
@Composable
private fun confirmTitle(
- isWalletConnect: Boolean,
+ params: ConfirmParams?,
transactionType: TransactionType?,
perpetualType: PerpetualType?,
): String = when {
- isWalletConnect -> stringResource(R.string.transfer_review_request)
+ params is ConfirmParams.TransferParams.Generic && params.payment != null -> stringResource(R.string.transfer_payment_title)
+ params is ConfirmParams.TransferParams.Generic -> stringResource(R.string.transfer_review_request)
perpetualType != null -> perpetualType.title()
else -> stringResource(transactionType?.getTitle() ?: R.string.transfer_title)
}
diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt
index f33a591b54..598a9b675c 100644
--- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt
+++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt
@@ -1,12 +1,15 @@
package com.gemwallet.android.features.confirm.viewmodels
+import android.util.Log
import com.gemwallet.android.domains.confirm.ConfirmError
import com.gemwallet.android.ext.toGemNetworkError
internal fun Throwable.toPreloadConfirmError(): ConfirmError =
toGemNetworkError()
?.let { ConfirmError.NetworkError(it) }
- ?: ConfirmError.PreloadError
+ ?: ConfirmError.PreloadError.also { Log.e(TAG, "Preload failed", this) }
+
+private const val TAG = "ConfirmPreload"
internal fun Throwable.toBroadcastConfirmError(): ConfirmError = when (this) {
is ConfirmError -> this
diff --git a/android/features/payment/presents/build.gradle.kts b/android/features/payment/presents/build.gradle.kts
new file mode 100644
index 0000000000..ec6f0e5e43
--- /dev/null
+++ b/android/features/payment/presents/build.gradle.kts
@@ -0,0 +1,70 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.compiler)
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "com.gemwallet.android.features.payment.presents"
+ compileSdk = 37
+
+ defaultConfig {
+ minSdk = 28
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn")
+ }
+ }
+ buildFeatures {
+ compose = true
+ }
+ packaging {
+ resources {
+ excludes += "META-INF/*"
+ excludes += "META-INF/DEPENDENCIES"
+ excludes += "/META-INF/LICENSE-notice.md"
+ excludes += "/META-INF/LICENSE.md"
+ excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
+ }
+ }
+}
+
+dependencies {
+ implementation(project(":ui"))
+ implementation(project(":gemcore"))
+ implementation(project(":features:payment:viewmodels"))
+ implementation(project(":features:confirm:presents"))
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.lifecycle.viewmodel.compose)
+
+ debugImplementation(libs.androidx.ui.tooling)
+ implementation(libs.androidx.ui.tooling.preview)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/android/features/payment/presents/consumer-rules.pro b/android/features/payment/presents/consumer-rules.pro
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/android/features/payment/presents/proguard-rules.pro b/android/features/payment/presents/proguard-rules.pro
new file mode 100644
index 0000000000..481bb43481
--- /dev/null
+++ b/android/features/payment/presents/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt
new file mode 100644
index 0000000000..88f3254551
--- /dev/null
+++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt
@@ -0,0 +1,160 @@
+package com.gemwallet.android.features.payment.presents
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.graphics.Bitmap
+import android.util.Log
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.webkit.ConsoleMessage
+import android.webkit.CookieManager
+import android.webkit.JavascriptInterface
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import com.gemwallet.android.ui.R
+import com.gemwallet.android.ui.components.screen.Scene
+import com.gemwallet.android.ui.open
+import org.json.JSONObject
+import uniffi.gemstone.paymentWalletConnectHost
+
+private const val MESSAGE_HANDLER = "payDataCollectionComplete"
+private const val MESSAGE_TYPE_KEY = "type"
+private const val MESSAGE_ERROR_KEY = "error"
+private const val COMPLETE = "IC_COMPLETE"
+private const val ERROR = "IC_ERROR"
+private const val TAG = "PaymentDataCollection"
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+internal fun PaymentDataCollectionScene(
+ url: String,
+ onAction: (PaymentSceneAction) -> Unit,
+) {
+ var webView by remember { mutableStateOf(null) }
+ val uriHandler = LocalUriHandler.current
+
+ BackHandler {
+ val view = webView
+ if (view != null && view.canGoBack()) view.goBack() else onAction(PaymentSceneAction.Cancel)
+ }
+
+ Scene(
+ title = stringResource(R.string.transfer_payment_title),
+ onClose = { onAction(PaymentSceneAction.Cancel) },
+ ) {
+ AndroidView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ factory = { context ->
+ if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ WebView.setWebContentsDebuggingEnabled(true)
+ }
+ WebView(context).apply {
+ layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.useWideViewPort = true
+ settings.loadWithOverviewMode = true
+ CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
+ webViewClient = AllowedHostWebViewClient(context, uriHandler)
+ webChromeClient = LoggingWebChromeClient()
+ addJavascriptInterface(CollectDataBridge(onAction), MESSAGE_HANDLER)
+ loadUrl(url)
+ webView = this
+ }
+ },
+ )
+ }
+}
+
+private class CollectDataBridge(
+ private val onAction: (PaymentSceneAction) -> Unit,
+) {
+ @JavascriptInterface
+ fun postMessage(payload: String) {
+ val message = runCatching { JSONObject(payload) }.getOrNull() ?: return
+ when (message.optString(MESSAGE_TYPE_KEY)) {
+ COMPLETE -> onAction(PaymentSceneAction.DataCollected)
+ ERROR -> onAction(PaymentSceneAction.DataCollectionFailed(message.optString(MESSAGE_ERROR_KEY).takeIf { it.isNotEmpty() }))
+ }
+ }
+}
+
+private class AllowedHostWebViewClient(
+ private val context: Context,
+ private val uriHandler: UriHandler,
+) : WebViewClient() {
+ private val allowedHost = paymentWalletConnectHost()
+
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+ val uri = request?.url ?: return true
+ if (uri.scheme != "https" && uri.scheme != "http") return true
+ val host = uri.host?.lowercase() ?: return true
+ if (uri.scheme == "https" && (host == allowedHost || host.endsWith(".$allowedHost"))) return false
+ uriHandler.open(context, uri.toString())
+ return true
+ }
+
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ view?.evaluateJavascript(BRIDGE_SHIM, null)
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ view?.evaluateJavascript(BRIDGE_SHIM, null)
+ }
+
+ override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
+ if (request?.isForMainFrame != true) return
+ Log.e(TAG, "Load ${request.url}: ${error?.errorCode} ${error?.description}")
+ }
+
+ override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, response: WebResourceResponse?) {
+ if (request?.isForMainFrame != true) return
+ Log.e(TAG, "Load ${request.url}: HTTP ${response?.statusCode}")
+ }
+}
+
+private class LoggingWebChromeClient : WebChromeClient() {
+ override fun onConsoleMessage(message: ConsoleMessage?): Boolean {
+ if (message?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
+ Log.e(TAG, "Console ${message.sourceId()}:${message.lineNumber()} ${message.message()}")
+ }
+ return false
+ }
+}
+
+private val BRIDGE_SHIM = """
+(function() {
+ if (window.__gemPayBridge) { return; }
+ window.__gemPayBridge = true;
+ var android = $MESSAGE_HANDLER;
+ var post = function(message) {
+ if (message === null || message === undefined) { return; }
+ try {
+ android.postMessage(typeof message === 'string' ? message : JSON.stringify(message));
+ } catch (error) {}
+ };
+ window.webkit = window.webkit || {};
+ window.webkit.messageHandlers = window.webkit.messageHandlers || {};
+ window.webkit.messageHandlers.$MESSAGE_HANDLER = { postMessage: post };
+ window.addEventListener('message', function(event) { post(event.data); });
+})();
+""".trimIndent()
diff --git a/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt
new file mode 100644
index 0000000000..54fb31d492
--- /dev/null
+++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt
@@ -0,0 +1,11 @@
+package com.gemwallet.android.features.payment.presents
+
+internal sealed interface PaymentSceneAction {
+ data class SelectQuote(val quoteId: String) : PaymentSceneAction
+ data object ConfirmQuote : PaymentSceneAction
+ data object DataCollected : PaymentSceneAction
+ data class DataCollectionFailed(val message: String?) : PaymentSceneAction
+ data object Sign : PaymentSceneAction
+ data class ActionResult(val result: String) : PaymentSceneAction
+ data object Cancel : PaymentSceneAction
+}
diff --git a/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScreen.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScreen.kt
new file mode 100644
index 0000000000..19c53e866c
--- /dev/null
+++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScreen.kt
@@ -0,0 +1,385 @@
+package com.gemwallet.android.features.payment.presents
+
+import android.widget.Toast
+import android.widget.Toast.makeText
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.gemwallet.android.features.confirm.presents.AcquireAssetAction
+import com.gemwallet.android.features.confirm.presents.ConfirmScreen
+import com.gemwallet.android.features.payment.viewmodels.PaymentLinkError
+import com.gemwallet.android.features.payment.viewmodels.PaymentSceneState
+import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIModel
+import com.gemwallet.android.features.payment.viewmodels.PaymentViewModel
+import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel
+import com.gemwallet.android.model.AuthRequest
+import com.wallet.core.primitives.AssetId
+import com.wallet.core.primitives.PaymentProviderName
+import com.gemwallet.android.ui.R
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import com.gemwallet.android.ui.components.buttons.MainActionButton
+import com.gemwallet.android.ui.components.image.IconWithBadge
+import com.gemwallet.android.ui.components.list_head.CenteredListHead
+import com.gemwallet.android.ui.components.list_head.CenteredListHeadSubtitleLayout
+import com.gemwallet.android.ui.components.list_item.ListItem
+import com.gemwallet.android.ui.components.list_item.getBalanceInfo
+import com.gemwallet.android.ui.components.list_item.ListItemSupportText
+import com.gemwallet.android.ui.components.list_item.ListItemTitleText
+import com.gemwallet.android.ui.components.list_item.SelectionCheckmark
+import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron
+import com.gemwallet.android.ui.components.list_item.property.PropertyDataText
+import com.gemwallet.android.ui.components.list_item.property.PropertyExpiryItem
+import com.gemwallet.android.ui.components.list_item.property.PropertyItem
+import com.gemwallet.android.ui.components.list_item.property.PropertyNetworkItem
+import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText
+import com.gemwallet.android.ui.components.list_item.walletItemIconModel
+import com.gemwallet.android.ui.components.message.SignMessageFullMessageSheet
+import com.gemwallet.android.ui.components.message.SignMessageSheetType
+import com.gemwallet.android.ui.components.message.SignMessagePayloadDetailsSheet
+import com.gemwallet.android.ui.components.message.signMessageText
+import com.gemwallet.android.ui.components.screen.LoadingScene
+import com.gemwallet.android.ui.components.screen.ModalBottomSheet
+import com.gemwallet.android.ui.components.screen.Scene
+import com.gemwallet.android.ui.components.simulation.simulationPayloadFieldsContent
+import com.gemwallet.android.ui.components.simulation.simulationWarningsContent
+import com.gemwallet.android.ui.models.ButtonState
+import com.gemwallet.android.ui.models.buttonState
+import com.gemwallet.android.ui.models.hasCriticalWarning
+import com.gemwallet.android.ui.models.ListPosition
+import com.gemwallet.android.ui.requestAuth
+import com.gemwallet.android.ui.theme.paddingDefault
+import com.gemwallet.android.ui.theme.paddingSmall
+import uniffi.gemstone.PaymentException
+
+@Composable
+fun PaymentScreen(
+ provider: PaymentProviderName,
+ paymentId: String,
+ onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit,
+ onCancel: () -> Unit,
+ viewModel: PaymentViewModel = hiltViewModel(),
+) {
+ val state by viewModel.sceneState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(paymentId) { viewModel.onPayment(provider, paymentId) }
+
+ val onAction: (PaymentSceneAction) -> Unit = { action ->
+ when (action) {
+ is PaymentSceneAction.SelectQuote -> viewModel.onSelectQuote(action.quoteId)
+ PaymentSceneAction.ConfirmQuote -> viewModel.onConfirmQuote()
+ PaymentSceneAction.DataCollected -> viewModel.onDataCollected()
+ is PaymentSceneAction.DataCollectionFailed -> viewModel.onDataCollectionError(action.message)
+ PaymentSceneAction.Sign -> viewModel.onSign()
+ is PaymentSceneAction.ActionResult -> viewModel.onActionResult(action.result)
+ PaymentSceneAction.Cancel -> onCancel()
+ }
+ }
+
+ when (val sceneState = state) {
+ PaymentSceneState.Loading -> LoadingScene(
+ title = stringResource(R.string.transfer_payment_title),
+ onCancel = { onAction(PaymentSceneAction.Cancel) },
+ )
+ is PaymentSceneState.Quotes -> PaymentQuotesScene(
+ state = sceneState,
+ onAction = onAction,
+ )
+ is PaymentSceneState.CollectData -> PaymentDataCollectionScene(
+ url = sceneState.url,
+ onAction = onAction,
+ )
+ is PaymentSceneState.Approve -> ConfirmScreen(
+ params = sceneState.params,
+ finishAction = { hash -> onAction(PaymentSceneAction.ActionResult(hash)) },
+ cancelAction = { onAction(PaymentSceneAction.Cancel) },
+ onAcquireAsset = onAcquireAsset,
+ )
+ is PaymentSceneState.Confirm -> ConfirmScreen(
+ params = sceneState.params,
+ finishAction = { hash -> onAction(PaymentSceneAction.ActionResult(hash)) },
+ cancelAction = { onAction(PaymentSceneAction.Cancel) },
+ onAcquireAsset = onAcquireAsset,
+ )
+ is PaymentSceneState.SignMessage -> PaymentSignMessageScene(
+ state = sceneState,
+ onAction = onAction,
+ )
+ is PaymentSceneState.Outcome -> PaymentToastEffect(sceneState.outcome.messageRes()) { onAction(PaymentSceneAction.Cancel) }
+ is PaymentSceneState.Error -> PaymentToastEffect(sceneState.error.messageRes()) { onAction(PaymentSceneAction.Cancel) }
+ }
+}
+
+@Composable
+private fun PaymentQuotesScene(
+ state: PaymentSceneState.Quotes,
+ onAction: (PaymentSceneAction) -> Unit,
+) {
+ var isSelectingQuote by remember { mutableStateOf(false) }
+
+ Scene(
+ title = stringResource(R.string.transfer_payment_title),
+ backHandle = true,
+ onClose = { onAction(PaymentSceneAction.Cancel) },
+ mainAction = {
+ MainActionButton(
+ title = stringResource(R.string.common_continue),
+ state = if (state.expired || state.selected == null) ButtonState.Disabled else ButtonState.Enabled,
+ onClick = { onAction(PaymentSceneAction.ConfirmQuote) },
+ )
+ },
+ ) {
+ LazyColumn {
+ item {
+ CenteredListHead(
+ icon = state.merchant.iconUrl,
+ title = state.price ?: state.selectedQuote?.amountText.orEmpty(),
+ placeholderText = state.merchant.name.firstOrNull()?.uppercaseChar()?.toString(),
+ )
+ }
+ item {
+ PropertyItem(
+ title = { PropertyTitleText(R.string.transfer_merchant) },
+ data = {
+ PropertyDataText(
+ text = state.merchant.name,
+ badge = state.merchant.iconUrl?.let {
+ { DataBadgeChevron(icon = it, isShowChevron = false) }
+ },
+ )
+ },
+ listPosition = ListPosition.First,
+ )
+ }
+ item {
+ PropertyItem(
+ title = { PropertyTitleText(R.string.common_wallet) },
+ data = {
+ val walletIcon = walletItemIconModel(state.walletType, state.walletChain)
+ PropertyDataText(
+ text = state.walletName,
+ badge = walletIcon?.let { { DataBadgeChevron(icon = it, isShowChevron = false) } },
+ )
+ },
+ listPosition = if (state.expiresAt == null) ListPosition.Last else ListPosition.Middle,
+ )
+ }
+ state.expiresAt?.let { expiresAt ->
+ item {
+ PropertyExpiryItem(
+ title = stringResource(R.string.transfer_payment_expires_in),
+ expiresAt = expiresAt,
+ listPosition = ListPosition.Last,
+ )
+ }
+ }
+ item {
+ PropertyItem(
+ modifier = Modifier.clickable { if (!state.expired) isSelectingQuote = true },
+ title = { PropertyTitleText(R.string.transfer_pay_with) },
+ data = {
+ PropertyDataText(
+ text = state.selectedQuote?.amountText.orEmpty(),
+ badge = { DataBadgeChevron(icon = state.selectedQuote?.iconUrl.orEmpty()) },
+ )
+ },
+ listPosition = ListPosition.Single,
+ )
+ }
+ }
+ }
+
+ PaymentQuotesSelectModal(
+ isVisible = isSelectingQuote,
+ quotes = state.quotes,
+ selected = state.selected,
+ onSelect = {
+ onAction(PaymentSceneAction.SelectQuote(it))
+ isSelectingQuote = false
+ },
+ onDismissRequest = { isSelectingQuote = false },
+ )
+}
+
+@Composable
+private fun PaymentQuotesSelectModal(
+ isVisible: Boolean,
+ quotes: List,
+ selected: String?,
+ onSelect: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ ModalBottomSheet(
+ isVisible = isVisible,
+ title = stringResource(R.string.transfer_pay_with),
+ onDismissRequest = onDismissRequest,
+ ) {
+ LazyColumn {
+ itemsIndexed(quotes) { index, quote ->
+ ListItem(
+ modifier = Modifier.clickable { onSelect(quote.id) },
+ leading = {
+ IconWithBadge(
+ icon = quote.iconUrl,
+ placeholder = quote.symbol,
+ supportIcon = quote.supportIconUrl,
+ )
+ },
+ title = { ListItemTitleText(quote.name) },
+ subtitle = { ListItemSupportText(quote.networkName) },
+ trailing = {
+ getBalanceInfo(quote.amountText, quote.balance, false).invoke()
+ if (quote.id == selected) {
+ Spacer(modifier = Modifier.width(paddingSmall))
+ SelectionCheckmark()
+ }
+ },
+ listPosition = ListPosition.getPosition(index, quotes.size),
+ )
+ }
+ }
+ }
+}
+
+
+@Composable
+private fun PaymentSignMessageScene(
+ state: PaymentSceneState.SignMessage,
+ onAction: (PaymentSceneAction) -> Unit,
+) {
+ val context = LocalContext.current
+ var sheetType by remember { mutableStateOf(null) }
+
+ Scene(
+ title = stringResource(R.string.transfer_payment_title),
+ backHandle = true,
+ closeIcon = true,
+ onClose = { onAction(PaymentSceneAction.Cancel) },
+ mainAction = {
+ MainActionButton(
+ title = stringResource(R.string.transfer_confirm),
+ state = buttonState(enabled = !state.expired && !state.warnings.hasCriticalWarning()),
+ ) {
+ context.requestAuth(AuthRequest.Confirmation) { onAction(PaymentSceneAction.Sign) }
+ }
+ },
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = paddingValues.calculateBottomPadding() + paddingDefault),
+ ) {
+ item {
+ CenteredListHead(
+ icon = state.quote?.iconUrl ?: state.merchant.iconUrl,
+ title = state.quote?.amountText ?: state.merchant.name,
+ subtitle = state.price,
+ placeholderText = state.merchant.name.firstOrNull()?.uppercaseChar()?.toString(),
+ subtitleLayout = CenteredListHeadSubtitleLayout.Vertical,
+ )
+ }
+ item { PropertyItem(R.string.transfer_merchant, state.merchant.name, listPosition = ListPosition.First) }
+ item { PropertyItem(R.string.common_wallet, state.walletName, listPosition = ListPosition.Middle) }
+ item {
+ PropertyNetworkItem(
+ state.chain,
+ listPosition = if (state.expiresAt == null) ListPosition.Last else ListPosition.Middle,
+ )
+ }
+ state.expiresAt?.let { expiresAt ->
+ item {
+ PropertyExpiryItem(
+ title = stringResource(R.string.transfer_payment_expires_in),
+ expiresAt = expiresAt,
+ listPosition = ListPosition.Last,
+ )
+ }
+ }
+ simulationWarningsContent(state.warnings)
+ if (state.quote == null) {
+ if (state.hasPayload) {
+ simulationPayloadFieldsContent(
+ fields = state.primaryPayloadFields,
+ onDetailsClick = { sheetType = SignMessageSheetType.Details },
+ )
+ } else {
+ signMessageText(state.plainMessage)
+ }
+ }
+ }
+
+ when (sheetType) {
+ SignMessageSheetType.Details -> SignMessagePayloadDetailsSheet(
+ primaryFields = state.primaryPayloadFields,
+ secondaryFields = state.secondaryPayloadFields,
+ onViewFullMessage = { sheetType = SignMessageSheetType.FullMessage },
+ onDismissRequest = { sheetType = null },
+ )
+ SignMessageSheetType.FullMessage -> SignMessageFullMessageSheet(
+ message = state.plainMessage,
+ onDismissRequest = { sheetType = null },
+ )
+ null -> Unit
+ }
+ }
+}
+
+@Composable
+private fun PaymentToastEffect(
+ @StringRes message: Int?,
+ onDismiss: () -> Unit,
+) {
+ val context = LocalContext.current
+ LaunchedEffect(message) {
+ message?.let { makeText(context, it, Toast.LENGTH_SHORT).show() }
+ onDismiss()
+ }
+}
+
+private fun PaymentLinkError.messageRes(): Int = when (this) {
+ PaymentLinkError.NoWallet,
+ PaymentLinkError.NoQuotes,
+ PaymentLinkError.QuoteUnavailable,
+ PaymentLinkError.NoAccount -> R.string.errors_not_supported
+ PaymentLinkError.WatchWallet -> R.string.wallet_watch_tooltip_title
+ PaymentLinkError.DataCollection -> R.string.errors_error_occurred
+ PaymentLinkError.UnknownAsset -> R.string.errors_error_occurred
+ is PaymentLinkError.Gateway -> error.messageRes()
+}
+
+private fun PaymentException?.messageRes(): Int = when (this) {
+ is PaymentException.PaymentExpired,
+ is PaymentException.QuoteExpired -> R.string.errors_payment_expired
+ is PaymentException.Rejected -> R.string.errors_payment_not_allowed
+ is PaymentException.PaymentNotFound,
+ is PaymentException.RateLimited -> R.string.transaction_status_failed
+ is PaymentException.NoPaymentOptions,
+ is PaymentException.UnsupportedAccounts,
+ is PaymentException.NotSupported -> R.string.errors_not_supported
+ is PaymentException.InvalidRequest,
+ is PaymentException.Network,
+ null -> R.string.errors_error_occurred
+}
+
+private fun PaymentOutcomeUIModel.messageRes(): Int? = when (this) {
+ PaymentOutcomeUIModel.Success -> R.string.transaction_status_confirmed
+ PaymentOutcomeUIModel.Pending -> R.string.transaction_status_pending
+ PaymentOutcomeUIModel.Cancelled -> null
+ PaymentOutcomeUIModel.Expired -> R.string.errors_payment_expired
+ PaymentOutcomeUIModel.Failed -> R.string.transaction_status_failed
+}
diff --git a/android/features/payment/viewmodels/build.gradle.kts b/android/features/payment/viewmodels/build.gradle.kts
new file mode 100644
index 0000000000..9b9c9ecac1
--- /dev/null
+++ b/android/features/payment/viewmodels/build.gradle.kts
@@ -0,0 +1,67 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
+
+plugins {
+ alias(libs.plugins.android.library)
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "com.gemwallet.android.features.payment.viewmodels"
+ compileSdk = 37
+
+ defaultConfig {
+ minSdk = 28
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn")
+ }
+ }
+ packaging {
+ resources {
+ excludes += "META-INF/*"
+ excludes += "META-INF/DEPENDENCIES"
+ excludes += "/META-INF/LICENSE-notice.md"
+ excludes += "/META-INF/LICENSE.md"
+ excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
+ }
+ }
+}
+
+dependencies {
+ api(project(":ui-models"))
+ implementation(project(":ui"))
+ implementation(project(":data:repositories"))
+ implementation(project(":gemcore"))
+ implementation(project(":blockchain"))
+
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+
+ implementation(libs.lifecycle.runtime.ktx)
+ implementation(libs.lifecycle.viewmodel.savedstate)
+
+ testImplementation(libs.junit)
+ testImplementation(testFixtures(project(":gemcore")))
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/android/features/payment/viewmodels/consumer-rules.pro b/android/features/payment/viewmodels/consumer-rules.pro
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/android/features/payment/viewmodels/proguard-rules.pro b/android/features/payment/viewmodels/proguard-rules.pro
new file mode 100644
index 0000000000..481bb43481
--- /dev/null
+++ b/android/features/payment/viewmodels/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt
new file mode 100644
index 0000000000..ecac8b2dc3
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt
@@ -0,0 +1,51 @@
+package com.gemwallet.android.features.payment.viewmodels
+
+import com.gemwallet.android.model.PreparedPayment
+import com.wallet.core.primitives.PaymentProviderName
+import com.wallet.core.primitives.PaymentQuote
+import com.wallet.core.primitives.PaymentQuotes
+import com.wallet.core.primitives.TransactionPaymentMetadata
+import com.wallet.core.primitives.Wallet
+import uniffi.gemstone.PaymentAction
+
+internal data class ActivePayment(
+ val provider: PaymentProviderName,
+ val quotes: PaymentQuotes,
+ val wallet: Wallet,
+ val quote: PaymentQuote? = null,
+ val collecting: PaymentQuote? = null,
+ val actions: List = emptyList(),
+ val results: List = emptyList(),
+ val completed: Int = 0,
+ val isRelayed: Boolean = false,
+) {
+ val step: Step?
+ get() = actions.getOrNull(completed)?.let { Step(it, completed) }
+
+ fun paymentMetadata(quote: PaymentQuote) = TransactionPaymentMetadata(
+ paymentId = quote.paymentId,
+ merchant = quotes.merchant,
+ provider = provider,
+ )
+
+ fun collecting(quote: PaymentQuote) = copy(collecting = quote)
+
+ fun prepared(payment: PreparedPayment) = copy(
+ quote = payment.quote,
+ collecting = null,
+ actions = payment.actions,
+ results = List(payment.actions.size) { "" },
+ completed = 0,
+ isRelayed = payment.isRelayed,
+ )
+
+ fun completing(result: String): ActivePayment {
+ val index = completed.takeIf { it in actions.indices } ?: return this
+ return copy(
+ results = results.toMutableList().also { it[index] = result },
+ completed = completed + 1,
+ )
+ }
+
+ data class Step(val action: PaymentAction, val index: Int)
+}
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt
new file mode 100644
index 0000000000..bd5358f46b
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt
@@ -0,0 +1,72 @@
+package com.gemwallet.android.features.payment.viewmodels
+
+import com.gemwallet.android.features.payment.viewmodels.model.PaymentMerchantUIModel
+import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel
+import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIModel
+import com.gemwallet.android.model.ConfirmParams
+import com.gemwallet.android.ui.models.PayloadField
+import com.wallet.core.primitives.SimulationWarning
+import com.wallet.core.primitives.Chain
+import com.wallet.core.primitives.WalletType
+import uniffi.gemstone.PaymentException
+
+sealed interface PaymentSceneState {
+ data object Loading : PaymentSceneState
+
+ data class Quotes(
+ val merchant: PaymentMerchantUIModel,
+ val walletName: String,
+ val walletType: WalletType,
+ val walletChain: Chain?,
+ val price: String?,
+ val quotes: List,
+ val selected: String?,
+ val expiresAt: Long?,
+ val expired: Boolean,
+ ) : PaymentSceneState {
+ val selectedQuote: PaymentQuoteUIModel?
+ get() = quotes.firstOrNull { it.id == selected }
+ }
+
+ data class CollectData(val url: String) : PaymentSceneState
+
+ data class Approve(
+ val params: ConfirmParams.TokenApprovalParams,
+ ) : PaymentSceneState
+
+ data class Confirm(
+ val params: ConfirmParams.TransferParams.Generic,
+ ) : PaymentSceneState
+
+ data class SignMessage(
+ val merchant: PaymentMerchantUIModel,
+ val chain: Chain,
+ val walletName: String,
+ val quote: PaymentQuoteUIModel?,
+ val price: String?,
+ val expiresAt: Long?,
+ val plainMessage: String,
+ val primaryPayloadFields: List,
+ val secondaryPayloadFields: List,
+ val warnings: List,
+ val expired: Boolean,
+ ) : PaymentSceneState {
+ val hasPayload: Boolean
+ get() = primaryPayloadFields.isNotEmpty() || secondaryPayloadFields.isNotEmpty()
+ }
+
+ data class Outcome(val outcome: PaymentOutcomeUIModel) : PaymentSceneState
+
+ data class Error(val error: PaymentLinkError) : PaymentSceneState
+}
+
+sealed interface PaymentLinkError {
+ data object NoWallet : PaymentLinkError
+ data object WatchWallet : PaymentLinkError
+ data object NoQuotes : PaymentLinkError
+ data object QuoteUnavailable : PaymentLinkError
+ data object NoAccount : PaymentLinkError
+ data object DataCollection : PaymentLinkError
+ data object UnknownAsset : PaymentLinkError
+ data class Gateway(val error: PaymentException?) : PaymentLinkError
+}
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt
new file mode 100644
index 0000000000..fb80e85c4b
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt
@@ -0,0 +1,329 @@
+package com.gemwallet.android.features.payment.viewmodels
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.gemwallet.android.application.PasswordStore
+import com.gemwallet.android.application.assets.coordinators.GetAssetInfo
+import com.gemwallet.android.blockchain.gemstone.toGem
+import com.gemwallet.android.blockchain.gemstone.toPrimitives
+import com.gemwallet.android.blockchain.services.GemSignMessageOperator
+import com.gemwallet.android.blockchain.services.PaymentService
+import com.gemwallet.android.blockchain.services.WalletConnectSimulationService
+import com.gemwallet.android.cases.tokens.SearchTokensCase
+import com.gemwallet.android.data.repositories.session.SessionRepository
+import com.gemwallet.android.ext.getAccount
+import com.gemwallet.android.ext.runCatchingCancellable
+import com.gemwallet.android.ext.toAppMetadata
+import com.gemwallet.android.ext.toChain
+import com.gemwallet.android.ext.toConfirmParams
+import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel
+import com.gemwallet.android.features.payment.viewmodels.model.toPriceText
+import com.gemwallet.android.features.payment.viewmodels.model.toUIModel
+import com.gemwallet.android.model.AssetInfo
+import com.gemwallet.android.model.ConfirmParams
+import com.gemwallet.android.model.toModel
+import com.gemwallet.android.ui.models.withExplorerLinks
+import com.wallet.core.primitives.Account
+import com.wallet.core.primitives.Asset
+import com.wallet.core.primitives.AssetId
+import com.wallet.core.primitives.PaymentLink
+import com.wallet.core.primitives.PaymentOptions
+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 com.wallet.core.primitives.WalletType
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import uniffi.gemstone.MessageSigner
+import uniffi.gemstone.PaymentAction
+import uniffi.gemstone.PaymentException
+import uniffi.gemstone.SignableTransaction
+import uniffi.gemstone.paymentWalletConnectUrl
+
+@HiltViewModel
+class PaymentViewModel @Inject constructor(
+ private val paymentService: PaymentService,
+ private val simulationService: WalletConnectSimulationService,
+ private val sessionRepository: SessionRepository,
+ private val signMessageOperator: GemSignMessageOperator,
+ private val passwordStore: PasswordStore,
+ private val getAssetInfo: GetAssetInfo,
+ private val searchTokensCase: SearchTokensCase,
+ private val recordPayment: RecordPayment,
+) : ViewModel() {
+
+ private val state = MutableStateFlow(PaymentSceneState.Loading)
+ val sceneState = state.asStateFlow()
+
+ private val payment = MutableStateFlow(null)
+ private val lock = Mutex()
+ private var expiryJob: Job? = null
+
+ fun onPayment(provider: PaymentProviderName, paymentId: String) {
+ val link = PaymentLink(provider = provider, id = paymentId)
+ state.value = PaymentSceneState.Loading
+ viewModelScope.launch(Dispatchers.IO) {
+ val wallet = wallet() ?: return@launch
+ val options = runGateway { paymentService.getPaymentOptions(link, wallet) } ?: return@launch
+ when (options) {
+ is PaymentOptions.Outcome -> state.value = PaymentSceneState.Outcome(options.content.status.toUIModel())
+ is PaymentOptions.Quotes -> {
+ val quotes = options.content
+ payment.value = ActivePayment(link.provider, quotes, wallet)
+ if (quotes.quotes.size > 1) {
+ state.value = quotes.toSceneState(wallet)
+ watchExpiry(quotes)
+ } else {
+ select(quotes.quotes.firstOrNull())
+ }
+ }
+ }
+ }
+ }
+
+ fun onSelectQuote(quoteId: String) {
+ val current = state.value as? PaymentSceneState.Quotes ?: return
+ state.value = current.copy(selected = quoteId)
+ }
+
+ fun onConfirmQuote() {
+ val selected = (state.value as? PaymentSceneState.Quotes)?.selected ?: return
+ val quote = payment.value?.quotes?.quotes?.firstOrNull { it.id == selected }
+ if (quote == null) {
+ state.value = failure(PaymentLinkError.QuoteUnavailable, "confirm: quote $selected is gone")
+ return
+ }
+ expiryJob?.cancel()
+ state.value = PaymentSceneState.Loading
+ viewModelScope.launch(Dispatchers.IO) { select(quote) }
+ }
+
+ fun onDataCollected() {
+ val quote = payment.value?.collecting ?: return
+ state.value = PaymentSceneState.Loading
+ viewModelScope.launch(Dispatchers.IO) { prepare(quote) }
+ }
+
+ fun onDataCollectionError(message: String?) {
+ Log.e(TAG, "Payment data collection failed: $message")
+ state.value = PaymentSceneState.Error(PaymentLinkError.DataCollection)
+ }
+
+ fun onActionResult(result: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ lock.withLock {
+ payment.value?.step ?: return@launch
+ payment.value = payment.value?.completing(result)
+ }
+ advance()
+ }
+ }
+
+ fun onSign() {
+ val current = payment.value ?: return
+ val action = current.step?.action as? PaymentAction.SignMessage ?: return
+ viewModelScope.launch(Dispatchers.IO) {
+ val signature = runGateway {
+ signMessageOperator.sign(
+ MessageSigner(action.message),
+ current.wallet,
+ passwordStore.getPassword(current.wallet.id.id),
+ )
+ } ?: return@launch
+ onActionResult(signature)
+ }
+ }
+
+ private suspend fun PaymentQuotes.toSceneState(wallet: Wallet) = PaymentSceneState.Quotes(
+ merchant = merchant.toUIModel(),
+ walletName = wallet.name,
+ walletType = wallet.type,
+ walletChain = wallet.accounts.firstOrNull()?.chain,
+ price = price?.toPriceText(),
+ quotes = quotes.map { it.toUIModel(assetInfo(it.amount.assetId)) },
+ selected = quotes.firstOrNull()?.id,
+ expiresAt = expiresAt,
+ expired = false,
+ )
+
+ private fun watchExpiry(quotes: PaymentQuotes) {
+ val expiresAt = quotes.expiresAt ?: return
+ expiryJob?.cancel()
+ expiryJob = viewModelScope.launch(Dispatchers.IO) {
+ delay((expiresAt - System.currentTimeMillis()).coerceAtLeast(0))
+ state.value = when (val current = state.value) {
+ is PaymentSceneState.Quotes -> current.copy(expired = true)
+ is PaymentSceneState.SignMessage -> current.copy(expired = true)
+ else -> current
+ }
+ }
+ }
+
+ private suspend fun select(quote: PaymentQuote?) {
+ if (quote == null) {
+ state.value = PaymentSceneState.Error(PaymentLinkError.NoQuotes)
+ return
+ }
+ val collectDataUrl = quote.collectDataUrl
+ if (collectDataUrl == null) {
+ prepare(quote)
+ return
+ }
+ payment.value = payment.value?.collecting(quote)
+ state.value = PaymentSceneState.CollectData(collectDataUrl)
+ }
+
+ private suspend fun prepare(quote: PaymentQuote) {
+ val current = payment.value ?: return
+ val prepared = runGateway {
+ paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet)
+ } ?: return
+ payment.value = current.prepared(prepared)
+ advance()
+ }
+
+ private suspend fun advance() {
+ val current = payment.value ?: return
+ val step = current.step
+ if (step == null) {
+ val quote = current.quote ?: return
+ if (current.isRelayed) {
+ recordPayment.recordPayment(current.paymentMetadata(quote), quote, current.wallet)
+ }
+ val settled = runCatchingCancellable {
+ paymentService.confirmPayment(current.provider, quote, current.results)
+ }.onFailure { Log.e(TAG, "Confirm payment failed", it) }.getOrNull()
+ state.value = PaymentSceneState.Outcome(settled?.status?.toUIModel() ?: PaymentOutcomeUIModel.Pending)
+ return
+ }
+ val next = when (val action = step.action) {
+ is PaymentAction.SignMessage -> signMessageState(action, current)
+ is PaymentAction.SendTransaction -> confirmState(action.chain, action.transaction, true, current)
+ is PaymentAction.SignTransaction -> confirmState(action.chain, action.transaction, false, current)
+ is PaymentAction.ApproveToken -> approvalState(action, current)
+ }
+ state.value = next
+ if (next is PaymentSceneState.SignMessage) {
+ watchExpiry(current.quotes)
+ }
+ }
+
+ private suspend fun signMessageState(
+ action: PaymentAction.SignMessage,
+ current: ActivePayment,
+ ): PaymentSceneState {
+ val chain = action.message.chain.toChain() ?: return PaymentSceneState.Error(PaymentLinkError.NoAccount)
+ val simulation = runCatchingCancellable {
+ simulationService.simulateSignMessage(action.message, paymentWalletConnectUrl())
+ }.getOrNull()
+ val signer = runCatchingCancellable { MessageSigner(action.message) }.getOrNull()
+ val preview = signer?.let { runCatchingCancellable { it.payloadPreview(simulation?.payload.orEmpty().map { field -> field.toGem() }) }.getOrNull() }
+ return PaymentSceneState.SignMessage(
+ merchant = current.quotes.merchant.toUIModel(),
+ chain = chain,
+ walletName = current.wallet.name,
+ quote = current.quote?.toUIModel(),
+ price = current.quotes.price?.toPriceText(),
+ expiresAt = current.quotes.expiresAt,
+ plainMessage = signer?.let { runCatchingCancellable { it.plainPreview() }.getOrNull() }.orEmpty(),
+ primaryPayloadFields = preview?.primary?.map { it.toPrimitives() }.orEmpty()
+ .withExplorerLinks(chain, null),
+ secondaryPayloadFields = preview?.secondary?.map { it.toPrimitives() }.orEmpty()
+ .withExplorerLinks(chain, null),
+ warnings = simulation?.warnings.orEmpty(),
+ expired = false,
+ )
+ }
+
+ private suspend fun approvalState(
+ action: PaymentAction.ApproveToken,
+ current: ActivePayment,
+ ): PaymentSceneState {
+ val account = current.account(action.chain) ?: return failure(PaymentLinkError.NoAccount, "approval: no ${action.chain} account")
+ val assetId = current.quote?.amount?.assetId ?: return failure(PaymentLinkError.QuoteUnavailable, "approval: no prepared quote")
+ val asset = asset(assetId) ?: return failure(PaymentLinkError.UnknownAsset, "approval: unresolved asset ${action.approval.token}")
+ return PaymentSceneState.Approve(
+ ConfirmParams.TokenApprovalParams(
+ asset = asset,
+ from = account,
+ data = "",
+ provider = current.quotes.merchant.name,
+ contract = action.approval.spender,
+ approval = action.approval.toModel(),
+ )
+ )
+ }
+
+ private fun confirmState(
+ chain: String,
+ transaction: SignableTransaction,
+ isSendable: Boolean,
+ current: ActivePayment,
+ ): PaymentSceneState {
+ val account = current.account(chain) ?: return PaymentSceneState.Error(PaymentLinkError.NoAccount)
+ return PaymentSceneState.Confirm(
+ transaction.toConfirmParams(
+ requestId = current.quote?.paymentId.orEmpty(),
+ account = account,
+ appMetadata = current.quotes.merchant.toAppMetadata(),
+ isSendable = isSendable,
+ payment = current.quote?.let(current::paymentMetadata),
+ inputType = if (isSendable) {
+ ConfirmParams.TransferParams.InputType.EncodeTransaction
+ } else {
+ ConfirmParams.TransferParams.InputType.Signature
+ },
+ )
+ )
+ }
+
+ private fun failure(error: PaymentLinkError, reason: String): PaymentSceneState {
+ Log.e(TAG, reason)
+ return PaymentSceneState.Error(error)
+ }
+
+ private suspend fun asset(assetId: AssetId): Asset? = assetInfo(assetId)?.asset
+
+ private suspend fun assetInfo(assetId: AssetId): AssetInfo? = getAssetInfo(assetId).firstOrNull()
+ ?: sessionRepository.session().firstOrNull()?.currency
+ ?.also { searchTokensCase.search(assetId, it) }
+ ?.let { getAssetInfo(assetId).firstOrNull() }
+
+ private fun ActivePayment.account(chain: String): Account? =
+ chain.toChain()?.let { wallet.getAccount(it) }
+
+ private suspend fun wallet(): Wallet? {
+ val wallet = sessionRepository.session().firstOrNull()?.wallet
+ if (wallet == null) {
+ state.value = PaymentSceneState.Error(PaymentLinkError.NoWallet)
+ return null
+ }
+ if (wallet.type == WalletType.View) {
+ state.value = PaymentSceneState.Error(PaymentLinkError.WatchWallet)
+ return null
+ }
+ return wallet
+ }
+
+ private suspend fun runGateway(block: suspend () -> T): T? = runCatchingCancellable(block)
+ .onFailure { err ->
+ Log.e(TAG, "Payment gateway request failed", err)
+ state.value = PaymentSceneState.Error(PaymentLinkError.Gateway(err as? PaymentException))
+ }
+ .getOrNull()
+
+ private companion object {
+ const val TAG = "PaymentViewModel"
+ }
+}
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt
new file mode 100644
index 0000000000..b687f45b08
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt
@@ -0,0 +1,57 @@
+package com.gemwallet.android.features.payment.viewmodels
+
+import android.util.Log
+import com.gemwallet.android.cases.transactions.CreateTransaction
+import com.gemwallet.android.model.Fee
+import com.gemwallet.android.serializer.toJson
+import com.wallet.core.primitives.AssetId
+import com.wallet.core.primitives.FeePriority
+import com.wallet.core.primitives.PaymentQuote
+import com.wallet.core.primitives.TransactionDirection
+import com.wallet.core.primitives.TransactionPaymentMetadata
+import com.wallet.core.primitives.TransactionState
+import com.wallet.core.primitives.TransactionType
+import com.wallet.core.primitives.Wallet
+import java.math.BigInteger
+import javax.inject.Inject
+
+class RecordPayment @Inject constructor(
+ private val createTransaction: CreateTransaction,
+) {
+ suspend fun recordPayment(
+ payment: TransactionPaymentMetadata,
+ quote: PaymentQuote,
+ wallet: Wallet,
+ ) {
+ val assetId = quote.amount.assetId
+ val account = wallet.accounts.firstOrNull { it.chain == assetId.chain }
+ if (account == null) {
+ Log.e(TAG, "Record payment: no ${assetId.chain} account")
+ return
+ }
+ createTransaction.createTransaction(
+ hash = quote.paymentId,
+ walletId = wallet.id,
+ assetId = assetId,
+ owner = account,
+ to = "",
+ state = TransactionState.Pending,
+ fee = Fee.Plain(
+ feeAssetId = AssetId(assetId.chain),
+ priority = FeePriority.Normal,
+ amount = BigInteger.ZERO,
+ options = emptyMap(),
+ ),
+ amount = BigInteger(quote.amount.value),
+ memo = "",
+ type = TransactionType.Transfer,
+ metadata = payment.toJson(),
+ direction = TransactionDirection.Outgoing,
+ blockNumber = "0",
+ )
+ }
+
+ private companion object {
+ const val TAG = "RecordPayment"
+ }
+}
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt
new file mode 100644
index 0000000000..516333f41d
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt
@@ -0,0 +1,13 @@
+package com.gemwallet.android.features.payment.viewmodels.model
+
+import com.wallet.core.primitives.PaymentMerchant
+
+data class PaymentMerchantUIModel(
+ val name: String,
+ val iconUrl: String?,
+)
+
+fun PaymentMerchant.toUIModel() = PaymentMerchantUIModel(
+ name = name,
+ iconUrl = iconUrl,
+)
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt
new file mode 100644
index 0000000000..68a11d1888
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt
@@ -0,0 +1,20 @@
+package com.gemwallet.android.features.payment.viewmodels.model
+
+import com.wallet.core.primitives.PaymentStatus
+
+enum class PaymentOutcomeUIModel {
+ Success,
+ Pending,
+ Cancelled,
+ Expired,
+ Failed,
+}
+
+fun PaymentStatus.toUIModel() = when (this) {
+ PaymentStatus.Succeeded -> PaymentOutcomeUIModel.Success
+ PaymentStatus.Processing -> PaymentOutcomeUIModel.Pending
+ PaymentStatus.Cancelled -> PaymentOutcomeUIModel.Cancelled
+ PaymentStatus.Expired -> PaymentOutcomeUIModel.Expired
+ PaymentStatus.Failed,
+ PaymentStatus.RequiresAction -> PaymentOutcomeUIModel.Failed
+}
diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt
new file mode 100644
index 0000000000..697bb30ddf
--- /dev/null
+++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt
@@ -0,0 +1,44 @@
+package com.gemwallet.android.features.payment.viewmodels.model
+
+import com.gemwallet.android.domains.asset.getIconUrl
+import com.gemwallet.android.domains.asset.getSupportIconUrl
+import com.gemwallet.android.ext.asset
+import com.gemwallet.android.model.AssetInfo
+import com.gemwallet.android.model.Crypto
+import com.gemwallet.android.model.ValueFormatter
+import com.wallet.core.primitives.PaymentPrice
+import com.wallet.core.primitives.PaymentQuote
+
+private val amountFormatter = ValueFormatter(ValueFormatter.Style.Short)
+private val priceFormatter = ValueFormatter(ValueFormatter.Style.Full)
+
+data class PaymentQuoteUIModel(
+ val id: String,
+ val name: String,
+ val networkName: String,
+ val symbol: String,
+ val amount: String,
+ val balance: String,
+ val iconUrl: String?,
+ val supportIconUrl: String?,
+) {
+ val amountText: String get() = "$amount $symbol"
+}
+
+fun PaymentQuote.toUIModel(assetInfo: AssetInfo? = null): PaymentQuoteUIModel = PaymentQuoteUIModel(
+ id = id,
+ name = assetInfo?.asset?.name ?: amount.symbol,
+ networkName = amount.assetId.chain.asset().name,
+ symbol = amount.symbol,
+ amount = amountFormatter.string(Crypto(amount.value).value(amount.decimals)),
+ balance = assetInfo?.balanceText().orEmpty(),
+ iconUrl = amount.assetId.getIconUrl(),
+ supportIconUrl = amount.assetId.getSupportIconUrl(),
+)
+
+fun PaymentPrice.toPriceText(): String = priceFormatter.string(Crypto(value).value(decimals), currency = symbol)
+
+private fun AssetInfo.balanceText(): String = amountFormatter.string(
+ Crypto(balance.balance.available).value(asset.decimals),
+ currency = asset.symbol,
+)
diff --git a/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt b/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt
new file mode 100644
index 0000000000..ec25b5e580
--- /dev/null
+++ b/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt
@@ -0,0 +1,80 @@
+package com.gemwallet.android.features.payment.viewmodels
+
+import com.gemwallet.android.model.PreparedPayment
+import com.gemwallet.android.testkit.mockWallet
+import com.wallet.core.primitives.AssetId
+import com.wallet.core.primitives.Chain
+import com.wallet.core.primitives.PaymentAmount
+import com.wallet.core.primitives.PaymentMerchant
+import com.wallet.core.primitives.PaymentProviderName
+import com.wallet.core.primitives.PaymentQuote
+import com.wallet.core.primitives.PaymentQuotes
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import uniffi.gemstone.GemApprovalData
+import uniffi.gemstone.PaymentAction
+import uniffi.gemstone.SignDigestType
+import uniffi.gemstone.SignMessage
+
+class ActivePaymentTest {
+
+ @Test
+ fun `results keep the gateway's action order`() {
+ var payment = payment(listOf(approve(), signMessage()))
+
+ payment = payment.completing("approval-hash")
+ payment = payment.completing("signature")
+
+ assertEquals(listOf("approval-hash", "signature"), payment.results)
+ assertNull(payment.step)
+ }
+
+ @Test
+ fun `completing past the last action is a no-op`() {
+ var payment = payment(listOf(signMessage()))
+ payment = payment.completing("signature")
+
+ val settled = payment.completing("duplicate")
+
+ assertEquals(listOf("signature"), settled.results)
+ assertEquals(1, settled.completed)
+ }
+
+ private fun payment(actions: List): ActivePayment =
+ ActivePayment(
+ provider = PaymentProviderName.WalletConnectPay,
+ quotes = quotes(),
+ wallet = mockWallet(),
+ ).prepared(PreparedPayment(quotes(), quote(), actions, isRelayed = true))
+
+ private fun quotes() = PaymentQuotes(
+ merchant = PaymentMerchant(name = "Gem Wallet Test Merchant", iconUrl = null),
+ price = null,
+ expiresAt = null,
+ quotes = listOf(quote()),
+ )
+
+ private fun quote() = PaymentQuote(
+ id = "opt_1",
+ paymentId = "pay_1",
+ amount = PaymentAmount(
+ assetId = AssetId(Chain.Ethereum),
+ value = "1",
+ symbol = "USDT",
+ decimals = 6,
+ ),
+ expiresAt = null,
+ collectDataUrl = null,
+ providerData = "",
+ )
+
+ private fun signMessage() = PaymentAction.SignMessage(
+ SignMessage(chain = "ethereum", signType = SignDigestType.EIP712, data = ByteArray(0)),
+ )
+
+ private fun approve() = PaymentAction.ApproveToken(
+ chain = "ethereum",
+ approval = GemApprovalData(token = "0xtoken", spender = "0xspender", value = "1", isUnlimited = true),
+ )
+}
diff --git a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt
index eb3466bb42..d8aa0408a0 100644
--- a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt
+++ b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt
@@ -17,6 +17,8 @@ import com.gemwallet.android.ext.checksumAddress
import com.gemwallet.android.ext.getAccount
import com.gemwallet.android.ext.isMemoSupport
import com.gemwallet.android.ext.mutableStateIn
+import com.gemwallet.android.ext.request
+import com.gemwallet.android.ext.toPrimitives
import com.gemwallet.android.features.recipient.viewmodel.models.QrScanField
import com.gemwallet.android.features.recipient.viewmodel.models.RecipientError
import com.gemwallet.android.features.recipient.viewmodel.models.RecipientState
@@ -223,15 +225,15 @@ class RecipientViewModel @Inject constructor(
}
fun setQrData(type: RecipientType, field: QrScanField, data: String, confirmAction: ConfirmTransactionAction) {
- val paymentWrapper = uniffi.gemstone.paymentDecodeUrl(data)
+ val request = uniffi.gemstone.paymentDecodeUrl(data).toPrimitives().request ?: return
val amount = try {
- BigInteger(paymentWrapper.amount ?: throw IllegalArgumentException())
+ BigInteger(request.amount ?: throw IllegalArgumentException())
} catch (_: Throwable) {
null
}
val assetInfo = type.assetInfo
- val address = assetInfo.asset.chain.checksumAddress(paymentWrapper.address)
- val memo = paymentWrapper.memo
+ val address = assetInfo.asset.chain.checksumAddress(request.address)
+ val memo = request.memo
val owner = assetInfo.owner
if (
@@ -253,7 +255,7 @@ class RecipientViewModel @Inject constructor(
}
QrScanField.Memo -> {
_address.value = address.ifEmpty { _address.value }
- _memo.value = paymentWrapper.memo ?: data
+ _memo.value = request.memo ?: data
}
}
}
diff --git a/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt b/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt
index 177ec28607..0f29b0fd79 100644
--- a/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt
+++ b/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt
@@ -8,6 +8,8 @@ import com.gemwallet.android.cases.contacts.AddContact
import com.gemwallet.android.cases.contacts.GetContacts
import com.gemwallet.android.cases.contacts.UpdateContact
import com.gemwallet.android.cases.name.ResolveName
+import com.gemwallet.android.ext.request
+import com.gemwallet.android.ext.toPrimitives
import com.gemwallet.android.features.recipient.viewmodel.NameRecordState
import com.gemwallet.android.features.recipient.viewmodel.NameResolveController
import com.gemwallet.android.features.settings.contacts.viewmodels.models.ContactAddressInput
@@ -124,9 +126,9 @@ class ManageContactViewModel @Inject constructor(
private fun applyExternalAddress(data: String) {
resolver.reset()
- val decoded = runCatching { uniffi.gemstone.paymentDecodeUrl(data) }.getOrNull()
- val address = (decoded?.address?.ifBlank { null } ?: data).trim()
- val memo = decoded?.memo
+ val request = runCatching { uniffi.gemstone.paymentDecodeUrl(data).toPrimitives() }.getOrNull()?.request
+ val address = (request?.address?.ifBlank { null } ?: data).trim()
+ val memo = request?.memo
state.update {
val input = it.addressInput ?: return@update it
it.copy(
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt
index 3aff2031c9..f2db336ac9 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt
@@ -6,4 +6,5 @@ object Constants {
const val ASSETS_URL = "https://assets.gemwallet.com"
const val DEVICE_STREAM_PATH = "/v2/devices/stream"
const val DEVICE_STREAM_WEBSOCKET_URL = "wss://$API_HOST$DEVICE_STREAM_PATH"
+ const val WALLET_CONNECT_PROJECT_ID = "3bc07cd7179d11ea65335fb9377702b6"
}
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt
index 9260c6b2e5..7e489d2103 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt
@@ -53,7 +53,7 @@ sealed interface ConfirmProperty {
is ConfirmParams.TransferParams.Native -> params.destination()?.let {
Transfer(domain = it.name ?: addressName?.name, address = it.address, chain = params.assetId.chain)
} ?: throw ConfirmError.RecipientEmpty
- is ConfirmParams.TransferParams.Generic -> Generic(params.name)
+ is ConfirmParams.TransferParams.Generic -> Generic(params.appMetadata.name)
}
}
}
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/transaction/values/TransactionDetailsValue.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/transaction/values/TransactionDetailsValue.kt
index 59aa5b7511..97017ce24b 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/transaction/values/TransactionDetailsValue.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/transaction/values/TransactionDetailsValue.kt
@@ -83,6 +83,7 @@ sealed interface TransactionDetailsValue {
explorerLink: BlockExplorerLink? = null,
) : Destination(data, chain = chain, name = name, explorerLink = explorerLink)
class Provider(name: String) : Destination(name)
+ class Merchant(name: String) : Destination(name)
}
class Status(val data: TransactionState) : TransactionDetailsValue
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt
new file mode 100644
index 0000000000..4d256d05e0
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt
@@ -0,0 +1,154 @@
+package com.gemwallet.android.ext
+
+import com.wallet.core.primitives.Payment
+import com.wallet.core.primitives.PaymentAmount
+import com.wallet.core.primitives.PaymentLink
+import com.wallet.core.primitives.PaymentMerchant
+import com.wallet.core.primitives.PaymentOptions
+import com.wallet.core.primitives.PaymentOutcome
+import com.wallet.core.primitives.PaymentPrice
+import com.wallet.core.primitives.PaymentProviderName
+import com.wallet.core.primitives.PaymentQuote
+import com.wallet.core.primitives.PaymentQuotes
+import com.wallet.core.primitives.PaymentRequest
+import com.wallet.core.primitives.PaymentStatus
+import com.wallet.core.primitives.TransactionAppMetadata
+import uniffi.gemstone.GemPayment
+import uniffi.gemstone.GemPaymentAmount
+import uniffi.gemstone.GemPaymentLink
+import uniffi.gemstone.GemPaymentMerchant
+import uniffi.gemstone.GemPaymentOptions
+import uniffi.gemstone.GemPaymentOutcome
+import uniffi.gemstone.GemPaymentPrice
+import uniffi.gemstone.GemPaymentProviderName
+import uniffi.gemstone.GemPaymentQuote
+import uniffi.gemstone.GemPaymentQuotes
+import uniffi.gemstone.GemPaymentRequest
+import uniffi.gemstone.GemPaymentStatus
+import uniffi.gemstone.paymentWalletConnectUrl
+
+fun GemPayment.toPrimitives(): Payment = when (this) {
+ is GemPayment.Request -> Payment.Request(v1.toPrimitives())
+ is GemPayment.Link -> Payment.Link(v1.toPrimitives())
+}
+
+fun GemPaymentRequest.toPrimitives(): PaymentRequest = PaymentRequest(
+ address = address,
+ amount = amount,
+ memo = memo,
+ assetId = assetId?.toAssetId(),
+)
+
+fun GemPaymentLink.toPrimitives(): PaymentLink = PaymentLink(
+ provider = provider.toPrimitives(),
+ id = id,
+)
+
+fun PaymentLink.toGem(): GemPaymentLink = GemPaymentLink(
+ provider = provider.toGem(),
+ id = id,
+)
+
+fun GemPaymentMerchant.toPrimitives(): PaymentMerchant = PaymentMerchant(
+ name = name,
+ iconUrl = iconUrl,
+)
+
+fun PaymentMerchant.toGem(): GemPaymentMerchant = GemPaymentMerchant(
+ name = name,
+ iconUrl = iconUrl,
+)
+
+fun PaymentMerchant.toAppMetadata(): TransactionAppMetadata = TransactionAppMetadata(
+ name = name,
+ description = null,
+ url = paymentWalletConnectUrl(),
+ icon = iconUrl,
+)
+
+fun GemPaymentProviderName.toPrimitives(): PaymentProviderName = when (this) {
+ GemPaymentProviderName.SOLANA_PAY -> PaymentProviderName.SolanaPay
+ GemPaymentProviderName.WALLET_CONNECT_PAY -> PaymentProviderName.WalletConnectPay
+}
+
+fun PaymentProviderName.toGem(): GemPaymentProviderName = when (this) {
+ PaymentProviderName.SolanaPay -> GemPaymentProviderName.SOLANA_PAY
+ PaymentProviderName.WalletConnectPay -> GemPaymentProviderName.WALLET_CONNECT_PAY
+}
+
+fun GemPaymentAmount.toPrimitives(): PaymentAmount = PaymentAmount(
+ assetId = requireNotNull(assetId.toAssetId()) { "unknown payment asset $assetId" },
+ value = value,
+ symbol = symbol,
+ decimals = decimals,
+)
+
+fun PaymentAmount.toGem(): GemPaymentAmount = GemPaymentAmount(
+ assetId = assetId.toIdentifier(),
+ value = value,
+ symbol = symbol,
+ decimals = decimals,
+)
+
+fun GemPaymentPrice.toPrimitives(): PaymentPrice = PaymentPrice(
+ symbol = symbol,
+ value = value,
+ decimals = decimals,
+)
+
+fun PaymentPrice.toGem(): GemPaymentPrice = GemPaymentPrice(
+ symbol = symbol,
+ value = value,
+ decimals = decimals,
+)
+
+fun GemPaymentQuote.toPrimitives(): PaymentQuote = PaymentQuote(
+ id = id,
+ paymentId = paymentId,
+ amount = amount.toPrimitives(),
+ expiresAt = expiresAt?.secondsToMillis(),
+ collectDataUrl = collectDataUrl,
+ providerData = providerData,
+)
+
+fun PaymentQuote.toGem(): GemPaymentQuote = GemPaymentQuote(
+ id = id,
+ paymentId = paymentId,
+ amount = amount.toGem(),
+ expiresAt = expiresAt?.millisToSeconds(),
+ collectDataUrl = collectDataUrl,
+ providerData = providerData,
+)
+
+fun GemPaymentQuotes.toPrimitives(): PaymentQuotes = PaymentQuotes(
+ merchant = merchant.toPrimitives(),
+ price = price?.toPrimitives(),
+ expiresAt = expiresAt?.secondsToMillis(),
+ quotes = quotes.map { it.toPrimitives() },
+)
+
+fun PaymentQuotes.toGem(): GemPaymentQuotes = GemPaymentQuotes(
+ merchant = merchant.toGem(),
+ price = price?.toGem(),
+ expiresAt = expiresAt?.millisToSeconds(),
+ quotes = quotes.map { it.toGem() },
+)
+
+fun GemPaymentStatus.toPrimitives(): PaymentStatus = when (this) {
+ GemPaymentStatus.REQUIRES_ACTION -> PaymentStatus.RequiresAction
+ GemPaymentStatus.PROCESSING -> PaymentStatus.Processing
+ GemPaymentStatus.SUCCEEDED -> PaymentStatus.Succeeded
+ GemPaymentStatus.FAILED -> PaymentStatus.Failed
+ GemPaymentStatus.EXPIRED -> PaymentStatus.Expired
+ GemPaymentStatus.CANCELLED -> PaymentStatus.Cancelled
+}
+
+fun GemPaymentOutcome.toPrimitives(): PaymentOutcome = PaymentOutcome(
+ status = status.toPrimitives(),
+ transactionId = transactionId,
+)
+
+fun GemPaymentOptions.toPrimitives(): PaymentOptions = when (this) {
+ is GemPaymentOptions.Quotes -> PaymentOptions.Quotes(v1.toPrimitives())
+ is GemPaymentOptions.Outcome -> PaymentOptions.Outcome(v1.toPrimitives())
+}
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Payment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Payment.kt
new file mode 100644
index 0000000000..1aa6f987fb
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Payment.kt
@@ -0,0 +1,17 @@
+package com.gemwallet.android.ext
+
+import com.wallet.core.primitives.Payment
+import com.wallet.core.primitives.PaymentLink
+import com.wallet.core.primitives.PaymentRequest
+
+val Payment.request: PaymentRequest?
+ get() = when (this) {
+ is Payment.Request -> content
+ is Payment.Link -> null
+ }
+
+val Payment.link: PaymentLink?
+ get() = when (this) {
+ is Payment.Request -> null
+ is Payment.Link -> content
+ }
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt
new file mode 100644
index 0000000000..d7afa20a72
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt
@@ -0,0 +1,98 @@
+package com.gemwallet.android.ext
+
+import com.gemwallet.android.math.hexToBigInteger
+import com.gemwallet.android.model.ConfirmParams
+import com.gemwallet.android.model.DestinationAddress
+import com.wallet.core.primitives.Account
+import com.wallet.core.primitives.Asset
+import com.wallet.core.primitives.TransactionAppMetadata
+import com.wallet.core.primitives.TransactionPaymentMetadata
+import com.wallet.core.primitives.TransactionType
+import uniffi.gemstone.SignableTransaction
+import uniffi.gemstone.TransferDataOutputType
+import java.math.BigInteger
+
+fun SignableTransaction.toConfirmParams(
+ requestId: String,
+ account: Account,
+ appMetadata: TransactionAppMetadata,
+ isSendable: Boolean,
+ inputType: ConfirmParams.TransferParams.InputType?,
+ payment: TransactionPaymentMetadata? = null,
+): ConfirmParams.TransferParams.Generic {
+ val asset = account.chain.asset()
+ return when (this) {
+ is SignableTransaction.Ethereum -> generic(
+ requestId = requestId,
+ asset = asset,
+ account = account,
+ appMetadata = appMetadata,
+ memo = data.data,
+ gasLimit = data.gasLimit,
+ inputType = inputType,
+ destination = DestinationAddress(data.to),
+ amount = data.value?.hexToBigInteger() ?: BigInteger.ZERO,
+ isSendable = isSendable,
+ transactionType = transactionType.toPrimitives(),
+ payment = payment,
+ )
+ is SignableTransaction.Solana -> encoded(requestId, asset, account, appMetadata, data.transaction, outputType, isSendable, payment)
+ is SignableTransaction.Sui -> encoded(requestId, asset, account, appMetadata, data.transaction, outputType, isSendable, payment)
+ is SignableTransaction.Ton -> encoded(requestId, asset, account, appMetadata, data, outputType, isSendable, payment)
+ is SignableTransaction.Tron -> encoded(requestId, asset, account, appMetadata, data, outputType, isSendable, payment)
+ }
+}
+
+private fun encoded(
+ requestId: String,
+ asset: Asset,
+ account: Account,
+ appMetadata: TransactionAppMetadata,
+ payload: String,
+ outputType: TransferDataOutputType,
+ isSendable: Boolean,
+ payment: TransactionPaymentMetadata?,
+) = generic(
+ requestId = requestId,
+ asset = asset,
+ account = account,
+ appMetadata = appMetadata,
+ memo = payload,
+ gasLimit = "",
+ inputType = when (outputType) {
+ TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction
+ TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature
+ },
+ destination = DestinationAddress(""),
+ amount = BigInteger.ZERO,
+ isSendable = isSendable,
+ payment = payment,
+)
+
+private fun generic(
+ requestId: String,
+ asset: Asset,
+ account: Account,
+ appMetadata: TransactionAppMetadata,
+ memo: String?,
+ gasLimit: String?,
+ inputType: ConfirmParams.TransferParams.InputType?,
+ destination: DestinationAddress,
+ amount: BigInteger,
+ isSendable: Boolean,
+ transactionType: TransactionType = TransactionType.SmartContractCall,
+ payment: TransactionPaymentMetadata? = null,
+) = ConfirmParams.TransferParams.Generic(
+ requestId = requestId,
+ asset = asset,
+ from = account,
+ memo = memo,
+ appMetadata = appMetadata,
+ gasLimit = gasLimit,
+ inputType = inputType,
+ destination = destination,
+ amount = amount,
+ isSendable = isSendable,
+ decodedTransactionType = transactionType,
+ payment = payment,
+)
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/TransactionExtendedExt.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/TransactionExtendedExt.kt
index ea754b0085..708b74ff53 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/TransactionExtendedExt.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/TransactionExtendedExt.kt
@@ -4,6 +4,7 @@ import com.wallet.core.primitives.Transaction
import com.gemwallet.android.serializer.jsonEncoder
import com.wallet.core.primitives.AssetId
import com.wallet.core.primitives.TransactionNFTTransferMetadata
+import com.wallet.core.primitives.TransactionPaymentMetadata
import com.wallet.core.primitives.TransactionPerpetualMetadata
import com.wallet.core.primitives.TransactionResourceTypeMetadata
import com.wallet.core.primitives.TransferDataOutputAction
@@ -26,6 +27,14 @@ fun getTransactionSwapMetadata(
metadata: String?,
): TransactionSwapMetadata? = decodeMetadata(type == TransactionType.Swap, metadata)
+fun Transaction.getPaymentMetadata(): TransactionPaymentMetadata? =
+ getTransactionPaymentMetadata(type, metadata)
+
+fun getTransactionPaymentMetadata(
+ type: TransactionType,
+ metadata: String?,
+): TransactionPaymentMetadata? = decodeMetadata(type == TransactionType.Transfer, metadata)
+
fun Transaction.getPerpetualMetadata(): TransactionPerpetualMetadata? {
val isPerpetual = type == TransactionType.PerpetualOpenPosition ||
type == TransactionType.PerpetualClosePosition ||
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt
index fee70d6f0c..a9d5951ab4 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt
@@ -8,6 +8,7 @@ import com.wallet.core.primitives.AssetType
import com.wallet.core.primitives.Chain
import com.wallet.core.primitives.Wallet
import com.wallet.core.primitives.WalletType
+import uniffi.gemstone.ChainAddress as GemChainAddress
fun Wallet.getAccount(chain: Chain): Account? {
return accounts.firstOrNull { it.chain == chain }
@@ -15,6 +16,9 @@ fun Wallet.getAccount(chain: Chain): Account? {
fun Wallet.getAccount(assetId: AssetId): Account? = getAccount(assetId.chain)
+fun Wallet.gemChainAddresses(): List =
+ accounts.map { GemChainAddress(chain = it.chain.string, address = it.address) }
+
val WalletType.isViewOnly: Boolean get() = this == WalletType.View
val WalletType.canSign: Boolean get() = !isViewOnly
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt
index 3abe85a4c1..7e16976de3 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt
@@ -2,16 +2,8 @@ package com.gemwallet.android.ext
import com.wallet.core.primitives.Account
import com.wallet.core.primitives.WalletConnectionSessionAppMetadata
-import uniffi.gemstone.GemWalletConnectionSessionAppMetadata
import uniffi.gemstone.walletConnectAppShortName
-fun WalletConnectionSessionAppMetadata.toGem() = GemWalletConnectionSessionAppMetadata(
- name = name,
- description = description,
- url = url,
- icon = icon,
-)
-
fun Account.toGem() = uniffi.gemstone.Account(
chain = chain.string,
address = address,
@@ -20,7 +12,7 @@ fun Account.toGem() = uniffi.gemstone.Account(
)
val WalletConnectionSessionAppMetadata.shortName: String
- get() = walletConnectAppShortName(toGem())
+ get() = walletConnectAppShortName(name)
fun List?.walletConnectIcon(): String {
return this?.firstOrNull { it.endsWith("png", ignoreCase = true) || it.endsWith("jpg", ignoreCase = true) }
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt
index 97decda16e..fb9f99f348 100644
--- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt
@@ -22,8 +22,10 @@ import com.wallet.core.primitives.DelegationValidator
import com.wallet.core.primitives.NFTAsset
import com.wallet.core.primitives.PerpetualType
import com.wallet.core.primitives.Resource
+import com.wallet.core.primitives.TransactionPaymentMetadata
+import com.wallet.core.primitives.TransactionAppMetadata
import com.wallet.core.primitives.TransactionType
-import com.wallet.core.primitives.swap.ApprovalData
+import com.wallet.core.primitives.ApprovalData
import kotlinx.serialization.Serializable
import uniffi.gemstone.GemAccountDataType
import uniffi.gemstone.GemApprovalData
@@ -33,7 +35,7 @@ import uniffi.gemstone.GemSwapQuoteDataType
import uniffi.gemstone.GemTransactionInputType
import uniffi.gemstone.GemTransactionInputType.*
import uniffi.gemstone.GemTransferDataExtra
-import uniffi.gemstone.GemWalletConnectionSessionAppMetadata
+import uniffi.gemstone.GemTransactionAppMetadata
import uniffi.gemstone.SwapperProvider
import uniffi.gemstone.TransferDataOutputAction
import uniffi.gemstone.TransferDataOutputType
@@ -184,22 +186,20 @@ sealed class ConfirmParams() {
override val useMaxAmount: Boolean = false,
override val inputType: InputType? = null,
val isSendable: Boolean,
- val name: String,
- val description: String,
- val url: String,
- val icon: String,
+ val appMetadata: TransactionAppMetadata,
val gasLimit: String?,
val decodedTransactionType: TransactionType = TransactionType.SmartContractCall,
+ val payment: TransactionPaymentMetadata? = null,
) : TransferParams() {
override fun toDto(): GemTransactionInputType {
val type = requireNotNull(inputType) { "inputType is required for Generic transactions" }
return Generic(
asset = asset.toGem(),
- metadata = GemWalletConnectionSessionAppMetadata(
- name = name,
- description = description,
- url = url,
- icon = icon,
+ appMetadata = GemTransactionAppMetadata(
+ name = appMetadata.name,
+ description = appMetadata.description,
+ url = appMetadata.url,
+ icon = appMetadata.icon,
),
extra = GemTransferDataExtra(
gasLimit = null,
@@ -233,10 +233,8 @@ sealed class ConfirmParams() {
result = 31 * result + destination.hashCode()
result = 31 * result + memo.hashCode()
result = 31 * result + useMaxAmount.hashCode()
- result = 31 * result + name.hashCode()
+ result = 31 * result + appMetadata.hashCode()
result = 31 * result + destination.hashCode()
- result = 31 * result + url.hashCode()
- result = 31 * result + icon.hashCode()
result = 31 * result + (gasLimit?.hashCode() ?: 0)
result = 31 * result + decodedTransactionType.hashCode()
return result
@@ -312,6 +310,7 @@ sealed class ConfirmParams() {
val data: String,
val provider: String,
val contract: String,
+ val approval: ApprovalData? = null,
) : ConfirmParams() {
override val useMaxAmount: Boolean = false
@@ -323,8 +322,8 @@ sealed class ConfirmParams() {
GemApprovalData(
assetId.tokenId!!,
spender = contract,
- value = amount.toString(),
- isUnlimited = true,
+ value = approval?.value ?: amount.toString(),
+ isUnlimited = approval?.isUnlimited ?: true,
)
)
diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt
new file mode 100644
index 0000000000..9ab1eb709a
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt
@@ -0,0 +1,12 @@
+package com.gemwallet.android.model
+
+import com.wallet.core.primitives.PaymentQuote
+import com.wallet.core.primitives.PaymentQuotes
+import uniffi.gemstone.PaymentAction
+
+data class PreparedPayment(
+ val quotes: PaymentQuotes,
+ val quote: PaymentQuote,
+ val actions: List,
+ val isRelayed: Boolean,
+)
diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Payment.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Payment.kt
new file mode 100644
index 0000000000..d282fa0099
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Payment.kt
@@ -0,0 +1,112 @@
+/**
+ * Generated by typeshare 1.13.3
+ */
+
+package com.wallet.core.primitives
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerialName
+
+@Serializable
+data class PaymentAmount (
+ val assetId: AssetId,
+ val value: String,
+ val symbol: String,
+ val decimals: Int
+)
+
+@Serializable
+enum class PaymentProviderName(val string: String) {
+ @SerialName("solanaPay")
+ SolanaPay("solanaPay"),
+ @SerialName("walletConnectPay")
+ WalletConnectPay("walletConnectPay"),
+}
+
+@Serializable
+data class PaymentLink (
+ val provider: PaymentProviderName,
+ val id: String
+)
+
+@Serializable
+data class PaymentMerchant (
+ val name: String,
+ val iconUrl: String? = null
+)
+
+@Serializable
+enum class PaymentStatus(val string: String) {
+ @SerialName("requires_action")
+ RequiresAction("requires_action"),
+ @SerialName("processing")
+ Processing("processing"),
+ @SerialName("succeeded")
+ Succeeded("succeeded"),
+ @SerialName("failed")
+ Failed("failed"),
+ @SerialName("expired")
+ Expired("expired"),
+ @SerialName("cancelled")
+ Cancelled("cancelled"),
+}
+
+@Serializable
+data class PaymentOutcome (
+ val status: PaymentStatus,
+ val transactionId: String? = null
+)
+
+@Serializable
+data class PaymentPrice (
+ val symbol: String,
+ val value: String,
+ val decimals: Int
+)
+
+@Serializable
+data class PaymentQuote (
+ val id: String,
+ val paymentId: String,
+ val amount: PaymentAmount,
+ val expiresAt: SerializedDate? = null,
+ val collectDataUrl: String? = null,
+ val providerData: String
+)
+
+@Serializable
+data class PaymentQuotes (
+ val merchant: PaymentMerchant,
+ val price: PaymentPrice? = null,
+ val expiresAt: SerializedDate? = null,
+ val quotes: List
+)
+
+@Serializable
+data class PaymentRequest (
+ val address: String,
+ val amount: String? = null,
+ val memo: String? = null,
+ val assetId: AssetId? = null
+)
+
+@Serializable
+sealed class Payment {
+ @Serializable
+ @SerialName("request")
+ data class Request(val content: PaymentRequest): Payment()
+ @Serializable
+ @SerialName("link")
+ data class Link(val content: PaymentLink): Payment()
+}
+
+@Serializable
+sealed class PaymentOptions {
+ @Serializable
+ @SerialName("quotes")
+ data class Quotes(val content: PaymentQuotes): PaymentOptions()
+ @Serializable
+ @SerialName("outcome")
+ data class Outcome(val content: PaymentOutcome): PaymentOptions()
+}
+
diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Signing.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Signing.kt
new file mode 100644
index 0000000000..137982d3dc
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Signing.kt
@@ -0,0 +1,42 @@
+/**
+ * Generated by typeshare 1.13.3
+ */
+
+package com.wallet.core.primitives
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerialName
+
+@Serializable
+data class EthereumTransactionData (
+ val chainId: Long? = null,
+ val from: String,
+ val to: String,
+ val value: String? = null,
+ val gas: String? = null,
+ val gasLimit: String? = null,
+ val gasPrice: String? = null,
+ val maxFeePerGas: String? = null,
+ val maxPriorityFeePerGas: String? = null,
+ val nonce: String? = null,
+ val data: String? = null
+)
+
+@Serializable
+enum class SignDigestType(val string: String) {
+ @SerialName("eip191")
+ Eip191("eip191"),
+ @SerialName("eip712")
+ Eip712("eip712"),
+ @SerialName("base58")
+ Base58("base58"),
+ @SerialName("suiPersonal")
+ SuiPersonal("suiPersonal"),
+ @SerialName("siwe")
+ Siwe("siwe"),
+ @SerialName("tonPersonal")
+ TonPersonal("tonPersonal"),
+ @SerialName("tronPersonal")
+ TronPersonal("tronPersonal"),
+}
+
diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionAppMetadata.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionAppMetadata.kt
new file mode 100644
index 0000000000..798123850e
--- /dev/null
+++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionAppMetadata.kt
@@ -0,0 +1,17 @@
+/**
+ * Generated by typeshare 1.13.3
+ */
+
+package com.wallet.core.primitives
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerialName
+
+@Serializable
+data class TransactionAppMetadata (
+ val name: String,
+ val description: String? = null,
+ val url: String? = null,
+ val icon: String? = null
+)
+
diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt
index 99ce863141..b6a6b2df49 100644
--- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt
+++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt
@@ -13,6 +13,13 @@ data class TransactionNFTTransferMetadata (
val name: String? = null
)
+@Serializable
+data class TransactionPaymentMetadata (
+ val paymentId: String,
+ val merchant: PaymentMerchant,
+ val provider: PaymentProviderName
+)
+
@Serializable
data class TransactionPerpetualMetadata (
val pnl: Double,
diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt
index 406e30a7b1..e7b744a35a 100644
--- a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt
+++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt
@@ -15,6 +15,7 @@ import com.gemwallet.android.testkit.mockSwapParams
import com.wallet.core.primitives.Chain
import com.wallet.core.primitives.PerpetualType
import com.wallet.core.primitives.Resource
+import com.wallet.core.primitives.TransactionAppMetadata
import com.wallet.core.primitives.TransactionType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -52,10 +53,12 @@ class ConfirmParamsTest {
memo = "0x01",
inputType = ConfirmParams.TransferParams.InputType.EncodeTransaction,
isSendable = true,
- name = "App",
- description = "Description",
- url = "https://example.com",
- icon = "https://example.com/icon.png",
+ appMetadata = TransactionAppMetadata(
+ name = "App",
+ description = "Description",
+ url = "https://example.com",
+ icon = "https://example.com/icon.png",
+ ),
gasLimit = "21000",
decodedTransactionType = TransactionType.SmartContractCall,
),
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index 904b5f8ee0..8e86b2ae28 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -121,6 +121,8 @@ include(":features:settings:security:viewmodels")
include(":features:settings:settings")
include(":features:settings:settings:presents")
include(":features:settings:settings:viewmodels")
+include(":features:payment:presents")
+include(":features:payment:viewmodels")
include(":features:bridge:presents")
include(":features:bridge:viewmodels")
include(":features:assets:presents")
diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/QRScanner.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/QRScanner.kt
index ee012c9e60..c3e5fc9fc9 100644
--- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/QRScanner.kt
+++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/QRScanner.kt
@@ -50,6 +50,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest
+import com.gemwallet.android.ext.request
+import com.gemwallet.android.ext.toPrimitives
import com.gemwallet.android.ui.R
import com.gemwallet.android.ui.components.screen.Scene
import com.gemwallet.android.ui.icons.AppIcons
@@ -130,16 +132,19 @@ fun QRScannerScene(
val coroutineScope = rememberCoroutineScope()
var imageUri by remember { mutableStateOf(null) }
var imageResult by remember { mutableStateOf("") }
+ var imagePreview by remember { mutableStateOf("") }
var imageError by remember { mutableStateOf("") }
val galleryLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? ->
imageUri = uri
imageResult = ""
+ imagePreview = ""
imageError = ""
}
val cancel = {
imageUri = null
imageError = ""
imageResult = ""
+ imagePreview = ""
}
LaunchedEffect(imageUri) {
val image = imageUri ?: return@LaunchedEffect
@@ -161,10 +166,12 @@ fun QRScannerScene(
mapOf(DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE))
)
}.decode(binaryBmp)
- imageResult = paymentDecodeUrl(result.text).address
- if (imageResult.isEmpty()) {
+ val preview = paymentDecodeUrl(result.text).toPrimitives().request?.address ?: result.text
+ if (preview.isEmpty()) {
throw Exception()
}
+ imageResult = result.text
+ imagePreview = preview
} catch (e: Exception) {
imageError = e.message ?: "Unknown error"
}
@@ -210,7 +217,7 @@ fun QRScannerScene(
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
- if (imageResult.isNotEmpty()) {
+ if (imagePreview.isNotEmpty()) {
Column(
modifier = Modifier
.padding(40.dp)
@@ -222,7 +229,7 @@ fun QRScannerScene(
.defaultPadding()
.background(Color.Black, MaterialTheme.shapes.medium)
.defaultPadding(),
- text = imageResult,
+ text = imagePreview,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.W300)
diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt
new file mode 100644
index 0000000000..a716fa4bea
--- /dev/null
+++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt
@@ -0,0 +1,35 @@
+package com.gemwallet.android.ui.components.list_item.property
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.gemwallet.android.ui.models.ListPosition
+import kotlinx.coroutines.delay
+
+private const val TICK_MS = 1000L
+
+@Composable
+fun PropertyExpiryItem(
+ title: String,
+ expiresAt: Long,
+ listPosition: ListPosition,
+) {
+ var remaining by remember(expiresAt) { mutableLongStateOf(expiresAt - System.currentTimeMillis()) }
+
+ LaunchedEffect(expiresAt) {
+ while (remaining > 0) {
+ delay(TICK_MS)
+ remaining = expiresAt - System.currentTimeMillis()
+ }
+ }
+
+ val seconds = (remaining / TICK_MS).coerceAtLeast(0)
+ PropertyItem(
+ title = title,
+ data = "%d:%02d".format(seconds / 60, seconds % 60),
+ listPosition = listPosition,
+ )
+}
diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/message/SignMessageContent.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/message/SignMessageContent.kt
new file mode 100644
index 0000000000..2bd919cb6a
--- /dev/null
+++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/message/SignMessageContent.kt
@@ -0,0 +1,93 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package com.gemwallet.android.ui.components.message
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.gemwallet.android.ui.R
+import com.gemwallet.android.ui.components.list_item.SubheaderItem
+import com.gemwallet.android.ui.components.list_item.listItem
+import com.gemwallet.android.ui.components.list_item.property.PropertyItem
+import com.gemwallet.android.ui.components.screen.ModalBottomSheet
+import com.gemwallet.android.ui.components.simulation.simulationPayloadDetailsContent
+import com.gemwallet.android.ui.models.ListPosition
+import com.gemwallet.android.ui.models.PayloadField
+import com.gemwallet.android.ui.theme.paddingDefault
+
+enum class SignMessageSheetType {
+ Details,
+ FullMessage,
+}
+
+fun LazyListScope.signMessageText(message: String) {
+ item {
+ SubheaderItem(R.string.sign_message_message)
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .listItem()
+ .padding(paddingDefault),
+ text = message,
+ )
+ }
+}
+
+@Composable
+fun SignMessagePayloadDetailsSheet(
+ primaryFields: List,
+ secondaryFields: List,
+ onViewFullMessage: () -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ title = stringResource(R.string.common_details),
+ ) {
+ LazyColumn {
+ simulationPayloadDetailsContent(
+ primaryFields = primaryFields,
+ secondaryFields = secondaryFields,
+ )
+ item {
+ PropertyItem(
+ action = R.string.sign_message_view_full_message,
+ listPosition = ListPosition.Single,
+ onClick = onViewFullMessage,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SignMessageFullMessageSheet(
+ message: String,
+ onDismissRequest: () -> Unit,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ title = stringResource(R.string.sign_message_view_full_message),
+ ) {
+ LazyColumn(
+ contentPadding = PaddingValues(paddingDefault),
+ ) {
+ item {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = message,
+ )
+ }
+ }
+ }
+}
diff --git a/android/ui/src/main/res/values-ar/strings.xml b/android/ui/src/main/res/values-ar/strings.xml
index 5468390d19..51b979da85 100644
--- a/android/ui/src/main/res/values-ar/strings.xml
+++ b/android/ui/src/main/res/values-ar/strings.xml
@@ -303,6 +303,12 @@
الأذونات
اطلع على رصيدك ونشاطك
إرسال طلبات الموافقة
+ انتهت صلاحية الدفعة
+ الدفع غير مسموح به
+ تنتهي صلاحية الدفعة خلال
+ ادفع بواسطة
+ الدفع
+ التاجر
انت تدفع
انت تستقبل
تأثير السعر
diff --git a/android/ui/src/main/res/values-bn/strings.xml b/android/ui/src/main/res/values-bn/strings.xml
index e6a05d1363..67f418b68e 100644
--- a/android/ui/src/main/res/values-bn/strings.xml
+++ b/android/ui/src/main/res/values-bn/strings.xml
@@ -303,6 +303,12 @@
অনুমতি
আপনার ব্যালেন্স এবং কার্যকলাপ দেখুন
অনুমোদনের অনুরোধ পাঠান
+ পেমেন্টের মেয়াদ শেষ
+ পেমেন্ট অনুমোদিত নয়
+ পেমেন্টের মেয়াদ শেষ হবে
+ যা দিয়ে পরিশোধ করবেন
+ পেমেন্ট
+ বিক্রেতা
আপনি পরিশোধ করেন
আপনি পাবেন
দামের প্রভাব
diff --git a/android/ui/src/main/res/values-cs/strings.xml b/android/ui/src/main/res/values-cs/strings.xml
index a85ddb32eb..0cd1880c99 100644
--- a/android/ui/src/main/res/values-cs/strings.xml
+++ b/android/ui/src/main/res/values-cs/strings.xml
@@ -303,6 +303,12 @@
Oprávnění
Zobrazení zůstatku a aktivity
Odeslat žádosti o schválení
+ Platba vypršela
+ Platba není povolena
+ Platba vyprší za
+ Zaplatit pomocí
+ Platba
+ Obchodník
Vy platíte
Přijímáte
Vliv ceny
diff --git a/android/ui/src/main/res/values-da/strings.xml b/android/ui/src/main/res/values-da/strings.xml
index d73e5b91fe..4332e890b0 100644
--- a/android/ui/src/main/res/values-da/strings.xml
+++ b/android/ui/src/main/res/values-da/strings.xml
@@ -303,6 +303,12 @@
Tilladelser
Se din saldo og aktivitet
Send godkendelsesanmodninger
+ Betaling udløbet
+ Betaling ikke tilladt
+ Betaling udløber om
+ Betal med
+ Betaling
+ Forhandler
Du betaler
Du modtager
Prispåvirkning
diff --git a/android/ui/src/main/res/values-de/strings.xml b/android/ui/src/main/res/values-de/strings.xml
index 4fb763974d..5d1e5c04e8 100644
--- a/android/ui/src/main/res/values-de/strings.xml
+++ b/android/ui/src/main/res/values-de/strings.xml
@@ -303,6 +303,12 @@
Berechtigungen
Sehen Sie Ihren Kontostand und Ihre Aktivitäten ein.
Genehmigungsanfragen senden
+ Zahlung abgelaufen
+ Zahlung nicht zulässig
+ Zahlung läuft ab in
+ Bezahlen mit
+ Zahlung
+ Händler
Sie bezahlen
Sie erhalten
Preisauswirkungen
diff --git a/android/ui/src/main/res/values-es/strings.xml b/android/ui/src/main/res/values-es/strings.xml
index 43c0965100..55037a87af 100644
--- a/android/ui/src/main/res/values-es/strings.xml
+++ b/android/ui/src/main/res/values-es/strings.xml
@@ -303,6 +303,12 @@
Permisos
Consulta tu saldo y actividad.
Enviar solicitudes de aprobación
+ Pago caducado
+ Pago no permitido
+ El pago caduca en
+ Pagar con
+ Pago
+ Comercio
Tu pagas
Recibes
Impacto en el precio
diff --git a/android/ui/src/main/res/values-fa/strings.xml b/android/ui/src/main/res/values-fa/strings.xml
index 94bc1cf2e0..5c23927298 100644
--- a/android/ui/src/main/res/values-fa/strings.xml
+++ b/android/ui/src/main/res/values-fa/strings.xml
@@ -303,6 +303,12 @@
مجوزها
مشاهده موجودی و فعالیت خود
ارسال درخواستهای تأیید
+ پرداخت منقضی شد
+ پرداخت مجاز نیست
+ انقضای پرداخت تا
+ پرداخت با
+ پرداخت
+ فروشنده
شما پرداخت میکنید
شما دریافت میکنید
تاثیر قیمت
diff --git a/android/ui/src/main/res/values-fil/strings.xml b/android/ui/src/main/res/values-fil/strings.xml
index 24bf2cf613..7e145967bc 100644
--- a/android/ui/src/main/res/values-fil/strings.xml
+++ b/android/ui/src/main/res/values-fil/strings.xml
@@ -303,6 +303,12 @@
Mga Pahintulot
Tingnan ang iyong balanse at aktibidad
Magpadala ng mga kahilingan sa pag-apruba
+ Nag-expire ang bayad
+ Hindi pinapayagan ang bayad
+ Mag-e-expire ang bayad sa
+ Bayaran gamit ang
+ Bayad
+ Merchant
Babayaran Mo
Matatanggap Mo
Epekto sa Presyo
diff --git a/android/ui/src/main/res/values-fr/strings.xml b/android/ui/src/main/res/values-fr/strings.xml
index ab64e8cb1a..b6a8752ff8 100644
--- a/android/ui/src/main/res/values-fr/strings.xml
+++ b/android/ui/src/main/res/values-fr/strings.xml
@@ -303,6 +303,12 @@
Autorisations
Consultez votre solde et votre activité
Envoyer les demandes d\'approbation
+ Paiement expiré
+ Paiement non autorisé
+ Le paiement expire dans
+ Payer avec
+ Paiement
+ Marchand
Vous payez
Vous recevez
Impact sur les prix
diff --git a/android/ui/src/main/res/values-ha/strings.xml b/android/ui/src/main/res/values-ha/strings.xml
index 6c1dbb1aaf..49313559d0 100644
--- a/android/ui/src/main/res/values-ha/strings.xml
+++ b/android/ui/src/main/res/values-ha/strings.xml
@@ -303,6 +303,12 @@
Izini
Duba ma\'aunin ku da ayyukan ku
Aika buƙatun amincewa
+ Biyan ya ƙare
+ Ba a yarda da biyan ba
+ Biyan zai ƙare cikin
+ Biya da
+ Biya
+ Ɗan kasuwa
Kuna Biya
Kuna karba
Tasirin Farashin
diff --git a/android/ui/src/main/res/values-hi/strings.xml b/android/ui/src/main/res/values-hi/strings.xml
index 5736df52e3..4b82b6ea3e 100644
--- a/android/ui/src/main/res/values-hi/strings.xml
+++ b/android/ui/src/main/res/values-hi/strings.xml
@@ -303,6 +303,12 @@
अनुमतियां
अपना बैलेंस और गतिविधि देखें
अनुमोदन अनुरोध भेजें
+ भुगतान समाप्त
+ भुगतान की अनुमति नहीं है
+ भुगतान समाप्त होने में
+ इससे भुगतान करें
+ भुगतान
+ व्यापारी
आप भुगतान करें
आप प्राप्त करें
मूल्य प्रभाव
diff --git a/android/ui/src/main/res/values-id/strings.xml b/android/ui/src/main/res/values-id/strings.xml
index 4cbc5df66f..4519bf5c76 100644
--- a/android/ui/src/main/res/values-id/strings.xml
+++ b/android/ui/src/main/res/values-id/strings.xml
@@ -303,6 +303,12 @@
Izin
Lihat saldo dan aktivitas Anda
Kirim permintaan persetujuan
+ Pembayaran kedaluwarsa
+ Pembayaran tidak diizinkan
+ Pembayaran kedaluwarsa dalam
+ Bayar dengan
+ Pembayaran
+ Pedagang
Kamu Membayar
Kamu Menerima
Dampak Harga
diff --git a/android/ui/src/main/res/values-it/strings.xml b/android/ui/src/main/res/values-it/strings.xml
index 56aa9b4006..e6d5868aae 100644
--- a/android/ui/src/main/res/values-it/strings.xml
+++ b/android/ui/src/main/res/values-it/strings.xml
@@ -303,6 +303,12 @@
Autorizzazioni
Visualizza il tuo saldo e la tua attività
Invia richieste di approvazione
+ Pagamento scaduto
+ Pagamento non consentito
+ Il pagamento scade tra
+ Paga con
+ Pagamento
+ Esercente
Paghi tu
Ricevi
Impatto sul prezzo
diff --git a/android/ui/src/main/res/values-iw/strings.xml b/android/ui/src/main/res/values-iw/strings.xml
index d400a49560..cfedefb08e 100644
--- a/android/ui/src/main/res/values-iw/strings.xml
+++ b/android/ui/src/main/res/values-iw/strings.xml
@@ -303,6 +303,12 @@
הרשאות
צפה ביתרה ובפעילות שלך
שלח בקשות אישור
+ התשלום פג תוקף
+ התשלום אינו מורשה
+ התשלום יפוג בעוד
+ שלם באמצעות
+ תשלום
+ בית עסק
אתה משלם
אתה מקבל
השפעת המחיר
diff --git a/android/ui/src/main/res/values-ja/strings.xml b/android/ui/src/main/res/values-ja/strings.xml
index 9a04538995..6118ab3fd7 100644
--- a/android/ui/src/main/res/values-ja/strings.xml
+++ b/android/ui/src/main/res/values-ja/strings.xml
@@ -303,6 +303,12 @@
権限
残高とアクティビティを確認する
承認依頼を送信する
+ 支払いの期限切れ
+ 支払いは許可されていません
+ 支払い期限まで
+ 支払い方法
+ 支払い
+ 加盟店
あなたが支払う
受け取るもの
価格の影響
diff --git a/android/ui/src/main/res/values-ko/strings.xml b/android/ui/src/main/res/values-ko/strings.xml
index e22828ce6e..0a31c80a13 100644
--- a/android/ui/src/main/res/values-ko/strings.xml
+++ b/android/ui/src/main/res/values-ko/strings.xml
@@ -303,6 +303,12 @@
권한
잔액과 활동 내역을 확인하세요
승인 요청 보내기
+ 결제 만료됨
+ 결제가 허용되지 않음
+ 결제 만료까지
+ 결제 수단
+ 결제
+ 가맹점
지불 금액
수령 금액
가격 영향
diff --git a/android/ui/src/main/res/values-ms/strings.xml b/android/ui/src/main/res/values-ms/strings.xml
index 93f9910b24..53e4bb45aa 100644
--- a/android/ui/src/main/res/values-ms/strings.xml
+++ b/android/ui/src/main/res/values-ms/strings.xml
@@ -303,6 +303,12 @@
Kebenaran
Lihat baki dan aktiviti anda
Hantar permintaan kelulusan
+ Pembayaran tamat tempoh
+ Pembayaran tidak dibenarkan
+ Pembayaran tamat tempoh dalam
+ Bayar dengan
+ Pembayaran
+ Peniaga
Anda Bayar
Anda Terima
Kesan Harga
diff --git a/android/ui/src/main/res/values-nl/strings.xml b/android/ui/src/main/res/values-nl/strings.xml
index 87a96b1940..647614ab2c 100644
--- a/android/ui/src/main/res/values-nl/strings.xml
+++ b/android/ui/src/main/res/values-nl/strings.xml
@@ -303,6 +303,12 @@
Toestemmingen
Bekijk je saldo en activiteit
Verzoeken om goedkeuring verzenden
+ Betaling verlopen
+ Betaling niet toegestaan
+ Betaling verloopt over
+ Betalen met
+ Betaling
+ Verkoper
Jij betaalt
Jij ontvangt
Prijsimpact
diff --git a/android/ui/src/main/res/values-pl/strings.xml b/android/ui/src/main/res/values-pl/strings.xml
index 8a0c22167c..6ad8043161 100644
--- a/android/ui/src/main/res/values-pl/strings.xml
+++ b/android/ui/src/main/res/values-pl/strings.xml
@@ -303,6 +303,12 @@
Uprawnienia
Wyświetl swoje saldo i aktywność
Wyślij prośby o zatwierdzenie
+ Płatność wygasła
+ Płatność niedozwolona
+ Płatność wygasa za
+ Zapłać za pomocą
+ Płatność
+ Sprzedawca
Ty płacisz
Otrzymujesz
Wpływ na cenę
diff --git a/android/ui/src/main/res/values-pt-rBR/strings.xml b/android/ui/src/main/res/values-pt-rBR/strings.xml
index 16f34b26cb..56c1c9025e 100644
--- a/android/ui/src/main/res/values-pt-rBR/strings.xml
+++ b/android/ui/src/main/res/values-pt-rBR/strings.xml
@@ -303,6 +303,12 @@
Permissões
Veja seu saldo e atividade
Enviar solicitações de aprovação
+ Pagamento expirado
+ Pagamento não permitido
+ O pagamento expira em
+ Pagar com
+ Pagamento
+ Comerciante
Você paga
Você recebe
Impacto no preço
diff --git a/android/ui/src/main/res/values-ro/strings.xml b/android/ui/src/main/res/values-ro/strings.xml
index fe38847937..14c17b7781 100644
--- a/android/ui/src/main/res/values-ro/strings.xml
+++ b/android/ui/src/main/res/values-ro/strings.xml
@@ -303,6 +303,12 @@
Permisiuni
Vizualizați soldul și activitatea dvs.
Trimiteți cereri de aprobare
+ Plată expirată
+ Plată nepermisă
+ Plata expiră în
+ Plătește cu
+ Plată
+ Comerciant
Tu plătești
Tu Primești
Impactul prețului
diff --git a/android/ui/src/main/res/values-ru/strings.xml b/android/ui/src/main/res/values-ru/strings.xml
index 6d2a08aae7..0d92d13883 100644
--- a/android/ui/src/main/res/values-ru/strings.xml
+++ b/android/ui/src/main/res/values-ru/strings.xml
@@ -303,6 +303,12 @@
Разрешения
Просматривать баланс и активность
Отправлять запросы на подтверждение
+ Срок платежа истёк
+ Платёж не разрешён
+ Платёж истекает через
+ Оплатить с помощью
+ Платёж
+ Продавец
Вы платите
Вы получаете
Влияние цены
diff --git a/android/ui/src/main/res/values-sw/strings.xml b/android/ui/src/main/res/values-sw/strings.xml
index e2364c6f8b..9a13ec7d34 100644
--- a/android/ui/src/main/res/values-sw/strings.xml
+++ b/android/ui/src/main/res/values-sw/strings.xml
@@ -303,6 +303,12 @@
Ruhusa
Tazama salio na shughuli zako
Tuma maombi ya idhini
+ Malipo yamekwisha muda
+ Malipo hayaruhusiwi
+ Malipo yataisha baada ya
+ Lipa kwa
+ Malipo
+ Mfanyabiashara
Unalipa
Unapokea
Athari ya Bei
diff --git a/android/ui/src/main/res/values-th/strings.xml b/android/ui/src/main/res/values-th/strings.xml
index 77c28184ca..d192526bbe 100644
--- a/android/ui/src/main/res/values-th/strings.xml
+++ b/android/ui/src/main/res/values-th/strings.xml
@@ -303,6 +303,12 @@
สิทธิ์การเข้าถึง
ตรวจสอบยอดเงินคงเหลือและกิจกรรมของคุณ
ส่งคำขออนุมัติ
+ การชำระเงินหมดอายุ
+ ไม่อนุญาตให้ชำระเงิน
+ การชำระเงินหมดอายุใน
+ ชำระด้วย
+ การชำระเงิน
+ ร้านค้า
คุณจ่าย
คุณได้รับ
ผลกระทบต่อราคา
diff --git a/android/ui/src/main/res/values-tr/strings.xml b/android/ui/src/main/res/values-tr/strings.xml
index 3b9595343a..3c5cbd1909 100644
--- a/android/ui/src/main/res/values-tr/strings.xml
+++ b/android/ui/src/main/res/values-tr/strings.xml
@@ -303,6 +303,12 @@
İzinler
Bakiyenizi ve işlemlerinizi görüntüleyin.
Onay isteklerini gönderin
+ Ödeme süresi doldu
+ Ödemeye izin verilmiyor
+ Ödemenin süresi doluyor
+ Şununla öde
+ Ödeme
+ Satıcı
Öde
Alacağın
Fiyat Etkisi
diff --git a/android/ui/src/main/res/values-uk/strings.xml b/android/ui/src/main/res/values-uk/strings.xml
index 2fa25a18a9..f9b3a6cbfc 100644
--- a/android/ui/src/main/res/values-uk/strings.xml
+++ b/android/ui/src/main/res/values-uk/strings.xml
@@ -303,6 +303,12 @@
Дозволи
Перегляд вашого балансу та активності
Надсилати запити на схвалення
+ Термін платежу минув
+ Платіж не дозволено
+ Платіж спливає через
+ Оплатити за допомогою
+ Платіж
+ Продавець
Ви платите
Ви отримуєте
Вплив ціни
diff --git a/android/ui/src/main/res/values-ur/strings.xml b/android/ui/src/main/res/values-ur/strings.xml
index 1fc6d29294..cd242af38f 100644
--- a/android/ui/src/main/res/values-ur/strings.xml
+++ b/android/ui/src/main/res/values-ur/strings.xml
@@ -303,6 +303,12 @@
اجازتیں
اپنا توازن اور سرگرمی دیکھیں
منظوری کی درخواستیں بھیجیں۔
+ ادائیگی کی میعاد ختم ہو گئی
+ ادائیگی کی اجازت نہیں
+ ادائیگی ختم ہونے میں
+ اس سے ادائیگی کریں
+ ادائیگی
+ تاجر
آپ ادا کرتے ہیں
آپ وصول کرتے ہیں
قیمت کا اثر
diff --git a/android/ui/src/main/res/values-vi/strings.xml b/android/ui/src/main/res/values-vi/strings.xml
index 934f48ad74..cbad2da399 100644
--- a/android/ui/src/main/res/values-vi/strings.xml
+++ b/android/ui/src/main/res/values-vi/strings.xml
@@ -303,6 +303,12 @@
Quyền hạn
Xem số dư và hoạt động của bạn
Gửi yêu cầu phê duyệt
+ Thanh toán đã hết hạn
+ Thanh toán không được phép
+ Thanh toán hết hạn sau
+ Thanh toán bằng
+ Thanh toán
+ Người bán
Bạn trả
Bạn nhận được
Tác động giá
diff --git a/android/ui/src/main/res/values-zh-rCN/strings.xml b/android/ui/src/main/res/values-zh-rCN/strings.xml
index 268af76263..c1170d6071 100644
--- a/android/ui/src/main/res/values-zh-rCN/strings.xml
+++ b/android/ui/src/main/res/values-zh-rCN/strings.xml
@@ -303,6 +303,12 @@
权限
查看您的余额和活动
发送审批请求
+ 支付已过期
+ 不允许支付
+ 支付将于以下时间过期
+ 支付方式
+ 支付
+ 商户
将支付
将收到
价格影响
diff --git a/android/ui/src/main/res/values-zh-rTW/strings.xml b/android/ui/src/main/res/values-zh-rTW/strings.xml
index 4a23544a75..b6c9e81069 100644
--- a/android/ui/src/main/res/values-zh-rTW/strings.xml
+++ b/android/ui/src/main/res/values-zh-rTW/strings.xml
@@ -303,6 +303,12 @@
權限
查看您的餘額和活動
發送審批請求
+ 付款已過期
+ 不允許付款
+ 付款將於以下時間過期
+ 付款方式
+ 付款
+ 商戶
將支付
將收到
價格影響
diff --git a/android/ui/src/main/res/values/strings.xml b/android/ui/src/main/res/values/strings.xml
index 36d869cfab..4e81fda170 100644
--- a/android/ui/src/main/res/values/strings.xml
+++ b/android/ui/src/main/res/values/strings.xml
@@ -303,6 +303,12 @@
Permissions
View your balance and activity
Send approval requests
+ Payment Expired
+ Payment not allowed
+ Payment expires in
+ Pay with
+ Payment
+ Merchant
You Pay
You Receive
Price Impact
diff --git a/core/Cargo.lock b/core/Cargo.lock
index f1b4ad88ae..571b1de44b 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -4121,6 +4121,7 @@ dependencies = [
"hex",
"num-bigint 0.5.1",
"number_formatter",
+ "payment",
"primitives",
"serde_json",
"signer",
@@ -5985,6 +5986,24 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+[[package]]
+name = "payment"
+version = "2.114.6"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "gem_client",
+ "gem_evm",
+ "gem_jsonrpc",
+ "gem_wallet_connect",
+ "num-bigint 0.5.1",
+ "primitives",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
[[package]]
name = "pbkdf2"
version = "0.12.2"
diff --git a/core/Cargo.toml b/core/Cargo.toml
index 3cf330d403..841b8de99d 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -31,6 +31,7 @@ members = [
"crates/lists",
"crates/settings",
"crates/settings_chain",
+ "crates/payment",
"crates/pricer",
"crates/chain_primitives",
"crates/chain_traits",
diff --git a/core/crates/gem_evm/src/signer/chain_signer.rs b/core/crates/gem_evm/src/signer/chain_signer.rs
index 24f2c86c01..6030ef363a 100644
--- a/core/crates/gem_evm/src/signer/chain_signer.rs
+++ b/core/crates/gem_evm/src/signer/chain_signer.rs
@@ -200,7 +200,7 @@ mod tests {
use super::*;
use primitives::testkit::signer_mock::TEST_PRIVATE_KEY;
use primitives::{
- Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, WalletConnectionSessionAppMetadata,
+ Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionAppMetadata, TransactionInputType, TransactionLoadMetadata, TransferDataExtra,
contract_call_data::ContractCallData, nft::NFTAsset, swap::*,
};
@@ -338,7 +338,7 @@ mod tests {
let signer = EvmChainSigner;
let extra = TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]);
let input = SignerInput::mock_evm(
- TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), WalletConnectionSessionAppMetadata::mock(), extra),
+ TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), TransactionAppMetadata::mock(), extra),
"0",
100000,
);
diff --git a/core/crates/gem_tron/src/signer/chain_signer.rs b/core/crates/gem_tron/src/signer/chain_signer.rs
index 02e653309c..668c857d50 100644
--- a/core/crates/gem_tron/src/signer/chain_signer.rs
+++ b/core/crates/gem_tron/src/signer/chain_signer.rs
@@ -45,9 +45,9 @@ mod tests {
use gem_hash::sha2::sha256;
use num_bigint::BigInt;
use primitives::{
- Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, SwapProvider, TransactionFee,
- TransactionInputType, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote,
- WalletConnectionSessionAppMetadata, decode_hex,
+ Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, SwapProvider, TransactionAppMetadata,
+ TransactionFee, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote,
+ decode_hex,
swap::{ApprovalData, SwapData, SwapQuote, SwapQuoteData},
};
use serde_json::{Value, json};
@@ -655,12 +655,7 @@ mod tests {
SignerInput::mock_tron(
TransactionInputType::Generic(
Asset::from_chain(Chain::Tron),
- WalletConnectionSessionAppMetadata {
- name: "Test".to_string(),
- description: "Test".to_string(),
- url: "https://example.com".to_string(),
- icon: "https://example.com/icon.png".to_string(),
- },
+ TransactionAppMetadata::mock(),
TransferDataExtra {
data: Some(payload),
output_type,
diff --git a/core/crates/gem_wallet_connect/Cargo.toml b/core/crates/gem_wallet_connect/Cargo.toml
index a46d872743..9289abc476 100644
--- a/core/crates/gem_wallet_connect/Cargo.toml
+++ b/core/crates/gem_wallet_connect/Cargo.toml
@@ -3,15 +3,20 @@ name = "gem_wallet_connect"
edition = { workspace = true }
version = { workspace = true }
+[features]
+default = []
+request = ["dep:url"]
+session = ["request", "dep:base64"]
+
[dependencies]
primitives = { path = "../primitives" }
gem_evm = { path = "../gem_evm" }
gem_ton = { path = "../gem_ton", features = ["signer"] }
-base64 = { workspace = true }
+base64 = { workspace = true, optional = true }
hex = { workspace = true }
serde_json = { workspace = true }
-url = { workspace = true }
+url = { workspace = true, optional = true }
[dev-dependencies]
primitives = { path = "../primitives", features = ["testkit"] }
diff --git a/core/crates/gem_wallet_connect/src/actions.rs b/core/crates/gem_wallet_connect/src/actions.rs
index c06b49043c..ec8d564804 100644
--- a/core/crates/gem_wallet_connect/src/actions.rs
+++ b/core/crates/gem_wallet_connect/src/actions.rs
@@ -1,5 +1,4 @@
-use crate::sign_type::SignDigestType;
-use primitives::{Chain, TransactionType, TransferDataOutputType, WCEthereumTransaction};
+use primitives::{Chain, SignDigestType, SignableTransactionType};
#[derive(Debug, Clone, PartialEq)]
pub enum WalletConnectAction {
@@ -10,17 +9,17 @@ pub enum WalletConnectAction {
},
SignTransaction {
chain: Chain,
- transaction_type: WalletConnectTransactionType,
+ transaction_type: SignableTransactionType,
data: String,
},
SignAllTransactions {
chain: Chain,
- transaction_type: WalletConnectTransactionType,
+ transaction_type: SignableTransactionType,
transactions: Vec,
},
SendTransaction {
chain: Chain,
- transaction_type: WalletConnectTransactionType,
+ transaction_type: SignableTransactionType,
data: String,
},
ChainOperation {
@@ -34,24 +33,6 @@ pub enum WalletConnectAction {
},
}
-#[derive(Debug, Clone, PartialEq)]
-pub enum WalletConnectTransactionType {
- Ethereum,
- Solana { output_type: TransferDataOutputType },
- Sui { output_type: TransferDataOutputType },
- Ton { output_type: TransferDataOutputType },
- Tron { output_type: TransferDataOutputType },
-}
-
-impl WalletConnectTransactionType {
- pub fn get_output_type(&self) -> Option {
- match self {
- Self::Ethereum => None,
- Self::Solana { output_type } | Self::Sui { output_type } | Self::Ton { output_type } | Self::Tron { output_type } => Some(output_type.clone()),
- }
- }
-}
-
#[derive(Debug, Clone, PartialEq)]
pub enum WalletConnectChainOperation {
AddChain,
@@ -59,77 +40,8 @@ pub enum WalletConnectChainOperation {
GetChainId,
}
-#[derive(Debug, Clone)]
-pub struct WCEthereumTransactionData {
- pub chain_id: Option,
- pub from: String,
- pub to: String,
- pub value: Option,
- pub gas: Option,
- pub gas_limit: Option,
- pub gas_price: Option,
- pub max_fee_per_gas: Option,
- pub max_priority_fee_per_gas: Option,
- pub nonce: Option,
- pub data: Option,
-}
-
-#[derive(Debug, Clone)]
-pub struct WCSolanaTransactionData {
- pub transaction: String,
-}
-
-#[derive(Debug, Clone)]
-pub struct WCSuiTransactionData {
- pub transaction: String,
- pub wallet_address: String,
-}
-
-#[derive(Debug, Clone)]
-#[allow(clippy::large_enum_variant)]
-pub enum WalletConnectTransaction {
- Ethereum {
- data: WCEthereumTransactionData,
- transaction_type: TransactionType,
- },
- Solana {
- data: WCSolanaTransactionData,
- output_type: TransferDataOutputType,
- },
- Sui {
- data: WCSuiTransactionData,
- output_type: TransferDataOutputType,
- },
- Ton {
- data: String,
- output_type: TransferDataOutputType,
- },
- Tron {
- data: String,
- output_type: TransferDataOutputType,
- },
-}
-
#[derive(Debug, Clone, PartialEq)]
pub enum WalletConnectResponseType {
String { value: String },
Object { json: String },
}
-
-impl From for WCEthereumTransactionData {
- fn from(tx: WCEthereumTransaction) -> Self {
- Self {
- chain_id: tx.chain_id,
- from: tx.from,
- to: tx.to,
- value: tx.value,
- gas: tx.gas,
- gas_limit: tx.gas_limit,
- gas_price: tx.gas_price,
- max_fee_per_gas: tx.max_fee_per_gas,
- max_priority_fee_per_gas: tx.max_priority_fee_per_gas,
- nonce: tx.nonce,
- data: tx.data,
- }
- }
-}
diff --git a/core/crates/gem_wallet_connect/src/decode.rs b/core/crates/gem_wallet_connect/src/decode.rs
index 91d2d74ca2..f266fc5aa2 100644
--- a/core/crates/gem_wallet_connect/src/decode.rs
+++ b/core/crates/gem_wallet_connect/src/decode.rs
@@ -2,7 +2,7 @@ use gem_evm::siwe::SiweMessage;
use hex::FromHex;
use primitives::Chain;
-use crate::sign_type::{SignDigestType, SignMessage};
+use primitives::{SignDigestType, SignMessage};
pub fn decode_sign_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage {
let mut utf8_value = None;
diff --git a/core/crates/gem_wallet_connect/src/lib.rs b/core/crates/gem_wallet_connect/src/lib.rs
index c854ad5a0b..2277fb4852 100644
--- a/core/crates/gem_wallet_connect/src/lib.rs
+++ b/core/crates/gem_wallet_connect/src/lib.rs
@@ -1,21 +1,36 @@
-pub mod accounts;
+#[cfg(feature = "request")]
pub mod actions;
+#[cfg(feature = "request")]
pub mod decode;
+#[cfg(feature = "request")]
pub mod request_handler;
+
+#[cfg(feature = "session")]
+pub mod accounts;
+#[cfg(feature = "session")]
pub mod response_handler;
+#[cfg(feature = "session")]
pub mod session;
-pub mod sign_type;
+#[cfg(feature = "session")]
pub mod validator;
+#[cfg(feature = "session")]
pub mod verifier;
#[cfg(test)]
mod testkit;
+#[cfg(feature = "request")]
pub use actions::*;
+#[cfg(feature = "request")]
pub use decode::decode_sign_message;
+#[cfg(feature = "request")]
pub use request_handler::WalletConnectRequestHandler;
+
+#[cfg(feature = "session")]
pub use response_handler::WalletConnectResponseHandler;
+#[cfg(feature = "session")]
pub use session::config_session_properties;
-pub use sign_type::SignDigestType;
+#[cfg(feature = "session")]
pub use validator::{SignMessageValidation, validate_send_transaction, validate_sign_message};
+#[cfg(feature = "session")]
pub use verifier::WalletConnectVerifier;
diff --git a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs
index 876fc70bde..dd28bac820 100644
--- a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs
+++ b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs
@@ -1,6 +1,7 @@
-use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType};
-use crate::sign_type::SignDigestType;
+use crate::actions::WalletConnectAction;
+use primitives::SignDigestType;
use primitives::{Chain, ValueAccess, WCEthereumTransaction, WalletConnectionMethods};
+use primitives::{SignableTransaction, SignableTransactionType};
use serde_json::Value;
pub struct EthereumRequestHandler;
@@ -49,7 +50,7 @@ impl EthereumRequestHandler {
Ok(WalletConnectAction::SignTransaction {
chain,
- transaction_type: WalletConnectTransactionType::Ethereum,
+ transaction_type: SignableTransactionType::Ethereum,
data,
})
}
@@ -59,15 +60,15 @@ impl EthereumRequestHandler {
Ok(WalletConnectAction::SendTransaction {
chain,
- transaction_type: WalletConnectTransactionType::Ethereum,
+ transaction_type: SignableTransactionType::Ethereum,
data,
})
}
- pub fn decode_send_transaction(data: String) -> Result {
+ pub fn decode_send_transaction(data: String) -> Result {
let transaction: WCEthereumTransaction = serde_json::from_str(&data).map_err(|e| e.to_string())?;
let transaction_type = gem_evm::transaction::decode_transaction_type(transaction.data.as_deref());
- Ok(WalletConnectTransaction::Ethereum {
+ Ok(SignableTransaction::Ethereum {
data: transaction.into(),
transaction_type,
})
@@ -199,7 +200,7 @@ mod tests {
WalletConnectAction::SendTransaction { chain, transaction_type, data } => {
let transaction: Value = serde_json::from_str(&data).unwrap();
assert_eq!(chain, Chain::Ethereum);
- assert_eq!(transaction_type, WalletConnectTransactionType::Ethereum);
+ assert_eq!(transaction_type, SignableTransactionType::Ethereum);
assert_eq!(transaction["from"], "0xsender");
assert_eq!(transaction["chainId"], "0x1");
}
diff --git a/core/crates/gem_wallet_connect/src/request_handler/mod.rs b/core/crates/gem_wallet_connect/src/request_handler/mod.rs
index 522adc410f..36e1988f11 100644
--- a/core/crates/gem_wallet_connect/src/request_handler/mod.rs
+++ b/core/crates/gem_wallet_connect/src/request_handler/mod.rs
@@ -4,9 +4,10 @@ mod sui;
mod ton;
mod tron;
-use crate::actions::{WalletConnectAction, WalletConnectChainOperation, WalletConnectTransaction, WalletConnectTransactionType};
+use crate::actions::{WalletConnectAction, WalletConnectChainOperation};
use ethereum::EthereumRequestHandler;
use primitives::{Chain, ChainType, ValueAccess, WalletConnectCAIP2, WalletConnectRequest, WalletConnectionMethods, hex};
+use primitives::{SignableTransaction, SignableTransactionType};
use serde_json::Value;
use solana::SolanaRequestHandler;
use sui::SuiRequestHandler;
@@ -20,6 +21,10 @@ impl WalletConnectRequestHandler {
let WalletConnectRequest {
method, params, chain_id, domain, ..
} = request;
+ Self::parse_action(method, params, chain_id, domain)
+ }
+
+ pub fn parse_action(method: String, params: String, chain_id: Option, domain: String) -> Result {
let method_name = method;
let method = match serde_json::from_value::(serde_json::Value::String(method_name.clone())) {
Ok(m) => m,
@@ -73,13 +78,13 @@ impl WalletConnectRequestHandler {
}
}
- pub fn decode_send_transaction(transaction_type: WalletConnectTransactionType, data: String) -> Result {
+ pub fn decode_send_transaction(transaction_type: SignableTransactionType, data: String) -> Result {
match transaction_type {
- WalletConnectTransactionType::Ethereum => EthereumRequestHandler::decode_send_transaction(data),
- WalletConnectTransactionType::Solana { output_type } => SolanaRequestHandler::decode_send_transaction(data, output_type),
- WalletConnectTransactionType::Sui { output_type } => SuiRequestHandler::decode_send_transaction(data, output_type),
- WalletConnectTransactionType::Ton { output_type } => TonRequestHandler::decode_send_transaction(data, output_type),
- WalletConnectTransactionType::Tron { output_type } => TronRequestHandler::decode_send_transaction(data, output_type),
+ SignableTransactionType::Ethereum => EthereumRequestHandler::decode_send_transaction(data),
+ SignableTransactionType::Solana { output_type } => SolanaRequestHandler::decode_send_transaction(data, output_type),
+ SignableTransactionType::Sui { output_type } => SuiRequestHandler::decode_send_transaction(data, output_type),
+ SignableTransactionType::Ton { output_type } => TonRequestHandler::decode_send_transaction(data, output_type),
+ SignableTransactionType::Tron { output_type } => TronRequestHandler::decode_send_transaction(data, output_type),
}
}
@@ -104,8 +109,8 @@ impl WalletConnectRequestHandler {
#[cfg(test)]
mod tests {
use super::*;
- use crate::sign_type::SignDigestType;
use gem_evm::testkit::eip712_mock::mock_eip712_json;
+ use primitives::SignDigestType;
use primitives::TransferDataOutputType;
#[test]
@@ -256,7 +261,7 @@ mod tests {
assert_eq!(transactions.len(), 1);
let decoded = WalletConnectRequestHandler::decode_send_transaction(transaction_type.clone(), transactions[0].clone()).unwrap();
match decoded {
- WalletConnectTransaction::Solana { data, output_type } => {
+ SignableTransaction::Solana { data, output_type } => {
assert!(data.transaction.starts_with("AQAAAAAAAAA"));
assert_eq!(output_type, TransferDataOutputType::EncodedTransaction);
}
@@ -271,13 +276,13 @@ mod tests {
fn test_decode_ethereum_transaction_accepts_numeric_and_hex_string_chain_id() {
for (chain_id, expected) in [(r#"4663"#, 4663), (r#""0x1237""#, 4663)] {
let decoded = WalletConnectRequestHandler::decode_send_transaction(
- WalletConnectTransactionType::Ethereum,
+ SignableTransactionType::Ethereum,
format!(r#"{{"chainId":{chain_id},"from":"0xsender","to":"0xrouter","data":"0x1234","value":"0x0"}}"#),
)
.unwrap();
match decoded {
- WalletConnectTransaction::Ethereum { data, transaction_type } => {
+ SignableTransaction::Ethereum { data, transaction_type } => {
assert_eq!(data.chain_id, Some(expected));
assert_eq!(data.data, Some("0x1234".to_string()));
assert_eq!(transaction_type, primitives::TransactionType::SmartContractCall);
@@ -297,13 +302,13 @@ mod tests {
];
for (data, expected) in cases {
let decoded = WalletConnectRequestHandler::decode_send_transaction(
- WalletConnectTransactionType::Ethereum,
+ SignableTransactionType::Ethereum,
format!(r#"{{"from":"0xsender","to":"0xrouter","data":{data},"value":"0x0"}}"#),
)
.unwrap();
match decoded {
- WalletConnectTransaction::Ethereum { transaction_type, .. } => assert_eq!(transaction_type, expected),
+ SignableTransaction::Ethereum { transaction_type, .. } => assert_eq!(transaction_type, expected),
_ => panic!("Expected Ethereum transaction"),
}
}
diff --git a/core/crates/gem_wallet_connect/src/request_handler/solana.rs b/core/crates/gem_wallet_connect/src/request_handler/solana.rs
index 8cbe0b0385..bd56f7690c 100644
--- a/core/crates/gem_wallet_connect/src/request_handler/solana.rs
+++ b/core/crates/gem_wallet_connect/src/request_handler/solana.rs
@@ -1,6 +1,7 @@
-use crate::actions::{WCSolanaTransactionData, WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType};
-use crate::sign_type::SignDigestType;
+use crate::actions::WalletConnectAction;
+use primitives::SignDigestType;
use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods};
+use primitives::{SignableTransaction, SignableTransactionType, SolanaTransactionData};
use serde_json::Value;
pub struct SolanaRequestHandler;
@@ -31,7 +32,7 @@ impl SolanaRequestHandler {
Ok(WalletConnectAction::SignTransaction {
chain: Chain::Solana,
- transaction_type: WalletConnectTransactionType::Solana {
+ transaction_type: SignableTransactionType::Solana {
output_type: TransferDataOutputType::Signature,
},
data: params.to_string(),
@@ -43,22 +44,22 @@ impl SolanaRequestHandler {
Ok(WalletConnectAction::SendTransaction {
chain: Chain::Solana,
- transaction_type: WalletConnectTransactionType::Solana {
+ transaction_type: SignableTransactionType::Solana {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: params.to_string(),
})
}
- pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
+ pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
let json: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
let transaction = json
.get("transaction")
.and_then(|value| value.as_str())
.ok_or_else(|| "Missing transaction field".to_string())?
.to_string();
- Ok(WalletConnectTransaction::Solana {
- data: WCSolanaTransactionData { transaction },
+ Ok(SignableTransaction::Solana {
+ data: SolanaTransactionData { transaction },
output_type,
})
}
@@ -79,7 +80,7 @@ impl SolanaRequestHandler {
Ok(WalletConnectAction::SignAllTransactions {
chain: Chain::Solana,
- transaction_type: WalletConnectTransactionType::Solana {
+ transaction_type: SignableTransactionType::Solana {
output_type: TransferDataOutputType::EncodedTransaction,
},
transactions,
@@ -112,7 +113,7 @@ mod tests {
SolanaRequestHandler::parse_sign_transaction(Chain::Solana, params).unwrap(),
WalletConnectAction::SignTransaction {
chain: Chain::Solana,
- transaction_type: WalletConnectTransactionType::Solana {
+ transaction_type: SignableTransactionType::Solana {
output_type: TransferDataOutputType::Signature,
},
data: expected_data,
diff --git a/core/crates/gem_wallet_connect/src/request_handler/sui.rs b/core/crates/gem_wallet_connect/src/request_handler/sui.rs
index eacba2f239..9c54f2362a 100644
--- a/core/crates/gem_wallet_connect/src/request_handler/sui.rs
+++ b/core/crates/gem_wallet_connect/src/request_handler/sui.rs
@@ -1,6 +1,7 @@
-use crate::actions::{WCSuiTransactionData, WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType};
-use crate::sign_type::SignDigestType;
+use crate::actions::WalletConnectAction;
+use primitives::SignDigestType;
use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods};
+use primitives::{SignableTransaction, SignableTransactionType, SuiTransactionData};
use serde_json::Value;
pub struct SuiRequestHandler;
@@ -31,7 +32,7 @@ impl SuiRequestHandler {
Ok(WalletConnectAction::SignTransaction {
chain: Chain::Sui,
- transaction_type: WalletConnectTransactionType::Sui {
+ transaction_type: SignableTransactionType::Sui {
output_type: TransferDataOutputType::Signature,
},
data: params.to_string(),
@@ -43,14 +44,14 @@ impl SuiRequestHandler {
Ok(WalletConnectAction::SendTransaction {
chain: Chain::Sui,
- transaction_type: WalletConnectTransactionType::Sui {
+ transaction_type: SignableTransactionType::Sui {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: params.to_string(),
})
}
- pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
+ pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
let json: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
let transaction = json
.get("transaction")
@@ -63,8 +64,8 @@ impl SuiRequestHandler {
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
- Ok(WalletConnectTransaction::Sui {
- data: WCSuiTransactionData { transaction, wallet_address },
+ Ok(SignableTransaction::Sui {
+ data: SuiTransactionData { transaction, wallet_address },
output_type,
})
}
@@ -95,7 +96,7 @@ mod tests {
SuiRequestHandler::parse_sign_transaction(Chain::Sui, params).unwrap(),
WalletConnectAction::SignTransaction {
chain: Chain::Sui,
- transaction_type: WalletConnectTransactionType::Sui {
+ transaction_type: SignableTransactionType::Sui {
output_type: TransferDataOutputType::Signature,
},
data: expected_data,
@@ -111,7 +112,7 @@ mod tests {
SuiRequestHandler::parse_send_transaction(Chain::Sui, params).unwrap(),
WalletConnectAction::SendTransaction {
chain: Chain::Sui,
- transaction_type: WalletConnectTransactionType::Sui {
+ transaction_type: SignableTransactionType::Sui {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: expected_data,
diff --git a/core/crates/gem_wallet_connect/src/request_handler/ton.rs b/core/crates/gem_wallet_connect/src/request_handler/ton.rs
index 00cb034a7a..17c1725205 100644
--- a/core/crates/gem_wallet_connect/src/request_handler/ton.rs
+++ b/core/crates/gem_wallet_connect/src/request_handler/ton.rs
@@ -1,7 +1,8 @@
-use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType};
-use crate::sign_type::SignDigestType;
+use crate::actions::WalletConnectAction;
use gem_ton::signer::TonSignMessageData;
+use primitives::SignDigestType;
use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods};
+use primitives::{SignableTransaction, SignableTransactionType};
use serde_json::Value;
pub struct TonRequestHandler;
@@ -36,15 +37,15 @@ impl TonRequestHandler {
params.get_value("messages")?;
Ok(WalletConnectAction::SendTransaction {
chain: Chain::Ton,
- transaction_type: WalletConnectTransactionType::Ton {
+ transaction_type: SignableTransactionType::Ton {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: params.to_string(),
})
}
- pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
- Ok(WalletConnectTransaction::Ton { data, output_type })
+ pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
+ Ok(SignableTransaction::Ton { data, output_type })
}
}
diff --git a/core/crates/gem_wallet_connect/src/request_handler/tron.rs b/core/crates/gem_wallet_connect/src/request_handler/tron.rs
index 6254fa283c..988c2bbc9f 100644
--- a/core/crates/gem_wallet_connect/src/request_handler/tron.rs
+++ b/core/crates/gem_wallet_connect/src/request_handler/tron.rs
@@ -1,6 +1,7 @@
-use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType};
-use crate::sign_type::SignDigestType;
+use crate::actions::WalletConnectAction;
+use primitives::SignDigestType;
use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods};
+use primitives::{SignableTransaction, SignableTransactionType};
use serde_json::Value;
pub struct TronRequestHandler;
@@ -30,7 +31,7 @@ impl TronRequestHandler {
Ok(WalletConnectAction::SignTransaction {
chain: Chain::Tron,
- transaction_type: WalletConnectTransactionType::Tron {
+ transaction_type: SignableTransactionType::Tron {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: params.to_string(),
@@ -42,15 +43,15 @@ impl TronRequestHandler {
Ok(WalletConnectAction::SendTransaction {
chain: Chain::Tron,
- transaction_type: WalletConnectTransactionType::Tron {
+ transaction_type: SignableTransactionType::Tron {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: params.to_string(),
})
}
- pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
- Ok(WalletConnectTransaction::Tron { data, output_type })
+ pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result {
+ Ok(SignableTransaction::Tron { data, output_type })
}
}
@@ -79,7 +80,7 @@ mod tests {
TronRequestHandler::parse_sign_transaction(Chain::Tron, params).unwrap(),
WalletConnectAction::SignTransaction {
chain: Chain::Tron,
- transaction_type: WalletConnectTransactionType::Tron {
+ transaction_type: SignableTransactionType::Tron {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: expected_data,
@@ -95,7 +96,7 @@ mod tests {
TronRequestHandler::parse_send_transaction(Chain::Tron, params).unwrap(),
WalletConnectAction::SendTransaction {
chain: Chain::Tron,
- transaction_type: WalletConnectTransactionType::Tron {
+ transaction_type: SignableTransactionType::Tron {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: expected_data,
@@ -118,7 +119,7 @@ mod tests {
action,
WalletConnectAction::SendTransaction {
chain: Chain::Tron,
- transaction_type: WalletConnectTransactionType::Tron {
+ transaction_type: SignableTransactionType::Tron {
output_type: TransferDataOutputType::EncodedTransaction,
},
data: expected_data,
diff --git a/core/crates/gem_wallet_connect/src/validator.rs b/core/crates/gem_wallet_connect/src/validator.rs
index f49c8ae956..8daa21a036 100644
--- a/core/crates/gem_wallet_connect/src/validator.rs
+++ b/core/crates/gem_wallet_connect/src/validator.rs
@@ -1,10 +1,9 @@
use std::time::{SystemTime, UNIX_EPOCH};
-use crate::actions::WalletConnectTransactionType;
-use crate::sign_type::SignDigestType;
use gem_evm::domain::host_only;
use gem_evm::siwe::SiweMessage;
use primitives::Chain;
+use primitives::{SignDigestType, SignableTransactionType};
pub struct SignMessageValidation<'a> {
pub chain: Chain,
@@ -65,8 +64,8 @@ fn validate_session_domain(message: &SiweMessage, session_domain: &str) -> Resul
Ok(())
}
-pub fn validate_send_transaction(transaction_type: &WalletConnectTransactionType, data: &str) -> Result<(), String> {
- let WalletConnectTransactionType::Ton { .. } = transaction_type else {
+pub fn validate_send_transaction(transaction_type: &SignableTransactionType, data: &str) -> Result<(), String> {
+ let SignableTransactionType::Ton { .. } = transaction_type else {
return Ok(());
};
@@ -135,7 +134,7 @@ mod tests {
#[test]
fn test_validate_ton_send_transaction_expired() {
- let ton_type = WalletConnectTransactionType::Ton {
+ let ton_type = SignableTransactionType::Ton {
output_type: TransferDataOutputType::EncodedTransaction,
};
assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 1234567890, "messages": []}"#).is_err());
@@ -143,7 +142,7 @@ mod tests {
#[test]
fn test_validate_ton_send_transaction_valid() {
- let ton_type = WalletConnectTransactionType::Ton {
+ let ton_type = SignableTransactionType::Ton {
output_type: TransferDataOutputType::EncodedTransaction,
};
assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 9999999999, "messages": []}"#).is_ok());
@@ -151,12 +150,12 @@ mod tests {
#[test]
fn test_validate_ethereum_send_transaction_always_ok() {
- assert!(validate_send_transaction(&WalletConnectTransactionType::Ethereum, "{}").is_ok());
+ assert!(validate_send_transaction(&SignableTransactionType::Ethereum, "{}").is_ok());
}
#[test]
fn test_validate_ton_send_transaction_no_expiry() {
- let ton_type = WalletConnectTransactionType::Ton {
+ let ton_type = SignableTransactionType::Ton {
output_type: TransferDataOutputType::EncodedTransaction,
};
assert!(validate_send_transaction(&ton_type, r#"{"messages": []}"#).is_ok());
diff --git a/core/crates/payment/Cargo.toml b/core/crates/payment/Cargo.toml
new file mode 100644
index 0000000000..11126d8d9e
--- /dev/null
+++ b/core/crates/payment/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "payment"
+edition = { workspace = true }
+version = { workspace = true }
+
+[dependencies]
+primitives = { path = "../primitives" }
+num-bigint = { workspace = true }
+gem_client = { path = "../gem_client" }
+gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"] }
+gem_evm = { path = "../gem_evm" }
+gem_wallet_connect = { path = "../gem_wallet_connect", features = ["request"] }
+
+async-trait = { workspace = true }
+chrono = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+url = { workspace = true }
+
+[dev-dependencies]
+gem_client = { path = "../gem_client", features = ["testkit"] }
+tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/core/crates/payment/src/action.rs b/core/crates/payment/src/action.rs
new file mode 100644
index 0000000000..a3e2c436da
--- /dev/null
+++ b/core/crates/payment/src/action.rs
@@ -0,0 +1,159 @@
+use primitives::swap::ApprovalData;
+use primitives::{AssetId, Chain, ChainAddress, PaymentQuote, PaymentQuotes, SignMessage, SignableTransaction};
+
+use crate::error::PaymentError;
+
+#[derive(Debug)]
+pub enum PaymentAction {
+ SignMessage { message: SignMessage },
+ SignTransaction { chain: Chain, transaction: SignableTransaction },
+ SendTransaction { chain: Chain, transaction: SignableTransaction },
+ ApproveToken { chain: Chain, approval: ApprovalData },
+}
+
+impl PaymentAction {
+ pub fn chain(&self) -> Chain {
+ match self {
+ Self::SignMessage { message } => message.chain,
+ Self::SignTransaction { chain, .. } | Self::SendTransaction { chain, .. } | Self::ApproveToken { chain, .. } => *chain,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct PreparedPayment {
+ pub quotes: PaymentQuotes,
+ pub quote: PaymentQuote,
+ pub actions: Vec,
+}
+
+impl PreparedPayment {
+ pub fn validate(&self, addresses: &[ChainAddress]) -> Result<(), PaymentError> {
+ validate_actions(&self.actions, addresses)?;
+ validate_approvals(&self.actions, &self.quote.amount.asset_id)
+ }
+
+ pub fn is_relayed(&self) -> bool {
+ !self.actions.iter().any(|action| matches!(action, PaymentAction::SendTransaction { .. }))
+ }
+}
+
+fn validate_actions(actions: &[PaymentAction], addresses: &[ChainAddress]) -> Result<(), PaymentError> {
+ if actions.is_empty() {
+ return Err(PaymentError::InvalidRequest("Payment has no actions".to_string()));
+ }
+ match actions.iter().find(|action| !addresses.iter().any(|address| address.chain == action.chain())) {
+ Some(action) => Err(PaymentError::InvalidRequest(format!("Payment asks to sign on {}", action.chain().as_ref()))),
+ None => Ok(()),
+ }
+}
+
+fn validate_approvals(actions: &[PaymentAction], asset_id: &AssetId) -> Result<(), PaymentError> {
+ for action in actions {
+ if let PaymentAction::ApproveToken { chain, .. } = action
+ && *chain != asset_id.chain
+ {
+ return Err(PaymentError::InvalidRequest(format!(
+ "Payment asks to approve on {} for an asset on {}",
+ chain.as_ref(),
+ asset_id.chain.as_ref()
+ )));
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use primitives::{PaymentAmount, PaymentMerchant, TransferDataOutputType};
+
+ fn send(chain: Chain) -> PaymentAction {
+ PaymentAction::SendTransaction {
+ chain,
+ transaction: SignableTransaction::Ton {
+ data: String::new(),
+ output_type: TransferDataOutputType::EncodedTransaction,
+ },
+ }
+ }
+
+ fn sign(chain: Chain) -> PaymentAction {
+ PaymentAction::SignTransaction {
+ chain,
+ transaction: SignableTransaction::Ton {
+ data: String::new(),
+ output_type: TransferDataOutputType::Signature,
+ },
+ }
+ }
+
+ fn approve(chain: Chain) -> PaymentAction {
+ PaymentAction::ApproveToken {
+ chain,
+ approval: ApprovalData {
+ token: "0xtoken".to_string(),
+ spender: "0xspender".to_string(),
+ value: "1".to_string(),
+ is_unlimited: false,
+ },
+ }
+ }
+
+ fn prepared(actions: Vec) -> PreparedPayment {
+ PreparedPayment {
+ quotes: PaymentQuotes {
+ merchant: PaymentMerchant {
+ name: "Merchant".to_string(),
+ icon_url: None,
+ },
+ price: None,
+ expires_at: None,
+ quotes: vec![],
+ },
+ quote: PaymentQuote {
+ id: "option_1".to_string(),
+ payment_id: "pay_1".to_string(),
+ amount: PaymentAmount {
+ asset_id: AssetId::from_chain(Chain::Ethereum),
+ value: "1".to_string(),
+ symbol: "ETH".to_string(),
+ decimals: 18,
+ },
+ expires_at: None,
+ collect_data_url: None,
+ provider_data: "{}".to_string(),
+ },
+ actions,
+ }
+ }
+
+ #[test]
+ fn test_is_relayed() {
+ assert!(prepared(vec![sign(Chain::Ethereum)]).is_relayed());
+ assert!(prepared(vec![]).is_relayed());
+ assert!(!prepared(vec![send(Chain::Ethereum)]).is_relayed());
+ assert!(!prepared(vec![sign(Chain::Ethereum), send(Chain::Ethereum)]).is_relayed());
+ }
+
+ #[test]
+ fn test_validate_approvals() {
+ let addresses = vec![
+ ChainAddress::new(Chain::Ethereum, "0x1".to_string()),
+ ChainAddress::new(Chain::Polygon, "0x1".to_string()),
+ ];
+
+ assert!(prepared(vec![approve(Chain::Ethereum)]).validate(&addresses).is_ok());
+ assert!(prepared(vec![approve(Chain::Polygon)]).validate(&addresses).is_err());
+ assert!(prepared(vec![approve(Chain::Ethereum), send(Chain::Ethereum)]).validate(&addresses).is_ok());
+ }
+
+ #[test]
+ fn test_validate_actions() {
+ let addresses = vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string())];
+
+ assert!(validate_actions(&[send(Chain::Ethereum)], &addresses).is_ok());
+ assert!(validate_actions(&[send(Chain::Ethereum), send(Chain::Solana)], &addresses).is_err());
+ assert!(validate_actions(&[], &addresses).is_err());
+ }
+}
diff --git a/core/crates/payment/src/config.rs b/core/crates/payment/src/config.rs
new file mode 100644
index 0000000000..860cced875
--- /dev/null
+++ b/core/crates/payment/src/config.rs
@@ -0,0 +1,12 @@
+use crate::wallet_connect_pay::WalletConnectPayAuth;
+
+#[derive(Debug, Clone)]
+pub struct PaymentConfig {
+ pub wallet_connect_pay: WalletConnectPayAuth,
+}
+
+impl PaymentConfig {
+ pub fn new(wallet_connect_pay: WalletConnectPayAuth) -> Self {
+ Self { wallet_connect_pay }
+ }
+}
diff --git a/core/crates/payment/src/error.rs b/core/crates/payment/src/error.rs
new file mode 100644
index 0000000000..42655597b4
--- /dev/null
+++ b/core/crates/payment/src/error.rs
@@ -0,0 +1,33 @@
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum PaymentError {
+ NotSupported,
+ PaymentNotFound,
+ PaymentExpired,
+ QuoteExpired,
+ NoPaymentOptions,
+ UnsupportedAccounts,
+ Rejected,
+ RateLimited,
+ InvalidRequest(String),
+ Network(String),
+}
+
+impl fmt::Display for PaymentError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NotSupported => write!(f, "Payment is not supported"),
+ Self::PaymentNotFound => write!(f, "Payment not found"),
+ Self::PaymentExpired => write!(f, "Payment expired"),
+ Self::QuoteExpired => write!(f, "Quote expired"),
+ Self::NoPaymentOptions => write!(f, "No payment options"),
+ Self::UnsupportedAccounts => write!(f, "No supported accounts"),
+ Self::Rejected => write!(f, "Payment rejected"),
+ Self::RateLimited => write!(f, "Too many requests"),
+ Self::InvalidRequest(message) | Self::Network(message) => write!(f, "{message}"),
+ }
+ }
+}
+
+impl std::error::Error for PaymentError {}
diff --git a/core/crates/payment/src/lib.rs b/core/crates/payment/src/lib.rs
new file mode 100644
index 0000000000..0b0b332c86
--- /dev/null
+++ b/core/crates/payment/src/lib.rs
@@ -0,0 +1,11 @@
+mod action;
+mod config;
+mod error;
+mod service;
+mod wallet_connect_pay;
+
+pub use action::{PaymentAction, PreparedPayment};
+pub use config::PaymentConfig;
+pub use error::PaymentError;
+pub use service::PaymentService;
+pub use wallet_connect_pay::WalletConnectPayAuth;
diff --git a/core/crates/payment/src/service.rs b/core/crates/payment/src/service.rs
new file mode 100644
index 0000000000..ba1b0dc97c
--- /dev/null
+++ b/core/crates/payment/src/service.rs
@@ -0,0 +1,52 @@
+use std::sync::Arc;
+
+use gem_jsonrpc::alien::{RpcClient, RpcProvider};
+use primitives::{ChainAddress, PaymentLink, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes};
+
+use crate::action::PreparedPayment;
+use crate::config::PaymentConfig;
+use crate::error::PaymentError;
+use crate::wallet_connect_pay::{WALLET_CONNECT_PAY_API_URL, WalletConnectPayService};
+
+pub struct PaymentService {
+ wallet_connect_pay: WalletConnectPayService,
+}
+
+impl PaymentService {
+ pub fn new(provider: Arc, config: PaymentConfig) -> Self {
+ Self {
+ wallet_connect_pay: WalletConnectPayService::new(RpcClient::new(WALLET_CONNECT_PAY_API_URL.to_string(), provider), config.wallet_connect_pay),
+ }
+ }
+
+ fn provider(&self, provider: PaymentProviderName) -> Result<&WalletConnectPayService, PaymentError> {
+ match provider {
+ PaymentProviderName::WalletConnectPay => Ok(&self.wallet_connect_pay),
+ PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported),
+ }
+ }
+
+ pub async fn get_options(&self, link: &PaymentLink, addresses: &[ChainAddress]) -> Result {
+ self.provider(link.provider)?.options(&link.id, addresses).await
+ }
+
+ pub async fn get_prepared_payment(
+ &self,
+ provider: PaymentProviderName,
+ quotes: &PaymentQuotes,
+ quote: &PaymentQuote,
+ addresses: &[ChainAddress],
+ ) -> Result {
+ let payment = self.provider(provider)?.prepare_payment(quotes, quote, addresses).await?;
+ payment.validate(addresses)?;
+ Ok(payment)
+ }
+
+ pub async fn confirm(&self, provider: PaymentProviderName, quote: &PaymentQuote, action_results: Vec) -> Result {
+ self.provider(provider)?.confirm_payment(quote, action_results).await
+ }
+
+ pub async fn get_status(&self, provider: PaymentProviderName, payment_id: &str) -> Result {
+ self.provider(provider)?.get_payment_status(payment_id).await
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/account.rs b/core/crates/payment/src/wallet_connect_pay/account.rs
new file mode 100644
index 0000000000..236a1b8660
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/account.rs
@@ -0,0 +1,46 @@
+use primitives::{Chain, ChainType, WalletConnectCAIP2};
+
+fn is_supported(chain: Chain) -> bool {
+ matches!(chain.chain_type(), ChainType::Ethereum | ChainType::Solana)
+}
+
+pub fn account_identifier(chain: Chain, address: &str) -> Option {
+ if !is_supported(chain) {
+ return None;
+ }
+ let namespace = WalletConnectCAIP2::get_namespace(chain)?;
+ let reference = WalletConnectCAIP2::get_reference(chain)?;
+ Some(format!("{namespace}:{reference}:{address}"))
+}
+
+pub fn account_chain(account: &str) -> Option {
+ let chain = WalletConnectCAIP2::parse_account(account.to_string())?.chain;
+ is_supported(chain).then_some(chain)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_account_identifier() {
+ assert_eq!(account_identifier(Chain::Ethereum, "0x1"), Some("eip155:1:0x1".to_string()));
+ assert_eq!(account_identifier(Chain::Base, "0x1"), Some("eip155:8453:0x1".to_string()));
+ assert_eq!(account_identifier(Chain::Solana, "abc"), Some("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:abc".to_string()));
+
+ assert_eq!(account_identifier(Chain::Bitcoin, "bc1"), None);
+ assert_eq!(account_identifier(Chain::Cosmos, "cosmos1"), None);
+ assert_eq!(account_identifier(Chain::Ton, "UQA"), None);
+ assert_eq!(account_identifier(Chain::Tron, "TX"), None);
+ }
+
+ #[test]
+ fn test_account_chain() {
+ assert_eq!(account_chain("eip155:1:0x1"), Some(Chain::Ethereum));
+ assert_eq!(account_chain("eip155:8453:0x1"), Some(Chain::Base));
+ assert_eq!(account_chain("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:abc"), Some(Chain::Solana));
+
+ assert_eq!(account_chain("cosmos:cosmoshub-4:cosmos1"), None);
+ assert_eq!(account_chain("not-an-account"), None);
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/action_mapper.rs b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs
new file mode 100644
index 0000000000..a6d1ebe71f
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs
@@ -0,0 +1,229 @@
+use gem_evm::call_decoder::decode_call;
+use gem_wallet_connect::{WalletConnectAction, WalletConnectRequestHandler, decode_sign_message};
+use num_bigint::BigUint;
+use primitives::payment_decoder::wallet_connect_pay::WALLET_CONNECT_PAY_HOST;
+use primitives::swap::ApprovalData;
+use primitives::{SignableTransaction, SignableTransactionType, TransferDataOutputType};
+
+use crate::PaymentAction;
+use crate::error::PaymentError;
+use crate::wallet_connect_pay::model::WalletRpcAction;
+use crate::wallet_connect_pay::{params, validator};
+
+const APPROVE_CALL: &str = "approve";
+const APPROVE_SPENDER: &str = "spender";
+const APPROVE_VALUE: &str = "value";
+
+pub fn map_wallet_rpc(account: &str, wallet_rpc: &WalletRpcAction) -> Result {
+ let params = params::map_signer_params(&wallet_rpc.method, &wallet_rpc.params)?;
+ let action = WalletConnectRequestHandler::parse_action(
+ wallet_rpc.method.clone(),
+ params.to_string(),
+ Some(wallet_rpc.chain_id.clone()),
+ WALLET_CONNECT_PAY_HOST.to_string(),
+ )
+ .map_err(PaymentError::InvalidRequest)?;
+ if let WalletConnectAction::Unsupported { method } = action {
+ return Err(PaymentError::InvalidRequest(method));
+ }
+ validator::validate_signer(account, ¶ms, &action)?;
+ map_action(with_encoded_solana_output(action))
+}
+
+fn map_action(action: WalletConnectAction) -> Result {
+ match action {
+ WalletConnectAction::SignMessage { chain, sign_type, data } => Ok(PaymentAction::SignMessage {
+ message: decode_sign_message(chain, sign_type, data),
+ }),
+ WalletConnectAction::SignTransaction { chain, transaction_type, data } => Ok(PaymentAction::SignTransaction {
+ chain,
+ transaction: WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(PaymentError::InvalidRequest)?,
+ }),
+ WalletConnectAction::SendTransaction { chain, transaction_type, data } => {
+ let transaction = WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(PaymentError::InvalidRequest)?;
+ Ok(match map_approval(&transaction) {
+ Some(approval) => PaymentAction::ApproveToken { chain, approval },
+ None => PaymentAction::SendTransaction { chain, transaction },
+ })
+ }
+ WalletConnectAction::SignAllTransactions { .. } => Err(PaymentError::InvalidRequest("signAllTransactions".to_string())),
+ WalletConnectAction::ChainOperation { .. } => Err(PaymentError::InvalidRequest("chainOperation".to_string())),
+ WalletConnectAction::GetAccounts { .. } => Err(PaymentError::InvalidRequest("getAccounts".to_string())),
+ WalletConnectAction::Unsupported { method } => Err(PaymentError::InvalidRequest(method)),
+ }
+}
+
+fn map_approval(transaction: &SignableTransaction) -> Option {
+ let SignableTransaction::Ethereum { data, .. } = transaction else {
+ return None;
+ };
+ let call = decode_call(data.data.as_ref()?, None).ok()?;
+ if call.function != APPROVE_CALL {
+ return None;
+ }
+ let param = |name: &str| call.params.iter().find(|param| param.name == name).map(|param| param.value.clone());
+ let value = param(APPROVE_VALUE)?;
+ Some(ApprovalData {
+ is_unlimited: is_unlimited_approval(&value),
+ token: data.to.clone(),
+ spender: param(APPROVE_SPENDER)?,
+ value,
+ })
+}
+
+fn with_encoded_solana_output(action: WalletConnectAction) -> WalletConnectAction {
+ match action {
+ WalletConnectAction::SignTransaction {
+ chain,
+ transaction_type: SignableTransactionType::Solana { .. },
+ data,
+ } => WalletConnectAction::SignTransaction {
+ chain,
+ transaction_type: SignableTransactionType::Solana {
+ output_type: TransferDataOutputType::EncodedTransaction,
+ },
+ data,
+ },
+ action => action,
+ }
+}
+
+const UNLIMITED_APPROVE_BIT_WIDTHS: [u32; 2] = [160, 256];
+
+fn is_unlimited_approval(value: &str) -> bool {
+ let Ok(value) = value.parse::() else {
+ return false;
+ };
+ UNLIMITED_APPROVE_BIT_WIDTHS.iter().any(|bits| value == (BigUint::from(1u8) << bits) - BigUint::from(1u8))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::wallet_connect_pay::model::{FetchActionsResponse, WalletConnectPayAction};
+ use crate::wallet_connect_pay::testkit::{TEST_ACCOUNT_ETHEREUM, TEST_ACCOUNT_POLYGON, TEST_ACCOUNT_SOLANA};
+ use primitives::Chain;
+ use primitives::{SignDigestType, SignMessage};
+ use serde_json::Value;
+
+ fn get_actions(json: &str) -> Vec {
+ let response: FetchActionsResponse = serde_json::from_str(json).unwrap();
+ response
+ .actions
+ .into_iter()
+ .map(|action| match action {
+ WalletConnectPayAction::WalletRpc(wallet_rpc) => wallet_rpc,
+ WalletConnectPayAction::Build(_) => panic!("Expected walletRpc action"),
+ })
+ .collect()
+ }
+
+ fn sign_message_text(account: &str, action: &WalletRpcAction) -> String {
+ let PaymentAction::SignMessage { message } = map_wallet_rpc(account, action).unwrap() else {
+ panic!("Expected a SignMessage action");
+ };
+ String::from_utf8(message.data).unwrap()
+ }
+
+ #[test]
+ fn test_map_wallet_rpc_action() {
+ let actions = get_actions(include_str!("../../testdata/fetch_response_transfer_authorization.json"));
+ assert!(matches!(
+ map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &actions[0]).unwrap(),
+ PaymentAction::SignMessage {
+ message: SignMessage {
+ chain: Chain::Ethereum,
+ sign_type: SignDigestType::Eip712,
+ ..
+ }
+ }
+ ));
+
+ let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json"));
+ match map_wallet_rpc(TEST_ACCOUNT_POLYGON, &actions[0]).unwrap() {
+ PaymentAction::ApproveToken { chain, approval } => {
+ assert_eq!(chain, Chain::Polygon);
+ assert_eq!(approval.token, "0xc2132d05d31c914a87c6611c10748aeb04b58e8f");
+ assert_eq!(approval.spender, "0x000000000022D473030F116dDEE9F6B43aC78BA3");
+ assert!(approval.is_unlimited);
+ }
+ action => panic!("Expected an approval, got {action:?}"),
+ }
+ assert!(matches!(
+ map_wallet_rpc(TEST_ACCOUNT_POLYGON, &actions[1]).unwrap(),
+ PaymentAction::SignMessage {
+ message: SignMessage {
+ chain: Chain::Polygon,
+ sign_type: SignDigestType::Eip712,
+ ..
+ }
+ }
+ ));
+
+ let actions = get_actions(include_str!("../../testdata/fetch_response_solana.json"));
+ match map_wallet_rpc(TEST_ACCOUNT_SOLANA, &actions[0]).unwrap() {
+ PaymentAction::SignTransaction { chain, transaction } => {
+ assert_eq!(chain, Chain::Solana);
+ let SignableTransaction::Solana { data, output_type } = transaction else {
+ panic!("Expected a Solana transaction");
+ };
+ assert_eq!(output_type, TransferDataOutputType::EncodedTransaction);
+ assert!(!data.transaction.is_empty());
+ }
+ action => panic!("Expected Solana SignTransaction, got {action:?}"),
+ }
+ }
+
+ #[test]
+ fn test_map_wallet_rpc_action_normalizes_typed_data() {
+ let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json"));
+ let typed_data: Value = serde_json::from_str(&sign_message_text(TEST_ACCOUNT_POLYGON, &actions[1])).unwrap();
+ assert_eq!(
+ typed_data["types"]["EIP712Domain"],
+ serde_json::json!([
+ { "name": "name", "type": "string" },
+ { "name": "chainId", "type": "uint256" },
+ { "name": "verifyingContract", "type": "address" }
+ ])
+ );
+
+ let actions = get_actions(include_str!("../../testdata/fetch_response_transfer_authorization.json"));
+ assert_eq!(Value::String(sign_message_text(TEST_ACCOUNT_ETHEREUM, &actions[0])), actions[0].params[1]);
+ }
+
+ #[test]
+ fn test_map_wallet_rpc_action_rejects() {
+ let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json"));
+
+ let mismatched_account = "eip155:137:0x9999999999999999999999999999999999999999";
+ assert!(matches!(
+ map_wallet_rpc(mismatched_account, &actions[0]),
+ Err(PaymentError::InvalidRequest(message)) if message.contains("mismatch")
+ ));
+ assert!(matches!(
+ map_wallet_rpc(mismatched_account, &actions[1]),
+ Err(PaymentError::InvalidRequest(message)) if message.contains("mismatch")
+ ));
+
+ let wrong_chain = WalletRpcAction {
+ chain_id: "eip155:1".to_string(),
+ ..actions[1].clone()
+ };
+ assert!(map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &wrong_chain).is_err());
+
+ let unsupported = WalletRpcAction {
+ chain_id: "eip155:1".to_string(),
+ method: "eth_sign".to_string(),
+ params: serde_json::json!([]),
+ };
+ assert!(matches!(
+ map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &unsupported),
+ Err(PaymentError::InvalidRequest(method)) if method == "eth_sign"
+ ));
+
+ assert!(matches!(
+ map_wallet_rpc("invalid-account", &actions[0]),
+ Err(PaymentError::InvalidRequest(message)) if message.contains("Invalid option account")
+ ));
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/client.rs b/core/crates/payment/src/wallet_connect_pay/client.rs
new file mode 100644
index 0000000000..a025370b05
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/client.rs
@@ -0,0 +1,217 @@
+use gem_client::{Client, build_path_with_query};
+use std::collections::HashMap;
+
+use crate::error::PaymentError;
+use crate::wallet_connect_pay::model::{
+ ConfirmPaymentRequest, FetchActionsRequest, FetchActionsResponse, PaymentOptionsRequest, PaymentOptionsResponse, PaymentStatusResponse, WalletConnectPayAction,
+ WalletConnectPayActionResult,
+};
+use primitives::payment_decoder::wallet_connect_pay::is_payment_id;
+
+pub const WALLET_CONNECT_PAY_API_URL: &str = "https://api.pay.walletconnect.com";
+const WALLET_CONNECT_PAY_VERSION: &str = "2026-02-18";
+
+const HEADER_WALLET_CONNECT_PAY_VERSION: &str = "WCP-Version";
+const HEADER_APP_ID: &str = "App-Id";
+const HEADER_CLIENT_ID: &str = "Client-Id";
+const HEADER_SDK_NAME: &str = "Sdk-Name";
+const HEADER_SDK_VERSION: &str = "Sdk-Version";
+
+const SDK_NAME: &str = "gem-wallet";
+const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+const QUERY_INCLUDE_PAYMENT_INFO: &str = "includePaymentInfo";
+const QUERY_MAX_POLL_MS: &str = "maxPollMs";
+const MAX_POLL_MS: i64 = 60_000;
+
+#[derive(Debug, Clone)]
+pub struct WalletConnectPayAuth {
+ pub app_id: String,
+ pub client_id: String,
+}
+
+impl WalletConnectPayAuth {
+ pub fn new(app_id: String, client_id: String) -> Self {
+ Self { app_id, client_id }
+ }
+}
+
+#[derive(Debug)]
+pub struct WalletConnectPayClient {
+ client: C,
+ auth: WalletConnectPayAuth,
+}
+
+impl WalletConnectPayClient {
+ pub fn new(client: C, auth: WalletConnectPayAuth) -> Self {
+ Self { client, auth }
+ }
+
+ pub async fn get_options(&self, payment_id: &str, accounts: Vec) -> Result {
+ let path = Self::path(payment_id, "/options", &[(QUERY_INCLUDE_PAYMENT_INFO, "true".to_string())])?;
+ let request = PaymentOptionsRequest { accounts };
+ Ok(self.client.post_with(&path, &request, self.headers()).await?)
+ }
+
+ pub async fn get_actions(&self, payment_id: &str, option_id: &str, data: String) -> Result, PaymentError> {
+ let path = Self::path(payment_id, "/fetch", &[])?;
+ let request = FetchActionsRequest {
+ option_id: option_id.to_string(),
+ data,
+ };
+ let response: FetchActionsResponse = self.client.post_with(&path, &request, self.headers()).await?;
+ Ok(response.actions)
+ }
+
+ pub async fn confirm(&self, payment_id: &str, option_id: &str, action_results: Vec) -> Result {
+ let path = Self::path(payment_id, "/confirm", &[(QUERY_MAX_POLL_MS, MAX_POLL_MS.to_string())])?;
+ let request = ConfirmPaymentRequest {
+ option_id: option_id.to_string(),
+ results: action_results.into_iter().map(WalletConnectPayActionResult::wallet_rpc).collect(),
+ };
+ Ok(self.client.post_with(&path, &request, self.headers()).await?)
+ }
+
+ pub async fn get_status(&self, payment_id: &str) -> Result {
+ let path = Self::path(payment_id, "/status", &[(QUERY_MAX_POLL_MS, MAX_POLL_MS.to_string())])?;
+ Ok(self.client.get_with(&path, &[], self.headers()).await?)
+ }
+
+ fn path(payment_id: &str, suffix: &str, query: &[(&str, String)]) -> Result {
+ if !is_payment_id(payment_id) {
+ return Err(PaymentError::InvalidRequest(format!("Invalid payment id: {payment_id}")));
+ }
+ let path = format!("/v1/gateway/payment/{payment_id}{suffix}");
+ if query.is_empty() {
+ return Ok(path);
+ }
+ build_path_with_query(&path, &query).map_err(|error| PaymentError::InvalidRequest(error.to_string()))
+ }
+
+ fn headers(&self) -> HashMap {
+ [
+ (HEADER_WALLET_CONNECT_PAY_VERSION.to_string(), WALLET_CONNECT_PAY_VERSION.to_string()),
+ (HEADER_SDK_NAME.to_string(), SDK_NAME.to_string()),
+ (HEADER_SDK_VERSION.to_string(), SDK_VERSION.to_string()),
+ (HEADER_APP_ID.to_string(), self.auth.app_id.clone()),
+ (HEADER_CLIENT_ID.to_string(), self.auth.client_id.clone()),
+ ]
+ .into_iter()
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::wallet_connect_pay::model::WalletRpcAction;
+ use crate::wallet_connect_pay::testkit::{TEST_APP_ID, TEST_CLIENT_ID};
+ use gem_client::{ClientError, testkit::MockClient};
+ use primitives::PaymentStatus;
+
+ fn client(mock: MockClient) -> WalletConnectPayClient {
+ WalletConnectPayClient::new(mock, WalletConnectPayAuth::mock())
+ }
+
+ #[tokio::test]
+ async fn test_get_options() {
+ let mock = MockClient::new().with_post_with_headers(|path, body, headers| {
+ assert_eq!(headers.get(HEADER_WALLET_CONNECT_PAY_VERSION).unwrap(), WALLET_CONNECT_PAY_VERSION);
+ assert_eq!(headers.get(HEADER_SDK_NAME).unwrap(), SDK_NAME);
+ assert_eq!(headers.get(HEADER_APP_ID).unwrap(), TEST_APP_ID);
+ assert_eq!(headers.get(HEADER_CLIENT_ID).unwrap(), TEST_CLIENT_ID);
+ assert!(path.ends_with("/v1/gateway/payment/pay_123/options?includePaymentInfo=true"));
+ assert_eq!(serde_json::from_slice::(body).unwrap()["accounts"], serde_json::json!(["eip155:1:0x1"]));
+ Ok(include_str!("../../testdata/options_response.json").as_bytes().to_vec())
+ });
+
+ let response = client(mock).get_options("pay_123", vec!["eip155:1:0x1".to_string()]).await.unwrap();
+ let info = response.info.unwrap();
+ assert_eq!(info.status, PaymentStatus::RequiresAction);
+ assert_eq!(info.merchant.name, "Gem Wallet Test Merchant");
+ assert_eq!(info.amount.value, "5000");
+ assert_eq!(info.amount.display.decimals, 2);
+
+ let option = response.options.unwrap().remove(0);
+ assert_eq!(option.id, "opt_1");
+ assert_eq!(option.account, "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD");
+ assert_eq!(option.amount.display.asset_symbol, "USDC");
+ assert!(option.actions.is_empty());
+ assert!(option.collect_data.is_none());
+ }
+
+ #[tokio::test]
+ async fn test_fetch_actions() {
+ let mock = MockClient::new().with_post(|path, body| {
+ assert!(path.ends_with("/v1/gateway/payment/pay_123/fetch"));
+ assert_eq!(
+ serde_json::from_slice::(body).unwrap(),
+ serde_json::json!({"optionId": "opt_1", "data": ""})
+ );
+ Ok(include_str!("../../testdata/fetch_response_transfer_authorization.json").as_bytes().to_vec())
+ });
+
+ let actions = client(mock).get_actions("pay_123", "opt_1", String::new()).await.unwrap();
+ assert!(matches!(actions.as_slice(), [WalletConnectPayAction::WalletRpc(WalletRpcAction { method, .. })] if method == "eth_signTypedData_v4"));
+ }
+
+ #[tokio::test]
+ async fn test_confirm() {
+ let mock = MockClient::new().with_post(|path, body| {
+ assert!(path.ends_with("/v1/gateway/payment/pay_123/confirm?maxPollMs=60000"));
+ assert_eq!(
+ serde_json::from_slice::(body).unwrap(),
+ serde_json::json!({
+ "optionId": "opt_1",
+ "results": [{ "type": "walletRpc", "data": ["0xsignature"] }]
+ })
+ );
+ Ok(br#"{"status":"processing","isFinal":false,"pollInMs":1000}"#.to_vec())
+ });
+
+ let response = client(mock).confirm("pay_123", "opt_1", vec!["0xsignature".to_string()]).await.unwrap();
+ assert_eq!(response.status, PaymentStatus::Processing);
+ assert!(!response.is_final);
+ assert_eq!(response.poll_in_ms, Some(1000));
+ }
+
+ #[tokio::test]
+ async fn test_get_status() {
+ let mock = MockClient::new().with_get(|path| {
+ assert!(path.ends_with("/v1/gateway/payment/pay_123/status?maxPollMs=60000"));
+ Ok(include_str!("../../testdata/status_response_succeeded.json").as_bytes().to_vec())
+ });
+
+ let response = client(mock).get_status("pay_123").await.unwrap();
+ assert_eq!(response.status, PaymentStatus::Succeeded);
+ assert!(response.is_final);
+ assert_eq!(response.info.unwrap().tx_id, "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4");
+ }
+
+ #[test]
+ fn test_path_rejects_unsafe_payment_id() {
+ assert!(WalletConnectPayClient::::path("pay_123", "/status", &[]).is_ok());
+ assert!(WalletConnectPayClient::::path("pay_123/cancel", "/status", &[]).is_err());
+ assert!(WalletConnectPayClient::::path("pay_123?x=1", "", &[]).is_err());
+ assert!(WalletConnectPayClient::::path("pay 123", "", &[]).is_err());
+ assert!(WalletConnectPayClient::::path("", "/status", &[]).is_err());
+ }
+
+ #[tokio::test]
+ async fn test_error_mapping() {
+ let mock = MockClient::new().with_get(|_| Err(ClientError::Http { status: 410, body: vec![] }));
+ assert_eq!(client(mock).get_status("pay_123").await, Err(PaymentError::PaymentExpired));
+
+ assert_eq!(PaymentError::from(ClientError::Http { status: 404, body: vec![] }), PaymentError::PaymentNotFound);
+ assert_eq!(PaymentError::from(ClientError::Http { status: 409, body: vec![] }), PaymentError::QuoteExpired);
+ assert_eq!(PaymentError::from(ClientError::Http { status: 429, body: vec![] }), PaymentError::RateLimited);
+ assert!(matches!(
+ PaymentError::from(ClientError::Http {
+ status: 400,
+ body: b"bad".to_vec()
+ }),
+ PaymentError::InvalidRequest(_)
+ ));
+ assert!(matches!(PaymentError::from(ClientError::Http { status: 500, body: vec![] }), PaymentError::Network(_)));
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/error.rs b/core/crates/payment/src/wallet_connect_pay/error.rs
new file mode 100644
index 0000000000..5cf70bec0f
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/error.rs
@@ -0,0 +1,92 @@
+use crate::error::PaymentError;
+use gem_client::ClientError;
+
+const CODE_PAYMENT_NOT_FOUND: &str = "payment_not_found";
+const CODE_PAYMENT_EXPIRED: &str = "payment_expired";
+const CODE_QUOTE_EXPIRED: &str = "quote_expired";
+const CODE_RATE_LIMITED: &str = "rate_limited";
+const CODE_SANCTIONED_USER: &str = "sanctioned_user";
+const STALE_OPTION_MESSAGE: &str = "Option not found";
+
+impl From for PaymentError {
+ fn from(error: ClientError) -> Self {
+ match error {
+ ClientError::Http { status, body } => Self::from_response(status, &body),
+ ClientError::Network(msg) | ClientError::Serialization(msg) => Self::Network(msg),
+ ClientError::Timeout => Self::Network("Timeout".to_string()),
+ }
+ }
+}
+
+impl PaymentError {
+ fn from_response(status: u16, body: &[u8]) -> Self {
+ let body = String::from_utf8_lossy(&body[..body.len().min(512)]).to_string();
+ match Self::error_code(&body).as_deref() {
+ Some(CODE_PAYMENT_NOT_FOUND) => Self::PaymentNotFound,
+ Some(CODE_PAYMENT_EXPIRED) => Self::PaymentExpired,
+ Some(CODE_QUOTE_EXPIRED) => Self::QuoteExpired,
+ Some(CODE_RATE_LIMITED) => Self::RateLimited,
+ Some(CODE_SANCTIONED_USER) => Self::Rejected,
+ _ if body.contains(STALE_OPTION_MESSAGE) => Self::QuoteExpired,
+ _ => Self::from_status(status, body),
+ }
+ }
+
+ fn from_status(status: u16, body: String) -> Self {
+ match status {
+ 404 => Self::PaymentNotFound,
+ 409 => Self::QuoteExpired,
+ 410 => Self::PaymentExpired,
+ 429 => Self::RateLimited,
+ 400 | 422 => Self::InvalidRequest(format!("{status}: {body}")),
+ _ => Self::Network(format!("{status}: {body}")),
+ }
+ }
+
+ fn error_code(body: &str) -> Option {
+ serde_json::from_str::(body).ok()?.get("code")?.as_str().map(str::to_string)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_error_from_client_error() {
+ let stale = ClientError::Http {
+ status: 400,
+ body: br#"{"code":"params_validation","message":"Validation error: Option not found"}"#.to_vec(),
+ };
+ assert_eq!(PaymentError::from(stale), PaymentError::QuoteExpired);
+
+ let invalid = ClientError::Http {
+ status: 400,
+ body: br#"{"message":"Invalid URL"}"#.to_vec(),
+ };
+ assert!(matches!(PaymentError::from(invalid), PaymentError::InvalidRequest(_)));
+
+ assert_eq!(PaymentError::from(ClientError::Http { status: 410, body: vec![] }), PaymentError::PaymentExpired);
+ }
+
+ #[test]
+ fn test_error_reads_the_gateway_code_over_the_status() {
+ let sanctioned = ClientError::Http {
+ status: 400,
+ body: br#"{"code":"sanctioned_user","message":"User is sanctioned"}"#.to_vec(),
+ };
+ assert_eq!(PaymentError::from(sanctioned), PaymentError::Rejected);
+
+ let not_found = ClientError::Http {
+ status: 400,
+ body: br#"{"code":"payment_not_found","message":"No such payment"}"#.to_vec(),
+ };
+ assert_eq!(PaymentError::from(not_found), PaymentError::PaymentNotFound);
+
+ let expired = ClientError::Http {
+ status: 500,
+ body: br#"{"code":"payment_expired"}"#.to_vec(),
+ };
+ assert_eq!(PaymentError::from(expired), PaymentError::PaymentExpired);
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/mod.rs b/core/crates/payment/src/wallet_connect_pay/mod.rs
new file mode 100644
index 0000000000..4964a28a15
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/mod.rs
@@ -0,0 +1,17 @@
+mod account;
+mod action_mapper;
+mod client;
+mod error;
+mod model;
+mod params;
+mod payment_mapper;
+mod quote;
+mod service;
+mod validator;
+
+#[cfg(test)]
+mod testkit;
+
+pub(crate) use client::WALLET_CONNECT_PAY_API_URL;
+pub use client::WalletConnectPayAuth;
+pub(crate) use service::WalletConnectPayService;
diff --git a/core/crates/payment/src/wallet_connect_pay/model.rs b/core/crates/payment/src/wallet_connect_pay/model.rs
new file mode 100644
index 0000000000..d1e8856806
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/model.rs
@@ -0,0 +1,151 @@
+use primitives::{PaymentMerchant, PaymentStatus};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::error::PaymentError;
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentAmount {
+ pub unit: String,
+ pub value: String,
+ pub display: PaymentAmountDisplay,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentAmountDisplay {
+ pub asset_symbol: String,
+ pub asset_name: String,
+ pub decimals: i32,
+ #[serde(default)]
+ pub icon_url: Option,
+ #[serde(default)]
+ pub network_name: Option,
+ #[serde(default)]
+ pub network_icon_url: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentInfo {
+ pub status: PaymentStatus,
+ pub amount: PaymentAmount,
+ pub expires_at: i64,
+ pub merchant: PaymentMerchant,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentOption {
+ pub id: String,
+ pub account: String,
+ pub amount: PaymentAmount,
+ #[serde(default)]
+ pub expires_at: Option,
+ #[serde(default)]
+ pub actions: Vec,
+ #[serde(default)]
+ pub collect_data: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentCollectData {
+ pub url: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", content = "data", rename_all = "camelCase")]
+pub enum WalletConnectPayAction {
+ WalletRpc(WalletRpcAction),
+ Build(BuildAction),
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct WalletRpcAction {
+ pub chain_id: String,
+ pub method: String,
+ pub params: Value,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct BuildAction {
+ pub data: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize)]
+#[serde(tag = "type", content = "data", rename_all = "camelCase")]
+pub enum WalletConnectPayActionResult {
+ WalletRpc(Vec),
+}
+
+impl WalletConnectPayActionResult {
+ pub fn wallet_rpc(result: String) -> Self {
+ Self::WalletRpc(vec![Value::String(result)])
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentOptionsRequest {
+ pub accounts: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentOptionsResponse {
+ #[serde(default)]
+ pub info: Option,
+ #[serde(default)]
+ pub options: Option>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FetchActionsRequest {
+ pub option_id: String,
+ pub data: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct FetchActionsResponse {
+ pub actions: Vec,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConfirmPaymentRequest {
+ pub option_id: String,
+ pub results: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentStatusResponse {
+ pub status: PaymentStatus,
+ pub is_final: bool,
+ #[serde(default)]
+ pub poll_in_ms: Option,
+ #[serde(default)]
+ pub info: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentResultInfo {
+ pub tx_id: String,
+ #[serde(default)]
+ pub option_amount: Option,
+}
+
+impl TryFrom for WalletRpcAction {
+ type Error = PaymentError;
+
+ fn try_from(action: WalletConnectPayAction) -> Result {
+ match action {
+ WalletConnectPayAction::WalletRpc(wallet_rpc) => Ok(wallet_rpc),
+ WalletConnectPayAction::Build(_) => Err(PaymentError::InvalidRequest("Payment action is not a wallet RPC call".to_string())),
+ }
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/params.rs b/core/crates/payment/src/wallet_connect_pay/params.rs
new file mode 100644
index 0000000000..f71a51346b
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/params.rs
@@ -0,0 +1,154 @@
+use gem_evm::eip712::{EIP712Type, eip712_domain_types};
+use primitives::ValueAccess;
+use serde_json::{Map, Value};
+use std::fmt::Display;
+
+use crate::error::PaymentError;
+
+const SOLANA_METHOD_PREFIX: &str = "solana_";
+const METHOD_ETH_SIGN_TYPED_DATA_V4: &str = "eth_signTypedData_v4";
+const TYPE_EIP712_DOMAIN: &str = "EIP712Domain";
+
+pub fn map_signer_params(method: &str, params: &Value) -> Result {
+ if method.starts_with(SOLANA_METHOD_PREFIX) {
+ return Ok(unwrapped_solana_transaction(params));
+ }
+ if method == METHOD_ETH_SIGN_TYPED_DATA_V4 {
+ return typed_data_with_schema(params);
+ }
+ Ok(params.clone())
+}
+
+fn unwrapped_solana_transaction(params: &Value) -> Value {
+ match params.as_array().map(Vec::as_slice) {
+ Some([transaction]) => transaction.clone(),
+ _ => params.clone(),
+ }
+}
+
+fn typed_data_with_schema(params: &Value) -> Result {
+ let signer = params.at(0).map_err(invalid)?;
+ let typed_data = params.at(1).map_err(invalid)?;
+ Ok(Value::Array(vec![signer.clone(), with_domain_schema(typed_data)?]))
+}
+
+fn with_domain_schema(typed_data: &Value) -> Result {
+ let Value::String(json) = typed_data else {
+ return insert_domain_schema(typed_data);
+ };
+ let decoded = serde_json::from_str(json).map_err(|error| invalid(format!("Invalid typed data: {error}")))?;
+ let encoded = serde_json::to_string(&insert_domain_schema(&decoded)?).map_err(invalid)?;
+ Ok(Value::String(encoded))
+}
+
+fn insert_domain_schema(typed_data: &Value) -> Result {
+ if typed_data.get_value("types").and_then(|types| types.get_value(TYPE_EIP712_DOMAIN)).is_ok() {
+ return Ok(typed_data.clone());
+ }
+
+ let domain = typed_data.get_value("domain").map_err(invalid)?;
+ let schema = serde_json::to_value(domain_schema(domain)?).map_err(invalid)?;
+ let types = object(typed_data.get_value("types").map_err(invalid)?, "types")?
+ .clone()
+ .into_iter()
+ .chain([(TYPE_EIP712_DOMAIN.to_string(), schema)])
+ .collect();
+
+ Ok(Value::Object(
+ object(typed_data, "typed data")?
+ .clone()
+ .into_iter()
+ .chain([("types".to_string(), Value::Object(types))])
+ .collect(),
+ ))
+}
+
+fn domain_schema(domain: &Value) -> Result, PaymentError> {
+ let domain = object(domain, "EIP712 domain")?;
+ let fields = eip712_domain_types();
+
+ for (name, value) in domain {
+ if !fields.iter().any(|field| field.name == *name) {
+ return Err(invalid(format!("Unsupported EIP712 domain field: {name}")));
+ }
+ if value.is_null() {
+ return Err(invalid(format!("Missing EIP712 domain field value: {name}")));
+ }
+ }
+
+ Ok(fields.into_iter().filter(|field| domain.contains_key(&field.name)).collect())
+}
+
+fn object<'a>(value: &'a Value, name: &str) -> Result<&'a Map, PaymentError> {
+ value.as_object().ok_or_else(|| invalid(format!("Expected {name} object")))
+}
+
+fn invalid(message: impl Display) -> PaymentError {
+ PaymentError::InvalidRequest(message.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn signer_params(typed_data: Value) -> Value {
+ Value::Array(vec![Value::String("0x1".to_string()), typed_data])
+ }
+
+ fn domain_schema_of(params: &Value) -> Value {
+ let typed_data = match ¶ms[1] {
+ Value::String(json) => serde_json::from_str(json).unwrap(),
+ value => value.clone(),
+ };
+ typed_data["types"][TYPE_EIP712_DOMAIN].clone()
+ }
+
+ fn permit2_typed_data() -> Value {
+ serde_json::json!({
+ "domain": {"name": "Permit2", "chainId": 1, "verifyingContract": "0x00"},
+ "types": {"PermitSingle": []},
+ "primaryType": "PermitSingle",
+ "message": {},
+ })
+ }
+
+ #[test]
+ fn test_map_signer_params() {
+ let transaction = Value::String("base64".to_string());
+ let solana_params = Value::Array(vec![transaction.clone()]);
+ assert_eq!(map_signer_params("solana_signTransaction", &solana_params).unwrap(), transaction);
+ assert_eq!(map_signer_params("eth_sendTransaction", &solana_params).unwrap(), solana_params);
+
+ let params = map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(permit2_typed_data())).unwrap();
+ assert_eq!(
+ domain_schema_of(¶ms),
+ serde_json::json!([
+ {"name": "name", "type": "string"},
+ {"name": "chainId", "type": "uint256"},
+ {"name": "verifyingContract", "type": "address"},
+ ])
+ );
+
+ let as_string = Value::String(serde_json::to_string(&permit2_typed_data()).unwrap());
+ let params = map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(as_string)).unwrap();
+ assert!(params[1].is_string());
+ assert_eq!(domain_schema_of(¶ms).as_array().unwrap().len(), 3);
+
+ let with_schema = signer_params(serde_json::json!({
+ "domain": {"name": "Permit2"},
+ "types": {TYPE_EIP712_DOMAIN: [{"name": "verifyingContract", "type": "address"}]},
+ }));
+ assert_eq!(map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &with_schema).unwrap(), with_schema);
+ }
+
+ #[test]
+ fn test_wallet_connect_params_rejects_unusable_typed_data() {
+ let rejected = |typed_data: Value| map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(typed_data)).is_err();
+
+ assert!(rejected(serde_json::json!({"domain": {"chainId": 1, "unexpected": "1"}, "types": {}})));
+ assert!(rejected(serde_json::json!({"domain": {"chainId": 1, "salt": "0x00"}, "types": {}})));
+ assert!(rejected(serde_json::json!({"domain": {"chainId": null}, "types": {}})));
+ assert!(rejected(serde_json::json!({"domain": {"chainId": 1}})));
+ assert!(rejected(Value::String("{".to_string())));
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs b/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs
new file mode 100644
index 0000000000..2b0d33e12d
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs
@@ -0,0 +1,160 @@
+use chrono::{DateTime, Utc};
+use primitives::{AssetId, PaymentAmount, PaymentOutcome, PaymentPrice, WalletConnectCAIP19};
+use url::Url;
+
+use crate::error::PaymentError;
+use crate::wallet_connect_pay::account::account_chain;
+use crate::wallet_connect_pay::model::{PaymentInfo, PaymentOption, PaymentOptionsResponse, PaymentStatusResponse};
+use crate::wallet_connect_pay::quote::{QuotedOption, QuotedPayment};
+
+const CAIP19_PREFIX: &str = "caip19";
+const COLLECT_DATA_HOST: &str = "walletconnect.com";
+
+pub fn map_quoted_payment(response: PaymentOptionsResponse) -> Result {
+ let payment = response.info.ok_or(PaymentError::PaymentNotFound)?;
+ Ok(QuotedPayment {
+ status: payment.status,
+ expires_at: map_expiry(&payment)?,
+ price: PaymentPrice {
+ symbol: payment.amount.display.asset_symbol.clone(),
+ value: payment.amount.value.clone(),
+ decimals: payment.amount.display.decimals,
+ },
+ merchant: payment.merchant,
+ options: response.options.unwrap_or_default().into_iter().filter_map(map_option).collect(),
+ })
+}
+
+pub fn map_payment_outcome(response: PaymentStatusResponse) -> PaymentOutcome {
+ PaymentOutcome {
+ status: response.status,
+ transaction_id: response.info.map(|info| info.tx_id),
+ }
+}
+
+fn map_expiry(payment: &PaymentInfo) -> Result, PaymentError> {
+ DateTime::from_timestamp(payment.expires_at, 0).ok_or_else(|| PaymentError::InvalidRequest("Invalid payment expiry".to_string()))
+}
+
+fn map_option(option: PaymentOption) -> Option {
+ let provider_data = serde_json::to_string(&option).ok()?;
+ let collect_data_url = match &option.collect_data {
+ Some(collect_data) => Some(collect_data_url(&collect_data.url)?),
+ None => None,
+ };
+ Some(QuotedOption {
+ id: option.id.clone(),
+ expires_at: option.expires_at.and_then(|expires_at| DateTime::from_timestamp(expires_at, 0)),
+ chain: account_chain(&option.account)?,
+ provider_data,
+ amount: PaymentAmount {
+ asset_id: asset_id(&option.amount.unit)?,
+ value: option.amount.value,
+ symbol: option.amount.display.asset_symbol,
+ decimals: option.amount.display.decimals,
+ },
+ collect_data_url,
+ })
+}
+
+fn collect_data_url(url: &str) -> Option {
+ let parsed = Url::parse(url).ok()?;
+ if parsed.scheme() != "https" {
+ return None;
+ }
+ let host = parsed.host_str()?.to_lowercase();
+ if host == COLLECT_DATA_HOST || host.ends_with(&format!(".{COLLECT_DATA_HOST}")) {
+ Some(url.to_string())
+ } else {
+ None
+ }
+}
+
+fn asset_id(unit: &str) -> Option {
+ match unit.split_once('/')? {
+ (CAIP19_PREFIX, asset) => WalletConnectCAIP19::get_asset_id(asset),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use primitives::{Chain, PaymentStatus};
+
+ fn options_response() -> PaymentOptionsResponse {
+ serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap()
+ }
+
+ #[test]
+ fn test_map_quoted_payment() {
+ let quoted = map_quoted_payment(options_response()).unwrap();
+
+ assert_eq!(quoted.status, PaymentStatus::RequiresAction);
+ assert_eq!(quoted.merchant.name, "Gem Wallet Test Merchant");
+ assert_eq!(quoted.expires_at.timestamp(), 1785175272);
+
+ let option = "ed.options[0];
+ assert_eq!(option.id, "opt_1");
+ assert_eq!(option.chain, Chain::Ethereum);
+ assert_eq!(option.collect_data_url, None);
+ }
+
+ #[test]
+ fn test_asset_id_reads_the_chain_coin_and_its_tokens() {
+ assert_eq!(asset_id("caip19/eip155:1/slip44:60"), Some(AssetId::from(Chain::Ethereum, None)));
+ assert_eq!(
+ asset_id("caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"),
+ Some(AssetId::from_token(Chain::Base, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"))
+ );
+ assert_eq!(asset_id("caip19/eip155:1").unwrap(), AssetId::from(Chain::Ethereum, None));
+ assert_eq!(asset_id("caip19/eip155:99999/erc20:0x1"), None);
+ assert_eq!(asset_id("eip155:1/erc20:0x1"), None);
+ assert_eq!(asset_id("iso4217/USD"), None);
+ }
+
+ #[test]
+ fn test_map_option_with_collect_data() {
+ let option: PaymentOption = serde_json::from_str(include_str!("../../testdata/option_collect_data.json")).unwrap();
+ let mapped = map_option(option.clone()).unwrap();
+
+ assert_eq!(mapped.collect_data_url.unwrap(), "https://data-collection.walletconnect.com/ic/pay_123");
+
+ for url in [
+ "https://evil.com/ic/pay_123",
+ "https://evil-walletconnect.com/ic/pay_123",
+ "http://walletconnect.com/ic/pay_123",
+ "not a url",
+ ] {
+ let mut off_domain = option.clone();
+ off_domain.collect_data.as_mut().unwrap().url = url.to_string();
+
+ assert!(map_option(off_domain).is_none(), "{url} must not reach the collection web view");
+ }
+ }
+
+ #[test]
+ fn test_map_payment_options_drops_unpayable_option() {
+ let mut response = options_response();
+ let options = response.options.as_mut().unwrap();
+ options[0].account = "cosmos:cosmoshub-4:cosmos1".to_string();
+
+ assert!(map_quoted_payment(response).unwrap().options.is_empty());
+ }
+
+ #[test]
+ fn test_map_payment_options_without_info() {
+ let response = PaymentOptionsResponse { info: None, options: None };
+
+ assert_eq!(map_quoted_payment(response), Err(PaymentError::PaymentNotFound));
+ }
+
+ #[test]
+ fn test_map_payment_outcome() {
+ let response: PaymentStatusResponse = serde_json::from_str(include_str!("../../testdata/status_response_succeeded.json")).unwrap();
+ let outcome = map_payment_outcome(response);
+
+ assert_eq!(outcome.status, PaymentStatus::Succeeded);
+ assert_eq!(outcome.transaction_id.unwrap(), "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4");
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/quote.rs b/core/crates/payment/src/wallet_connect_pay/quote.rs
new file mode 100644
index 0000000000..be0debd41f
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/quote.rs
@@ -0,0 +1,21 @@
+use chrono::{DateTime, Utc};
+use primitives::{Chain, PaymentAmount, PaymentMerchant, PaymentPrice, PaymentStatus};
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct QuotedPayment {
+ pub status: PaymentStatus,
+ pub expires_at: DateTime,
+ pub merchant: PaymentMerchant,
+ pub price: PaymentPrice,
+ pub options: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct QuotedOption {
+ pub id: String,
+ pub expires_at: Option>,
+ pub chain: Chain,
+ pub amount: PaymentAmount,
+ pub collect_data_url: Option,
+ pub provider_data: String,
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/service.rs b/core/crates/payment/src/wallet_connect_pay/service.rs
new file mode 100644
index 0000000000..6fc06c7ac8
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/service.rs
@@ -0,0 +1,411 @@
+use crate::wallet_connect_pay::account::account_identifier;
+use crate::wallet_connect_pay::action_mapper::map_wallet_rpc;
+use chrono::Utc;
+use gem_client::Client;
+use primitives::{AssetId, ChainAddress, PaymentOptions, PaymentOutcome, PaymentQuote, PaymentQuotes, PaymentStatus};
+
+use crate::error::PaymentError;
+use crate::wallet_connect_pay::client::{WalletConnectPayAuth, WalletConnectPayClient};
+use crate::wallet_connect_pay::model::{PaymentOption, WalletConnectPayAction, WalletRpcAction};
+use crate::wallet_connect_pay::payment_mapper;
+use crate::wallet_connect_pay::quote::QuotedOption;
+use crate::{PaymentAction, PreparedPayment};
+use primitives::payment_decoder::wallet_connect_pay::{WALLET_CONNECT_HOST, is_wallet_connect_url};
+
+#[derive(Debug)]
+pub struct WalletConnectPayService {
+ client: WalletConnectPayClient,
+}
+
+impl WalletConnectPayService {
+ pub fn new(client: C, auth: WalletConnectPayAuth) -> Self {
+ Self {
+ client: WalletConnectPayClient::new(client, auth),
+ }
+ }
+
+ pub(crate) async fn prepare_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result {
+ if Self::is_expired(quote) {
+ return self.get_requoted_actions(quote, addresses).await;
+ }
+ match self.actions(quote).await {
+ Ok(actions) => Ok(PreparedPayment {
+ quotes: quotes.clone(),
+ quote: quote.clone(),
+ actions,
+ }),
+ Err(PaymentError::QuoteExpired) => self.get_requoted_actions(quote, addresses).await,
+ Err(error) => Err(error),
+ }
+ }
+
+ async fn get_requoted_actions(&self, expired: &PaymentQuote, addresses: &[ChainAddress]) -> Result {
+ let quotes = match self.options(&expired.payment_id, addresses).await? {
+ PaymentOptions::Quotes(quotes) => quotes,
+ PaymentOptions::Outcome(_) => return Err(PaymentError::PaymentExpired),
+ };
+ let quote = Self::get_quote("es, &expired.amount.asset_id)?;
+ let actions = self.actions("e).await?;
+ Ok(PreparedPayment { quotes, quote, actions })
+ }
+
+ pub(crate) async fn confirm_payment(&self, quote: &PaymentQuote, action_results: Vec) -> Result {
+ let response = self.client.confirm("e.payment_id, "e.id, action_results).await?;
+ Ok(payment_mapper::map_payment_outcome(response))
+ }
+
+ pub(crate) async fn options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result {
+ let identifiers: Vec = addresses.iter().filter_map(|address| account_identifier(address.chain, &address.address)).collect();
+ if identifiers.is_empty() {
+ return Err(PaymentError::UnsupportedAccounts);
+ }
+ let response = self.client.get_options(payment_id, identifiers).await?;
+ let quoted = payment_mapper::map_quoted_payment(response)?;
+
+ match quoted.status {
+ PaymentStatus::RequiresAction => {}
+ PaymentStatus::Succeeded | PaymentStatus::Processing => {
+ return Ok(PaymentOptions::Outcome(PaymentOutcome {
+ status: quoted.status,
+ transaction_id: None,
+ }));
+ }
+ PaymentStatus::Failed | PaymentStatus::Expired | PaymentStatus::Cancelled => return Err(PaymentError::PaymentExpired),
+ }
+ if quoted.expires_at <= Utc::now() {
+ return Err(PaymentError::PaymentExpired);
+ }
+
+ let quotes = Self::quotes(payment_id, quoted.options)?;
+ if quotes.is_empty() {
+ return Err(PaymentError::NoPaymentOptions);
+ }
+
+ Ok(PaymentOptions::Quotes(PaymentQuotes {
+ merchant: quoted.merchant,
+ price: Some(quoted.price),
+ expires_at: Some(quoted.expires_at),
+ quotes,
+ }))
+ }
+
+ fn is_expired(quote: &PaymentQuote) -> bool {
+ quote.expires_at.is_some_and(|expires_at| expires_at <= Utc::now())
+ }
+
+ fn get_quote(quotes: &PaymentQuotes, asset_id: &AssetId) -> Result {
+ quotes
+ .quotes
+ .iter()
+ .find(|quote| "e.amount.asset_id == asset_id)
+ .cloned()
+ .ok_or(PaymentError::QuoteExpired)
+ }
+
+ async fn actions(&self, quote: &PaymentQuote) -> Result, PaymentError> {
+ let option = Self::option(quote)?;
+ let actions = self.wallet_rpc_actions("e.payment_id, &option).await?;
+ if actions.is_empty() {
+ return Err(PaymentError::InvalidRequest("Payment option has no executable actions".to_string()));
+ }
+
+ actions.iter().map(|action| map_wallet_rpc(&option.account, action)).collect()
+ }
+
+ pub(crate) async fn get_payment_status(&self, payment_id: &str) -> Result {
+ let response = self.client.get_status(payment_id).await?;
+ Ok(payment_mapper::map_payment_outcome(response))
+ }
+
+ fn quotes(payment_id: &str, options: Vec) -> Result, PaymentError> {
+ let (open, collecting): (Vec, Vec) = options.into_iter().partition(|option| option.collect_data_url.is_none());
+ open.into_iter()
+ .chain(collecting)
+ .map(|option| {
+ if let Some(url) = &option.collect_data_url
+ && !is_wallet_connect_url(url)
+ {
+ return Err(PaymentError::InvalidRequest(format!("Payment collects data outside {WALLET_CONNECT_HOST}")));
+ }
+ Ok(PaymentQuote {
+ id: option.id,
+ payment_id: payment_id.to_string(),
+ amount: option.amount,
+ expires_at: option.expires_at,
+ collect_data_url: option.collect_data_url,
+ provider_data: option.provider_data,
+ })
+ })
+ .collect()
+ }
+
+ async fn wallet_rpc_actions(&self, payment_id: &str, option: &PaymentOption) -> Result, PaymentError> {
+ let actions = match option.actions.first() {
+ None => self.client.get_actions(payment_id, &option.id, String::new()).await?,
+ Some(WalletConnectPayAction::Build(build)) => self.client.get_actions(payment_id, &option.id, build.data.clone()).await?,
+ Some(WalletConnectPayAction::WalletRpc(_)) => option.actions.clone(),
+ };
+
+ actions.into_iter().map(WalletRpcAction::try_from).collect()
+ }
+
+ fn option(quote: &PaymentQuote) -> Result {
+ serde_json::from_str("e.provider_data).map_err(|error| PaymentError::InvalidRequest(error.to_string()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use chrono::Duration;
+ use gem_client::ClientError;
+ use gem_client::testkit::MockClient;
+ use primitives::{AssetId, Chain, PaymentAmount, PaymentMerchant};
+ use std::sync::{Arc, Mutex};
+
+ fn service(mock: MockClient) -> WalletConnectPayService {
+ WalletConnectPayService::new(mock, WalletConnectPayAuth::mock())
+ }
+
+ fn service_with_response(transform: impl Fn(&mut serde_json::Value) + Send + Sync + 'static) -> WalletConnectPayService {
+ service(MockClient::new().with_post(move |_, _| {
+ let mut response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap();
+ transform(&mut response);
+ Ok(response.to_string().into_bytes())
+ }))
+ }
+
+ #[tokio::test]
+ async fn test_prepared_payment_requotes_a_stale_quote() {
+ let requested = Arc::new(Mutex::new(Vec::::new()));
+ let seen = requested.clone();
+ let client = MockClient::new().with_post(move |path: &str, _| {
+ seen.lock().unwrap().push(path.to_string());
+ if path.contains("/fetch") {
+ return Err(ClientError::Http {
+ status: 409,
+ body: br#"{"code":"quote_expired"}"#.to_vec(),
+ });
+ }
+ let mut response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap();
+ far_future(&mut response);
+ Ok(response.to_string().into_bytes())
+ });
+ let service = service(client);
+ let PaymentOptions::Quotes(quotes) = service.options("pay_123", &addresses()).await.unwrap() else {
+ panic!("Expected quotes");
+ };
+ let selected = quotes.quotes.first().unwrap().clone();
+
+ let result = service.prepare_payment("es, &selected, &addresses()).await;
+
+ let paths = requested.lock().unwrap().clone();
+ assert!(paths.iter().filter(|path| path.contains("/options")).count() == 2, "expected a requote, got {paths:?}");
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn test_prepared_payment_refetches_before_a_dead_quote_is_used() {
+ let requested = Arc::new(Mutex::new(Vec::::new()));
+ let seen = requested.clone();
+ let client = MockClient::new().with_post(move |path: &str, _| {
+ seen.lock().unwrap().push(path.to_string());
+ let mut response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap();
+ far_future(&mut response);
+ Ok(response.to_string().into_bytes())
+ });
+ let service = service(client);
+ let PaymentOptions::Quotes(quotes) = service.options("pay_123", &addresses()).await.unwrap() else {
+ panic!("Expected quotes");
+ };
+ let expired = PaymentQuote {
+ expires_at: Some(Utc::now() - Duration::seconds(1)),
+ ..quotes.quotes.first().unwrap().clone()
+ };
+
+ let _ = service.prepare_payment("es, &expired, &addresses()).await;
+
+ let paths = requested.lock().unwrap().clone();
+ assert_eq!(paths.iter().filter(|path| path.contains("/options")).count(), 2);
+ assert_eq!(paths.iter().filter(|path| path.contains("/fetch")).count(), 1);
+ }
+
+ #[test]
+ fn test_get_quote_keeps_the_chosen_asset() {
+ let quote = |chain: Chain| PaymentQuote {
+ id: chain.as_ref().to_string(),
+ payment_id: "pay_123".to_string(),
+ amount: PaymentAmount {
+ asset_id: AssetId::from_chain(chain),
+ value: "1".to_string(),
+ symbol: chain.as_ref().to_string(),
+ decimals: 6,
+ },
+ expires_at: None,
+ collect_data_url: None,
+ provider_data: "{}".to_string(),
+ };
+ let quotes = PaymentQuotes {
+ merchant: PaymentMerchant {
+ name: "Merchant".to_string(),
+ icon_url: None,
+ },
+ price: None,
+ expires_at: None,
+ quotes: vec![quote(Chain::Ethereum), quote(Chain::Bitcoin)],
+ };
+
+ let chosen = WalletConnectPayService::::get_quote("es, &AssetId::from_chain(Chain::Bitcoin)).unwrap();
+ assert_eq!(chosen.amount.asset_id, AssetId::from_chain(Chain::Bitcoin));
+
+ let gone = WalletConnectPayService::::get_quote("es, &AssetId::from_chain(Chain::Solana));
+ assert_eq!(gone, Err(PaymentError::QuoteExpired));
+ }
+
+ fn addresses() -> Vec {
+ vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string()), ChainAddress::new(Chain::Bitcoin, "bc1".to_string())]
+ }
+
+ fn far_future(response: &mut serde_json::Value) {
+ response["info"]["expiresAt"] = serde_json::json!(4102444800i64);
+ }
+
+ #[tokio::test]
+ async fn test_options() {
+ let service = service_with_response(far_future);
+ let prepared = service.options("pay_123", &addresses()).await.unwrap();
+
+ let PaymentOptions::Quotes(quotes) = prepared else {
+ panic!("Expected Ready, got {prepared:?}");
+ };
+ assert_eq!(quotes.merchant.name, "Gem Wallet Test Merchant");
+ let quote = quotes.quotes.first().unwrap();
+ assert_eq!(quote.payment_id, "pay_123");
+ assert_eq!(quote.amount.symbol, "USDC");
+ assert_eq!(quote.amount.value, "50000000");
+ assert_eq!(WalletConnectPayService::::option(quote).unwrap().id, "opt_1");
+ }
+
+ #[tokio::test]
+ async fn test_options_collect_data() {
+ let service = service_with_response(|response| {
+ far_future(response);
+ response["options"][0]["collectData"] = serde_json::json!({"url": "https://data-collection.walletconnect.com/ic/pay_123"});
+ });
+
+ let prepared = service.options("pay_123", &addresses()).await.unwrap();
+ let PaymentOptions::Quotes(quotes) = prepared else {
+ panic!("Expected Ready, got {prepared:?}");
+ };
+ assert_eq!(
+ quotes.quotes.first().unwrap().collect_data_url.as_deref(),
+ Some("https://data-collection.walletconnect.com/ic/pay_123")
+ );
+ }
+
+ #[tokio::test]
+ async fn test_options_settled() {
+ let service = service_with_response(|response| {
+ far_future(response);
+ response["info"]["status"] = serde_json::json!("succeeded");
+ });
+
+ let options = service.options("pay_123", &addresses()).await.unwrap();
+ assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Succeeded));
+
+ let processing = service_with_response(|response| {
+ far_future(response);
+ response["info"]["status"] = serde_json::json!("processing");
+ });
+ let options = processing.options("pay_123", &addresses()).await.unwrap();
+ assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Processing));
+ }
+
+ #[tokio::test]
+ async fn test_options_rejects_unpayable() {
+ let failed = service_with_response(|response| {
+ far_future(response);
+ response["info"]["status"] = serde_json::json!("failed");
+ });
+ assert_eq!(failed.options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired));
+
+ let expired = service_with_response(|response| {
+ response["info"]["expiresAt"] = serde_json::json!(1);
+ });
+ assert_eq!(expired.options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired));
+
+ let no_options = service_with_response(|response| {
+ far_future(response);
+ response["options"] = serde_json::json!([]);
+ });
+ assert_eq!(no_options.options("pay_123", &addresses()).await, Err(PaymentError::NoPaymentOptions));
+
+ let unsupported_addresses = vec![ChainAddress::new(Chain::Bitcoin, "bc1".to_string())];
+ assert_eq!(
+ service_with_response(far_future).options("pay_123", &unsupported_addresses).await,
+ Err(PaymentError::UnsupportedAccounts)
+ );
+ }
+
+ #[test]
+ fn test_quotes_offer_options_asking_for_no_personal_data_first() {
+ let option = |id: &str, collect_data_url: Option<&str>| QuotedOption {
+ id: id.to_string(),
+ expires_at: None,
+ chain: Chain::Ethereum,
+ amount: PaymentAmount {
+ asset_id: AssetId::from(Chain::Ethereum, None),
+ value: "1".to_string(),
+ symbol: "ETH".to_string(),
+ decimals: 18,
+ },
+ collect_data_url: collect_data_url.map(str::to_string),
+ provider_data: format!("{{\"id\":\"{id}\"}}"),
+ };
+
+ let quotes = WalletConnectPayService::::quotes(
+ "pay_123",
+ vec![option("opt_form", Some("https://pay.walletconnect.com/collect?pid=pay_123")), option("opt_plain", None)],
+ )
+ .unwrap();
+
+ assert_eq!(quotes.len(), 2);
+ assert!(quotes[0].collect_data_url.is_none());
+ assert!(quotes[1].collect_data_url.is_some());
+ assert!(quotes.iter().all(|quote| quote.payment_id == "pay_123"));
+ }
+
+ #[test]
+ fn test_quotes_reject_a_collection_url_off_the_payment_host() {
+ let option = |url: &str| QuotedOption {
+ id: "opt_form".to_string(),
+ expires_at: None,
+ chain: Chain::Ethereum,
+ amount: PaymentAmount {
+ asset_id: AssetId::from(Chain::Ethereum, None),
+ value: "1".to_string(),
+ symbol: "ETH".to_string(),
+ decimals: 18,
+ },
+ collect_data_url: Some(url.to_string()),
+ provider_data: "{}".to_string(),
+ };
+
+ for url in [
+ "https://evil.com/collect",
+ "http://pay.walletconnect.com/collect",
+ "https://pay.walletconnect.com.evil.com/collect",
+ "https://notwalletconnect.com/collect",
+ "not a url",
+ ] {
+ assert!(
+ WalletConnectPayService::::quotes("pay_123", vec![option(url)]).is_err(),
+ "{url} was accepted"
+ );
+ }
+
+ assert!(WalletConnectPayService::::quotes("pay_123", vec![option("https://pay.walletconnect.com/collect")]).is_ok());
+ assert!(WalletConnectPayService::::quotes("pay_123", vec![option("https://data-collection.walletconnect.com/ic/pay_123")]).is_ok());
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/testkit.rs b/core/crates/payment/src/wallet_connect_pay/testkit.rs
new file mode 100644
index 0000000000..fbf3e2ee4f
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/testkit.rs
@@ -0,0 +1,16 @@
+use crate::wallet_connect_pay::client::WalletConnectPayAuth;
+
+pub const TEST_ACCOUNT_ETHEREUM: &str = "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD";
+pub const TEST_ACCOUNT_POLYGON: &str = "eip155:137:0x1085c5f70F7F7591D97da281A64688385455c2bD";
+pub const TEST_ACCOUNT_SOLANA: &str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5";
+pub const TEST_APP_ID: &str = "app_1";
+pub const TEST_CLIENT_ID: &str = "client_1";
+
+impl WalletConnectPayAuth {
+ pub fn mock() -> Self {
+ Self {
+ app_id: TEST_APP_ID.to_string(),
+ client_id: TEST_CLIENT_ID.to_string(),
+ }
+ }
+}
diff --git a/core/crates/payment/src/wallet_connect_pay/validator.rs b/core/crates/payment/src/wallet_connect_pay/validator.rs
new file mode 100644
index 0000000000..7b748e4ac5
--- /dev/null
+++ b/core/crates/payment/src/wallet_connect_pay/validator.rs
@@ -0,0 +1,100 @@
+use gem_wallet_connect::WalletConnectAction;
+use primitives::{ChainType, SignDigestType, ValueAccess, WCEthereumTransaction, WalletConnectCAIP2};
+use serde_json::Value;
+
+use crate::error::PaymentError;
+
+pub fn validate_signer(account: &str, params: &Value, action: &WalletConnectAction) -> Result<(), PaymentError> {
+ let chain_address = WalletConnectCAIP2::parse_account(account.to_string()).ok_or_else(|| PaymentError::InvalidRequest(format!("Invalid option account: {account}")))?;
+ if chain_address.chain.chain_type() != ChainType::Ethereum {
+ return Ok(());
+ }
+
+ match action {
+ WalletConnectAction::SendTransaction { data, .. } | WalletConnectAction::SignTransaction { data, .. } => {
+ let transaction: WCEthereumTransaction = serde_json::from_str(data).map_err(|error| PaymentError::InvalidRequest(format!("Invalid transaction payload: {error}")))?;
+ validate_address_match(&chain_address.address, &transaction.from)
+ }
+ WalletConnectAction::SignMessage {
+ sign_type: SignDigestType::Eip712,
+ ..
+ } => {
+ let signer = params.at(0).and_then(Value::string).map_err(PaymentError::InvalidRequest)?;
+ validate_address_match(&chain_address.address, signer)
+ }
+ WalletConnectAction::SignMessage {
+ sign_type:
+ SignDigestType::Eip191 | SignDigestType::Base58 | SignDigestType::SuiPersonal | SignDigestType::Siwe | SignDigestType::TonPersonal | SignDigestType::TronPersonal,
+ ..
+ }
+ | WalletConnectAction::SignAllTransactions { .. }
+ | WalletConnectAction::ChainOperation { .. }
+ | WalletConnectAction::GetAccounts { .. }
+ | WalletConnectAction::Unsupported { .. } => Ok(()),
+ }
+}
+
+fn validate_address_match(expected: &str, actual: &str) -> Result<(), PaymentError> {
+ if actual.to_lowercase() == expected.to_lowercase() {
+ return Ok(());
+ }
+ Err(PaymentError::InvalidRequest(format!("Signer address mismatch: expected {expected}, got {actual}")))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::wallet_connect_pay::testkit::{TEST_ACCOUNT_ETHEREUM, TEST_ACCOUNT_SOLANA};
+ use primitives::SignableTransactionType;
+ use primitives::{Chain, TransferDataOutputType};
+
+ fn send_transaction(from: &str) -> WalletConnectAction {
+ WalletConnectAction::SendTransaction {
+ chain: Chain::Ethereum,
+ transaction_type: SignableTransactionType::Ethereum,
+ data: serde_json::json!({"from": from, "to": "0x00"}).to_string(),
+ }
+ }
+
+ fn sign_message(sign_type: SignDigestType) -> WalletConnectAction {
+ WalletConnectAction::SignMessage {
+ chain: Chain::Ethereum,
+ sign_type,
+ data: "{}".to_string(),
+ }
+ }
+
+ #[test]
+ fn test_validate_signer_transaction() {
+ let address = "0x1085c5f70F7F7591D97da281A64688385455c2bD";
+
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction(address)).is_ok());
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction(&address.to_lowercase())).is_ok());
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction("0xdead")).is_err());
+ }
+
+ #[test]
+ fn test_validate_signer_typed_data() {
+ let signer = serde_json::json!(["0x1085c5f70f7f7591d97da281a64688385455c2bd", {}]);
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &signer, &sign_message(SignDigestType::Eip712)).is_ok());
+
+ let other_signer = serde_json::json!(["0xdead", {}]);
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &other_signer, &sign_message(SignDigestType::Eip712)).is_err());
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &sign_message(SignDigestType::Eip712)).is_err());
+ assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &other_signer, &sign_message(SignDigestType::Eip191)).is_ok());
+ }
+
+ #[test]
+ fn test_validate_signer_skips_non_ethereum() {
+ let action = WalletConnectAction::SignTransaction {
+ chain: Chain::Solana,
+ transaction_type: SignableTransactionType::Solana {
+ output_type: TransferDataOutputType::EncodedTransaction,
+ },
+ data: "base64".to_string(),
+ };
+
+ assert!(validate_signer(TEST_ACCOUNT_SOLANA, &Value::Null, &action).is_ok());
+ assert!(validate_signer("not-an-account", &Value::Null, &action).is_err());
+ }
+}
diff --git a/core/crates/payment/testdata/fetch_response_permit2.json b/core/crates/payment/testdata/fetch_response_permit2.json
new file mode 100644
index 0000000000..65a20e92ec
--- /dev/null
+++ b/core/crates/payment/testdata/fetch_response_permit2.json
@@ -0,0 +1,30 @@
+{
+ "actions": [
+ {
+ "type": "walletRpc",
+ "data": {
+ "chain_id": "eip155:137",
+ "method": "eth_sendTransaction",
+ "params": [
+ {
+ "from": "0x1085c5f70F7F7591D97da281A64688385455c2bD",
+ "to": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f",
+ "value": "0x0",
+ "data": "0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+ }
+ ]
+ }
+ },
+ {
+ "type": "walletRpc",
+ "data": {
+ "chain_id": "eip155:137",
+ "method": "eth_signTypedData_v4",
+ "params": [
+ "0x1085c5f70F7F7591D97da281A64688385455c2bD",
+ "{\"domain\":{\"name\":\"Permit2\",\"chainId\":\"0x89\",\"verifyingContract\":\"0x000000000022d473030f116ddee9f6b43ac78ba3\"},\"types\":{\"PermitTransferFrom\":[{\"name\":\"permitted\",\"type\":\"TokenPermissions\"},{\"name\":\"spender\",\"type\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\"}],\"TokenPermissions\":[{\"name\":\"token\",\"type\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\"}]},\"primaryType\":\"PermitTransferFrom\",\"message\":{\"permitted\":{\"token\":\"0xc2132d05d31c914a87c6611c10748aeb04b58e8f\",\"amount\":\"1000000\"},\"spender\":\"0x0000000000a84d1a9b0063a910315c7ffa9cd248\",\"nonce\":\"0x08fda2bbc9b3fcd08a09d38f61b4c56a80a3a5a7e0a050e3a5f9c012b3c60a11\",\"deadline\":\"1785175272\"}}"
+ ]
+ }
+ }
+ ]
+}
diff --git a/core/crates/payment/testdata/fetch_response_solana.json b/core/crates/payment/testdata/fetch_response_solana.json
new file mode 100644
index 0000000000..c6920791bb
--- /dev/null
+++ b/core/crates/payment/testdata/fetch_response_solana.json
@@ -0,0 +1,16 @@
+{
+ "actions": [
+ {
+ "type": "walletRpc",
+ "data": {
+ "chain_id": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
+ "method": "solana_signTransaction",
+ "params": [
+ {
+ "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/core/crates/payment/testdata/fetch_response_transfer_authorization.json b/core/crates/payment/testdata/fetch_response_transfer_authorization.json
new file mode 100644
index 0000000000..4e13de757c
--- /dev/null
+++ b/core/crates/payment/testdata/fetch_response_transfer_authorization.json
@@ -0,0 +1,15 @@
+{
+ "actions": [
+ {
+ "type": "walletRpc",
+ "data": {
+ "chain_id": "eip155:1",
+ "method": "eth_signTypedData_v4",
+ "params": [
+ "0x1085c5f70F7F7591D97da281A64688385455c2bD",
+ "{\"domain\":{\"name\":\"USD Coin\",\"version\":\"2\",\"chainId\":\"0x1\",\"verifyingContract\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\"},\"types\":{\"EIP712Domain\":[{\"type\":\"string\",\"name\":\"name\"},{\"type\":\"string\",\"name\":\"version\"},{\"type\":\"uint256\",\"name\":\"chainId\"},{\"type\":\"address\",\"name\":\"verifyingContract\"}],\"ReceiveWithAuthorization\":[{\"type\":\"address\",\"name\":\"from\"},{\"type\":\"address\",\"name\":\"to\"},{\"type\":\"uint256\",\"name\":\"value\"},{\"type\":\"uint256\",\"name\":\"validAfter\"},{\"type\":\"uint256\",\"name\":\"validBefore\"},{\"type\":\"bytes32\",\"name\":\"nonce\"}]},\"primaryType\":\"ReceiveWithAuthorization\",\"message\":{\"from\":\"0x1085c5f70F7F7591D97da281A64688385455c2bD\",\"nonce\":\"0x1111111111111111111111111111111111111111111111111111111111111111\",\"to\":\"0x2222222222222222222222222222222222222222\",\"validAfter\":\"0x0\",\"validBefore\":\"0x699cb700\",\"value\":\"0x2faf080\"}}"
+ ]
+ }
+ }
+ ]
+}
diff --git a/core/crates/payment/testdata/option_collect_data.json b/core/crates/payment/testdata/option_collect_data.json
new file mode 100644
index 0000000000..4073ee0bb1
--- /dev/null
+++ b/core/crates/payment/testdata/option_collect_data.json
@@ -0,0 +1,32 @@
+{
+ "id": "opt_ic",
+ "account": "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD",
+ "amount": {
+ "unit": "caip19/eip155:1/slip44:60",
+ "value": "1064973576179385",
+ "display": { "assetSymbol": "ETH", "assetName": "Ethereum", "decimals": 18 }
+ },
+ "etaS": 5,
+ "collectData": {
+ "url": "https://data-collection.walletconnect.com/ic/pay_123",
+ "fields": [],
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "fullName": { "type": "string", "title": "Full Name", "minLength": 1, "description": "User's full legal name" },
+ "dob": { "type": "string", "format": "date", "title": "Date of Birth", "description": "Date of birth in YYYY-MM-DD format" },
+ "pobAddress": { "type": "string", "title": "Place of Birth Address", "maxLength": 200 },
+ "pobCountry": { "type": "string", "pattern": "^[A-Z]{2}$", "title": "Place of Birth Country" },
+ "porAddress": { "type": "string", "title": "Place of Residence Address", "maxLength": 200 },
+ "porCountry": { "type": "string", "pattern": "^[A-Z]{2}$", "title": "Place of Residence Country" },
+ "tosConfirmed": { "type": "boolean", "const": true, "title": "Terms of Service Confirmation" }
+ },
+ "required": ["fullName", "dob", "tosConfirmed"],
+ "anyOf": [
+ { "required": ["pobCountry", "pobAddress"] },
+ { "required": ["porCountry", "porAddress"] }
+ ]
+ }
+ }
+}
diff --git a/core/crates/payment/testdata/options_response.json b/core/crates/payment/testdata/options_response.json
new file mode 100644
index 0000000000..7916a68335
--- /dev/null
+++ b/core/crates/payment/testdata/options_response.json
@@ -0,0 +1,43 @@
+{
+ "paymentId": "pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4",
+ "info": {
+ "status": "requires_action",
+ "amount": {
+ "unit": "iso4217/USD",
+ "value": "5000",
+ "display": {
+ "assetSymbol": "USD",
+ "assetName": "US Dollar",
+ "decimals": 2
+ }
+ },
+ "expiresAt": 1785175272,
+ "merchant": {
+ "name": "Gem Wallet Test Merchant",
+ "iconUrl": "https://imagedelivery.net/example/md"
+ },
+ "buyer": null
+ },
+ "options": [
+ {
+ "id": "opt_1",
+ "account": "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD",
+ "amount": {
+ "unit": "caip19/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
+ "value": "50000000",
+ "display": {
+ "assetSymbol": "USDC",
+ "assetName": "USD Coin",
+ "decimals": 6,
+ "networkName": "Ethereum",
+ "iconUrl": "https://assets.walletconnect.com/usdc.png",
+ "networkIconUrl": "https://assets.walletconnect.com/ethereum.png"
+ }
+ },
+ "etaS": 5,
+ "collectData": null
+ }
+ ],
+ "collectData": null,
+ "resultInfo": null
+}
diff --git a/core/crates/payment/testdata/status_response_succeeded.json b/core/crates/payment/testdata/status_response_succeeded.json
new file mode 100644
index 0000000000..c6e812a61b
--- /dev/null
+++ b/core/crates/payment/testdata/status_response_succeeded.json
@@ -0,0 +1,16 @@
+{
+ "status": "succeeded",
+ "isFinal": true,
+ "info": {
+ "txId": "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4",
+ "optionAmount": {
+ "unit": "caip19/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
+ "value": "50000000",
+ "display": {
+ "assetSymbol": "USDC",
+ "assetName": "USD Coin",
+ "decimals": 6
+ }
+ }
+ }
+}
diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs
index 0fa726bcfa..c97fa50d38 100644
--- a/core/crates/primitives/src/lib.rs
+++ b/core/crates/primitives/src/lib.rs
@@ -119,6 +119,12 @@ pub mod platform;
pub use self::platform::Platform;
pub mod platform_store;
pub use self::platform_store::PlatformStore;
+pub mod payment;
+pub use self::payment::{
+ Payment, PaymentAmount, PaymentLink, PaymentMerchant, PaymentOptions, PaymentOutcome, PaymentPrice, PaymentProviderName, PaymentQuote, PaymentQuotes, PaymentRequest,
+ PaymentStatus,
+};
+
pub mod payment_type;
pub use self::payment_type::PaymentType;
pub mod contact;
@@ -177,10 +183,15 @@ pub mod hex;
pub use self::hex::{HexError, decode_hex, decode_hex_array};
pub mod transaction_metadata_types;
pub use self::transaction_metadata_types::{
- TransactionNFTTransferMetadata, TransactionPerpetualMetadata, TransactionResourceTypeMetadata, TransactionSmartContractMetadata, TransactionSwapMetadata,
+ TransactionNFTTransferMetadata, TransactionPaymentMetadata, TransactionPerpetualMetadata, TransactionResourceTypeMetadata, TransactionSmartContractMetadata,
+ TransactionSwapMetadata,
};
+pub mod transaction_app_metadata;
+pub use self::transaction_app_metadata::TransactionAppMetadata;
pub mod wallet_connect_namespace;
-pub use self::wallet_connect_namespace::WalletConnectCAIP2;
+pub use self::wallet_connect_namespace::{WalletConnectCAIP2, WalletConnectCAIP19};
+pub mod signing;
+pub use self::signing::{EthereumTransactionData, SignDigestType, SignMessage, SignableTransaction, SignableTransactionType, SolanaTransactionData, SuiTransactionData};
pub mod wallet_connect;
pub use self::wallet_connect::{WCEthereumTransaction, WCTonMessage, WalletConnectLink, WalletConnectRequest};
pub mod account;
@@ -213,7 +224,7 @@ pub use self::tag::AssetTag;
pub mod chain_cosmos;
pub use self::chain_cosmos::CosmosDenom;
pub mod payment_decoder;
-pub use self::payment_decoder::{DecodedLinkType, PaymentURLDecoder};
+pub use self::payment_decoder::PaymentURLDecoder;
pub const DEFAULT_FIAT_CURRENCY: &str = "USD";
pub mod image_formatter;
diff --git a/core/crates/primitives/src/payment.rs b/core/crates/primitives/src/payment.rs
new file mode 100644
index 0000000000..ad5f032af9
--- /dev/null
+++ b/core/crates/primitives/src/payment.rs
@@ -0,0 +1,142 @@
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use typeshare::typeshare;
+
+use crate::asset_id::AssetId;
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(tag = "type", content = "content", rename_all = "camelCase")]
+pub enum Payment {
+ Request(PaymentRequest),
+ Link(PaymentLink),
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentRequest {
+ pub address: String,
+ pub amount: Option,
+ pub memo: Option,
+ pub asset_id: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentLink {
+ pub provider: PaymentProviderName,
+ pub id: String,
+}
+
+impl PaymentLink {
+ pub fn new(provider: PaymentProviderName, id: String) -> Self {
+ Self { provider, id }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentMerchant {
+ pub name: String,
+ pub icon_url: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "snake_case")]
+pub enum PaymentStatus {
+ RequiresAction,
+ Processing,
+ Succeeded,
+ Failed,
+ Expired,
+ Cancelled,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentOutcome {
+ pub status: PaymentStatus,
+ pub transaction_id: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(tag = "type", content = "content", rename_all = "camelCase")]
+pub enum PaymentOptions {
+ Quotes(PaymentQuotes),
+ Outcome(PaymentOutcome),
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentQuotes {
+ pub merchant: PaymentMerchant,
+ pub price: Option,
+ pub expires_at: Option>,
+ pub quotes: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentPrice {
+ pub symbol: String,
+ pub value: String,
+ pub decimals: i32,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentQuote {
+ pub id: String,
+ pub payment_id: String,
+ pub amount: PaymentAmount,
+ pub expires_at: Option>,
+ pub collect_data_url: Option,
+ pub provider_data: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub enum PaymentProviderName {
+ SolanaPay,
+ WalletConnectPay,
+}
+
+impl PaymentProviderName {
+ pub fn has_status(&self) -> bool {
+ match self {
+ Self::WalletConnectPay => true,
+ Self::SolanaPay => false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct PaymentAmount {
+ pub asset_id: AssetId,
+ pub value: String,
+ pub symbol: String,
+ pub decimals: i32,
+}
+
+impl PaymentRequest {
+ pub fn new_address(address: &str) -> Self {
+ Self {
+ address: address.to_string(),
+ amount: None,
+ memo: None,
+ asset_id: None,
+ }
+ }
+}
diff --git a/core/crates/primitives/src/payment_decoder/decoder.rs b/core/crates/primitives/src/payment_decoder/decoder.rs
index 0cd86ebefe..31ab4540a3 100644
--- a/core/crates/primitives/src/payment_decoder/decoder.rs
+++ b/core/crates/primitives/src/payment_decoder/decoder.rs
@@ -1,62 +1,27 @@
use super::error::{PaymentDecoderError, Result};
-use crate::{Chain, asset_id::AssetId};
-use std::{collections::HashMap, fmt, str::FromStr};
+use crate::{
+ Chain,
+ asset_id::AssetId,
+ payment::{Payment, PaymentLink, PaymentProviderName, PaymentRequest},
+};
+use std::{collections::HashMap, str::FromStr};
use super::{
erc681::{ETHEREUM_SCHEME, TransactionRequest},
solana_pay::{self, PayTransfer as SolanaPayTransfer, SOLANA_PAY_SCHEME},
ton_pay::{self, TON_PAY_SCHEME},
+ wallet_connect_pay,
};
-#[derive(Debug, PartialEq)]
-pub struct Payment {
- pub address: String,
- pub amount: Option,
- pub memo: Option,
- pub asset_id: Option,
- pub link: Option,
-}
-
-impl Payment {
- pub fn new_address(address: &str) -> Self {
- Self {
- address: address.to_string(),
- amount: None,
- memo: None,
- asset_id: None,
- link: None,
- }
- }
-
- pub fn new_link(link: DecodedLinkType) -> Self {
- Self {
- address: "".to_string(),
- amount: None,
- memo: None,
- asset_id: None,
- link: Some(link),
- }
- }
-}
-
-#[derive(Debug, PartialEq)]
-pub enum DecodedLinkType {
- SolanaPay(String),
-}
-
-impl fmt::Display for DecodedLinkType {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- DecodedLinkType::SolanaPay(link) => write!(f, "{link}"),
- }
- }
-}
-
#[derive(Debug)]
pub struct PaymentURLDecoder;
impl PaymentURLDecoder {
pub fn decode(string: &str) -> Result {
+ if let Ok(payment) = wallet_connect_pay::parse(string) {
+ return Ok(Payment::Link(PaymentLink::new(PaymentProviderName::WalletConnectPay, payment.payment_id)));
+ }
+
let chunks: Vec<&str> = string.split(':').collect();
match chunks.len() {
@@ -68,51 +33,43 @@ impl PaymentURLDecoder {
if parts.len() == 2 {
let address = parts[0].to_string();
let params = Self::decode_query_string(parts[1]);
- return Ok(Payment {
+ return Ok(Payment::Request(PaymentRequest {
address,
amount: params.get("amount").cloned(),
memo: params.get("memo").cloned(),
asset_id: None,
- link: None,
- });
+ }));
}
}
// No scheme and no query parameters
- Ok(Payment::new_address(string))
+ Ok(Payment::Request(PaymentRequest::new_address(string)))
}
// Handle case with scheme
2 => {
let scheme = chunks[0];
if scheme == ETHEREUM_SCHEME {
let transaction_request = TransactionRequest::parse(string)?;
- return Ok(transaction_request.into());
+ return Ok(Payment::Request(transaction_request.into()));
}
if scheme == SOLANA_PAY_SCHEME {
let pay_request = solana_pay::parse(string)?;
match pay_request {
solana_pay::RequestType::Transfer(transfer) => {
- return Ok(transfer.into());
+ return Ok(Payment::Request(transfer.into()));
}
solana_pay::RequestType::Transaction(link) => {
- return Ok(Payment {
- address: "".to_string(),
- amount: None,
- memo: None,
- asset_id: None,
- link: Some(DecodedLinkType::SolanaPay(link)),
- });
+ return Ok(Payment::Link(PaymentLink::new(PaymentProviderName::SolanaPay, link)));
}
}
}
if scheme == TON_PAY_SCHEME {
let ton_payment = ton_pay::parse(string)?;
- return Ok(Payment {
+ return Ok(Payment::Request(PaymentRequest {
address: ton_payment.recipient,
amount: None,
memo: None,
asset_id: Some(ton_payment.asset_id),
- link: None,
- });
+ }));
}
let path: &str = chunks[1];
@@ -121,32 +78,25 @@ impl PaymentURLDecoder {
let asset_id = Self::decode_scheme(scheme);
if path_chunks.len() == 1 {
- Ok(Payment {
+ Ok(Payment::Request(PaymentRequest {
address,
amount: None,
memo: None,
asset_id,
- link: None,
- })
+ }))
} else if path_chunks.len() == 2 {
let query = path_chunks[1];
let params = Self::decode_query_string(query);
let amount = params.get("amount").cloned();
let memo = params.get("memo").cloned();
- Ok(Payment {
- address,
- amount,
- memo,
- asset_id,
- link: None,
- })
+ Ok(Payment::Request(PaymentRequest { address, amount, memo, asset_id }))
} else {
Err(PaymentDecoderError::InvalidFormat("BIP21 format is incorrect".to_string()))
}
}
// Handle any other case (shouldn't normally happen)
- _ => Ok(Payment::new_address(string)),
+ _ => Ok(Payment::Request(PaymentRequest::new_address(string))),
}
}
@@ -170,7 +120,7 @@ impl PaymentURLDecoder {
}
}
-impl From for Payment {
+impl From for PaymentRequest {
fn from(val: TransactionRequest) -> Self {
let address: String;
let mut amount: Option;
@@ -194,24 +144,17 @@ impl From for Payment {
}
asset_id = Some(AssetId::from(chain, None));
};
- Self {
- address,
- amount,
- memo,
- asset_id,
- link: None,
- }
+ Self { address, amount, memo, asset_id }
}
}
-impl From for Payment {
+impl From for PaymentRequest {
fn from(val: SolanaPayTransfer) -> Self {
Self {
address: val.recipient,
amount: val.amount,
memo: val.memo,
asset_id: Some(AssetId::from(Chain::Solana, val.spl_token.map(|x| x.to_string()))),
- link: None,
}
}
}
@@ -225,7 +168,7 @@ mod tests {
fn test_address() {
assert_eq!(
PaymentURLDecoder::decode("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326").unwrap(),
- Payment::new_address("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326")
+ Payment::Request(PaymentRequest::new_address("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"))
);
}
@@ -233,21 +176,39 @@ mod tests {
fn test_solana() {
assert_eq!(
PaymentURLDecoder::decode("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5").unwrap(),
- Payment::new_address("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5")
+ Payment::Request(PaymentRequest::new_address("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5"))
);
assert_eq!(
PaymentURLDecoder::decode("solana:HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5?amount=0.266232").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5".to_string(),
amount: Some("0.266232".to_string()),
memo: None,
asset_id: Some(AssetId::from_chain(Chain::Solana)),
- link: None,
- }
+ })
);
assert_eq!(
PaymentURLDecoder::decode("solana:https%3A%2F%2Fapi.spherepay.co%2Fv1%2Fpublic%2FpaymentLink%2Fpay%2FpaymentLink_1df6564b6b4d43eaa077b732ad9b6ab9%3Fstate%3DAlabama%26country%3DUSA%26lineItems%3D%255B%257B%2522id%2522%253A%2522lineItem_82032b8ea67244e692cd322051e35689%2522%252C%2522quantity%2522%253A500%257D%255D%26solanaPayReference%3D4Vqsq8WhoTbFu8Lw2DbbtnCiHXXmBRN6afF8HkgxrXs7%26paymentReference%3DOZ_UxaOrU_F8fM5GhlrE2%26network%3Dsol%26skipPreflight%3Dfalse").unwrap(),
- Payment::new_link(DecodedLinkType::SolanaPay("https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1df6564b6b4d43eaa077b732ad9b6ab9?state=Alabama&country=USA&lineItems=%5B%7B%22id%22%3A%22lineItem_82032b8ea67244e692cd322051e35689%22%2C%22quantity%22%3A500%7D%5D&solanaPayReference=4Vqsq8WhoTbFu8Lw2DbbtnCiHXXmBRN6afF8HkgxrXs7&paymentReference=OZ_UxaOrU_F8fM5GhlrE2&network=sol&skipPreflight=false".to_string())),
+ Payment::Link(PaymentLink::new(
+ PaymentProviderName::SolanaPay,
+ "https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1df6564b6b4d43eaa077b732ad9b6ab9?state=Alabama&country=USA&lineItems=%5B%7B%22id%22%3A%22lineItem_82032b8ea67244e692cd322051e35689%22%2C%22quantity%22%3A500%7D%5D&solanaPayReference=4Vqsq8WhoTbFu8Lw2DbbtnCiHXXmBRN6afF8HkgxrXs7&paymentReference=OZ_UxaOrU_F8fM5GhlrE2&network=sol&skipPreflight=false".to_string()
+ )),
+ );
+ }
+
+ #[test]
+ fn test_wallet_connect_pay() {
+ assert_eq!(
+ PaymentURLDecoder::decode("https://pay.walletconnect.com/?pid=pay_123").unwrap(),
+ Payment::Link(PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()))
+ );
+ assert_eq!(
+ PaymentURLDecoder::decode("wc:abc@2?pay=https%3A%2F%2Fpay.walletconnect.com%2F%3Fpid%3Dpay_123").unwrap(),
+ Payment::Link(PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()))
+ );
+ assert_eq!(
+ PaymentURLDecoder::decode("wc:abc@2?pay=https://pay.walletconnect.com/?pid=pay_123").unwrap(),
+ Payment::Link(PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()))
);
}
@@ -255,35 +216,32 @@ mod tests {
fn test_bip21() {
assert_eq!(
PaymentURLDecoder::decode("bitcoin:bc1pn6pua8a566z7t822kphpd2el45ntm23354c3krfmpe3nnn33lkcskuxrdl?amount=0.00001").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "bc1pn6pua8a566z7t822kphpd2el45ntm23354c3krfmpe3nnn33lkcskuxrdl".to_string(),
amount: Some("0.00001".to_string()),
memo: None,
asset_id: Some(AssetId::from_chain(Chain::Bitcoin)),
- link: None,
- }
+ })
);
assert_eq!(
PaymentURLDecoder::decode("ethereum:0xA20d8935d61812b7b052E08f0768cFD6D81cB088?amount=0.01233&memo=test").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "0xA20d8935d61812b7b052E08f0768cFD6D81cB088".to_string(),
amount: Some("0.01233".to_string()),
memo: Some("test".to_string()),
asset_id: Some(AssetId::from_chain(Chain::Ethereum)),
- link: None,
- }
+ })
);
assert_eq!(
PaymentURLDecoder::decode("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(),
amount: Some("0.42301".to_string()),
memo: None,
asset_id: Some(AssetId::from_chain(Chain::Solana)),
- link: None,
- }
+ })
);
}
@@ -291,23 +249,21 @@ mod tests {
fn test_erc681() {
assert_eq!(
PaymentURLDecoder::decode("ethereum:0xcB3028d6120802148f03d6c884D6AD6A210Df62A@1").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "0xcB3028d6120802148f03d6c884D6AD6A210Df62A".to_string(),
amount: None,
memo: None,
asset_id: Some(AssetId::from_chain(Chain::Ethereum)),
- link: None,
- }
+ })
);
assert_eq!(
PaymentURLDecoder::decode("ethereum:0xcB3028d6120802148f03d6c884D6AD6A210Df62A@0x38?amount=1.23").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "0xcB3028d6120802148f03d6c884D6AD6A210Df62A".to_string(),
amount: Some("1.23".to_string()),
memo: None,
asset_id: Some(AssetId::from_chain(Chain::SmartChain)),
- link: None,
- }
+ })
);
}
@@ -315,23 +271,21 @@ mod tests {
fn test_ton_address() {
assert_eq!(
PaymentURLDecoder::decode("UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA".to_string(),
amount: None,
memo: None,
asset_id: None,
- link: None,
- }
+ })
);
assert_eq!(
PaymentURLDecoder::decode("ton://transfer/UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "UQA5olhYULHkui4mTQM0LodWG0EqUaxmK6-e3mHrCZFO2diA".to_string(),
amount: None,
memo: None,
asset_id: Some(AssetId::from_chain(Chain::Ton)),
- link: None,
- }
+ })
);
}
@@ -339,13 +293,12 @@ mod tests {
fn test_address_with_amount() {
assert_eq!(
PaymentURLDecoder::decode("0x25851Bf7D35293A89F710eBFbD4718322eF7B174?amount=50.72").unwrap(),
- Payment {
+ Payment::Request(PaymentRequest {
address: "0x25851Bf7D35293A89F710eBFbD4718322eF7B174".to_string(),
amount: Some("50.72".to_string()),
memo: None,
asset_id: None,
- link: None,
- }
+ })
);
}
}
diff --git a/core/crates/primitives/src/payment_decoder/mod.rs b/core/crates/primitives/src/payment_decoder/mod.rs
index 2b672ee002..41424ea3e4 100644
--- a/core/crates/primitives/src/payment_decoder/mod.rs
+++ b/core/crates/primitives/src/payment_decoder/mod.rs
@@ -3,6 +3,7 @@ pub mod erc681;
pub mod error;
pub mod solana_pay;
pub mod ton_pay;
+pub mod wallet_connect_pay;
-pub use self::decoder::{DecodedLinkType, Payment, PaymentURLDecoder};
+pub use self::decoder::PaymentURLDecoder;
pub use self::error::{PaymentDecoderError, Result};
diff --git a/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs b/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs
new file mode 100644
index 0000000000..b7157b8ea5
--- /dev/null
+++ b/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs
@@ -0,0 +1,125 @@
+use super::error::{PaymentDecoderError, Result};
+use url::Url;
+
+use crate::url_query::query_value;
+use crate::{HTTP_URL_SCHEME, HTTPS_URL_SCHEME, WalletConnectLink};
+
+pub const WALLET_CONNECT_PAY_HOST: &str = "pay.walletconnect.com";
+pub const WALLET_CONNECT_HOST: &str = "walletconnect.com";
+const WALLET_CONNECT_HOST_SUFFIX: &str = ".walletconnect.com";
+const WALLET_CONNECT_PAY_HOST_SUFFIX: &str = ".pay.walletconnect.com";
+const PAYMENT_ID_PREFIX: &str = "pay_";
+const PAYMENT_ID_EXTRA_CHARACTERS: &str = "-._~";
+
+const QUERY_PAYMENT_ID: &str = "pid";
+const QUERY_PAY: &str = "pay";
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct WalletConnectPayLink {
+ pub payment_id: String,
+}
+
+pub fn parse(uri: &str) -> Result {
+ let url = Url::parse(uri)?;
+ let payment_id = payment_id(&url).ok_or(PaymentDecoderError::InvalidScheme)?;
+ Ok(WalletConnectPayLink { payment_id })
+}
+
+fn payment_id(url: &Url) -> Option {
+ match url.scheme() {
+ HTTP_URL_SCHEME | HTTPS_URL_SCHEME => from_payment_url(url),
+ _ => from_pairing_uri(url),
+ }
+}
+
+fn from_payment_url(url: &Url) -> Option {
+ if !is_payment_host(url) {
+ return None;
+ }
+ query_value(url, QUERY_PAYMENT_ID)
+ .or_else(|| Some(url.path().trim_matches('/').to_string()))
+ .filter(|payment_id| is_payment_id(payment_id))
+}
+
+pub fn is_payment_id(payment_id: &str) -> bool {
+ payment_id.starts_with(PAYMENT_ID_PREFIX)
+ && payment_id
+ .chars()
+ .all(|character| character.is_ascii_alphanumeric() || PAYMENT_ID_EXTRA_CHARACTERS.contains(character))
+}
+
+fn from_pairing_uri(url: &Url) -> Option {
+ let WalletConnectLink::Connect { uri } = WalletConnectLink::from_url(url.as_str())? else {
+ return None;
+ };
+ let pairing_uri = Url::parse(&uri).ok()?;
+ let payment_url = Url::parse(&query_value(&pairing_uri, QUERY_PAY)?).ok()?;
+ from_payment_url(&payment_url)
+}
+
+pub fn is_wallet_connect_url(uri: &str) -> bool {
+ Url::parse(uri)
+ .is_ok_and(|url| url.scheme() == HTTPS_URL_SCHEME && url.host_str().is_some_and(|host| host == WALLET_CONNECT_HOST || host.ends_with(WALLET_CONNECT_HOST_SUFFIX)))
+}
+
+fn is_payment_host(url: &Url) -> bool {
+ url.scheme() == "https"
+ && url
+ .host_str()
+ .is_some_and(|host| host == WALLET_CONNECT_PAY_HOST || host.ends_with(WALLET_CONNECT_PAY_HOST_SUFFIX))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn payment_id(uri: &str) -> Option {
+ parse(uri).ok().map(|payment| payment.payment_id)
+ }
+
+ #[test]
+ fn test_parse_payment_url() {
+ assert_eq!(payment_id("https://pay.walletconnect.com/?pid=pay_1"), Some("pay_1".to_string()));
+ assert_eq!(payment_id("http://pay.walletconnect.com/?pid=pay_1"), None);
+ assert_eq!(payment_id("https://pay.walletconnect.com/pay_1"), Some("pay_1".to_string()));
+ assert_eq!(payment_id("https://staging.pay.walletconnect.com/?pid=pay_1"), Some("pay_1".to_string()));
+
+ assert_eq!(payment_id("https://pay.walletconnect.com"), None);
+ assert_eq!(payment_id("https://pay.walletconnect.com/terms"), None);
+ assert_eq!(payment_id("https://pay.walletconnect.com/?pid=checkout"), None);
+ assert_eq!(payment_id("https://notpay.walletconnect.com/pay_1"), None);
+ assert_eq!(payment_id("https://pay.walletconnect.com.attacker.io/pay_1"), None);
+ assert_eq!(payment_id("https://gemwallet.com/tokens/bitcoin"), None);
+ }
+
+ #[test]
+ fn test_is_payment_id() {
+ assert!(is_payment_id("pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4"));
+ assert!(is_payment_id("pay_1-2.3~4"));
+
+ assert!(!is_payment_id("checkout"));
+ assert!(!is_payment_id(""));
+ assert!(!is_payment_id("pay_../../admin"));
+ assert!(!is_payment_id("pay_1?maxPollMs=0"));
+ assert!(!is_payment_id("pay_1/status"));
+ assert!(!is_payment_id("pay_1 2"));
+ }
+
+ #[test]
+ fn test_parse_pairing_uri() {
+ assert_eq!(payment_id("wc:abc@2?pay=https%3A%2F%2Fpay.walletconnect.com%2F%3Fpid%3Dpay_1"), Some("pay_1".to_string()));
+ assert_eq!(payment_id("wc:abc@2?pay=https://pay.walletconnect.com/?pid=pay_1"), Some("pay_1".to_string()));
+ assert_eq!(payment_id("wc:abc@2?pay=https%3A%2F%2Fmerchant.example.com%2Fcheckout"), None);
+ assert_eq!(payment_id("wc:abc@2?relay-protocol=irn&symKey=123"), None);
+ }
+
+ #[test]
+ fn test_parse_deep_link() {
+ assert_eq!(
+ payment_id("gem://wc?uri=wc%3Aabc%402%3Fpay%3Dhttps%253A%252F%252Fpay.walletconnect.com%252F%253Fpid%253Dpay_1"),
+ Some("pay_1".to_string())
+ );
+ assert_eq!(payment_id("gem://wc?uri=wc%3Atopic%402%3Frelay-protocol%3Dirn%26symKey%3Dabc"), None);
+ assert_eq!(payment_id("gem://wc?sessionTopic=abc"), None);
+ }
+}
diff --git a/core/crates/primitives/src/signing.rs b/core/crates/primitives/src/signing.rs
new file mode 100644
index 0000000000..d34d3f02d8
--- /dev/null
+++ b/core/crates/primitives/src/signing.rs
@@ -0,0 +1,112 @@
+use crate::{Chain, TransactionType, TransferDataOutputType, UInt64, WCEthereumTransaction};
+use serde::{Deserialize, Serialize};
+use typeshare::typeshare;
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub enum SignDigestType {
+ Eip191,
+ Eip712,
+ Base58,
+ SuiPersonal,
+ Siwe,
+ TonPersonal,
+ TronPersonal,
+}
+
+#[derive(Debug)]
+pub struct SignMessage {
+ pub chain: Chain,
+ pub sign_type: SignDigestType,
+ pub data: Vec,
+}
+
+#[derive(Debug, Clone)]
+#[allow(clippy::large_enum_variant)]
+pub enum SignableTransaction {
+ Ethereum {
+ data: EthereumTransactionData,
+ transaction_type: TransactionType,
+ },
+ Solana {
+ data: SolanaTransactionData,
+ output_type: TransferDataOutputType,
+ },
+ Sui {
+ data: SuiTransactionData,
+ output_type: TransferDataOutputType,
+ },
+ Ton {
+ data: String,
+ output_type: TransferDataOutputType,
+ },
+ Tron {
+ data: String,
+ output_type: TransferDataOutputType,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum SignableTransactionType {
+ Ethereum,
+ Solana { output_type: TransferDataOutputType },
+ Sui { output_type: TransferDataOutputType },
+ Ton { output_type: TransferDataOutputType },
+ Tron { output_type: TransferDataOutputType },
+}
+
+impl SignableTransactionType {
+ pub fn get_output_type(&self) -> Option {
+ match self {
+ Self::Ethereum => None,
+ Self::Solana { output_type } | Self::Sui { output_type } | Self::Ton { output_type } | Self::Tron { output_type } => Some(output_type.clone()),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct EthereumTransactionData {
+ pub chain_id: Option,
+ pub from: String,
+ pub to: String,
+ pub value: Option,
+ pub gas: Option,
+ pub gas_limit: Option,
+ pub gas_price: Option,
+ pub max_fee_per_gas: Option,
+ pub max_priority_fee_per_gas: Option,
+ pub nonce: Option,
+ pub data: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct SolanaTransactionData {
+ pub transaction: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct SuiTransactionData {
+ pub transaction: String,
+ pub wallet_address: String,
+}
+
+impl From for EthereumTransactionData {
+ fn from(transaction: WCEthereumTransaction) -> Self {
+ Self {
+ chain_id: transaction.chain_id,
+ from: transaction.from,
+ to: transaction.to,
+ value: transaction.value,
+ gas: transaction.gas,
+ gas_limit: transaction.gas_limit,
+ gas_price: transaction.gas_price,
+ max_fee_per_gas: transaction.max_fee_per_gas,
+ max_priority_fee_per_gas: transaction.max_priority_fee_per_gas,
+ nonce: transaction.nonce,
+ data: transaction.data,
+ }
+ }
+}
diff --git a/core/crates/primitives/src/testkit/mod.rs b/core/crates/primitives/src/testkit/mod.rs
index 2d2617adcc..5382872c03 100644
--- a/core/crates/primitives/src/testkit/mod.rs
+++ b/core/crates/primitives/src/testkit/mod.rs
@@ -17,6 +17,7 @@ pub mod signer_mock;
pub mod simulation_mock;
pub mod subscription_mock;
pub mod swap_mock;
+pub mod transaction_app_metadata_mock;
pub mod transaction_fee_mock;
pub mod transaction_load_input_mock;
pub mod transaction_load_metadata_mock;
diff --git a/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs b/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs
new file mode 100644
index 0000000000..db8dfa5af6
--- /dev/null
+++ b/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs
@@ -0,0 +1,12 @@
+use crate::TransactionAppMetadata;
+
+impl TransactionAppMetadata {
+ pub fn mock() -> Self {
+ TransactionAppMetadata {
+ name: "Test Dapp".to_string(),
+ description: None,
+ url: Some("https://example.com".to_string()),
+ icon: Some("https://example.com/icon.png".to_string()),
+ }
+ }
+}
diff --git a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs
index 30aea4aa28..3c2ce082a8 100644
--- a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs
+++ b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs
@@ -1,7 +1,7 @@
use super::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER, TEST_OSMOSIS_SENDER};
use crate::{
- Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction,
- TransferDataOutputType, WalletConnectionSessionAppMetadata,
+ Asset, Chain, GasPriceType, SignerInput, TransactionAppMetadata, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra,
+ TransferDataOutputAction, TransferDataOutputType,
};
use num_bigint::BigInt;
use std::collections::HashMap;
@@ -208,7 +208,7 @@ impl TransactionLoadInput {
TransactionLoadInput {
input_type: TransactionInputType::Generic(
Asset::from_chain(chain),
- WalletConnectionSessionAppMetadata::mock(),
+ TransactionAppMetadata::mock(),
TransferDataExtra {
data: Some(data.as_bytes().to_vec()),
output_type,
diff --git a/core/crates/primitives/src/transaction_app_metadata.rs b/core/crates/primitives/src/transaction_app_metadata.rs
new file mode 100644
index 0000000000..4fe7cac46e
--- /dev/null
+++ b/core/crates/primitives/src/transaction_app_metadata.rs
@@ -0,0 +1,20 @@
+use serde::{Deserialize, Serialize};
+use typeshare::typeshare;
+
+use crate::wallet_connector::short_name;
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct TransactionAppMetadata {
+ pub name: String,
+ pub description: Option,
+ pub url: Option,
+ pub icon: Option,
+}
+
+impl TransactionAppMetadata {
+ pub fn short_name(&self) -> String {
+ short_name(&self.name)
+ }
+}
diff --git a/core/crates/primitives/src/transaction_input_type.rs b/core/crates/primitives/src/transaction_input_type.rs
index 544efb20c2..3de41b0ded 100644
--- a/core/crates/primitives/src/transaction_input_type.rs
+++ b/core/crates/primitives/src/transaction_input_type.rs
@@ -1,10 +1,11 @@
+use crate::swap::ApprovalData;
use crate::contract_call_data::ContractCallData;
use crate::earn_type::EarnType;
use crate::stake_type::StakeType;
-use crate::swap::{ApprovalData, SwapData, SwapQuoteDataType};
+use crate::swap::{SwapData, SwapQuoteDataType};
use crate::transaction_fee::TransactionFee;
use crate::transaction_load_metadata::TransactionLoadMetadata;
-use crate::{Asset, GasPriceType, PerpetualType, SignerError, TransactionType, TransferDataExtra, WalletConnectionSessionAppMetadata, nft::NFTAsset, perpetual::AccountDataType};
+use crate::{Asset, GasPriceType, PerpetualType, SignerError, TransactionAppMetadata, TransactionType, TransferDataExtra, nft::NFTAsset, perpetual::AccountDataType};
use num_bigint::BigInt;
use num_traits::ToPrimitive;
use serde::{Deserialize, Serialize};
@@ -22,7 +23,7 @@ pub enum TransactionInputType {
Swap(Asset, Asset, SwapData),
Stake(Asset, StakeType),
TokenApprove(Asset, ApprovalData),
- Generic(Asset, WalletConnectionSessionAppMetadata, TransferDataExtra),
+ Generic(Asset, TransactionAppMetadata, TransferDataExtra),
TransferNft(Asset, NFTAsset),
Account(Asset, AccountDataType),
Perpetual(Asset, PerpetualType),
diff --git a/core/crates/primitives/src/transaction_metadata_types.rs b/core/crates/primitives/src/transaction_metadata_types.rs
index 39b88a17fa..99ef858da8 100644
--- a/core/crates/primitives/src/transaction_metadata_types.rs
+++ b/core/crates/primitives/src/transaction_metadata_types.rs
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
-use crate::{AssetId, NFTAssetId, PerpetualDirection, PerpetualProvider, stake_type::Resource};
+use crate::{AssetId, NFTAssetId, PaymentMerchant, PaymentProviderName, PerpetualDirection, PerpetualProvider, stake_type::Resource};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[typeshare(swift = "Sendable")]
@@ -26,6 +26,15 @@ pub struct TransactionSwapMetadata {
pub provider: Option,
}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[typeshare(swift = "Equatable, Hashable, Sendable")]
+#[serde(rename_all = "camelCase")]
+pub struct TransactionPaymentMetadata {
+ pub payment_id: String,
+ pub merchant: PaymentMerchant,
+ pub provider: PaymentProviderName,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[typeshare(swift = "Sendable")]
#[serde(rename_all = "camelCase")]
diff --git a/core/crates/primitives/src/transaction_preload_input.rs b/core/crates/primitives/src/transaction_preload_input.rs
index 9471a23c1b..d570f6b2a2 100644
--- a/core/crates/primitives/src/transaction_preload_input.rs
+++ b/core/crates/primitives/src/transaction_preload_input.rs
@@ -19,7 +19,7 @@ impl TransactionPreloadInput {
pub fn get_website(&self) -> Option {
match &self.input_type {
- TransactionInputType::Generic(_, app_metadata, _) => Some(app_metadata.url.clone()),
+ TransactionInputType::Generic(_, app, _) => app.url.clone(),
_ => None,
}
}
diff --git a/core/crates/primitives/src/url_action.rs b/core/crates/primitives/src/url_action.rs
index e85baf413d..becf112123 100644
--- a/core/crates/primitives/src/url_action.rs
+++ b/core/crates/primitives/src/url_action.rs
@@ -1,13 +1,17 @@
-use crate::{Deeplink, WalletConnectLink};
+use crate::{Deeplink, Payment, PaymentLink, PaymentURLDecoder, WalletConnectLink};
#[derive(Debug, Clone, PartialEq)]
pub enum UrlAction {
Deeplink { deeplink: Deeplink },
+ Payment { link: PaymentLink },
WalletConnect { link: WalletConnectLink },
}
impl UrlAction {
pub fn from_url(url: &str) -> Option {
+ if let Ok(Payment::Link(link)) = PaymentURLDecoder::decode(url) {
+ return Some(Self::Payment { link });
+ }
if let Some(link) = WalletConnectLink::from_url(url) {
return Some(Self::WalletConnect { link });
}
@@ -18,7 +22,7 @@ impl UrlAction {
#[cfg(test)]
mod tests {
use super::*;
- use crate::{AssetId, Chain};
+ use crate::{AssetId, Chain, PaymentProviderName};
#[test]
fn test_from_url() {
@@ -44,6 +48,18 @@ mod tests {
},
})
);
+ assert_eq!(
+ UrlAction::from_url("https://pay.walletconnect.com/?pid=pay_123"),
+ Some(UrlAction::Payment {
+ link: PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()),
+ })
+ );
+ assert_eq!(
+ UrlAction::from_url("wc:abc@2?pay=https%3A%2F%2Fpay.walletconnect.com%2F%3Fpid%3Dpay_123"),
+ Some(UrlAction::Payment {
+ link: PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()),
+ })
+ );
assert_eq!(UrlAction::from_url("https://example.com/tokens/bitcoin"), None);
assert_eq!(UrlAction::from_url("not a url"), None);
}
diff --git a/core/crates/primitives/src/wallet_connect_namespace.rs b/core/crates/primitives/src/wallet_connect_namespace.rs
index 44a7e33dd3..f739ac3378 100644
--- a/core/crates/primitives/src/wallet_connect_namespace.rs
+++ b/core/crates/primitives/src/wallet_connect_namespace.rs
@@ -1,4 +1,4 @@
-use crate::{Chain, ChainAddress, ChainType};
+use crate::{AssetId, Chain, ChainAddress, ChainType};
use serde::Serialize;
use std::str::FromStr;
use strum::{AsRefStr, EnumString};
@@ -170,3 +170,44 @@ mod tests {
assert_eq!(WalletConnectCAIP2::parse_account("eip155:99999:0x1".to_string()), None);
}
}
+
+const SLIP44_NAMESPACE: &str = "slip44";
+
+pub struct WalletConnectCAIP19;
+
+impl WalletConnectCAIP19 {
+ pub fn get_asset_id(asset: &str) -> Option {
+ let (chain_id, asset) = match asset.split_once('/') {
+ Some((chain_id, asset)) => (chain_id, Some(asset)),
+ None => (asset, None),
+ };
+ let chain = WalletConnectCAIP2::get_chain_from_id(Some(chain_id.to_string())).ok()?;
+ let Some(asset) = asset else {
+ return Some(AssetId::from(chain, None));
+ };
+ match asset.split_once(':')? {
+ (SLIP44_NAMESPACE, _) => Some(AssetId::from(chain, None)),
+ (_, token_id) => Some(AssetId::from_token(chain, token_id)),
+ }
+ }
+}
+
+#[cfg(test)]
+mod caip19_tests {
+ use super::*;
+
+ #[test]
+ fn test_get_asset_id() {
+ assert_eq!(WalletConnectCAIP19::get_asset_id("eip155:1/slip44:60"), Some(AssetId::from(Chain::Ethereum, None)));
+ assert_eq!(WalletConnectCAIP19::get_asset_id("eip155:1"), Some(AssetId::from(Chain::Ethereum, None)));
+ assert_eq!(
+ WalletConnectCAIP19::get_asset_id("eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"),
+ Some(AssetId::from_token(Chain::Base, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"))
+ );
+ assert_eq!(
+ WalletConnectCAIP19::get_asset_id("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"),
+ Some(AssetId::from_token(Chain::Solana, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"))
+ );
+ assert_eq!(WalletConnectCAIP19::get_asset_id("bitcoin:000000000019d6689c085ae165831e93"), None);
+ }
+}
diff --git a/core/crates/primitives/src/wallet_connector.rs b/core/crates/primitives/src/wallet_connector.rs
index 2b28f0d243..0a0c263814 100644
--- a/core/crates/primitives/src/wallet_connector.rs
+++ b/core/crates/primitives/src/wallet_connector.rs
@@ -108,17 +108,21 @@ const SHORT_NAME_MAX_LENGTH: usize = 80;
impl WalletConnectionSessionAppMetadata {
pub fn short_name(&self) -> String {
- let name = self.name.trim();
- for sep in SHORT_NAME_SEPARATORS {
- if let Some(idx) = name.find(sep) {
- return name[..idx].trim().to_string();
- }
- }
- if name.len() > SHORT_NAME_MAX_LENGTH {
- return name[..SHORT_NAME_MAX_LENGTH].to_string();
+ short_name(&self.name)
+ }
+}
+
+pub fn short_name(name: &str) -> String {
+ let name = name.trim();
+ for sep in SHORT_NAME_SEPARATORS {
+ if let Some(idx) = name.find(sep) {
+ return name[..idx].trim().to_string();
}
- name.to_string()
}
+ if name.len() > SHORT_NAME_MAX_LENGTH {
+ return name[..SHORT_NAME_MAX_LENGTH].to_string();
+ }
+ name.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
diff --git a/core/gemstone/Cargo.toml b/core/gemstone/Cargo.toml
index a941a533b1..e76ee4fed5 100644
--- a/core/gemstone/Cargo.toml
+++ b/core/gemstone/Cargo.toml
@@ -38,9 +38,10 @@ gem_cardano = { path = "../crates/gem_cardano", features = ["rpc", "signer"] }
gem_algorand = { path = "../crates/gem_algorand", features = ["rpc", "signer"] }
gem_stellar = { path = "../crates/gem_stellar", features = ["rpc", "signer"] }
gem_xrp = { path = "../crates/gem_xrp", features = ["rpc", "signer"] }
+payment = { path = "../crates/payment" }
gem_near = { path = "../crates/gem_near", features = ["rpc", "signer"] }
gem_polkadot = { path = "../crates/gem_polkadot", features = ["rpc", "signer"] }
-gem_wallet_connect = { path = "../crates/gem_wallet_connect" }
+gem_wallet_connect = { path = "../crates/gem_wallet_connect", features = ["session"] }
gem_keystore = { path = "../crates/gem_keystore", features = ["v3"] }
gem_derivation = { path = "../crates/gem_derivation" }
gem_crypto = { path = "../crates/gem_crypto", features = ["random"] }
diff --git a/core/gemstone/src/models/transaction.rs b/core/gemstone/src/models/transaction.rs
index 9960862bfe..4fef1644e4 100644
--- a/core/gemstone/src/models/transaction.rs
+++ b/core/gemstone/src/models/transaction.rs
@@ -1,17 +1,18 @@
use crate::address::checksum_address;
use crate::models::*;
+use swap::GemApprovalData;
use chrono::{DateTime, Utc};
use num_bigint::BigInt;
use primitives::contract_call_data::ContractCallData;
use primitives::{
AccountDataType, Asset, Chain, EarnType, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualProvider,
- PerpetualType, Resource, SignerInput, StakeType, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata,
- TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType, TransactionUpdate, TransferDataExtra,
- TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata,
+ PerpetualType, Resource, SignerInput, StakeType, TransactionAppMetadata, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput,
+ TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType,
+ TransactionUpdate, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata,
perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData},
};
use std::collections::HashMap;
-use swap::{GemApprovalData, GemSwapData};
+use swap::GemSwapData;
use swapper::SwapperProvider;
pub type GemPerpetualDirection = PerpetualDirection;
@@ -212,6 +213,16 @@ pub struct GemWalletConnectionSessionAppMetadata {
pub icon: String,
}
+pub type GemTransactionAppMetadata = TransactionAppMetadata;
+
+#[uniffi::remote(Record)]
+pub struct GemTransactionAppMetadata {
+ pub name: String,
+ pub description: Option,
+ pub url: Option,
+ pub icon: Option,
+}
+
#[derive(Debug, Clone, uniffi::Record)]
pub struct GemTransferDataExtra {
pub to: String,
@@ -310,7 +321,7 @@ pub enum GemTransactionInputType {
},
Generic {
asset: GemAsset,
- metadata: GemWalletConnectionSessionAppMetadata,
+ app_metadata: GemTransactionAppMetadata,
extra: GemTransferDataExtra,
},
TransferNft {
@@ -573,9 +584,9 @@ impl From for GemTransactionInputType {
stake_type: stake_type.into(),
},
TransactionInputType::TokenApprove(asset, approval_data) => GemTransactionInputType::TokenApprove { asset, approval_data },
- TransactionInputType::Generic(asset, metadata, extra) => GemTransactionInputType::Generic {
+ TransactionInputType::Generic(asset, app_metadata, extra) => GemTransactionInputType::Generic {
asset,
- metadata,
+ app_metadata,
extra: extra.into(),
},
TransactionInputType::TransferNft(asset, nft_asset) => GemTransactionInputType::TransferNft { asset, nft_asset },
@@ -727,7 +738,7 @@ impl From for TransactionInputType {
is_unlimited: approval_data.is_unlimited,
},
),
- GemTransactionInputType::Generic { asset, metadata, extra } => TransactionInputType::Generic(asset, metadata, extra.into()),
+ GemTransactionInputType::Generic { asset, app_metadata, extra } => TransactionInputType::Generic(asset, app_metadata, extra.into()),
GemTransactionInputType::TransferNft { asset, nft_asset } => TransactionInputType::TransferNft(asset, nft_asset),
GemTransactionInputType::Account { asset, account_type } => TransactionInputType::Account(asset, account_type),
GemTransactionInputType::Perpetual { asset, perpetual_type } => TransactionInputType::Perpetual(asset, perpetual_type),
diff --git a/core/gemstone/src/payment/error.rs b/core/gemstone/src/payment/error.rs
new file mode 100644
index 0000000000..61d1549542
--- /dev/null
+++ b/core/gemstone/src/payment/error.rs
@@ -0,0 +1,15 @@
+pub type PaymentError = payment::PaymentError;
+
+#[uniffi::remote(Enum)]
+pub enum PaymentError {
+ NotSupported,
+ PaymentNotFound,
+ PaymentExpired,
+ QuoteExpired,
+ NoPaymentOptions,
+ UnsupportedAccounts,
+ Rejected,
+ RateLimited,
+ InvalidRequest(String),
+ Network(String),
+}
diff --git a/core/gemstone/src/payment/mod.rs b/core/gemstone/src/payment/mod.rs
index feb4124d31..d1e34f035f 100644
--- a/core/gemstone/src/payment/mod.rs
+++ b/core/gemstone/src/payment/mod.rs
@@ -1,43 +1,109 @@
-use crate::GemstoneError;
-use primitives::PaymentURLDecoder;
+pub mod error;
+pub mod remote_types;
-#[derive(Debug, Clone, PartialEq, uniffi::Record)]
-pub struct PaymentWrapper {
- pub address: String,
- pub amount: Option,
- pub memo: Option,
- pub asset_id: Option,
- pub payment_link: Option,
+use std::sync::Arc;
+
+use payment::PaymentConfig;
+use payment::WalletConnectPayAuth;
+use payment::{PaymentAction as CorePaymentAction, PaymentService};
+use primitives::{Chain, ChainAddress, PaymentLink, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes};
+
+use crate::alien::{AlienProvider, AlienProviderWrapper};
+use crate::message::sign_type::SignMessage;
+use crate::models::swap::GemApprovalData;
+use crate::payment::error::PaymentError;
+use crate::wallet_connect::SignableTransaction;
+
+#[derive(Debug, uniffi::Record)]
+pub struct GemPreparedPayment {
+ pub quotes: PaymentQuotes,
+ pub quote: PaymentQuote,
+ pub actions: Vec,
+ pub is_relayed: bool,
+}
+
+#[derive(Debug, uniffi::Enum)]
+#[allow(clippy::large_enum_variant)]
+pub enum PaymentAction {
+ SignMessage { message: SignMessage },
+ SignTransaction { chain: Chain, transaction: SignableTransaction },
+ SendTransaction { chain: Chain, transaction: SignableTransaction },
+ ApproveToken { chain: Chain, approval: GemApprovalData },
+}
+
+impl From for PaymentAction {
+ fn from(action: CorePaymentAction) -> Self {
+ match action {
+ CorePaymentAction::SignMessage { message } => Self::SignMessage { message: message.into() },
+ CorePaymentAction::SignTransaction { chain, transaction } => Self::SignTransaction {
+ chain,
+ transaction: transaction.into(),
+ },
+ CorePaymentAction::SendTransaction { chain, transaction } => Self::SendTransaction {
+ chain,
+ transaction: transaction.into(),
+ },
+ CorePaymentAction::ApproveToken { chain, approval } => Self::ApproveToken { chain, approval },
+ }
+ }
+}
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct GemWalletConnectPayAuth {
+ pub app_id: String,
+ pub client_id: String,
+}
+
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct GemPaymentConfig {
+ pub wallet_connect_pay: GemWalletConnectPayAuth,
+}
+
+impl From for PaymentConfig {
+ fn from(config: GemPaymentConfig) -> Self {
+ PaymentConfig::new(WalletConnectPayAuth::new(config.wallet_connect_pay.app_id, config.wallet_connect_pay.client_id))
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct GemPaymentService {
+ service: PaymentService,
}
-/// Exports functions
#[uniffi::export]
-pub fn payment_decode_url(string: &str) -> Result {
- let payment = PaymentURLDecoder::decode(string)?;
- Ok(PaymentWrapper {
- address: payment.address,
- amount: payment.amount,
- memo: payment.memo,
- asset_id: payment.asset_id.map(|c| c.to_string()),
- payment_link: payment.link.map(|c| c.to_string()),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_address() {
- assert_eq!(
- payment_decode_url("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(),
- PaymentWrapper {
- address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(),
- amount: Some("0.42301".to_string()),
- memo: None,
- asset_id: Some("solana".to_string()),
- payment_link: None,
- }
- );
+impl GemPaymentService {
+ #[uniffi::constructor]
+ pub fn new(provider: Arc, config: GemPaymentConfig) -> Self {
+ Self {
+ service: PaymentService::new(Arc::new(AlienProviderWrapper::new(provider)), config.into()),
+ }
+ }
+
+ pub async fn get_payment_options(&self, link: PaymentLink, addresses: Vec) -> Result {
+ self.service.get_options(&link, &addresses).await
+ }
+
+ pub async fn get_prepared_payment(
+ &self,
+ provider: PaymentProviderName,
+ quotes: PaymentQuotes,
+ quote: PaymentQuote,
+ addresses: Vec,
+ ) -> Result {
+ let payment = self.service.get_prepared_payment(provider, "es, "e, &addresses).await?;
+ Ok(GemPreparedPayment {
+ is_relayed: payment.is_relayed(),
+ quotes: payment.quotes,
+ quote: payment.quote,
+ actions: payment.actions.into_iter().map(Into::into).collect(),
+ })
+ }
+
+ pub async fn confirm_payment(&self, provider: PaymentProviderName, quote: PaymentQuote, action_results: Vec) -> Result {
+ self.service.confirm(provider, "e, action_results).await
+ }
+
+ pub async fn get_payment_status(&self, provider: PaymentProviderName, payment_id: String) -> Result {
+ self.service.get_status(provider, &payment_id).await
}
}
diff --git a/core/gemstone/src/payment/remote_types.rs b/core/gemstone/src/payment/remote_types.rs
new file mode 100644
index 0000000000..1591992e16
--- /dev/null
+++ b/core/gemstone/src/payment/remote_types.rs
@@ -0,0 +1,162 @@
+use chrono::{DateTime, Utc};
+
+use crate::GemstoneError;
+use primitives::payment_decoder::wallet_connect_pay::{WALLET_CONNECT_HOST, WALLET_CONNECT_PAY_HOST};
+use primitives::{
+ AssetId, Payment, PaymentAmount, PaymentLink, PaymentMerchant, PaymentOptions, PaymentOutcome, PaymentPrice, PaymentProviderName, PaymentQuote, PaymentQuotes, PaymentRequest,
+ PaymentStatus, PaymentURLDecoder,
+};
+
+pub type GemPayment = Payment;
+pub type GemPaymentRequest = PaymentRequest;
+pub type GemPaymentLink = PaymentLink;
+pub type GemPaymentProviderName = PaymentProviderName;
+pub type GemPaymentMerchant = PaymentMerchant;
+pub type GemPaymentOutcome = PaymentOutcome;
+pub type GemPaymentStatus = PaymentStatus;
+pub type GemPaymentOptions = PaymentOptions;
+pub type GemPaymentQuotes = PaymentQuotes;
+pub type GemPaymentPrice = PaymentPrice;
+pub type GemPaymentQuote = PaymentQuote;
+pub type GemPaymentAmount = PaymentAmount;
+
+#[uniffi::remote(Enum)]
+pub enum GemPaymentOptions {
+ Quotes(GemPaymentQuotes),
+ Outcome(GemPaymentOutcome),
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentQuotes {
+ pub merchant: GemPaymentMerchant,
+ pub price: Option,
+ pub expires_at: Option>,
+ pub quotes: Vec,
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentQuote {
+ pub id: String,
+ pub payment_id: String,
+ pub amount: GemPaymentAmount,
+ pub expires_at: Option>,
+ pub collect_data_url: Option,
+ pub provider_data: String,
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentAmount {
+ pub asset_id: AssetId,
+ pub value: String,
+ pub symbol: String,
+ pub decimals: i32,
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentMerchant {
+ pub name: String,
+ pub icon_url: Option,
+}
+
+#[uniffi::remote(Enum)]
+pub enum GemPaymentStatus {
+ RequiresAction,
+ Processing,
+ Succeeded,
+ Failed,
+ Expired,
+ Cancelled,
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentOutcome {
+ pub status: GemPaymentStatus,
+ pub transaction_id: Option,
+}
+
+#[uniffi::remote(Enum)]
+pub enum GemPayment {
+ Request(GemPaymentRequest),
+ Link(GemPaymentLink),
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentRequest {
+ pub address: String,
+ pub amount: Option,
+ pub memo: Option,
+ pub asset_id: Option,
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentLink {
+ pub provider: PaymentProviderName,
+ pub id: String,
+}
+
+#[uniffi::remote(Enum)]
+pub enum GemPaymentProviderName {
+ SolanaPay,
+ WalletConnectPay,
+}
+
+#[uniffi::export]
+pub fn payment_wallet_connect_url() -> String {
+ format!("https://{WALLET_CONNECT_PAY_HOST}")
+}
+
+#[uniffi::export]
+pub fn payment_wallet_connect_host() -> String {
+ WALLET_CONNECT_HOST.to_string()
+}
+
+#[uniffi::export]
+pub fn payment_provider_has_status(provider: GemPaymentProviderName) -> bool {
+ provider.has_status()
+}
+
+#[uniffi::export]
+pub fn payment_decode_url(string: &str) -> Result {
+ Ok(PaymentURLDecoder::decode(string)?)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use primitives::Chain;
+
+ #[test]
+ fn test_request() {
+ assert_eq!(
+ payment_decode_url("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(),
+ GemPayment::Request(GemPaymentRequest {
+ address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(),
+ amount: Some("0.42301".to_string()),
+ memo: None,
+ asset_id: Some(AssetId::from_chain(Chain::Solana)),
+ })
+ );
+ }
+
+ #[test]
+ fn test_link() {
+ assert_eq!(
+ payment_decode_url("solana:https%3A%2F%2Fapi.spherepay.co%2Fv1%2Fpublic%2FpaymentLink%2Fpay%2FpaymentLink_1").unwrap(),
+ GemPayment::Link(GemPaymentLink::new(
+ PaymentProviderName::SolanaPay,
+ "https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1".to_string()
+ ))
+ );
+ assert_eq!(
+ payment_decode_url("https://pay.walletconnect.com/?pid=pay_123").unwrap(),
+ GemPayment::Link(GemPaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()))
+ );
+ }
+}
+
+#[uniffi::remote(Record)]
+pub struct GemPaymentPrice {
+ pub symbol: String,
+ pub value: String,
+ pub decimals: i32,
+}
diff --git a/core/gemstone/src/signer/chain.rs b/core/gemstone/src/signer/chain.rs
index c325b67260..71ddb6b691 100644
--- a/core/gemstone/src/signer/chain.rs
+++ b/core/gemstone/src/signer/chain.rs
@@ -208,8 +208,8 @@ mod tests {
use super::*;
use primitives::testkit::signer_mock::{TEST_EVM_RECIPIENT, TEST_PRIVATE_KEY};
use primitives::{
- DelegationValidator, StakeType, SwapProvider, TransactionFee, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputType,
- WalletConnectionSessionAppMetadata, contract_call_data::ContractCallData, nft::NFTAsset,
+ DelegationValidator, StakeType, SwapProvider, TransactionAppMetadata, TransactionFee, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra,
+ TransferDataOutputType, contract_call_data::ContractCallData, nft::NFTAsset,
};
#[test]
@@ -226,13 +226,13 @@ mod tests {
fn test_sign_input_ton_wallet_connect() {
let request = include_str!("../../../crates/gem_ton/testdata/wallet_connect_dedust_send_message.json");
let transaction = gem_wallet_connect::WalletConnectRequestHandler::decode_send_transaction(
- gem_wallet_connect::WalletConnectTransactionType::Ton {
+ primitives::SignableTransactionType::Ton {
output_type: TransferDataOutputType::EncodedTransaction,
},
request.to_string(),
)
.unwrap();
- let gem_wallet_connect::WalletConnectTransaction::Ton { data, .. } = transaction else {
+ let primitives::SignableTransaction::Ton { data, .. } = transaction else {
panic!("expected TON transaction");
};
let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").unwrap();
@@ -272,11 +272,7 @@ mod tests {
assert_eq!(sign_one(nft.clone()), vec![signer.sign_nft_transfer(nft, key.clone()).unwrap()]);
let generic: GemSignerInput = SignerInput::mock_evm(
- TransactionInputType::Generic(
- Asset::mock(),
- WalletConnectionSessionAppMetadata::mock(),
- TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]),
- ),
+ TransactionInputType::Generic(Asset::mock(), TransactionAppMetadata::mock(), TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd])),
"0",
100000,
)
diff --git a/core/gemstone/src/url_action.rs b/core/gemstone/src/url_action.rs
index 8a6d187d1d..d0e449d587 100644
--- a/core/gemstone/src/url_action.rs
+++ b/core/gemstone/src/url_action.rs
@@ -1,8 +1,9 @@
-use primitives::{Deeplink, UrlAction, WalletConnectLink};
+use primitives::{Deeplink, PaymentLink, UrlAction, WalletConnectLink};
#[uniffi::remote(Enum)]
pub enum UrlAction {
Deeplink { deeplink: Deeplink },
+ Payment { link: PaymentLink },
WalletConnect { link: WalletConnectLink },
}
@@ -19,6 +20,7 @@ mod tests {
fn test_url_action() {
assert!(matches!(url_action("https://gemwallet.com/tokens/bitcoin"), Some(UrlAction::Deeplink { .. })));
assert!(matches!(url_action("gem://wc?sessionTopic=abc"), Some(UrlAction::WalletConnect { .. })));
+ assert!(matches!(url_action("https://pay.walletconnect.com/?pid=pay_123"), Some(UrlAction::Payment { .. })));
assert_eq!(url_action("https://example.com"), None);
}
}
diff --git a/core/gemstone/src/wallet_connect/mod.rs b/core/gemstone/src/wallet_connect/mod.rs
index 31717480e9..df865d14c4 100644
--- a/core/gemstone/src/wallet_connect/mod.rs
+++ b/core/gemstone/src/wallet_connect/mod.rs
@@ -1,12 +1,14 @@
use gem_wallet_connect::{
- SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectAction as WcWalletConnectAction,
- WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler,
- WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectTransaction as WcWalletConnectTransaction,
- WalletConnectTransactionType as WcWalletConnectTransactionType, WalletConnectVerifier, config_session_properties,
+ WalletConnectAction as WcWalletConnectAction, WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler,
+ WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectVerifier, config_session_properties,
};
use primitives::{
Account, Chain, ChainAddress, TransactionType, TransferDataOutputType, WCEthereumTransaction, WalletConnectCAIP2, WalletConnectLink, WalletConnectRequest,
- WalletConnectionVerificationStatus,
+ WalletConnectionVerificationStatus, wallet_connector::short_name,
+};
+use primitives::{
+ EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignMessage as CoreSignMessage, SignableTransaction as CoreSignableTransaction,
+ SignableTransactionType as CoreSignableTransactionType,
};
use std::collections::HashMap;
use std::str::FromStr;
@@ -37,7 +39,7 @@ pub enum WalletConnectLink {
}
#[derive(Debug, Clone, uniffi::Record)]
-pub struct WCEthereumTransactionData {
+pub struct EthereumTransactionData {
pub chain_id: Option,
pub from: String,
pub to: String,
@@ -60,12 +62,12 @@ pub struct Account {
}
#[derive(Debug, Clone, uniffi::Record)]
-pub struct WCSolanaTransactionData {
+pub struct SolanaTransactionData {
pub transaction: String,
}
#[derive(Debug, Clone, uniffi::Record)]
-pub struct WCSuiTransactionData {
+pub struct SuiTransactionData {
pub transaction: String,
pub wallet_address: String,
}
@@ -79,17 +81,17 @@ pub enum WalletConnectAction {
},
SignTransaction {
chain: Chain,
- transaction_type: WalletConnectTransactionType,
+ transaction_type: SignableTransactionType,
data: String,
},
SignAllTransactions {
chain: Chain,
- transaction_type: WalletConnectTransactionType,
+ transaction_type: SignableTransactionType,
transactions: Vec,
},
SendTransaction {
chain: Chain,
- transaction_type: WalletConnectTransactionType,
+ transaction_type: SignableTransactionType,
data: String,
},
ChainOperation {
@@ -104,7 +106,7 @@ pub enum WalletConnectAction {
}
#[derive(Debug, Clone, PartialEq, uniffi::Enum)]
-pub enum WalletConnectTransactionType {
+pub enum SignableTransactionType {
Ethereum,
Solana { output_type: TransferDataOutputType },
Sui { output_type: TransferDataOutputType },
@@ -121,17 +123,17 @@ pub enum WalletConnectChainOperation {
#[derive(Debug, Clone, uniffi::Enum)]
#[allow(clippy::large_enum_variant)]
-pub enum WalletConnectTransaction {
+pub enum SignableTransaction {
Ethereum {
- data: WCEthereumTransactionData,
+ data: EthereumTransactionData,
transaction_type: TransactionType,
},
Solana {
- data: WCSolanaTransactionData,
+ data: SolanaTransactionData,
output_type: TransferDataOutputType,
},
Sui {
- data: WCSuiTransactionData,
+ data: SuiTransactionData,
output_type: TransferDataOutputType,
},
Ton {
@@ -152,7 +154,7 @@ pub enum WalletConnectResponseType {
// From conversions: primitives -> UniFFI
-impl From for WCEthereumTransactionData {
+impl From for EthereumTransactionData {
fn from(transaction: WCEthereumTransaction) -> Self {
Self {
chain_id: transaction.chain_id,
@@ -172,21 +174,31 @@ impl From for WCEthereumTransactionData {
// From conversions: gem_wallet_connect -> UniFFI
-impl From for SignDigestType {
- fn from(t: WcSignDigestType) -> Self {
+impl From for SignMessage {
+ fn from(message: CoreSignMessage) -> Self {
+ Self {
+ chain: message.chain,
+ sign_type: message.sign_type.into(),
+ data: message.data,
+ }
+ }
+}
+
+impl From for SignDigestType {
+ fn from(t: CoreSignDigestType) -> Self {
match t {
- WcSignDigestType::Eip191 => Self::Eip191,
- WcSignDigestType::Eip712 => Self::Eip712,
- WcSignDigestType::Base58 => Self::Base58,
- WcSignDigestType::SuiPersonal => Self::SuiPersonal,
- WcSignDigestType::Siwe => Self::Siwe,
- WcSignDigestType::TonPersonal => Self::TonPersonal,
- WcSignDigestType::TronPersonal => Self::TronPersonal,
+ CoreSignDigestType::Eip191 => Self::Eip191,
+ CoreSignDigestType::Eip712 => Self::Eip712,
+ CoreSignDigestType::Base58 => Self::Base58,
+ CoreSignDigestType::SuiPersonal => Self::SuiPersonal,
+ CoreSignDigestType::Siwe => Self::Siwe,
+ CoreSignDigestType::TonPersonal => Self::TonPersonal,
+ CoreSignDigestType::TronPersonal => Self::TronPersonal,
}
}
}
-impl From for WcSignDigestType {
+impl From for CoreSignDigestType {
fn from(t: SignDigestType) -> Self {
match t {
SignDigestType::Eip191 => Self::Eip191,
@@ -200,26 +212,26 @@ impl From for WcSignDigestType {
}
}
-impl From for WalletConnectTransactionType {
- fn from(t: WcWalletConnectTransactionType) -> Self {
+impl From for SignableTransactionType {
+ fn from(t: CoreSignableTransactionType) -> Self {
match t {
- WcWalletConnectTransactionType::Ethereum => Self::Ethereum,
- WcWalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type },
- WcWalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type },
- WcWalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type },
- WcWalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type },
+ CoreSignableTransactionType::Ethereum => Self::Ethereum,
+ CoreSignableTransactionType::Solana { output_type } => Self::Solana { output_type },
+ CoreSignableTransactionType::Sui { output_type } => Self::Sui { output_type },
+ CoreSignableTransactionType::Ton { output_type } => Self::Ton { output_type },
+ CoreSignableTransactionType::Tron { output_type } => Self::Tron { output_type },
}
}
}
-impl From