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 for WcWalletConnectTransactionType { - fn from(t: WalletConnectTransactionType) -> Self { +impl From for CoreSignableTransactionType { + fn from(t: SignableTransactionType) -> Self { match t { - WalletConnectTransactionType::Ethereum => Self::Ethereum, - WalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, - WalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, - WalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, - WalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + SignableTransactionType::Ethereum => Self::Ethereum, + SignableTransactionType::Solana { output_type } => Self::Solana { output_type }, + SignableTransactionType::Sui { output_type } => Self::Sui { output_type }, + SignableTransactionType::Ton { output_type } => Self::Ton { output_type }, + SignableTransactionType::Tron { output_type } => Self::Tron { output_type }, } } } @@ -268,8 +280,8 @@ impl From for WalletConnectAction { } } -impl From for WCEthereumTransactionData { - fn from(d: WcEthereumTransactionData) -> Self { +impl From for EthereumTransactionData { + fn from(d: CoreEthereumTransactionData) -> Self { Self { chain_id: d.chain_id, from: d.from, @@ -286,26 +298,26 @@ impl From for WCEthereumTransactionData { } } -impl From for WalletConnectTransaction { - fn from(t: WcWalletConnectTransaction) -> Self { +impl From for SignableTransaction { + fn from(t: CoreSignableTransaction) -> Self { match t { - WcWalletConnectTransaction::Ethereum { data, transaction_type } => Self::Ethereum { + CoreSignableTransaction::Ethereum { data, transaction_type } => Self::Ethereum { data: data.into(), transaction_type, }, - WcWalletConnectTransaction::Solana { data, output_type } => Self::Solana { - data: WCSolanaTransactionData { transaction: data.transaction }, + CoreSignableTransaction::Solana { data, output_type } => Self::Solana { + data: SolanaTransactionData { transaction: data.transaction }, output_type, }, - WcWalletConnectTransaction::Sui { data, output_type } => Self::Sui { - data: WCSuiTransactionData { + CoreSignableTransaction::Sui { data, output_type } => Self::Sui { + data: SuiTransactionData { transaction: data.transaction, wallet_address: data.wallet_address, }, output_type, }, - WcWalletConnectTransaction::Ton { data, output_type } => Self::Ton { data, output_type }, - WcWalletConnectTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, + CoreSignableTransaction::Ton { data, output_type } => Self::Ton { data, output_type }, + CoreSignableTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, } } } @@ -403,16 +415,16 @@ impl WalletConnect { config_session_properties(properties, &chains, &accounts) } - pub fn decode_send_transaction(&self, transaction_type: WalletConnectTransactionType, data: String) -> Result { - let wc_type: WcWalletConnectTransactionType = transaction_type.into(); + pub fn decode_send_transaction(&self, transaction_type: SignableTransactionType, data: String) -> Result { + let wc_type: CoreSignableTransactionType = transaction_type.into(); let wc_result = WalletConnectRequestHandler::decode_send_transaction(wc_type, data).map_err(|e| GemstoneError::AnyError { msg: e })?; Ok(wc_result.into()) } } #[uniffi::export] -pub fn wallet_connect_app_short_name(metadata: primitives::WalletConnectionSessionAppMetadata) -> String { - metadata.short_name() +pub fn wallet_connect_app_short_name(name: String) -> String { + short_name(&name) } #[cfg(test)] diff --git a/core/gemstone/src/wallet_connect/simulation.rs b/core/gemstone/src/wallet_connect/simulation.rs index 86e7939db2..cab6292b92 100644 --- a/core/gemstone/src/wallet_connect/simulation.rs +++ b/core/gemstone/src/wallet_connect/simulation.rs @@ -1,28 +1,22 @@ -use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, SignMessageValidation, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectRequestHandler, - WalletConnectTransaction as WcWalletConnectTransaction, WalletConnectTransactionType as WcWalletConnectTransactionType, decode_sign_message, validate_send_transaction, - validate_sign_message, -}; +use gem_wallet_connect::{SignMessageValidation, WalletConnectRequestHandler, decode_sign_message, validate_send_transaction, validate_sign_message}; use primitives::{Chain, SimulationWarning, hex}; +use primitives::{ + EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignableTransaction as CoreSignableTransaction, + SignableTransactionType as CoreSignableTransactionType, +}; use crate::message::sign_type::{SignDigestType, SignMessage}; pub fn decode_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { - let sign_type: WcSignDigestType = sign_type.into(); - let result = decode_sign_message(chain, sign_type, data); - - SignMessage { - chain: result.chain, - sign_type: result.sign_type.into(), - data: result.data, - } + let sign_type: CoreSignDigestType = sign_type.into(); + decode_sign_message(chain, sign_type, data).into() } pub(super) fn parse_eip712_message(data: &str) -> Option { serde_json::from_str(data).ok().and_then(|value| gem_evm::eip712::parse_eip712_json(&value).ok()) } -pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignDigestType, data: &str, session_domain: &str) -> Vec { +pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &CoreSignDigestType, data: &str, session_domain: &str) -> Vec { let input = SignMessageValidation { chain, sign_type, @@ -33,7 +27,7 @@ pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignD validate_sign_message(&input).err().into_iter().map(SimulationWarning::validation_error).collect() } -pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletConnectTransactionType, data: &str) -> Vec { +pub(super) fn send_transaction_validation_warnings(transaction_type: &CoreSignableTransactionType, data: &str) -> Vec { validate_send_transaction(transaction_type, data) .err() .into_iter() @@ -41,15 +35,15 @@ pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletCo .collect() } -pub(super) fn decode_ethereum_transaction(data: &str) -> Result { - let transaction = WalletConnectRequestHandler::decode_send_transaction(WcWalletConnectTransactionType::Ethereum, data.to_string())?; +pub(super) fn decode_ethereum_transaction(data: &str) -> Result { + let transaction = WalletConnectRequestHandler::decode_send_transaction(CoreSignableTransactionType::Ethereum, data.to_string())?; match transaction { - WcWalletConnectTransaction::Ethereum { data, .. } => Ok(data), + CoreSignableTransaction::Ethereum { data, .. } => Ok(data), _ => Err("Invalid Ethereum transaction".to_string()), } } -pub(super) fn decode_ethereum_calldata(transaction: &WcEthereumTransactionData) -> Vec { +pub(super) fn decode_ethereum_calldata(transaction: &CoreEthereumTransactionData) -> Vec { transaction.data.as_deref().and_then(|calldata| hex::decode_hex(calldata).ok()).unwrap_or_default() } diff --git a/core/gemstone/src/wallet_connect/simulation_client.rs b/core/gemstone/src/wallet_connect/simulation_client.rs index e5a9bf3c11..f730e65f6b 100644 --- a/core/gemstone/src/wallet_connect/simulation_client.rs +++ b/core/gemstone/src/wallet_connect/simulation_client.rs @@ -9,10 +9,8 @@ use gem_solana::rpc::{SolanaClient, SolanaProvider}; use gem_sui::rpc::{SuiClient, SuiProvider}; use gem_ton::rpc::client::TonClient; use gem_tron::rpc::{TronProvider, client::TronClient}; -use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectTransactionType as WcWalletConnectTransactionType, -}; use primitives::{Chain, EVMChain, SimulationInput, SimulationResult}; +use primitives::{EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignableTransactionType as CoreSignableTransactionType}; use crate::{ GemstoneError, @@ -21,7 +19,7 @@ use crate::{ network::JsonRpcClient, }; -use super::{WalletConnectTransactionType, simulation}; +use super::{SignableTransactionType, simulation}; #[derive(uniffi::Object)] pub struct WalletConnectSimulationClient { @@ -38,11 +36,11 @@ impl WalletConnectSimulationClient { } pub async fn simulate_sign_message(&self, chain: Chain, sign_type: SignDigestType, data: String, session_domain: String) -> Result { - let sign_type: WcSignDigestType = sign_type.into(); + let sign_type: CoreSignDigestType = sign_type.into(); let validation_warnings = simulation::sign_message_validation_warnings(chain, &sign_type, &data, &session_domain); let simulation = match sign_type { - WcSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { + CoreSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { Some(message) => self.simulate_eip712_message(chain, &message).await?, None => SimulationResult::default(), }, @@ -52,15 +50,15 @@ impl WalletConnectSimulationClient { Ok(simulation.prepend_warnings(validation_warnings)) } - pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: WalletConnectTransactionType, data: String) -> Result { - let transaction_type: WcWalletConnectTransactionType = transaction_type.into(); + pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: SignableTransactionType, data: String) -> Result { + let transaction_type: CoreSignableTransactionType = transaction_type.into(); let validation_warnings = simulation::send_transaction_validation_warnings(&transaction_type, &data); let simulation = match &transaction_type { - WcWalletConnectTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await, - WcWalletConnectTransactionType::Solana { .. } | WcWalletConnectTransactionType::Sui { .. } => self.simulate_encoded_transaction(&transaction_type, &data).await, - WcWalletConnectTransactionType::Ton { .. } => self.simulate_chain_transaction(Chain::Ton, SimulationInput::new(&data)).await, - WcWalletConnectTransactionType::Tron { .. } => self.simulate_chain_transaction(Chain::Tron, SimulationInput::new(&data)).await, + CoreSignableTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await, + CoreSignableTransactionType::Solana { .. } | CoreSignableTransactionType::Sui { .. } => self.simulate_encoded_transaction(&transaction_type, &data).await, + CoreSignableTransactionType::Ton { .. } => self.simulate_chain_transaction(Chain::Ton, SimulationInput::new(&data)).await, + CoreSignableTransactionType::Tron { .. } => self.simulate_chain_transaction(Chain::Tron, SimulationInput::new(&data)).await, } .unwrap_or_default(); @@ -99,7 +97,7 @@ impl WalletConnectSimulationClient { chain: Chain, calldata: &[u8], provider: &EthereumProvider, - transaction: &WcEthereumTransactionData, + transaction: &CoreEthereumTransactionData, ) -> (Result, Result) { let calldata_task = async { if calldata.is_empty() { @@ -117,17 +115,17 @@ impl WalletConnectSimulationClient { async fn simulate_ethereum_balance_changes( &self, provider: &EthereumProvider, - transaction: &WcEthereumTransactionData, + transaction: &CoreEthereumTransactionData, ) -> Result { let encoded_transaction = serde_json::to_string(&map_transaction_object(transaction)).map_err(|error| error.to_string())?; Ok(provider.simulate_transaction(SimulationInput::new(encoded_transaction)).await?) } - async fn simulate_encoded_transaction(&self, transaction_type: &WcWalletConnectTransactionType, data: &str) -> Result { + async fn simulate_encoded_transaction(&self, transaction_type: &CoreSignableTransactionType, data: &str) -> Result { let chain = match transaction_type { - WcWalletConnectTransactionType::Solana { .. } => Chain::Solana, - WcWalletConnectTransactionType::Sui { .. } => Chain::Sui, + CoreSignableTransactionType::Solana { .. } => Chain::Solana, + CoreSignableTransactionType::Sui { .. } => Chain::Sui, _ => return Err("Chain does not use encoded transaction simulation".into()), }; let input: SimulationInput = serde_json::from_str(data).map_err(|error| error.to_string())?; @@ -162,7 +160,7 @@ impl WalletConnectSimulationClient { } /// Keeps the gas limit so out-of-gas failures surface, but omits fee prices - they make the trace charge gas and leak fee accounting into the signer's balance diff. -fn map_transaction_object(transaction: &WcEthereumTransactionData) -> TransactionObject { +fn map_transaction_object(transaction: &CoreEthereumTransactionData) -> TransactionObject { TransactionObject { from: Some(transaction.from.clone()), to: transaction.to.clone(), @@ -180,8 +178,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER}; - fn mock_wc_transaction() -> WcEthereumTransactionData { - WcEthereumTransactionData { + fn mock_wc_transaction() -> CoreEthereumTransactionData { + CoreEthereumTransactionData { chain_id: None, from: TEST_EVM_SENDER.to_string(), to: TEST_EVM_RECIPIENT.to_string(), @@ -205,7 +203,7 @@ mod tests { #[test] fn test_map_transaction_object_passes_gas_limit_and_omits_fee_prices() { - let transaction = WcEthereumTransactionData { + let transaction = CoreEthereumTransactionData { gas_limit: Some("0x5208".to_string()), gas_price: Some("0x9502f900".to_string()), max_fee_per_gas: Some("0x59682f10".to_string()), diff --git a/core/skills/project-structure.md b/core/skills/project-structure.md index 2b1ff75b2c..da71235856 100644 --- a/core/skills/project-structure.md +++ b/core/skills/project-structure.md @@ -51,6 +51,7 @@ Individual `gem_*` crates for each blockchain with unified RPC client patterns: ### Cross-Chain Operations - `swapper/`: Standalone swap/exchange integration crate supporting DEX and CEX swaps across multiple chains +- `payment/`: Merchant payment providers; turns a payment link into the actions the wallet signs - `signer/`: Cryptographic signing operations for transactions across multiple blockchain types ### Data & Storage diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md new file mode 100644 index 0000000000..4b1cb246a8 --- /dev/null +++ b/docs/PAYMENTS.md @@ -0,0 +1,115 @@ +# Payments + +Scanned and pasted payment URLs decode to one of two shapes. Core owns the decoding and every rule; the apps own the screens and the signing. + +## Two shapes + +`PaymentURLDecoder::decode` returns a `Payment`, and the variant decides which flow runs. The split predates hosted payments and is where a provider with a different flow attaches. + +| URL | Shape | What runs | +|---|---|---| +| Bare address, BIP21, ERC-681 (`ethereum:`), TON (`ton://`) | `Request` | The ordinary send flow, recipient prefilled. No remote call. | +| Solana Pay transfer request | `Request` | As above. | +| Solana Pay transaction request | `Link(SolanaPay)` | Hosted flow below. | +| WalletConnect Pay | `Link(WalletConnectPay)` | Hosted flow below. | + +WalletConnect Pay is matched before the URL is split on `:`, so a payment link is never read as a WalletConnect pairing URI or a BIP21 address. + +Each shape reaches the wallet through its own scanner. The recipient scanner decodes to a `Request` and prefills the send form, and refuses a `Link` because a hosted payment is not a recipient. The wallet scanner starts the hosted flow. + +## Hosted flow + +Applies to `Payment::Link`. The four `PaymentService` calls are provider-agnostic; what a provider puts inside them is not. + +```mermaid +flowchart LR + Link["Payment::Link"] --> Options["get_options"] + Options --> Quote["Quote selection"] + Quote --> Prepare["get_prepared_payment"] + Prepare --> Sign["Sign actions in order"] + Sign --> Confirm["confirm"] +``` + +`get_options` returns `Quotes` or a settled `Outcome`, so a link that is already paid, expired, or cancelled never reaches quote selection. A quote may carry a `collect_data_url`; the app opens it in a web view and continues once the page reports completion. + +Only WalletConnect Pay implements this today, and parts of it are its shape rather than the contract. A quote list, a merchant price, an expiry, hosted data collection, and a multi-action list are things WalletConnect Pay happens to have. Solana Pay's transaction request is the counter-example already in the decoder: one GET for the label, one POST returning a single transaction, nothing to confirm afterwards. Such a provider fits by returning one quote and a one-element action list, with `confirm` reporting the outcome. + +`PaymentProviderName` is the whole provider surface, and `PaymentService::provider` returns `NotSupported` for anything unimplemented — Solana Pay transaction requests decode but are refused rather than half-handled. `PaymentProviderName::has_status` reports whether polling exists, for the same reason `is_relayed` is computed in core: capability belongs beside the provider, not at the call site. + +## Actions + +`get_prepared_payment` returns an ordered `Vec`. The apps execute them in order and collect one result string per action, positionally — result `n` belongs to action `n`. + +| Action | What the app does | Result | +|---|---|---| +| `SignMessage` | Signs an EIP-712 message after simulating it | Signature | +| `ApproveToken` | Broadcasts a token approval | Transaction hash | +| `SignTransaction` | Signs without broadcasting | Signature | +| `SendTransaction` | Broadcasts | Transaction hash | + +A payment is relayed when it contains no `SendTransaction`: the provider broadcasts on the wallet's behalf, so signing never yields a hash. + +## Validation + +`PreparedPayment::validate` runs in core before any action reaches a signing screen, so both apps get the same refusals: + +- A payment with no actions is rejected. +- An action on a chain the wallet has no address for is rejected. +- An `ApproveToken` on a chain other than the one the quote pays with is rejected, so a payment cannot use its approval step to reach another chain. + +Quote and price amounts arrive as decimal strings from the provider. They are machine strings, never human input — see [Decision Records](../skills/decisions.md#number-parsing-human-input-vs-machine-strings). + +## Failure and lifetime + +A relayed payment is written to the activity list as pending as soon as the last action is signed, keyed by the payment id, because there is no hash yet and the amount must not be invisible while the provider settles it. Reconciliation later swaps that id for the real hash. + +A failed `confirm` does not mean a failed payment. The actions are already signed and the provider may still settle it, so both apps report processing and let reconciliation decide. + +Quotes expire. Each app watches the expiry it was given and disables the action rather than sending a doomed request; an expired confirm is still rejected by the provider as the backstop. + +There is no execution journal. A payment interrupted between two actions is not resumable, and the user starts again from the link. + +## iOS and Android + +| | iOS | Android | +|---|---|---| +| Working entry point | wallet scanner | wallet scanner | +| Developer gate | `isDeveloperEnabled` | `userConfig.developEnabled()` | +| Flow driver | `PaymentManager.pay`, one async function awaiting each sheet | `PaymentViewModel`, a state machine over `ActivePayment` | +| Screen model | sheets via `PaymentSheetPresentable` | one `PaymentScreen` switching on `PaymentSceneState` | +| Action execution | `PaymentActionExecutor` → `SigningRequestInteractable` | `advance()` → `GemSignMessageOperator` / `ConfirmParams` | +| Pending record | `PaymentTransactionFactory` → `TransactionStateScheduler` | `RecordPayment` → `CreateTransaction` | +| Reconciliation | `TransactionStateService.paymentStateChanges` | `TransactionsRepositoryImpl.paymentStateChanges` | +| `Link` at recipient scanner | `notSupported` error | silently ignored | + +Both platforms can also route a payment link from an incoming URL, but neither claims the payment host: the iOS entitlement lists only `applinks:gemwallet.com`, and the Android manifest only `gemwallet.com` paths and the `wc`/`gem` schemes. Until the host is registered, that path is reachable only by an explicit intent. + +Deliberate difference today: iOS drives the flow as one linear `async` function awaiting each sheet, while Android drives it as a state machine whose single screen re-renders per step. Both consume the same ordered action list and produce the same positional results, so the difference is presentation only. + +Everything else is shared on purpose. Payments reuse the existing signing, simulation, confirmation, and transaction-update stacks rather than parallel ones — the same message-signing and confirm screens as WalletConnect, and the same update path, with both platforms implementing `paymentStateChanges` returning `TransactionChanges` carrying a hash change. Once recorded, a payment is an ordinary transaction. + +## Rules + +Changes on either platform must keep these true: + +- Provider capability is decided in core. No platform branches on `PaymentProviderName`. +- A `Request` goes to the send flow and a `Link` to the hosted flow. Neither scanner learns the other's shape. +- Action results stay positional and complete. Never reorder, skip, or substitute one. +- A relayed payment is recorded before the provider is confirmed, not after. +- A failed `confirm` is never reported to the user as a failed payment. +- Amounts from a provider are parsed as machine strings and validated before use. +- A new provider adds a `PaymentProviderName` arm and nothing on either platform. If its flow does not fit the four calls, widen them in core rather than adding a second path through the apps. + +Keep this document current in the same change when the decoded shapes, the action set, the validation rules, provider coverage, or the platform mechanisms above change. + +## Code map + +- [Link decoding](../core/crates/primitives/src/payment_decoder/decoder.rs) +- [Payment service](../core/crates/payment/src/service.rs) +- [Actions and validation](../core/crates/payment/src/action.rs) +- [WalletConnect Pay provider](../core/crates/payment/src/wallet_connect_pay/service.rs) +- [Gemstone bridge](../core/gemstone/src/payment/mod.rs) +- [iOS flow](../ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift) +- [iOS reconciliation](../ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift) +- [Android flow](../android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt) +- [Android reconciliation](../android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt) diff --git a/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift new file mode 100644 index 0000000000..17e002f32e --- /dev/null +++ b/ios/Features/Payments/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Payments", + platforms: [.iOS(.v17), + .macOS(.v15)], + products: [ + .library( + name: "Payments", + targets: ["Payments"], + ), + ], + dependencies: [ + .package(name: "Primitives", path: "../../Packages/Primitives"), + .package(name: "Store", path: "../../Packages/Store"), + .package(name: "ChainServices", path: "../../Packages/ChainServices"), + .package(name: "Components", path: "../../Packages/Components"), + .package(name: "Localization", path: "../../Packages/Localization"), + .package(name: "Style", path: "../../Packages/Style"), + .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), + .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), + .package(name: "EventPresenterService", path: "../EventPresenterService"), + .package(name: "Formatters", path: "../../Packages/Formatters"), + ], + targets: [ + .target( + name: "Payments", + dependencies: [ + "Primitives", + "Components", + "Localization", + "Style", + "PrimitivesComponents", + "Formatters", + "EventPresenterService", + .product(name: "SimulationService", package: "ChainServices"), + .product(name: "PaymentService", package: "FeatureServices"), + .product(name: "TransactionStateService", package: "FeatureServices"), + ], + path: "Sources/Payments", + ), + .testTarget( + name: "PaymentsTests", + dependencies: [ + "Payments", + .product(name: "PrimitivesTestKit", package: "Primitives"), + .product(name: "SimulationServiceTestKit", package: "ChainServices"), + .product(name: "PaymentServiceTestKit", package: "FeatureServices"), + .product(name: "StoreTestKit", package: "Store"), + .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), + ], + path: "Tests/PaymentsTests", + ), + ], +) diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift new file mode 100644 index 0000000000..7d2c00a6bc --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -0,0 +1,57 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Foundation +import PaymentService +import Primitives +import PrimitivesComponents +import SwiftUI + +public struct PaymentDataCollectionScene: View { + private static let messageHandlerName = "payDataCollectionComplete" + private static let completeMessageType = "IC_COMPLETE" + private static let errorMessageType = "IC_ERROR" + private static let messageTypeKey = "type" + private static let messageErrorKey = "error" + + private let callback: SheetCallback + private let onComplete: () -> Void + + public init( + callback: SheetCallback, + onComplete: @escaping () -> Void, + ) { + self.callback = callback + self.onComplete = onComplete + } + + public var body: some View { + WebView( + url: callback.payload.url, + allowedHost: callback.payload.url.host() ?? .empty, + messageHandler: WebViewMessageHandler(name: Self.messageHandlerName, onMessage: onMessage), + ) + .ignoresSafeArea(edges: .bottom) + } +} + +// MARK: - Actions + +extension PaymentDataCollectionScene { + private func onMessage(_ payload: [String: Any]) { + switch payload[Self.messageTypeKey] as? String { + case Self.completeMessageType: + finish(.success(.empty)) + case Self.errorMessageType: + debugLog("payment data collection error: \(payload[Self.messageErrorKey] as? String ?? .empty)") + finish(.failure(PaymentLinkError.dataCollection)) + default: + break + } + } + + private func finish(_ result: Result) { + callback.delegate(result) + onComplete() + } +} diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift new file mode 100644 index 0000000000..b9c097ad30 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift @@ -0,0 +1,87 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Localization +import PrimitivesComponents +import Style +import SwiftUI + +public struct PaymentQuotesScene: View { + @State private var model: PaymentQuotesSceneViewModel + private let onComplete: () -> Void + + public init( + model: PaymentQuotesSceneViewModel, + onComplete: @escaping () -> Void, + ) { + _model = State(wrappedValue: model) + self.onComplete = onComplete + } + + public var body: some View { + List { + ListAssetHeaderView(model: model.preview, subtitleLayout: .vertical) + + Section { + ListItemImageView( + title: model.merchantTitle, + subtitle: model.merchantText, + assetImage: model.merchantAssetImage, + ) + ListItemImageView( + title: Localized.Common.wallet, + subtitle: model.walletText, + assetImage: model.walletAssetImage, + ) + if let expiresAt = model.expiresAt { + ListItemExpiryView( + title: model.expiresTitle, + expiresAt: expiresAt, + ) + } + } + + if let selected = model.selectedItem { + Section { + NavigationCustomLink( + with: ListItemImageView( + title: model.quotesTitle, + subtitle: selected.amountText, + assetImage: selected.assetImage, + ), + action: model.onSelectQuotes, + ) + } + } + } + .contentMargins(.top, .scene.top, for: .scrollContent) + .listSectionSpacing(.compact) + .taskOnce { model.onAppear() } + .task { await model.awaitExpiry() } + .safeAreaButton { + StateButton( + text: model.buttonTitle, + type: model.buttonType, + action: confirm, + ) + } + .sheet(isPresented: $model.isPresentingQuotes) { + SelectableListNavigationStack( + model: model.quotesModel, + onFinishSelection: model.onFinishQuotesSelection, + listContent: { ListAssetItemView(model: $0) }, + ) + } + .navigationTitle(model.title) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Actions + +extension PaymentQuotesScene { + private func confirm() { + model.onConfirm() + onComplete() + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift new file mode 100644 index 0000000000..939d43280d --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -0,0 +1,121 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import SimulationService + +public protocol PaymentActionExecutable: Sendable { + @MainActor + func execute( + actions: [PaymentAction], + paymentId: String, + appMetadata: TransactionAppMetadata, + payment: PaymentData, + wallet: Wallet, + onSubmitted: @MainActor () -> Void, + ) async throws -> PaymentActionResults +} + +public struct PaymentActionExecutor: PaymentActionExecutable { + private let interactor: any SigningRequestInteractable + private let simulator: any SimulationServiceable + private let assetsProvider: any PaymentAssetsProvidable + + public init( + interactor: any SigningRequestInteractable, + simulator: any SimulationServiceable, + assetsProvider: any PaymentAssetsProvidable, + ) { + self.interactor = interactor + self.simulator = simulator + self.assetsProvider = assetsProvider + } + + @MainActor + public func execute( + actions: [PaymentAction], + paymentId: String, + appMetadata: TransactionAppMetadata, + payment: PaymentData, + wallet: Wallet, + onSubmitted: @MainActor () -> Void = {}, + ) async throws -> PaymentActionResults { + var results: [String] = [] + var transactionHash: String? + for (index, action) in actions.enumerated() { + let value = try await execute( + action: action, + id: "\(paymentId).\(index)", + appMetadata: appMetadata, + payment: payment, + wallet: wallet, + ) + results.append(value) + + if case .sendTransaction = action { + transactionHash = value + } + } + onSubmitted() + return PaymentActionResults(results: results, transactionHash: transactionHash) + } +} + +// MARK: - Private + +extension PaymentActionExecutor { + @MainActor + private func execute(action: PaymentAction, id: String, appMetadata: TransactionAppMetadata, payment: PaymentData, wallet: Wallet) async throws -> String { + switch action { + case let .signMessage(chain, message): + let payload = try await SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message.map(), + simulation: simulator.simulateSignMessage(message: message, sessionDomain: appMetadata.url ?? .empty), + payment: payment, + expiresAt: payment.expiresAt, + ) + return try await interactor.signMessage(payload: payload) + case let .signTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + asset: chain.asset, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .sign, + payment: payment, + ) + return try await interactor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + case let .approveToken(chain, approval): + let assetId = payment.quote.amount.assetId + guard assetId.chain == chain, + let asset = try assetsProvider.assetsData(walletId: wallet.id, assetIds: [assetId]).first?.asset + else { + throw PaymentLinkError.unknownAsset + } + let transferData = TransferData( + type: .tokenApprove(asset, approval), + recipientData: RecipientData( + recipient: Recipient(name: .none, address: approval.spender, memo: .none), + amount: .none, + ), + amount: .exact(.zero), + ) + return try await interactor.sendTransaction( + transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty), + ) + case let .sendTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + asset: chain.asset, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .send, + payment: payment, + ) + return try await interactor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift new file mode 100644 index 0000000000..a5bf4b2a42 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift @@ -0,0 +1,70 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import EventPresenterService +import Components +import PrimitivesComponents +import Foundation +import Localization +import Primitives +import Style + +public final class PaymentLinkManager: PaymentLinkPayable, Sendable { + private let paymentManager: PaymentManager + private let eventPresenterService: EventPresenterService + + public init( + paymentManager: PaymentManager, + eventPresenterService: EventPresenterService, + ) { + self.paymentManager = paymentManager + self.eventPresenterService = eventPresenterService + } + + @MainActor + public func pay(link: PaymentLink, wallet: Wallet) async { + guard !wallet.isViewOnly else { + return present(error: Localized.Wallet.Watch.Tooltip.title) + } + eventPresenterService.toastPresenter.toastMessage = ToastMessage( + title: Localized.Common.loading, + image: SystemImage.network, + ) + do { + present(outcome: try await paymentManager.pay(link: link, wallet: wallet)) + } catch { + present(error: error.localizedDescription) + } + } +} + +// MARK: - Private + +extension PaymentLinkManager { + @MainActor + private func present(outcome: PaymentOutcome) { + switch outcome.status { + case .succeeded: + eventPresenterService.toastPresenter.toastMessage = ToastMessage( + title: Localized.Transaction.Status.confirmed, + image: SystemImage.checkmark, + ) + case .processing: + eventPresenterService.toastPresenter.toastMessage = ToastMessage( + title: Localized.Transaction.Status.pending, + image: SystemImage.refresh, + ) + case .cancelled: + return + case .expired: + present(error: Localized.Errors.paymentExpired) + case .failed, .requiresAction: + present(error: Localized.Transaction.Status.failed) + } + } + + @MainActor + private func present(error: String) { + debugLog("PaymentLinkManager payment error: \(error)") + eventPresenterService.toastPresenter.toastMessage = .error(error) + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift new file mode 100644 index 0000000000..7827f01320 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -0,0 +1,118 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import PrimitivesComponents +import TransactionStateService + +public struct PaymentManager: Sendable { + private let service: any PaymentServiceable + private let executor: any PaymentActionExecutable + private let presenter: any PaymentSheetPresentable + private let assetsProvider: any PaymentAssetsProvidable + private let transactionStateScheduler: TransactionStateScheduler + + public init( + service: any PaymentServiceable, + executor: any PaymentActionExecutable, + presenter: any PaymentSheetPresentable, + assetsProvider: any PaymentAssetsProvidable, + transactionStateScheduler: TransactionStateScheduler, + ) { + self.service = service + self.executor = executor + self.presenter = presenter + self.assetsProvider = assetsProvider + self.transactionStateScheduler = transactionStateScheduler + } + + public func pay(link: PaymentLink, wallet: Wallet) async throws -> PaymentOutcome { + do { + let quotes: PaymentQuotes + switch try await service.getPaymentOptions(link: link, wallet: wallet) { + case let .outcome(outcome): + return outcome + case let .quotes(value): + quotes = value + } + let quote = try await select(quotes: quotes, wallet: wallet) + return try await submit(provider: link.provider, quotes: quotes, quote: quote, wallet: wallet) + } catch SheetDismissal.cancelled { + return PaymentOutcome(status: .cancelled, transactionId: .none) + } + } +} + +// MARK: - Private + +extension PaymentManager { + private func select(quotes: PaymentQuotes, wallet: Wallet) async throws -> PaymentQuote { + guard let first = quotes.quotes.first else { + throw PaymentLinkError.noQuotes + } + guard quotes.quotes.count > 1 else { + return first + } + let assetsData = try assetsProvider.assetsData(walletId: wallet.id, assetIds: quotes.quotes.map(\.amount.assetId)) + let selected = try await presenter.selectPaymentQuote( + request: PaymentQuotesRequest( + id: first.paymentId, + quotes: quotes, + wallet: wallet, + assetsData: assetsData, + ), + ) + guard let quote = quotes.quotes.first(where: { $0.id == selected }) else { + throw PaymentLinkError.quoteUnavailable + } + return quote + } + + private func submit(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PaymentOutcome { + if let url = quote.collectDataUrl { + try await collectData(paymentId: quote.paymentId, url: url) + } + let payment = try await service.getPreparedPayment(provider: provider, quotes: quotes, quote: quote, wallet: wallet) + let results = try await executor.execute( + actions: payment.actions, + paymentId: payment.quote.paymentId, + appMetadata: TransactionAppMetadata(merchant: payment.quotes.merchant), + payment: PaymentData(provider: provider, quotes: payment.quotes, quote: payment.quote), + wallet: wallet, + onSubmitted: { [self] in + guard payment.isRelayed else { + return + } + addPendingPayment(provider: provider, payment: payment, wallet: wallet) + }, + ) + do { + return try await service.confirmPayment(provider: provider, quote: payment.quote, actionResults: results.results) + } catch { + debugLog("confirm payment error: \(error)") + return PaymentOutcome(status: .processing, transactionId: results.transactionHash) + } + } + + private func collectData(paymentId: String, url: String) async throws { + guard let url = url.asURL else { + throw PaymentLinkError.invalidDataCollectionUrl + } + _ = try await presenter.collectPaymentData(request: PaymentDataCollectionRequest(id: paymentId, url: url)) + } + + private func addPendingPayment(provider: PaymentProviderName, payment: PreparedPayment, wallet: Wallet) { + do { + let transaction = try PaymentTransactionFactory.makePendingPayment( + provider: provider, + quote: payment.quote, + merchant: payment.quotes.merchant, + wallet: wallet, + ) + try transactionStateScheduler.addTransactions(wallet: wallet, transactions: [transaction]) + } catch { + debugLog("PaymentManager record payment error: \(error)") + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift new file mode 100644 index 0000000000..795ff501b8 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PaymentActionResults: Sendable { + public let results: [String] + public let transactionHash: String? +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift new file mode 100644 index 0000000000..9ecdc9a15d --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift @@ -0,0 +1,21 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization + +enum PaymentLinkError: Error, Equatable { + case noQuotes + case quoteUnavailable + case invalidDataCollectionUrl + case dataCollection + case unknownAsset +} + +extension PaymentLinkError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noQuotes, .quoteUnavailable, .invalidDataCollectionUrl: Localized.Errors.notSupported + case .dataCollection, .unknownAsset: Localized.Errors.errorOccurred + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift new file mode 100644 index 0000000000..51e48a3050 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift @@ -0,0 +1,38 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import PrimitivesComponents + +public protocol PaymentSheetPresentable: SigningRequestInteractable { + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String +} + +@Observable +public final class PaymentSheetPresenter: PaymentSheetPresentable, SheetPresenting, Sendable { + public let sheets = SheetPresenter() + + public init() {} + + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await present(payload: payload) { .signMessage($0) } + } + + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .confirm($0) } + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .confirm($0) } + } + + public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + try await present(payload: request) { .quotes($0) } + } + + public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + try await present(payload: request) { .dataCollection($0) } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift new file mode 100644 index 0000000000..d2bf85e2b3 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -0,0 +1,34 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import PrimitivesComponents + +public enum PaymentSheetType: Sendable, Identifiable { + case quotes(SheetCallback) + case dataCollection(SheetCallback) + case confirm(SheetCallback) + case signMessage(SheetCallback) + + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) + } + + private var callback: any SheetRejectable { + switch self { + case let .quotes(callback): callback + case let .dataCollection(callback): callback + case let .confirm(callback): callback + case let .signMessage(callback): callback + } + } +} + +// MARK: - SheetRejectable + +extension PaymentSheetType: SheetRejectable {} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentTransactionFactory.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentTransactionFactory.swift new file mode 100644 index 0000000000..31b9e04211 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentTransactionFactory.swift @@ -0,0 +1,35 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +enum PaymentTransactionFactory { + static func makePendingPayment(provider: PaymentProviderName, quote: PaymentQuote, merchant: PaymentMerchant, wallet: Wallet) throws -> Transaction { + let assetId = quote.amount.assetId + let account = try wallet.account(for: assetId.chain) + guard let metadata = AnyCodableValue.encode(TransactionPaymentMetadata(paymentId: quote.paymentId, merchant: merchant, provider: provider)) else { + throw AnyError("payment metadata is not encodable") + } + + return Transaction( + id: TransactionId(chain: assetId.chain, hash: quote.paymentId), + assetId: assetId, + from: account.address, + to: .empty, + contract: assetId.tokenId, + type: .transfer, + state: .pending, + blockNumber: .none, + sequence: .none, + fee: .zero, + feeAssetId: assetId.chain.assetId, + value: quote.amount.value, + memo: .none, + direction: .outgoing, + utxoInputs: .none, + utxoOutputs: .none, + metadata: metadata, + createdAt: Date(), + ) + } +} diff --git a/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift new file mode 100644 index 0000000000..44622621db --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift @@ -0,0 +1,155 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Components +import Formatters +import Foundation +import Style +import Localization +import PaymentService +import Primitives +import PrimitivesComponents + +@Observable +@MainActor +public final class PaymentQuotesSceneViewModel { + private static let priceFormatter = ValueFormatter(style: .full) + private static let amountFormatter = ValueFormatter(style: .short) + + private let request: PaymentQuotesRequest + private let confirmTransferDelegate: StringResultAction + + public init( + request: PaymentQuotesRequest, + confirmTransferDelegate: @escaping StringResultAction, + ) { + self.request = request + self.confirmTransferDelegate = confirmTransferDelegate + } + + public var title: String { + Localized.Transfer.paymentTitle + } + + public var quotesTitle: String { + Localized.Transfer.payWith + } + + public var expiresTitle: String { + Localized.Transfer.paymentExpiresIn + } + + public var expiresAt: Date? { + request.quotes.expiresAt + } + + public var walletText: String { + request.wallet.name + } + + public var walletAssetImage: AssetImage { + WalletViewModel(wallet: request.wallet).avatarImage + } + + public var selected: PaymentQuote? + public var isExpired: Bool = false + public var isPresentingQuotes: Bool = false + + var quotesModel: PaymentQuotesViewModel { + PaymentQuotesViewModel( + state: .data(.plain(items)), + selectedItems: items.filter { $0.id == selected?.id }, + selectionType: .checkmark, + ) + } + + public var merchantTitle: String { + Localized.Transfer.merchant + } + + public var merchantText: String { + request.quotes.merchant.name + } + + public var merchantAssetImage: AssetImage { + AssetImage(imageURL: request.quotes.merchant.iconUrl?.asURL) + } + + public var buttonTitle: String { + Localized.Common.continue + } + + public var selectedItem: PaymentQuoteItem? { + selected.map { item(for: $0) } + } + + public var buttonType: ButtonType { + .primary(isButtonDisabled ? .disabled : .normal) + } + + public var isButtonDisabled: Bool { + isExpired || selected == nil + } + + public var preview: AppPreviewModel { + AppPreviewModel( + assetImage: AssetImage(imageURL: request.quotes.merchant.iconUrl?.asURL), + name: priceText ?? selectedItem?.amountText ?? request.quotes.merchant.name, + subtitleSymbol: .none, + ) + } + + public func onSelectQuotes() { + isPresentingQuotes = true + } + + func onFinishQuotesSelection(items: [PaymentQuoteItem]) { + selected = items.first?.quote + isPresentingQuotes = false + } + + public func awaitExpiry() async { + guard let expiresAt else { + return + } + await expiresAt.sleepUntil() + isExpired = true + } + + public func onConfirm() { + guard let selected else { + return + } + confirmTransferDelegate(.success(selected.id)) + } + + public func onAppear() { + guard selected == nil else { + return + } + selected = request.quotes.quotes.first + } +} + +// MARK: - Private + +extension PaymentQuotesSceneViewModel { + private var items: [PaymentQuoteItem] { + request.quotes.quotes.map { item(for: $0) } + } + + private func item(for quote: PaymentQuote) -> PaymentQuoteItem { + PaymentQuoteItem( + quote: quote, + assetData: request.assetData(for: quote), + formatter: Self.amountFormatter, + ) + } + + private var priceText: String? { + guard let price = request.quotes.price, let value = BigInt(price.value) else { + return .none + } + return Self.priceFormatter.string(value, decimals: price.decimals.asInt, currency: price.symbol) + } +} diff --git a/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift new file mode 100644 index 0000000000..b725129db6 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift @@ -0,0 +1,44 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Foundation +import PrimitivesComponents +import Localization + +struct PaymentQuotesViewModel: SelectableListAdoptable { + typealias Item = PaymentQuoteItem + + var state: StateViewType> + var selectedItems: Set + var selectionType: SelectionType + + init( + state: StateViewType>, + selectedItems: [PaymentQuoteItem], + selectionType: SelectionType, + ) { + self.state = state + self.selectedItems = Set(selectedItems) + self.selectionType = selectionType + } + + var emptyStateTitle: String? { + Localized.Common.notAvailable + } + + var errorTitle: String? { + Localized.Errors.errorOccurred + } +} + +// MARK: - SelectableListNavigationAdoptable + +extension PaymentQuotesViewModel: SelectableListNavigationAdoptable { + var title: String { + Localized.Transfer.payWith + } + + var doneTitle: String { + Localized.Common.done + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift new file mode 100644 index 0000000000..5b00a9af5a --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +@testable import Payments +import PaymentService +import Primitives + +struct PaymentActionExecutableMock: PaymentActionExecutable { + let results: [String] + let transactionHash: String? + + init(results: [String] = [], transactionHash: String? = .none) { + self.results = results + self.transactionHash = transactionHash + } + + @MainActor + func execute( + actions _: [PaymentAction], + paymentId _: String, + appMetadata _: TransactionAppMetadata, + payment _: PaymentData, + wallet _: Wallet, + onSubmitted: @MainActor () -> Void, + ) async throws -> PaymentActionResults { + onSubmitted() + return PaymentActionResults(results: results, transactionHash: transactionHash) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift new file mode 100644 index 0000000000..d7f29c476d --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -0,0 +1,145 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +@testable import Payments +import PaymentService +import PaymentServiceTestKit +import Primitives +import PrimitivesTestKit +import SimulationServiceTestKit +import Testing + +@MainActor +struct PaymentActionExecutorTests { + @Test + func sendTransactionReturnsHash() async throws { + let interactor = SigningRequestInteractableMock() + interactor.transactionHash = "transaction-hash" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( + actions: [PaymentAction.sendTransaction(chain: .ethereum, transaction: .sui("transaction", .encodedTransaction))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(results.results == ["transaction-hash"]) + #expect(results.transactionHash == "transaction-hash") + } + + @Test + func relayedPaymentHasNoTransactionOfItsOwn() async throws { + let interactor = SigningRequestInteractableMock() + interactor.signature = "permit-signature" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: Self.approvalAssets()).execute( + actions: [ + .approveToken(chain: .ethereum, approval: ApprovalData(token: "0xtoken", spender: "0xspender", value: "1", isUnlimited: true)), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: Self.approvalPayment(), + wallet: .mock(), + ) + + #expect(results.results == ["1", "permit-signature"]) + #expect(results.transactionHash == nil) + } + + @Test + func performsEveryActionInOrder() async throws { + let interactor = SigningRequestInteractableMock() + interactor.signature = "permit-signature" + interactor.transactionHash = "approval-hash" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( + actions: [ + .sendTransaction(chain: .ethereum, transaction: .sui("approval", .encodedTransaction)), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(results.results == ["approval-hash", "permit-signature"]) + #expect(interactor.signMessagePayloads.count == 1) + } + + @Test + func signMessageCarriesPaymentAmount() async throws { + let interactor = SigningRequestInteractableMock() + let payment = PaymentData.mock(quote: .mock(amount: .mock(value: "25000", symbol: "USDT"))) + + _ = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( + actions: [PaymentAction.mockSignMessage(data: Data("pay".utf8))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: payment, + wallet: .mock(), + ) + + #expect(interactor.signMessagePayloads.first?.payment == payment) + } + + @Test + func paymentIsRecordedOnlyAfterEveryActionIsSubmitted() async throws { + let interactor = SigningRequestInteractableMock() + var approvalsWhenRecorded: Int? + var signaturesWhenRecorded: Int? + + _ = try await PaymentActionExecutor( + interactor: interactor, + simulator: SimulationServiceableMock(), + assetsProvider: Self.approvalAssets(), + ).execute( + actions: [ + .approveToken(chain: .ethereum, approval: .mock()), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: Self.approvalPayment(), + wallet: .mock(), + onSubmitted: { + approvalsWhenRecorded = interactor.sentTransferData.count + signaturesWhenRecorded = interactor.signMessagePayloads.count + }, + ) + + #expect(approvalsWhenRecorded == 1) + #expect(signaturesWhenRecorded == 1) + } + + @Test + func signMessageIsSimulatedBeforeItIsShown() async throws { + let interactor = SigningRequestInteractableMock() + let warning = SimulationWarning(severity: .critical, warning: .suspiciousSpender, message: "suspicious spender") + let simulator = SimulationServiceableMock( + result: SimulationResult(warnings: [warning], balanceChanges: [], payload: [], header: .none), + ) + + _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( + actions: [PaymentAction.mockSignMessage(data: Data("{}".utf8))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(interactor.signMessagePayloads.first?.simulation.warnings == [warning]) + } + + private static let approvalAssetId = AssetId(chain: .ethereum, tokenId: "0xtoken") + + private static func approvalPayment() -> PaymentData { + .mock(quote: .mock(amount: .mock(assetId: approvalAssetId))) + } + + private static func approvalAssets() -> PaymentAssetsProvidableMock { + PaymentAssetsProvidableMock(assetsData: [.mock(asset: .mock(id: approvalAssetId))]) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift new file mode 100644 index 0000000000..ccc741056e --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -0,0 +1,178 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +@testable import Payments +import PaymentService +import PaymentServiceTestKit +import Primitives +import PrimitivesComponents +import PrimitivesTestKit +import SimulationServiceTestKit +import Store +import StoreTestKit +import Testing +import TransactionStateServiceTestKit + +@MainActor +struct PaymentManagerTests { + private let presenter = PaymentSheetPresentableMock() + + private func makeManager( + service: PaymentServiceableMock, + executor: PaymentActionExecutableMock = PaymentActionExecutableMock(), + transactionStore: TransactionStore = .mock(), + ) -> PaymentManager { + PaymentManager( + service: service, + executor: executor, + presenter: presenter, + assetsProvider: PaymentAssetsProvidableMock(), + transactionStateScheduler: .mock( + transactionStore: transactionStore, + paymentStatusService: PaymentStatusServiceableMock(result: .mock(status: .processing)), + ), + ) + } + + @Test + func payReturnsSettledPaymentWithoutSigning() async throws { + let service = PaymentServiceableMock(options: [.outcome(.mock())]) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .succeeded) + #expect(await service.requestedQuotes.isEmpty) + #expect(await service.confirmedResults.isEmpty) + } + + @Test + func paySignsActionsAndConfirms() async throws { + let service = PaymentServiceableMock( + options: [.quotes(.mock(merchant: .mock(name: "Coffee Shop")))], + actions: [.mockSignMessage(data: Data("pay".utf8))], + ) + + let outcome = try await makeManager( + service: service, + executor: PaymentActionExecutableMock(results: ["signature"]), + ).pay(link: .mock(), wallet: .mock()) + + #expect(await service.confirmedResults == [["signature"]]) + #expect(outcome.status == .succeeded) + } + + @Test + func payStaysPendingWhenConfirmFails() async throws { + let service = PaymentServiceableMock( + options: [.quotes(.mock())], + actions: [.mockSignMessage(data: Data("pay".utf8))], + confirmError: AnyError("gateway timeout"), + ) + + let outcome = try await makeManager( + service: service, + executor: PaymentActionExecutableMock(transactionHash: "0xsent"), + ).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .processing) + #expect(outcome.transactionId == "0xsent") + } + + @Test + func payCollectsDataThenSigns() async throws { + let quote = PaymentQuote.mock(collectDataUrl: "https://data-collection.walletconnect.com/ic/pay_1") + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [quote]))]) + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.collectDataRequests.first?.url.absoluteString == "https://data-collection.walletconnect.com/ic/pay_1") + #expect(await service.confirmedResults == [[]]) + } + + @Test + func paySignsWithoutCollectingDataWhenQuoteDoesNotAskForIt() async throws { + let service = PaymentServiceableMock(options: [.quotes(.mock())]) + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.collectDataRequests.isEmpty) + #expect(await service.confirmedResults == [[]]) + } + + @Test + func payKeepsThePaymentAliveWhenUserClosesDataCollection() async throws { + presenter.collectDataError = SheetDismissal.cancelled + let quote = PaymentQuote.mock(collectDataUrl: "https://data-collection.walletconnect.com/ic/pay_1") + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [quote]))]) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .cancelled) + #expect(await service.confirmedResults.isEmpty) + } + + @Test + func payRecordsPendingPaymentBeforeConfirming() async throws { + let assetId = AssetId.mock(.ethereum) + let store = TransactionStore.mock(db: .mockAssets(assets: [.mock(asset: .mock(id: assetId))])) + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [.mock(amount: .mock(assetId: assetId))]))]) + + _ = try await makeManager(service: service, transactionStore: store) + .pay(link: .mock(), wallet: .mock(accounts: [.mock(chain: assetId.chain)])) + + let saved = try #require(store.getTransactions(states: [.pending]).first) + #expect(saved.id.hash == "pay_1") + #expect(saved.value == "10000") + #expect(saved.metadata?.decode(TransactionPaymentMetadata.self)?.merchant.name == "Test Merchant") + #expect(await service.confirmedResults == [[]]) + } + + @Test + func payUsesTheQuoteTheBuyerPicked() async throws { + let other = PaymentQuote.mock(amount: .mock(symbol: "USDT"), id: "option_2") + let service = PaymentServiceableMock( + options: [.quotes(.mock(quotes: [.mock(), other]))], + actions: [.mockSignMessage(data: Data("pay".utf8))], + ) + presenter.selectedQuoteId = other.id + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.quotesRequests.count == 1) + #expect(await service.requestedQuotes == [other]) + } + + @Test + func payDoesNotAskWhichQuoteWhenThereIsOnlyOne() async throws { + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [.mock()]))]) + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.quotesRequests.isEmpty) + #expect(await service.confirmedResults == [[]]) + } + + @Test + func payCollectsDataOnlyForTheQuoteTheBuyerPicked() async throws { + let url = "https://data-collection.walletconnect.com/ic/pay_1" + let other = PaymentQuote.mock(amount: .mock(symbol: "USDT"), id: "option_2") + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [.mock(collectDataUrl: url), other]))]) + presenter.selectedQuoteId = other.id + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.collectDataRequests.isEmpty) + } + + @Test + func payReportsUnpayableStatuses() async throws { + for status in [PaymentStatus.expired, .failed] { + let service = PaymentServiceableMock(options: [.outcome(.mock(status: status))]) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == status) + #expect(await service.confirmedResults.isEmpty) + } + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift new file mode 100644 index 0000000000..c3511ce12c --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift @@ -0,0 +1,61 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Formatters +import Foundation +import PaymentService +import Primitives +import PrimitivesComponents +import PrimitivesTestKit +import Testing +@testable import Payments + +@MainActor +struct PaymentQuotesSceneViewModelTests { + @Test + func preselectsTheFirstQuote() { + let first = PaymentQuote.mock(id: "option_1") + let model = Self.model(quotes: [first, .mock(id: "option_2")]) + + model.onAppear() + + #expect(model.selectedItem?.id == first.id) + #expect(!model.isButtonDisabled) + } + + @Test + func selectingAQuoteClosesThePicker() { + let second = PaymentQuote.mock(id: "option_2") + let model = Self.model(quotes: [.mock(id: "option_1"), second]) + model.onAppear() + model.onSelectQuotes() + + model.onFinishQuotesSelection(items: [PaymentQuoteItem(quote: second, formatter: ValueFormatter(style: .short))]) + + #expect(model.selectedItem?.id == second.id) + #expect(!model.isPresentingQuotes) + } + + @Test + func confirmIsBlockedOnceThePaymentExpires() async { + let model = Self.model(quotes: [.mock(id: "option_1")], expiresAt: Date(timeIntervalSinceNow: 0.2)) + model.onAppear() + + #expect(!model.isButtonDisabled) + + await model.awaitExpiry() + + #expect(model.isButtonDisabled) + } + + private static func model(quotes: [PaymentQuote], expiresAt: Date = Date(timeIntervalSinceNow: 900)) -> PaymentQuotesSceneViewModel { + PaymentQuotesSceneViewModel( + request: PaymentQuotesRequest( + id: "pay_1", + quotes: .mock(expiresAt: expiresAt, quotes: quotes), + wallet: .mock(), + assetsData: [], + ), + confirmTransferDelegate: { _ in }, + ) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift new file mode 100644 index 0000000000..7ada586415 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift @@ -0,0 +1,51 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +@testable import Payments +import PaymentService +import Primitives + +final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sendable { + init() {} + + var collectDataError: Error? + var selectedQuoteId: String? + + private(set) var collectDataRequests: [PaymentDataCollectionRequest] = [] + private(set) var quotesRequests: [PaymentQuotesRequest] = [] + + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + quotesRequests.append(request) + guard let selectedQuoteId else { + throw AnyError("no quote selected") + } + return selectedQuoteId + } + + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + collectDataRequests.append(request) + if let collectDataError { + throw collectDataError + } + return .empty + } + + var transactionHash = "1" + var signature = "signature" + private(set) var signMessagePayloads: [SignMessagePayload] = [] + private(set) var sentTransferData: [SigningTransferData] = [] + + func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + return signature + } + + func signTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + func sendTransaction(transferData: SigningTransferData) async throws -> String { + sentTransferData.append(transferData) + return transactionHash + } +} diff --git a/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift b/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift index f5fafdcd3b..1a0005a846 100644 --- a/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift +++ b/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift @@ -7,6 +7,8 @@ import SwiftUI public struct ScanQRCodeNavigationStack: View { private let resources: any QRScannerResources + @State private var scanned: String? + let action: (String) -> Void public init(action: @escaping (String) -> Void) { @@ -16,10 +18,14 @@ public struct ScanQRCodeNavigationStack: View { public var body: some View { NavigationStack { - QRScannerScene(resources: resources, action: action) + QRScannerScene(resources: resources, action: { scanned = $0 }) .navigationTitle(Localized.Wallet.scanQrCode) .navigationBarTitleDisplayMode(.inline) } + .onDisappear { + guard let scanned else { return } + action(scanned) + } } } diff --git a/ios/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift b/ios/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift index 328d5e62e0..bf0692df9f 100644 --- a/ios/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift +++ b/ios/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift @@ -37,7 +37,11 @@ struct TransactionExplorerViewModel { extension TransactionExplorerViewModel: ItemModelProvidable { var itemModel: TransactionItemModel { - .explorer( + guard !transactionViewModel.transaction.transaction.isAwaitingPaymentHash else { + return .empty + } + + return .explorer( url: transactionLink.url, text: Localized.Transaction.viewOn(transactionLink.name), ) diff --git a/ios/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift b/ios/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift index 9b872b2c2c..f875743a89 100644 --- a/ios/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift +++ b/ios/Features/Transactions/Sources/ViewModels/TransactionParticipantViewModel.swift @@ -25,7 +25,7 @@ extension TransactionParticipantViewModel: ItemModelProvidable { var itemModel: TransactionItemModel { switch transactionViewModel.transaction.transaction.type { case .stakeFreeze, .stakeUnfreeze: resourceItemModel - case .earnDeposit, .earnWithdraw, .transfer, .transferNFT, .tokenApproval, .smartContractCall, .stakeDelegate: participantItemModel + case .earnDeposit, .earnWithdraw, .transfer, .transferNFT, .tokenApproval, .smartContractCall, .stakeDelegate: merchantItemModel ?? participantItemModel case .swap, .stakeUndelegate, .stakeRedelegate, .stakeRewards, .stakeWithdraw, .assetActivation, .perpetualOpenPosition, .perpetualClosePosition, .perpetualModifyPosition: .empty } } @@ -69,6 +69,13 @@ extension TransactionParticipantViewModel { return type == .transfer || type == .transferNFT } + private var merchantItemModel: TransactionItemModel? { + guard let merchant = transactionViewModel.transaction.transaction.paymentMetadata?.merchant else { + return .none + } + return .listItem(ListItemModel(title: Localized.Transaction.recipient, subtitle: merchant.name)) + } + private var resourceItemModel: TransactionItemModel { guard let resourceType = transactionViewModel.transaction.transaction.metadata?.decode(TransactionResourceTypeMetadata.self)?.resourceType else { return .empty diff --git a/ios/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionExplorerViewModelTests.swift b/ios/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionExplorerViewModelTests.swift new file mode 100644 index 0000000000..d332645d0c --- /dev/null +++ b/ios/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionExplorerViewModelTests.swift @@ -0,0 +1,53 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import PrimitivesComponents +import PrimitivesTestKit +import Testing +@testable import Transactions + +struct TransactionExplorerViewModelTests { + @Test + func itemModelEmpty_whenPaymentStillCarriesItsPaymentId() { + let model = makeModel(hash: "pay_1", metadata: paymentMetadata(paymentId: "pay_1")) + + if case .empty = model.itemModel {} else { + Issue.record("Expected .empty") + } + } + + @Test + func itemModelExplorer_whenPaymentSettledOnChain() { + let model = makeModel(hash: "0xsettled", metadata: paymentMetadata(paymentId: "pay_1")) + + if case .explorer = model.itemModel {} else { + Issue.record("Expected .explorer") + } + } + + @Test + func itemModelExplorer_whenTransactionIsNotAPayment() { + let model = makeModel(hash: "0xhash", metadata: nil) + + if case .explorer = model.itemModel {} else { + Issue.record("Expected .explorer") + } + } + + private func paymentMetadata(paymentId: String) -> AnyCodableValue? { + AnyCodableValue.encode(TransactionPaymentMetadata(paymentId: paymentId, merchant: .mock(), provider: .walletConnectPay)) + } + + private func makeModel(hash: String, metadata: AnyCodableValue?) -> TransactionExplorerViewModel { + TransactionExplorerViewModel( + transactionViewModel: TransactionViewModel( + explorerService: MockExplorerLink(), + transaction: .mock( + transaction: .mock(hash: hash, metadata: metadata), + ), + currency: "USD", + ), + explorerService: MockExplorerLink(), + ) + } +} diff --git a/ios/Features/Transfer/Package.swift b/ios/Features/Transfer/Package.swift index 0ea7735f00..da31cd526b 100644 --- a/ios/Features/Transfer/Package.swift +++ b/ios/Features/Transfer/Package.swift @@ -34,7 +34,6 @@ let package = Package( .package(name: "Store", path: "../../Packages/Store"), .package(name: "Stake", path: "../Stake"), - .package(name: "WalletConnector", path: "../WalletConnector"), .package(name: "InfoSheet", path: "../InfoSheet"), .package(name: "Swap", path: "../Swap"), .package(name: "Perpetuals", path: "../Perpetuals"), @@ -63,13 +62,13 @@ let package = Package( "Validators", "Stake", - "WalletConnector", "InfoSheet", "Swap", "Perpetuals", "EventPresenterService", .product(name: "ChainService", package: "ChainServices"), + .product(name: "PaymentService", package: "FeatureServices"), .product(name: "WalletSessionService", package: "FeatureServices"), .product(name: "NodeService", package: "ChainServices"), .product(name: "TransactionStateService", package: "FeatureServices"), diff --git a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift b/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift index 0164b65982..eba55b2009 100644 --- a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift +++ b/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift @@ -4,13 +4,13 @@ import Foundation import Localization import Primitives -enum ScanTransactionError: Error, Equatable { +public enum ScanTransactionError: Error, Equatable { case malicious case memoRequired(symbol: String) } extension ScanTransactionError: LocalizedError { - var errorDescription: String? { + public var errorDescription: String? { switch self { case .malicious: Localized.Errors.ScanTransaction.Malicious.description case let .memoRequired(symbol): Localized.Errors.ScanTransaction.memoRequired(symbol.boldMarkdown()) diff --git a/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift b/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift index 94c4a42461..2029990665 100644 --- a/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift +++ b/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift @@ -13,6 +13,7 @@ extension TransferData { .swap, .tokenApprove, .generic, + .payment, .transferNft, .perpetual, .account(_, .activate), diff --git a/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift b/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift index 118043c0eb..fba8911215 100644 --- a/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift +++ b/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift @@ -30,6 +30,7 @@ public struct ConfirmTransferScene: View { .task(id: model.feeModel.selection) { await model.fetch() } + .task { await model.expiryCountdown.start() } .navigationTitle(model.title) .navigationBarTitleDisplayMode(.inline) .activityIndicator(isLoading: model.isConfirming, message: model.progressMessage) @@ -49,6 +50,8 @@ extension ConfirmTransferScene { headerType: model.headerType, showClearHeader: model.showClearHeader, ) + case let .paymentExpiry(title, expiresAt): + ListItemExpiryView(title: title, expiresAt: expiresAt) case let .app(model): ListItemImageView(model: model) .contextMenu( diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift b/ios/Features/Transfer/Sources/Scenes/SignMessageScene.swift similarity index 84% rename from ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift rename to ios/Features/Transfer/Sources/Scenes/SignMessageScene.swift index 19fee17e51..433a2a8240 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift +++ b/ios/Features/Transfer/Sources/Scenes/SignMessageScene.swift @@ -24,6 +24,13 @@ public struct SignMessageScene: View { ListAssetHeaderView(model: model.appPreview, subtitleLayout: .vertical) Section { + if let merchant = model.merchantText { + ListItemImageView( + title: model.merchantTitle, + subtitle: merchant, + assetImage: model.appAssetImage, + ) + } ListItemImageView( title: Localized.Common.wallet, subtitle: model.walletText, @@ -36,13 +43,22 @@ public struct SignMessageScene: View { ) } + if let expiresAt = model.expiresAt { + Section { + ListItemExpiryView( + title: model.expiresTitle, + expiresAt: expiresAt, + ) + } + } + if model.hasWarnings { Section { SimulationWarningsContent(warnings: model.simulationWarnings) } } - if model.hasPayload { + if model.showsPayload { Section { SimulationPayloadFieldsContent( fields: model.primaryPayloadFields, @@ -63,6 +79,7 @@ public struct SignMessageScene: View { .contentMargins(.top, .scene.top, for: .scrollContent) .listSectionSpacing(.compact) .taskOnce { model.fetch() } + .task { await model.expiryCountdown.start() } .safeAreaButton { StateButton( text: model.buttonTitle, diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/TextMessageScene.swift b/ios/Features/Transfer/Sources/Scenes/TextMessageScene.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/Scenes/TextMessageScene.swift rename to ios/Features/Transfer/Sources/Scenes/TextMessageScene.swift diff --git a/ios/Features/Transfer/Sources/Services/FeeRateService.swift b/ios/Features/Transfer/Sources/Services/FeeRateService.swift index 2317b86fe4..ba4bc88652 100644 --- a/ios/Features/Transfer/Sources/Services/FeeRateService.swift +++ b/ios/Features/Transfer/Sources/Services/FeeRateService.swift @@ -4,20 +4,20 @@ import Blockchain import Foundation import Primitives -protocol FeeRateProviding: Sendable { +public protocol FeeRateProviding: Sendable { func rates(for type: TransferDataType) async throws -> [FeeRate] } -struct FeeRateService: FeeRateProviding { +public struct FeeRateService: FeeRateProviding { private let service: any ChainFeeRateFetchable - init( + public init( service: any ChainFeeRateFetchable, ) { self.service = service } - func rates(for type: TransferDataType) async throws -> [FeeRate] { + public func rates(for type: TransferDataType) async throws -> [FeeRate] { try await service.feeRates(type: type) } } diff --git a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift b/ios/Features/Transfer/Sources/Services/TransactionFactory.swift index 70cfd3c74c..d83b0d9dc0 100644 --- a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift +++ b/ios/Features/Transfer/Sources/Services/TransactionFactory.swift @@ -3,8 +3,8 @@ import Foundation import Primitives -enum TransactionFactory { - static func makePendingTransaction( +public enum TransactionFactory { + public static func makePendingTransaction( wallet: Wallet, transferData: TransferData, transactionData: TransactionData, diff --git a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift b/ios/Features/Transfer/Sources/Services/TransferExecutor.swift index 1f9b822708..560944b7e3 100644 --- a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift +++ b/ios/Features/Transfer/Sources/Services/TransferExecutor.swift @@ -8,7 +8,8 @@ import Signer import TransactionStateService public protocol TransferExecutable: Sendable { - func execute(input: TransferConfirmationInput) async throws + @discardableResult + func execute(input: TransferConfirmationInput) async throws -> [String] } public struct TransferExecutor: TransferExecutable { @@ -33,8 +34,10 @@ public struct TransferExecutor: TransferExecutable { self.transactionStateScheduler = transactionStateScheduler } - public func execute(input: TransferConfirmationInput) async throws { + @discardableResult + public func execute(input: TransferConfirmationInput) async throws -> [String] { let signedData = try await sign(input: input) + var results: [String] = [] for (index, transactionData) in signedData.enumerated() { debugLog("TransferExecutor data \(transactionData)") @@ -42,15 +45,17 @@ public struct TransferExecutor: TransferExecutable { switch input.data.type.outputAction { case .sign: input.delegate?(.success(transactionData)) + results.append(transactionData) case .send: - try await send( + try await results.append(send( input: input, transactionData: transactionData, transactionIndex: index, totalTransactions: signedData.count, - ) + )) } } + return results } } @@ -62,7 +67,7 @@ extension TransferExecutor { transactionData: String, transactionIndex: Int, totalTransactions: Int, - ) async throws { + ) async throws -> String { let hash = try await chainService.broadcast(data: transactionData, options: broadcastOptions(data: input.data)) debugLog("TransferExecutor broadcast response hash \(hash)") @@ -98,6 +103,7 @@ extension TransferExecutor { if totalTransactions > 1, transactionIndex < totalTransactions - 1 { try await Task.sleep(for: transactionDelay(for: input.data.chain.type)) } + return hash } private func sign(input: TransferConfirmationInput) async throws -> [String] { @@ -131,7 +137,7 @@ extension TransferExecutor { && data.quote.providerData.provider == .hyperliquid && transactionIndex < totalTransactions - 1: return [] - case .stake, .perpetual, .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, .generic, .account, .earn: + case .stake, .perpetual, .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, .generic, .payment, .account, .earn: break } default: @@ -152,7 +158,7 @@ extension TransferExecutor { case .transfer, .deposit, .withdrawal, .transferNft, .stake, .account, .tokenApprove, .perpetual, .earn: BroadcastOptions( skipPreflight: false, ) - case .swap, .generic: BroadcastOptions(skipPreflight: true) + case .swap, .generic, .payment: BroadcastOptions(skipPreflight: true) } default: BroadcastOptions(skipPreflight: false) } diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift index ec02edaaef..899bd986c5 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift @@ -1,20 +1,20 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import PaymentService import Primitives -import WalletConnector public struct ConfirmTransferRequest: Sendable { public let wallet: Wallet public let data: TransferData public let simulation: SimulationResult? - public let delegate: TransferDataCallback.ConfirmTransferDelegate? + public let delegate: StringResultAction? public init( wallet: Wallet, data: TransferData, simulation: SimulationResult? = nil, - delegate: TransferDataCallback.ConfirmTransferDelegate? = nil, + delegate: StringResultAction? = nil, ) { self.wallet = wallet self.data = data diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift index b425dd2760..4c668986a8 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift @@ -11,6 +11,7 @@ enum ConfirmTransferSectionType: String, Identifiable, Equatable { case header case warnings case details + case paymentExpiry case balanceChanges case payload case fee @@ -30,6 +31,7 @@ public enum ConfirmTransferItem: Identifiable, Hashable, Sendable { case recipient case memo case details + case paymentExpiry case balanceChange(Int) case payload case networkFee @@ -47,6 +49,7 @@ public enum ConfirmTransferItemModel { case recipient(AddressListItemViewModel) case network(ListItemModel) case memo(ListItemModel) + case paymentExpiry(title: String, expiresAt: Date) case swapDetails(SwapDetailsViewModel) case networkFee(ListItemModel, selectable: Bool) case perpetualDetails(PerpetualDetailsViewModel) diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/SignMessageDisplayType.swift b/ios/Features/Transfer/Sources/Types/SignMessageDisplayType.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/Types/SignMessageDisplayType.swift rename to ios/Features/Transfer/Sources/Types/SignMessageDisplayType.swift diff --git a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift b/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift index 5568a0edf3..a69251024b 100644 --- a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift +++ b/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift @@ -1,8 +1,8 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import PaymentService import Primitives -import WalletConnector public struct TransferConfirmationInput: Sendable { public let data: TransferData @@ -10,7 +10,7 @@ public struct TransferConfirmationInput: Sendable { public let transactionData: TransactionData public let amount: TransferAmount public let simulation: SimulationResult? - public let delegate: TransferDataCallback.ConfirmTransferDelegate? + public let delegate: StringResultAction? public init( data: TransferData, @@ -18,7 +18,7 @@ public struct TransferConfirmationInput: Sendable { transactionData: TransactionData, amount: TransferAmount, simulation: SimulationResult? = nil, - delegate: TransferDataCallback.ConfirmTransferDelegate?, + delegate: StringResultAction?, ) { self.data = data self.wallet = wallet diff --git a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift b/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift index 55268f9441..62ff5d0f2c 100644 --- a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift +++ b/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift @@ -4,8 +4,8 @@ import Foundation import GemstonePrimitives import Primitives -enum ScanTransactionValidator { - static func validate( +public enum ScanTransactionValidator { + public static func validate( transaction: ScanTransaction, asset: Asset, memo: String?, diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift index 097898718d..30a663fb97 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift @@ -23,7 +23,7 @@ public extension ConfirmAppViewModel { return .app( ListItemModel( - title: Localized.WalletConnect.app, + title: title, subtitle: name, imageStyle: .list(assetImage: assetImage), ), @@ -34,6 +34,13 @@ public extension ConfirmAppViewModel { // MARK: - Private extension ConfirmAppViewModel { + private var title: String { + switch type { + case .payment: Localized.Transfer.merchant + default: Localized.WalletConnect.app + } + } + private var appValue: String? { switch type { case .transfer, @@ -46,8 +53,10 @@ extension ConfirmAppViewModel { .account, .perpetual, .earn: .none - case let .generic(_, metadata, _): - metadata.shortName + case let .generic(_, app, _): + app.shortName + case let .payment(_, payment, _): + payment.merchant.name } } @@ -64,8 +73,10 @@ extension ConfirmAppViewModel { .perpetual, .earn: .none - case let .generic(_, session, _): - AssetImage(imageURL: session.icon.asURL) + case let .generic(_, app, _): + AssetImage(imageURL: app.icon?.asURL) + case let .payment(_, payment, _): + AssetImage(imageURL: payment.merchant.iconUrl?.asURL) } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift index 2b7bc05cf4..f80f39207d 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift @@ -44,6 +44,7 @@ extension ConfirmDetailsViewModel: ItemModelProvidable { .stake, .account, .generic, + .payment, .earn: .empty } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift index 2faa2b901a..4a63973784 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift @@ -29,7 +29,7 @@ extension ConfirmMemoViewModel { private var showMemo: Bool { switch type { case .transfer, .deposit, .withdrawal: type.chain.isMemoSupported - case .transferNft, .swap, .tokenApprove, .generic, .account, .stake, .perpetual, .earn: false + case .transferNft, .swap, .tokenApprove, .generic, .payment, .account, .stake, .perpetual, .earn: false } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift index fba7679db9..845b9018fb 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift @@ -35,7 +35,7 @@ extension ConfirmNetworkViewModel { switch type { case .transfer, .deposit, .withdrawal: return model.networkFullName - case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .perpetual, .earn: + case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .payment, .perpetual, .earn: return model.networkName } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift index 7d8e351a9d..a468b04e8f 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift @@ -67,7 +67,7 @@ extension ConfirmRecipientViewModel { case .stake, .unstake, .redelegate, .rewards, .withdraw: Localized.Stake.validator case .freeze, .unfreeze: Localized.Stake.resource } - case .generic: + case .generic, .payment: switch model.type.outputAction { case .sign: Localized.Asset.contract case .send: Localized.Transfer.Recipient.title @@ -90,7 +90,7 @@ extension ConfirmRecipientViewModel { .swap, .perpetual: false case .earn: true - case .generic: + case .generic, .payment: switch model.type.outputAction { case .sign: false case .send: true diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift index 2d8226dbe2..71050e696d 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift @@ -14,7 +14,6 @@ import Store import Swap import SwiftUI import Validators -import WalletConnector @Observable @MainActor @@ -26,6 +25,8 @@ public final class ConfirmTransferSceneViewModel { public var isPresentingSheet: ConfirmTransferSheetType? + public let expiryCountdown: ExpiryCountdown + public var isPresentingAlertMessage: AlertMessage? { get { switch state.confirmation { @@ -55,6 +56,7 @@ public final class ConfirmTransferSceneViewModel { self.request = request self.confirmService = confirmService self.onComplete = onComplete + expiryCountdown = ExpiryCountdown(expiresAt: request.data.type.payment?.expiresAt) let currency = Currency(rawValue: Preferences.standard.currency) ?? .usd self.currency = currency @@ -123,7 +125,7 @@ public final class ConfirmTransferSceneViewModel { } var isButtonDisabled: Bool { - simulationWarnings.contains(where: { $0.severity == .critical }) + expiryCountdown.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } var confirmButtonModel: ConfirmButtonViewModel { @@ -146,11 +148,21 @@ public final class ConfirmTransferSceneViewModel { // MARK: - ListSectionProvideable +extension ConfirmTransferSceneViewModel { + private var paymentExpiryItemModel: ConfirmTransferItemModel { + guard let expiresAt = request.data.type.payment?.expiresAt else { + return .empty + } + return .paymentExpiry(title: Localized.Transfer.paymentExpiresIn, expiresAt: expiresAt) + } +} + extension ConfirmTransferSceneViewModel: ListSectionProvideable { public var sections: [ListSection] { [ ListSection(type: .header, [.header]), ListSection(type: .details, detailItems), + paymentItems.isEmpty ? nil : ListSection(type: .paymentExpiry, paymentItems), simulationWarnings.isEmpty ? nil : ListSection(type: .warnings, [.warnings]), primaryPayloadFields.isEmpty ? nil : ListSection(type: .payload, [.payload]), balanceChangeModels.isEmpty ? nil : ListSection(type: .balanceChanges, balanceChangeModels.indices.map(ConfirmTransferItem.balanceChange)), @@ -163,15 +175,27 @@ extension ConfirmTransferSceneViewModel: ListSectionProvideable { if case .generic = request.data.type { return [.app, .sender, .network] } + if case .payment = request.data.type { + return [.app, .sender, .recipient, .network] + } return [.app, .sender, .recipient, .network, .memo, .details] } + private var paymentItems: [ConfirmTransferItem] { + guard request.data.type.payment != nil else { + return [] + } + return [.paymentExpiry] + } + public func itemModel(for item: ConfirmTransferItem) -> any ItemModelProvidable { switch item { case .header: ConfirmHeaderViewModel(request: request, state: state) case .warnings: ConfirmTransferItemModel.warnings(simulationWarnings) + case .paymentExpiry: + paymentExpiryItemModel case .app: ConfirmAppViewModel(type: request.data.type) case .sender: diff --git a/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift index ed737b5e19..c08304baa8 100644 --- a/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift @@ -182,18 +182,7 @@ extension RecipientSceneViewModel { // MARK: - Private extension RecipientSceneViewModel { - // TODO: Add unit tests, will be added once moved to package - private func paymentScan(string: String) throws -> PaymentScanResult { - let payment = try PaymentURLDecoder.decode(string) - - return PaymentScanResult( - address: payment.address, - amount: payment.amount, - memo: payment.memo, - ) - } - - func getRecipientScanResult(payment: PaymentScanResult) throws -> RecipientScanResult { + func getRecipientScanResult(payment: PaymentRequest) throws -> RecipientScanResult { let address = asset.chain.checksumAddress(payment.address) if let amount = payment.amount, showMemo ? ((payment.memo?.isEmpty) == nil) : true, asset.chain.isValidAddress(address) @@ -252,17 +241,21 @@ extension RecipientSceneViewModel { } private func handleAddressScan(_ string: String) throws { - let payment = try paymentScan(string: string) - let scanResult = try getRecipientScanResult(payment: payment) - switch scanResult { - case let .transferData(data): - handle(transferData: data) - case let .recipient(address, memo, amount): - // TODO: - open if all fields filled - addressInputModel.update(text: address) - - if let memo { self.memo = memo } - if let amount { self.amount = amount } + let type = try PaymentURLDecoder.decode(string) + switch type { + case let .request(payment): + let scanResult = try getRecipientScanResult(payment: payment) + switch scanResult { + case let .transferData(data): + handle(transferData: data) + case let .recipient(address, memo, amount): + // TODO: - open if all fields filled + addressInputModel.update(text: address) + + if let memo { self.memo = memo } + if let amount { self.amount = amount } + } + case .link: throw AnyError(Localized.Errors.notSupported) } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift similarity index 72% rename from ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift rename to ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift index dbd28ab7ae..d9cd9d241b 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift @@ -1,44 +1,50 @@ // Copyright (c). Gem Wallet. All rights reserved. import AddressNameService +import BigInt import Components import ExplorerService +import Formatters import Foundation import class Gemstone.MessageSigner -import GemstonePrimitives import Keystore import Localization import Primitives import PrimitivesComponents import Style -import WalletConnectorService @Observable @MainActor public final class SignMessageSceneViewModel { + private static let priceFormatter = ValueFormatter(style: .full) + private static let feeFormatter = ValueFormatter(style: .auto) + private static let amountFormatter = ValueFormatter(style: .auto) + private let explorerService: ExplorerService = .standard private let keystore: any Keystore private let addressNameService: AddressNameService private let payload: SignMessagePayload - private let confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate + private let confirmTransferDelegate: StringResultAction private let signer: MessageSigner private let plainMessage: String public let messageDisplayType: SignMessageDisplayType public var isPresentingUrl: URL? public var isPresentingPayloadDetails: Bool = false + public let expiryCountdown: ExpiryCountdown private var payloadAddressNames: [ChainAddress: AddressName] = [:] public init( keystore: any Keystore, addressNameService: AddressNameService, payload: SignMessagePayload, - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, ) { self.keystore = keystore self.addressNameService = addressNameService self.payload = payload - let signer = MessageSigner(message: payload.message) + expiryCountdown = ExpiryCountdown(expiresAt: payload.expiresAt) + let signer = MessageSigner(message: payload.message.map()) self.signer = signer let plainMessage = signer.plainPreview() self.plainMessage = plainMessage @@ -56,12 +62,31 @@ public final class SignMessageSceneViewModel { self.confirmTransferDelegate = confirmTransferDelegate } + public var priceText: String? { + guard let price = payload.payment?.price, let value = BigInt(price.value) else { + return .none + } + return Self.priceFormatter.string(value, decimals: price.decimals.asInt, currency: price.symbol) + } + + public var expiresAt: Date? { + payload.expiresAt + } + + public var expiresTitle: String { + Localized.Transfer.paymentExpiresIn + } + + public var selectedQuoteItem: PaymentQuoteItem? { + payload.payment.map { PaymentQuoteItem(quote: $0.quote, formatter: Self.amountFormatter) } + } + public var networkText: String { payload.chain.networkName } public var title: String { - Localized.Transfer.reviewRequest + isPayment ? Localized.Transfer.paymentTitle : Localized.Transfer.reviewRequest } public var walletText: String { @@ -72,20 +97,24 @@ public final class SignMessageSceneViewModel { Localized.Transfer.confirm } - public var connectionViewModel: WalletConnectionViewModel { - WalletConnectionViewModel(connection: WalletConnection(session: payload.session, wallet: payload.wallet)) - } - public var appName: String { - payload.session.metadata.shortName + payload.appMetadata.shortName } public var appUrl: URL? { - payload.session.metadata.url.asURL + payload.appMetadata.url?.asURL } public var appAssetImage: AssetImage { - AssetImage(imageURL: connectionViewModel.imageUrl) + AssetImage(imageURL: payload.appMetadata.iconURL) + } + + public var merchantTitle: String { + Localized.Transfer.merchant + } + + public var merchantText: String? { + isPayment ? appName : .none } public var walletAssetImage: AssetImage { @@ -101,10 +130,17 @@ public final class SignMessageSceneViewModel { } public var appPreview: AppPreviewModel { - AppPreviewModel( - assetImage: appAssetImage, - name: appName, - subtitleSymbol: connectionViewModel.hostText, + guard let quote = selectedQuoteItem else { + return AppPreviewModel( + assetImage: appAssetImage, + name: appName, + subtitleSymbol: appUrl?.cleanHost(), + ) + } + return AppPreviewModel( + assetImage: quote.assetImage, + name: quote.amountText, + subtitleSymbol: priceText, ) } @@ -138,12 +174,20 @@ public final class SignMessageSceneViewModel { !payloadFields.isEmpty } + public var showsPayload: Bool { + hasPayload && !isPayment + } + + public var isPayment: Bool { + payload.payment != nil + } + public var hasWarnings: Bool { !simulationWarnings.isEmpty } public var isButtonDisabled: Bool { - simulationWarnings.contains(where: { $0.severity == .critical }) + expiryCountdown.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } public var buttonType: ButtonType { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/TextMessageViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/TextMessageViewModel.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/TextMessageViewModel.swift rename to ios/Features/Transfer/Sources/ViewModels/TextMessageViewModel.swift diff --git a/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift index 23aa1635ce..4ae99e7b00 100644 --- a/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift @@ -46,8 +46,10 @@ struct TransferDataViewModel { case .deposit: Localized.Wallet.deposit case .withdrawal: Localized.Wallet.withdraw case .transferNft: Localized.Transfer.Send.title - case .swap, .tokenApprove: Localized.Wallet.swap + case .swap: Localized.Wallet.swap + case .tokenApprove: Localized.Transfer.Approve.title case .generic: Localized.Transfer.reviewRequest + case .payment: Localized.Transfer.paymentTitle case let .stake(_, type): switch type { case .stake: Localized.Transfer.Stake.title @@ -89,9 +91,10 @@ struct TransferDataViewModel { .stake, .account, .perpetual, - .earn: .none - case let .generic(_, metadata, _): - URL(string: metadata.url) + .earn, + .payment: .none + case let .generic(_, app, _): + app.url?.asURL } } diff --git a/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift b/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift index 2eee829c75..d6d4794786 100644 --- a/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift +++ b/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import Transfer @testable import Primitives public extension TransactionData { diff --git a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift b/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift index 011e2d913b..91d7754804 100644 --- a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift +++ b/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift @@ -5,14 +5,18 @@ import Transfer public struct TransferExecutorMock: TransferExecutable { public var error: Error? + public var results: [String] - public init(error: Error? = nil) { + public init(error: Error? = nil, results: [String] = ["1"]) { self.error = error + self.results = results } - public func execute(input _: TransferConfirmationInput) async throws { + @discardableResult + public func execute(input _: TransferConfirmationInput) async throws -> [String] { if let error { throw error } + return results } } diff --git a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift index b068cac662..2e8198034d 100644 --- a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift @@ -93,6 +93,17 @@ struct RecipientSceneViewModelTests { #expect(recipientData?.recipient.address == checksummed) } + @Test + func onHandleScan_paymentLink_keepsAddress() { + let model = RecipientSceneViewModel.mock() + let address = "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326" + model.addressInputModel.update(text: address) + + model.onHandleScan("https://pay.walletconnect.com/?pid=pay_123", for: .address) + + #expect(model.addressInputModel.text == address) + } + @Test func getRecipientScanResult_transferData() throws { let asset = Asset.mockEthereum() @@ -100,10 +111,9 @@ struct RecipientSceneViewModelTests { let address = "0x5615e8ab93b9d695b6d4d6545f7792aa59e1069a" let checksummed = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a" - let payment = PaymentScanResult( + let payment = PaymentRequest.mock( address: " \n\(address)\r ", amount: "1.234", - memo: nil, ) let result = try model.getRecipientScanResult(payment: payment) @@ -121,9 +131,8 @@ struct RecipientSceneViewModelTests { func getRecipientScanResult_recipient() throws { let model = RecipientSceneViewModel.mock() - let payment = PaymentScanResult( + let payment = PaymentRequest.mock( address: "0x123", - amount: nil, memo: "test memo", ) diff --git a/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift b/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift index 50588ce12b..50c89c173e 100644 --- a/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift +++ b/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift @@ -53,7 +53,7 @@ struct ConfirmServiceTests { ) let state = await service.updateState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock(header: SimulationHeader(assetId: Asset.mockEthereumUSDT().id, value: "0", isUnlimited: true)), ) @@ -71,7 +71,7 @@ struct ConfirmServiceTests { ) let state = service.makeState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock( payload: [ SimulationPayloadField.standard(kind: .contract, value: "0x123", fieldType: .address, display: .primary), @@ -153,7 +153,7 @@ struct ConfirmServiceTests { ) let state = await service.updateState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock(payload: [ SimulationPayloadField.standard(kind: .contract, value: "0x123", fieldType: .address, display: .primary), ]), diff --git a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift b/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift index cedcefec04..fc4cc44b4a 100644 --- a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift +++ b/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift @@ -5,7 +5,6 @@ import Primitives import PrimitivesTestKit import Testing @testable import Transfer -import TransferTestKit struct ScanTransactionValidatorTests { @Test diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift index ec59fa3c75..779e3a48ef 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift @@ -11,11 +11,11 @@ import TransferTestKit struct ConfirmAppViewModelTests { @Test func generic() { - let metadata = WalletConnectionSessionAppMetadata.mock( + let appMetadata = TransactionAppMetadata.mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", ) - let model = ConfirmAppViewModel(type: .generic(asset: .mock(), metadata: metadata, extra: .mock())) + let model = ConfirmAppViewModel(type: .generic(asset: .mock(), appMetadata: appMetadata, extra: .mock())) guard case let .app(item) = model.itemModel else { return } #expect(item.title == Localized.WalletConnect.app) diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift index 064c2de4e4..cdc9f65524 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift @@ -52,7 +52,7 @@ struct ConfirmRecipientViewModelTests { @Test func genericSend() { let model = ConfirmRecipientViewModel( - model: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .send))), + model: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .send))), addressName: nil, addressLink: .mock(), ) @@ -64,7 +64,7 @@ struct ConfirmRecipientViewModelTests { @Test func genericSign() { let model = ConfirmRecipientViewModel( - model: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .sign))), + model: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .sign))), addressName: nil, addressLink: .mock(), ) diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift index 4c9c6aedea..3f0014a586 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift @@ -71,7 +71,7 @@ struct ConfirmTransferSceneViewModelTests { } let modelWithWebsite = ConfirmTransferSceneViewModel.mock( - data: .mock(type: .generic(asset: .mock(), metadata: .mock(name: "Gem Wallet", url: "https://example.com"), extra: .mock())), + data: .mock(type: .generic(asset: .mock(), appMetadata: .mock(name: "Gem Wallet", url: "https://example.com"), extra: .mock())), ) let appItemWithWebsite = modelWithWebsite.itemModel(for: .app) as? ConfirmAppViewModel @@ -87,8 +87,8 @@ struct ConfirmTransferSceneViewModelTests { #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .transfer(.mock()))).title == Localized.Transfer.Send.title) // #expect(ConfirmTransferViewModel.mock(data: .mock(type: .transferNft(.mock()))).title == Localized.Transfer.Send.title) #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .swap(.mock(), .mock(), .mock()))).title == Localized.Wallet.swap) - #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .tokenApprove(.mock(), .mock()))).title == Localized.Wallet.swap) - #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock()))).title == Localized.Transfer.reviewRequest) + #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .tokenApprove(.mock(), .mock()))).title == Localized.Transfer.Approve.title) + #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock()))).title == Localized.Transfer.reviewRequest) } @Test @@ -190,7 +190,7 @@ struct ConfirmTransferSceneViewModelTests { Issue.record("Expected network item model for USDT") } - let genericEthModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereum(), metadata: .mock(), extra: .mock()))) + let genericEthModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereum(), appMetadata: .mock(), extra: .mock()))) let genericEthNetworkItem = genericEthModel.itemModel(for: .network) as? ConfirmNetworkViewModel if case let .network(listItem) = genericEthNetworkItem?.itemModel { @@ -199,7 +199,7 @@ struct ConfirmTransferSceneViewModelTests { Issue.record("Expected network item model for generic ETH") } - let genericUsdtModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereumUSDT(), metadata: .mock(), extra: .mock()))) + let genericUsdtModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereumUSDT(), appMetadata: .mock(), extra: .mock()))) let genericUsdtNetworkItem = genericUsdtModel.itemModel(for: .network) as? ConfirmNetworkViewModel if case let .network(listItem) = genericUsdtNetworkItem?.itemModel { @@ -326,7 +326,7 @@ struct ConfirmTransferSceneViewModelTests { @Test func walletConnectSectionsStructure() { let model = ConfirmTransferSceneViewModel.mock( - data: .mock(type: .generic(asset: .mockEthereum(), metadata: .mock(), extra: .mock(to: "0x1111111111111111111111111111111111111111"))), + data: .mock(type: .generic(asset: .mockEthereum(), appMetadata: .mock(), extra: .mock(to: "0x1111111111111111111111111111111111111111"))), simulation: .mock( warnings: [SimulationWarning( severity: .warning, @@ -606,6 +606,19 @@ struct ConfirmTransferSceneViewModelTests { Issue.record("Expected non-empty model") } } + + @Test + func confirmIsDisabledOnceThePaymentQuoteExpires() async { + let model = ConfirmTransferSceneViewModel.mock( + data: .mock(type: .payment(asset: .mock(), payment: .mock(expiresAt: .now), extra: .mock())), + ) + + #expect(model.isButtonDisabled == false) + + await model.expiryCountdown.start() + + #expect(model.isButtonDisabled) + } } private extension ConfirmTransferSceneViewModel { diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift similarity index 88% rename from ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift rename to ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift index a72975f853..4eaeb909f3 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift @@ -1,25 +1,21 @@ // Copyright (c). Gem Wallet. All rights reserved. -import AddressNameServiceTestKit import Foundation -import struct Gemstone.SignMessage import KeystoreTestKit import Primitives import PrimitivesComponents import PrimitivesTestKit import Testing -@testable import WalletConnector -import WalletConnectorService -import WalletConnectorServiceTestKit +@testable import Transfer struct SignMessageSceneViewModelTests { @Test @MainActor func walletTextDisplaysPayloadWallet() throws { let wallet = Wallet.mock(name: "My Secure Wallet") - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: wallet, message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -35,38 +31,14 @@ struct SignMessageSceneViewModelTests { #expect(viewModel.walletText == "My Secure Wallet") } - @Test - @MainActor - func connectionViewModelUsesPayloadWallet() throws { - let wallet = Wallet.mock(id: .multicoin(address: "0xspecific"), name: "Test Wallet") - let session = WalletConnectionSession.mock(sessionId: "test-session") - let payload = try SignMessagePayload( - chain: .ethereum, - session: session, - wallet: wallet, - message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), - simulation: .mock(), - ) - - let viewModel = SignMessageSceneViewModel( - keystore: KeystoreMock(), - addressNameService: .mock(), - payload: payload, - confirmTransferDelegate: { _ in }, - ) - - #expect(viewModel.connectionViewModel.connection.wallet.id == .multicoin(address: "0xspecific")) - #expect(viewModel.connectionViewModel.connection.wallet.name == "Test Wallet") - } - @Test @MainActor func appTextUsesShortNameWithoutDomain() { let payload = SignMessagePayload.mock( - session: .mock(metadata: .mock( + appMetadata: .mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", - )), + ), ) let viewModel = SignMessageSceneViewModel( @@ -82,9 +54,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func titleUsesReviewRequest() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -103,9 +75,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func payloadStoresValidatedChainNotMessageChain() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "bitcoin", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -118,9 +90,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func networkTextUsesPayloadChain() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "bitcoin", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -139,9 +111,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func contextRowsProvideWalletAndNetworkImages() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -158,12 +130,34 @@ struct SignMessageSceneViewModelTests { #expect(viewModel.networkAssetImage == AssetIdViewModel(assetId: payload.chain.asset.id).networkAssetImage) } + @Test + @MainActor + func buttonDisabledOnceThePaymentQuoteExpires() async throws { + let payload = try SignMessagePayload.mock( + message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), + expiresAt: Date(timeIntervalSinceNow: 0.2), + ) + + let viewModel = SignMessageSceneViewModel( + keystore: KeystoreMock(), + addressNameService: .mock(), + payload: payload, + confirmTransferDelegate: { _ in }, + ) + + #expect(!viewModel.isButtonDisabled) + + await viewModel.expiryCountdown.start() + + #expect(viewModel.isButtonDisabled) + } + @Test @MainActor func buttonEnabledWithNoWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -182,9 +176,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func buttonEnabledWithNonCriticalWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [SimulationWarning( @@ -207,9 +201,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func simulationWarningsPassThroughUnlimitedAndFiniteApprovals() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [ @@ -240,9 +234,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func buttonDisabledWithCriticalWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [SimulationWarning(severity: .critical, warning: .suspiciousSpender, message: nil)]), @@ -261,9 +255,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func simulationWarningsPassThroughExternallyOwnedSpenderWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [ @@ -335,9 +329,9 @@ struct SignMessageSceneViewModelTests { } } """ - let payload = SignMessagePayload( + let payload = SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip712, data: Data(message.utf8)), simulation: .mock( @@ -367,9 +361,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func simulationWarningsPassThroughValidationWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [ @@ -414,9 +408,9 @@ struct SignMessageSceneViewModelTests { ] .joined(separator: "\n") - let payload = SignMessagePayload( + let payload = SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: Data(message.utf8)), simulation: .mock(warnings: [ diff --git a/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift index c0f0972846..d0d6e8b266 100644 --- a/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift @@ -15,13 +15,13 @@ struct TransferDataViewModelTests { @Test func genericSendTitle() { - let type = TransferDataType.generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .send)) + let type = TransferDataType.generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .send)) #expect(TransferDataViewModel.mock(type: type).title == Localized.Transfer.reviewRequest) } @Test func genericSignTitle() { - let type = TransferDataType.generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .sign)) + let type = TransferDataType.generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .sign)) #expect(TransferDataViewModel.mock(type: type).title == Localized.Transfer.reviewRequest) } diff --git a/ios/Features/WalletConnector/Package.swift b/ios/Features/WalletConnector/Package.swift index 38a138deb3..a73f9cbc0e 100644 --- a/ios/Features/WalletConnector/Package.swift +++ b/ios/Features/WalletConnector/Package.swift @@ -14,6 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "Primitives", path: "../../Packages/Primitives"), + .package(name: "GemstonePrimitives", path: "../../Packages/GemstonePrimitives"), .package(name: "ChainServices", path: "../../Packages/ChainServices"), .package(name: "Components", path: "../../Packages/Components"), .package(name: "Localization", path: "../../Packages/Localization"), @@ -22,7 +23,6 @@ let package = Package( .package(name: "Preferences", path: "../../Packages/Preferences"), .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), .package(name: "QRScanner", path: "../QRScanner"), - .package(name: "Keystore", path: "../../Packages/Keystore"), .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), .package(name: "Gemstone", path: "../../Packages/Gemstone"), .package(name: "Formatters", path: "../../Packages/Formatters"), @@ -32,8 +32,8 @@ let package = Package( name: "WalletConnector", dependencies: [ "Primitives", + .product(name: "GemstonePrimitives", package: "GemstonePrimitives"), .product(name: "WalletConnectorService", package: "ChainServices"), - .product(name: "ExplorerService", package: "ChainServices"), "Components", "Localization", "Style", @@ -41,10 +41,9 @@ let package = Package( "Preferences", "PrimitivesComponents", "QRScanner", - .product(name: "AddressNameService", package: "FeatureServices"), .product(name: "WalletSessionService", package: "FeatureServices"), + .product(name: "TransactionStateService", package: "FeatureServices"), .product(name: "ConnectionsService", package: "FeatureServices"), - "Keystore", "Gemstone", "Formatters", ], @@ -58,9 +57,8 @@ let package = Package( .product(name: "PreferencesTestKit", package: "Preferences"), .product(name: "WalletSessionServiceTestKit", package: "FeatureServices"), .product(name: "ConnectionsServiceTestKit", package: "FeatureServices"), - .product(name: "AddressNameServiceTestKit", package: "FeatureServices"), .product(name: "WalletConnectorServiceTestKit", package: "ChainServices"), - .product(name: "KeystoreTestKit", package: "Keystore"), + .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), "WalletConnector", "Gemstone", ], diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift index 9f25ad6d86..897a67b955 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift @@ -2,14 +2,9 @@ import Foundation import Primitives -import WalletConnectorService public protocol WalletConnectorInteractable: Sendable { func sessionReject(error: any Error) async func sessionApproval(payload: WCPairingProposal) async throws -> WalletId - func signMessage(payload: SignMessagePayload) async throws -> String - - func signTransaction(transferData: WCTransferData) async throws -> String - func sendTransaction(transferData: WCTransferData) async throws -> String - func sendRawTransaction(transferData: WCTransferData) async throws -> String + func sendRawTransaction(transferData: SigningTransferData) async throws -> String } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift index 6909a54eff..da3f04d577 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift @@ -5,7 +5,6 @@ import Localization import Primitives import PrimitivesComponents import QRScanner -import Store import Style import SwiftUI diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index 5357d6213f..f3df001a1f 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift @@ -1,7 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Primitives -import SwiftUI +import PrimitivesComponents import WalletConnectorService public final class WalletConnectorManager { @@ -16,7 +16,7 @@ public final class WalletConnectorManager { extension WalletConnectorManager: WalletConnectorInteractable { public func sessionReject(error: any Error) async { - if let error = error as? ConnectionsError, case .userCancelled = error { + if let error = error as? SheetDismissal, case .cancelled = error { return } await MainActor.run { [weak self] in @@ -26,46 +26,11 @@ extension WalletConnectorManager: WalletConnectorInteractable { } public func sessionApproval(payload: WCPairingProposal) async throws -> WalletId { - let value = try await presentSheet(payload: payload, sheetType: { .connectionProposal($0) }) + let value = try await presenter.present(payload: payload) { .connectionProposal($0) } return try WalletId.from(id: value) } - public func signMessage(payload: SignMessagePayload) async throws -> String { - try await presentSheet(payload: payload, sheetType: { .signMessage($0) }) - } - - public func sendTransaction(transferData: WCTransferData) async throws -> String { - try await presentSheet(payload: transferData, sheetType: { .transferData($0) }) - } - - public func signTransaction(transferData: WCTransferData) async throws -> String { - try await presentSheet(payload: transferData, sheetType: { .transferData($0) }) - } - - public func sendRawTransaction(transferData _: WCTransferData) async throws -> String { + public func sendRawTransaction(transferData _: SigningTransferData) async throws -> String { throw AnyError.notImplemented } - - // MARK: - Private - - private func presentSheet( - payload: T, - sheetType: @Sendable @escaping (TransferDataCallback) -> WalletConnectorSheetType, - ) async throws -> String { - let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) - - let callback = TransferDataCallback(payload: payload) { - continuation.yield(with: $0) - continuation.finish() - } - - await MainActor.run { [weak self] in - self?.presenter.isPresentingSheet = sheetType(callback) - } - - for try await value in stream { - return value - } - throw ConnectionsError.userCancelled - } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift index 00529d1242..fe6f50bcd2 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,33 +1,29 @@ // Copyright (c). Gem Wallet. All rights reserved. -import SwiftUI +import Foundation +import Primitives +import PrimitivesComponents @Observable -public final class WalletConnectorPresenter: Sendable { +public final class WalletConnectorPresenter: SheetPresenting, SigningRequestInteractable, Sendable { + public let sheets = SheetPresenter() + @MainActor public var isPresentingError: String? @MainActor public var isPresentingConnectionBar: Bool = false - @MainActor - public var isPresentingSheet: WalletConnectorSheetType? public init() {} - @MainActor - public func complete(type: WalletConnectorSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } - isPresentingSheet = nil + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await present(payload: payload) { .signMessage($0) } } - @MainActor - public func cancelSheet(type: WalletConnectorSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .transferData($0) } + } - type.reject(ConnectionsError.userCancelled) - isPresentingSheet = nil + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .transferData($0) } } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index f54a37769d..8ce238eb26 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift @@ -1,11 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. -import BigInt import Foundation import class Gemstone.Config import class Gemstone.MessageSigner import struct Gemstone.SignMessage -import GemstonePrimitives import Preferences import Primitives import Store @@ -16,15 +14,18 @@ import WalletSessionService public final class WalletConnectorSigner: WalletConnectorSignable { private let connectionsStore: ConnectionsStore private let walletConnectorInteractor: any WalletConnectorInteractable + private let signingInteractor: any SigningRequestInteractable private let walletSessionService: any WalletSessionManageable public init( connectionsStore: ConnectionsStore, walletSessionService: any WalletSessionManageable, walletConnectorInteractor: any WalletConnectorInteractable, + signingInteractor: any SigningRequestInteractable, ) { self.connectionsStore = connectionsStore self.walletConnectorInteractor = walletConnectorInteractor + self.signingInteractor = signingInteractor self.walletSessionService = walletSessionService } @@ -89,13 +90,14 @@ public final class WalletConnectorSigner: WalletConnectorSignable { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let payload = SignMessagePayload( + id: session.session.id, chain: chain, - session: session.session, + appMetadata: session.session.metadata.transactionAppMetadata, wallet: session.wallet, - message: message, + message: message.map(), simulation: simulation, ) - return try await walletConnectorInteractor.signMessage(payload: payload) + return try await signingInteractor.signMessage(payload: payload) } public func updateSessions(sessions: [WalletConnectionSession]) throws { @@ -119,33 +121,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { await walletConnectorInteractor.sessionReject(error: error) } - private func buildTransferData( - chain: Chain, - metadata: WalletConnectionSessionAppMetadata, - transaction: String, - outputType: TransferDataOutputType, - outputAction: TransferDataOutputAction, - ) -> TransferData { - TransferData( - type: .generic( - asset: chain.asset, - metadata: metadata, - extra: TransferDataExtra( - to: "", - data: transaction.data(using: .utf8), - outputType: outputType, - outputAction: outputAction, - ), - ), - recipientData: RecipientData( - recipient: Recipient(name: .none, address: "", memo: .none), - amount: .none, - ), - amount: .exact(.zero), - ) - } - - public func signTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String { + public func signTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let wallet = try getWallet(id: session.wallet.id) @@ -153,109 +129,47 @@ public final class WalletConnectorSigner: WalletConnectorSignable { switch transaction { case .ethereum: throw AnyError("Not supported") - case let .solana(transaction, outputType), - let .sui(transaction, outputType), - let .ton(transaction, outputType), - let .tron(transaction, outputType): - let transferData = buildTransferData( - chain: chain, - metadata: session.session.metadata, + case .solana, .sui, .ton, .tron: + let transferData = try SigningTransferDataFactory.transferData( + asset: chain.asset, + appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, - outputType: outputType, outputAction: .sign, ) - return try await walletConnectorInteractor.signTransaction(transferData: WCTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) + return try await signingInteractor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) } } - public func sendTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String { + public func sendTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let wallet = try getWallet(id: session.wallet.id) - switch transaction { - case let .ethereum(transaction, transactionType): - let address = transaction.to - let value = try BigInt.fromHex(transaction.value ?? .zero) - let gasLimit: BigInt? = { - if let value = transaction.gasLimit { - return BigInt(hex: value) - } else if let gas = transaction.gas { - return BigInt(hex: gas) - } - return .none - }() - - let gasPrice: GasPriceType? = { - if let maxFeePerGas = transaction.maxFeePerGas, - let maxPriorityFeePerGas = transaction.maxPriorityFeePerGas, - let maxFeePerGasBigInt = BigInt(hex: maxFeePerGas), - let maxPriorityFeePerGasBigInt = BigInt(hex: maxPriorityFeePerGas) - { - return .eip1559(gasPrice: maxFeePerGasBigInt, priorityFee: maxPriorityFeePerGasBigInt) - } - return .none - }() - let data: Data? = { - if let data = transaction.data { - return Data(hex: data) - } - return .none - }() - - let transferData = TransferData( - type: .generic(asset: chain.asset, metadata: session.session.metadata, extra: TransferDataExtra( - to: address, - gasLimit: gasLimit, - gasPrice: gasPrice, - data: data, - transactionType: transactionType, - )), - recipientData: RecipientData( - recipient: Recipient(name: .none, address: address, memo: .none), - amount: .none, - ), - amount: .exact(value), - ) - - return try await walletConnectorInteractor.sendTransaction(transferData: WCTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) - case let .solana(transaction, outputType), - let .sui(transaction, outputType), - let .ton(transaction, outputType), - let .tron(transaction, outputType): - let transferData = buildTransferData( - chain: chain, - metadata: session.session.metadata, - transaction: transaction, - outputType: outputType, - outputAction: .send, - ) - return try await walletConnectorInteractor.sendTransaction(transferData: WCTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) - } + let transferData = try SigningTransferDataFactory.transferData( + asset: chain.asset, + appMetadata: session.session.metadata.transactionAppMetadata, + transaction: transaction, + outputAction: .send, + ) + return try await signingInteractor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) } public func sendRawTransaction(sessionId: String, chain: Chain, transaction: String) async throws -> String { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let wallet = try getWallet(id: session.wallet.id) - let transferData = buildTransferData( - chain: chain, - metadata: session.session.metadata, + let transferData = SigningTransferDataFactory.encodedTransferData( + asset: chain.asset, + appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, outputType: .encodedTransaction, outputAction: .send, ) - let simulation = SimulationResult( - warnings: [], - balanceChanges: [], - payload: [], - header: nil, - ) return try await walletConnectorInteractor.sendRawTransaction( - transferData: WCTransferData( + transferData: SigningTransferData( transferData: transferData, wallet: wallet, - simulation: simulation, + simulation: .empty, ), ) } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 1132555b94..f051fbf7ad 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -1,29 +1,31 @@ // Copyright (c). Gem Wallet. All rights reserved. +import Foundation import Primitives -import WalletConnectorService +import PrimitivesComponents public enum WalletConnectorSheetType: Sendable, Identifiable { - case transferData(TransferDataCallback) - case signMessage(TransferDataCallback) - case connectionProposal(TransferDataCallback) + case connectionProposal(SheetCallback) + case transferData(SheetCallback) + case signMessage(SheetCallback) - public var id: Int { - switch self { - case let .transferData(callback): callback.id.hashValue - case let .signMessage(callback): callback.id.hashValue - case let .connectionProposal(callback): callback.id.hashValue - } + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) } - public func reject(_ error: Error) { + private var callback: any SheetRejectable { switch self { - case let .transferData(callback): - callback.delegate(.failure(error)) - case let .signMessage(callback): - callback.delegate(.failure(error)) - case let .connectionProposal(callback): - callback.delegate(.failure(error)) + case let .connectionProposal(callback): callback + case let .transferData(callback): callback + case let .signMessage(callback): callback } } } + +// MARK: - SheetRejectable + +extension WalletConnectorSheetType: SheetRejectable {} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift index c27e33be0d..25ce8b4230 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift @@ -10,13 +10,13 @@ import SwiftUI import WalletConnectorService public struct ConnectionProposalViewModel { - private let confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate + private let confirmTransferDelegate: StringResultAction private let pairingProposal: WCPairingProposal var walletSelectorModel: SelectWalletViewModel public init( - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, pairingProposal: WCPairingProposal, ) { self.confirmTransferDelegate = confirmTransferDelegate diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift index b8a0caaaa5..5c020155d3 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift @@ -12,13 +12,7 @@ public struct WalletConnectionViewModel: Sendable { } var imageUrl: URL? { - if let url = URL(string: connection.session.metadata.icon) { - if url.host() == nil { - return URL(string: connection.session.metadata.url + connection.session.metadata.icon) - } - return url - } - return .none + connection.session.metadata.transactionAppMetadata.iconURL } var hostText: String? { diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift new file mode 100644 index 0000000000..50fcfdd0a4 --- /dev/null +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift @@ -0,0 +1,42 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +@testable import WalletConnector +import WalletConnectorService + +final class WalletConnectorInteractableMock: WalletConnectorInteractable, @unchecked Sendable { + var transactionHash = "1" + var signature = "signature" + var signMessageError: Error? + private var failedSignOnce = false + + private(set) var signMessagePayloads: [SignMessagePayload] = [] + + func sessionReject(error _: any Error) async {} + + func sessionApproval(payload _: WCPairingProposal) async throws -> WalletId { + throw AnyError("Not supported") + } + + func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + if let signMessageError, !failedSignOnce { + failedSignOnce = true + throw signMessageError + } + return signature + } + + func signTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + func sendTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + func sendRawTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } +} diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift deleted file mode 100644 index 61a825cacd..0000000000 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation -import struct Gemstone.SignMessage -import Primitives -import PrimitivesTestKit -import Testing -@testable import WalletConnector -import WalletConnectorService - -struct WalletConnectorPresenterTests { - @Test - @MainActor - func completeDismissesSignMessageSheet() { - let presenter = WalletConnectorPresenter() - let type = WalletConnectorSheetType.signMessage( - TransferDataCallback( - payload: SignMessagePayload( - chain: .ethereum, - session: .mock(), - wallet: .mock(), - message: SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), - simulation: .mock(), - ), - delegate: { _ in }, - ), - ) - - presenter.isPresentingSheet = type - presenter.complete(type: type) - - #expect(presenter.isPresentingSheet == nil) - } -} diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift index bb04fe622d..62f31ae8c6 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift @@ -217,6 +217,7 @@ extension WalletConnectorSigner { connectionsStore: connectionsStore, walletSessionService: walletSessionService, walletConnectorInteractor: WalletConnectorManager(presenter: WalletConnectorPresenter()), + signingInteractor: WalletConnectorPresenter(), ) } diff --git a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift index 984b80c1fd..bbd26a5cda 100644 --- a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift +++ b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift @@ -26,6 +26,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { let balanceService: BalanceService private let bannerService: BannerService private let walletSessionService: any WalletSessionManageable + private let payService: any PaymentLinkPayable private let balanceCalculator = BalanceCalculator() let observablePreferences: ObservablePreferences @@ -42,6 +43,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { public var isPresentingSelectedAssetInput: Binding public var isPresentingSheet: WalletSheetType? public var isPresentingSearch = false + public var isPresentingScanner = false public var isPresentingUrl: URL? public var isPresentingToastMessage: ToastMessage? @@ -52,6 +54,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { balanceService: BalanceService, bannerService: BannerService, walletSessionService: any WalletSessionManageable, + payService: any PaymentLinkPayable, nftService: NFTService, observablePreferences: ObservablePreferences, wallet: Wallet, @@ -62,8 +65,9 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { self.balanceService = balanceService self.bannerService = bannerService self.walletSessionService = walletSessionService + self.payService = payService self.observablePreferences = observablePreferences - self.collectionsModel = CollectionsViewModel( + collectionsModel = CollectionsViewModel( nftService: nftService, walletSessionService: walletSessionService, wallet: wallet, @@ -74,7 +78,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { bannersQuery = ObservableQuery(BannersRequest(walletId: wallet.id, assetId: .none, chain: .none, events: [.accountBlockedMultiSignature, .onboarding]), initialValue: []) self.isPresentingSelectedAssetInput = isPresentingSelectedAssetInput } - + public var totalFiatValue: TotalFiatValue { balanceCalculator.totalFiatValue(fiatValuesQuery.value) } @@ -111,6 +115,14 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { Images.System.search } + public var scannerImage: Image { + Images.System.qrCodeViewfinder + } + + public var showScanner: Bool { + observablePreferences.isDeveloperEnabled + } + public var manageImage: Image { Images.Actions.manage } @@ -187,6 +199,22 @@ public extension WalletSceneViewModel { isPresentingSearch.toggle() } + func onSelectScanner() { + isPresentingScanner = true + } + + func onHandleScan(_ result: String) { + guard let action = try? URLParser.from(string: result) else { + return isPresentingToastMessage = .error(Localized.Errors.notSupported) + } + switch action { + case .deeplink, .walletConnect: + isPresentingToastMessage = .error(Localized.Errors.notSupported) + case let .payment(link): + Task { await payService.pay(link: link, wallet: wallet) } + } + } + func onSelectAddCustomToken() { isPresentingSheet = .addAsset } diff --git a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift index 1effc3fd57..31b8206a4b 100644 --- a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift +++ b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift @@ -19,6 +19,7 @@ public extension WalletSceneViewModel { balanceService: .mock(), bannerService: .mock(), walletSessionService: WalletSessionService.mock(), + payService: .mock(), nftService: .mock(), observablePreferences: .mock(), wallet: wallet, diff --git a/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift b/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift index 084ee7f487..4401d1ae42 100644 --- a/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift +++ b/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import BannerServiceTestKit +import Localization import Primitives import PrimitivesTestKit @testable import Store @@ -47,4 +48,18 @@ struct WalletSceneViewModelTests { #expect(model.wallet.id == .multicoin(address: "0x2")) } + + @Test + func onHandleScan() { + let model = WalletSceneViewModel.mock() + + model.onHandleScan("https://pay.walletconnect.com/?pid=pay_123") + #expect(model.isPresentingToastMessage == nil) + + model.onHandleScan("wc:abc@2?relay-protocol=irn&symKey=123") + #expect(model.isPresentingToastMessage?.title == Localized.Errors.notSupported) + + model.onHandleScan("WIFI:S:MyNet;T:WPA;P:secret;;") + #expect(model.isPresentingToastMessage?.title == Localized.Errors.notSupported) + } } diff --git a/ios/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index e01c6582a7..b9fbce1a08 100644 --- a/ios/Gem.xcodeproj/project.pbxproj +++ b/ios/Gem.xcodeproj/project.pbxproj @@ -17,7 +17,9 @@ 8327CBF12DF9E088000BF1E1 /* Validators in Frameworks */ = {isa = PBXBuildFile; productRef = 8327CBF02DF9E088000BF1E1 /* Validators */; }; 832AD1A72DEF9AED00096469 /* Assets in Frameworks */ = {isa = PBXBuildFile; productRef = 832AD1A62DEF9AED00096469 /* Assets */; }; 833809D02D273E450055D91F /* WalletConnectorService in Frameworks */ = {isa = PBXBuildFile; productRef = 83385DDD2D27147900D76803 /* WalletConnectorService */; }; + 0A11FA0000000000000000A1 /* PaymentService in Frameworks */ = {isa = PBXBuildFile; productRef = 0A11FA0000000000000000A2 /* PaymentService */; }; 833809D12D273E450055D91F /* WalletConnector in Frameworks */ = {isa = PBXBuildFile; productRef = 83FE37B32D273CC80048D54C /* WalletConnector */; }; + 0A11FA0000000000000000B1 /* Payments in Frameworks */ = {isa = PBXBuildFile; productRef = 0A11FA0000000000000000B2 /* Payments */; }; 833931A52D2D5B1D0063BB6A /* PriceAlerts in Frameworks */ = {isa = PBXBuildFile; productRef = 833931A42D2D5B1D0063BB6A /* PriceAlerts */; }; 833931A82D2D663C0063BB6A /* PriceService in Frameworks */ = {isa = PBXBuildFile; productRef = 833931A72D2D663C0063BB6A /* PriceService */; }; 833CDFD02D10622D00DAABEE /* ServicesFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833CDFCF2D10622D00DAABEE /* ServicesFactory.swift */; }; @@ -35,6 +37,8 @@ 836281B02DEF34DC00CA5C75 /* Assets in Frameworks */ = {isa = PBXBuildFile; productRef = 836281AF2DEF34DC00CA5C75 /* Assets */; }; 837A1E6B2D0B029200733AC1 /* AppResolver+ViewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */; }; 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */; }; + 83AA01012E4A000100000001 /* PaymentNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */; }; + 83AA01042E4A000100000004 /* SigningRequestSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83AA01052E4A000100000005 /* SigningRequestSheetView.swift */; }; 83863EE72D19B93E005048A7 /* NodeService in Frameworks */ = {isa = PBXBuildFile; productRef = 83863EE62D19B93E005048A7 /* NodeService */; }; 839C794B2D1C42A500E32072 /* PrimitivesComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 839C794A2D1C42A500E32072 /* PrimitivesComponents */; }; 83B08BDB2D0B4AE200CA1B33 /* AppResolver+Storages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B08BDA2D0B4AE200CA1B33 /* AppResolver+Storages.swift */; }; @@ -215,6 +219,8 @@ 836281AD2DEF32F600CA5C75 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Assets; sourceTree = ""; }; 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppResolver+ViewInjection.swift"; sourceTree = ""; }; 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectorNavigationStack.swift; sourceTree = ""; }; + 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentNavigationStack.swift; sourceTree = ""; }; + 83AA01052E4A000100000005 /* SigningRequestSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SigningRequestSheetView.swift; sourceTree = ""; }; 837D7DE62E005D2800BBBDDA /* Formatters */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Formatters; sourceTree = ""; }; 838383112D5E677A00D1CAF2 /* WalletTab */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WalletTab; sourceTree = ""; }; 838B9B9C2F0C120100380F29 /* Recents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Recents; sourceTree = ""; }; @@ -226,6 +232,7 @@ 83E041572D0AFCB10031D4BC /* RootScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootScene.swift; sourceTree = ""; }; 83E041592D0AFCB80031D4BC /* RootSceneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSceneViewModel.swift; sourceTree = ""; }; 83FE37B22D2715BF0048D54C /* WalletConnector */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WalletConnector; sourceTree = ""; }; + 0A11FA0000000000000000B3 /* Payments */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Payments; sourceTree = ""; }; B604C2E72F3F537800CBAFDC /* Contacts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Contacts; sourceTree = ""; }; B67B3EEE2EFD25A6007AFA9C /* EventPresenterService */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = EventPresenterService; sourceTree = ""; }; B67ED5462DDB5DCC009F74E6 /* NavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHandler.swift; sourceTree = ""; }; @@ -324,7 +331,9 @@ buildActionMask = 2147483647; files = ( 833809D12D273E450055D91F /* WalletConnector in Frameworks */, + 0A11FA0000000000000000B1 /* Payments in Frameworks */, 833809D02D273E450055D91F /* WalletConnectorService in Frameworks */, + 0A11FA0000000000000000A1 /* PaymentService in Frameworks */, C3D1C4E22A2A9485006E8EEA /* Signer in Frameworks */, D85AD2A62CD7017E0010DEF8 /* NativeProviderService in Frameworks */, 8361BB6D2D400E4F008D89CF /* AssetsService in Frameworks */, @@ -475,6 +484,14 @@ path = Transactions; sourceTree = ""; }; + 83AA01032E4A000100000003 /* Payments */ = { + isa = PBXGroup; + children = ( + 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */, + ); + path = Payments; + sourceTree = ""; + }; 8361BB6F2D402C63008D89CF /* WalletConnector */ = { isa = PBXGroup; children = ( @@ -486,6 +503,8 @@ 8361BB702D402C9D008D89CF /* Navigation */ = { isa = PBXGroup; children = ( + 83AA01052E4A000100000005 /* SigningRequestSheetView.swift */, + 83AA01032E4A000100000003 /* Payments */, 83A35C612DF2054B00360060 /* Transfer */, 836281B22DEF423400CA5C75 /* Assets */, 83ACA9792DEE01F00085B133 /* Swap */, @@ -712,6 +731,7 @@ 833931A32D2D54060063BB6A /* PriceAlerts */, D9F015752F10654000000001 /* InAppNotifications */, 83FE37B22D2715BF0048D54C /* WalletConnector */, + 0A11FA0000000000000000B3 /* Payments */, D8D83C822CECF90A0083AA53 /* Swap */, 8393897A2CCA8F0000735088 /* InfoSheet */, E1B4C7082E85B00000EARN01 /* Stake */, @@ -855,7 +875,9 @@ 833931A72D2D663C0063BB6A /* PriceService */, B6STREAM02F5000000000002 /* StreamService */, 83385DDD2D27147900D76803 /* WalletConnectorService */, + 0A11FA0000000000000000A2 /* PaymentService */, 83FE37B32D273CC80048D54C /* WalletConnector */, + 0A11FA0000000000000000B2 /* Payments */, 83EB5E2F2D2ECF00006A7CFB /* Onboarding */, 8352C8D42D393E05000DEDD9 /* ExplorerService */, 07A9FE3C2D3565CD00ACDB94 /* NFTService */, @@ -1180,6 +1202,8 @@ B67ED5482DDB5DCD009F74E6 /* NavigationPresenter.swift in Sources */, C366790A2A0B7E5800F1D74D /* Environment.swift in Sources */, 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */, + 83AA01012E4A000100000001 /* PaymentNavigationStack.swift in Sources */, + 83AA01042E4A000100000004 /* SigningRequestSheetView.swift in Sources */, D8D5C1D12E6CAC16007628A1 /* Errors.swift in Sources */, D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */, D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */, @@ -2000,6 +2024,14 @@ isa = XCSwiftPackageProductDependency; productName = WalletConnector; }; + 0A11FA0000000000000000A2 /* PaymentService */ = { + isa = XCSwiftPackageProductDependency; + productName = PaymentService; + }; + 0A11FA0000000000000000B2 /* Payments */ = { + isa = XCSwiftPackageProductDependency; + productName = Payments; + }; AA0000022F26000000000002 /* ConnectionsService */ = { isa = XCSwiftPackageProductDependency; productName = ConnectionsService; diff --git a/ios/Gem/App.swift b/ios/Gem/App.swift index d3d5869c20..f9323122e7 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -29,6 +29,7 @@ struct GemApp: App { model: RootSceneViewModel( observablePreferences: resolver.storages.observablePreferences, walletConnectorPresenter: resolver.services.walletConnectorManager.presenter, + paymentSheetPresenter: resolver.services.paymentSheetPresenter, onstartService: resolver.services.onstartService, onstartWalletService: resolver.services.onstartWalletService, transactionStateScheduler: resolver.services.transactionStateScheduler, @@ -38,6 +39,7 @@ struct GemApp: App { lockWindowManager: LockWindowManager(lockModel: LockSceneViewModel()), walletService: resolver.services.walletService, walletSessionService: resolver.services.walletSessionService, + payService: resolver.services.paymentLinkManager, walletSetupService: resolver.services.walletSetupService, nameService: resolver.services.nameService, releaseAlertService: resolver.services.releaseAlertService, diff --git a/ios/Gem/Navigation/NavigationHandler.swift b/ios/Gem/Navigation/NavigationHandler.swift index 5bea5ec63b..f983329be1 100644 --- a/ios/Gem/Navigation/NavigationHandler.swift +++ b/ios/Gem/Navigation/NavigationHandler.swift @@ -57,7 +57,7 @@ final class NavigationHandler: Sendable { guard let action = try? URLParser.from(url: url) else { return false } switch action { - case .walletConnect: + case .payment, .walletConnect: return false case .deeplink: Task { await handle(action) } @@ -72,7 +72,7 @@ final class NavigationHandler: Sendable { extension NavigationHandler { private func handleURLAction(_ action: URLAction) async throws { switch action { - case .walletConnect: break + case .payment, .walletConnect: break case let .deeplink(deeplink): try await handleDeepLink(deeplink) } } diff --git a/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift new file mode 100644 index 0000000000..24c6b2b84c --- /dev/null +++ b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift @@ -0,0 +1,45 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Payments +import SwiftUI + +struct PaymentNavigationStack: View { + private let type: PaymentSheetType + private let presenter: PaymentSheetPresenter + + init( + type: PaymentSheetType, + presenter: PaymentSheetPresenter, + ) { + self.type = type + self.presenter = presenter + } + + var body: some View { + NavigationStack { + Group { + switch type { + case let .quotes(data): + PaymentQuotesScene( + model: PaymentQuotesSceneViewModel( + request: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: complete, + ) + case let .dataCollection(data): + PaymentDataCollectionScene(callback: data, onComplete: complete) + case let .confirm(data): + SigningRequestSheetView(content: .transfer(data), onComplete: complete) + case let .signMessage(data): + SigningRequestSheetView(content: .signMessage(data), onComplete: complete) + } + } + .signingRequestChrome(onCancel: { presenter.cancelSheet(type: type) }) + } + } + + private func complete() { + presenter.complete(type: type) + } +} diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift new file mode 100644 index 0000000000..177d252bb9 --- /dev/null +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -0,0 +1,64 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import PrimitivesComponents +import Style +import SwiftUI +import Transfer + +struct SigningRequestSheetView: View { + @Environment(\.viewModelFactory) private var viewModelFactory + + private let content: SigningRequestSheetContent + private let onComplete: () -> Void + + init( + content: SigningRequestSheetContent, + onComplete: @escaping () -> Void, + ) { + self.content = content + self.onComplete = onComplete + } + + var body: some View { + switch content { + case let .transfer(data): + ConfirmTransferNavigationView( + model: viewModelFactory.confirmTransferScene( + wallet: data.payload.wallet, + data: data.payload.transferData, + confirmTransferDelegate: data.delegate, + simulation: data.payload.simulation, + onComplete: onComplete, + ), + ) + case let .signMessage(data): + SignMessageScene( + model: viewModelFactory.signMessageScene( + payload: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: onComplete, + ) + } + } +} + +enum SigningRequestSheetContent { + case transfer(SheetCallback) + case signMessage(SheetCallback) +} + +// MARK: - Chrome + +extension View { + func signingRequestChrome(onCancel: @escaping () -> Void) -> some View { + interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark, action: onCancel) + } + } + } +} diff --git a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift index dfb28d7304..d931506100 100644 --- a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift +++ b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift @@ -19,6 +19,7 @@ import Transactions import Transfer import WalletSessionService import WalletTab +import QRScanner struct WalletNavigationStack: View { @Environment(\.assetsEnabler) private var assetsEnabler @@ -81,6 +82,14 @@ struct WalletNavigationStack: View { .navigationBarTitleDisplayMode(.inline) .toolbar { if !model.isPresentingSearch { + if model.showScanner { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: model.onSelectScanner) { + model.scannerImage + } + .accessibilityIdentifier("scan") + } + } ToolbarItem(placement: .principal) { WalletBarView( model: model.walletBarModel, @@ -225,6 +234,9 @@ struct WalletNavigationStack: View { ), ) } + .sheet(isPresented: $model.isPresentingScanner) { + ScanQRCodeNavigationStack(action: model.onHandleScan(_:)) + } .sheet(item: $model.isPresentingSheet) { sheet in Group { switch sheet { diff --git a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift b/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift index 82f8fb9edb..3f8c5e9cc9 100644 --- a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift +++ b/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift @@ -1,17 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. -import ExplorerService -import Primitives -import Signer -import Style import SwiftUI -import TransactionStateService -import Transfer import WalletConnector struct WalletConnectorNavigationStack: View { - @Environment(\.viewModelFactory) private var viewModelFactory - private let type: WalletConnectorSheetType private let presenter: WalletConnectorPresenter @@ -28,42 +20,24 @@ struct WalletConnectorNavigationStack: View { Group { switch type { case let .transferData(data): - ConfirmTransferNavigationView( - model: viewModelFactory.confirmTransferScene( - wallet: data.payload.wallet, - data: data.payload.transferData, - confirmTransferDelegate: data.delegate, - simulation: data.payload.simulation, - onComplete: { presenter.complete(type: type) }, - ), - ) + SigningRequestSheetView(content: .transfer(data), onComplete: complete) case let .signMessage(data): - SignMessageScene( - model: viewModelFactory.signMessageScene( - payload: data.payload, - confirmTransferDelegate: data.delegate, - ), - onComplete: { presenter.complete(type: type) }, - ) + SigningRequestSheetView(content: .signMessage(data), onComplete: complete) case let .connectionProposal(data): ConnectionProposalScene( model: ConnectionProposalViewModel( confirmTransferDelegate: data.delegate, pairingProposal: data.payload, ), - onComplete: { presenter.complete(type: type) }, + onComplete: complete, ) } } - .interactiveDismissDisabled(true) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("", systemImage: SystemImage.xmark) { - presenter.cancelSheet(type: type) - } - } - } + .signingRequestChrome(onCancel: { presenter.cancelSheet(type: type) }) } } + + private func complete() { + presenter.complete(type: type) + } } diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index 4f44aaaa72..e68f613960 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -1,13 +1,14 @@ // Copyright (c). Gem Wallet. All rights reserved. import Components -import GemstonePrimitives import Localization import Onboarding +import Payments import PriceService import Primitives import Style import SwiftUI +import WalletConnector struct RootScene: View { @Environment(\.scenePhase) private var scenePhase @@ -34,12 +35,18 @@ struct RootScene: View { await model.handleOpenUrl(url) } } - .sheet(item: $model.isPresentingConnectorSheet) { type in + .sheet(item: $model.isPresentingConnectorSheet, onDismiss: model.walletConnectorPresenter.onSheetDismiss) { type in WalletConnectorNavigationStack( type: type, presenter: model.walletConnectorPresenter, ) } + .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.onSheetDismiss) { type in + PaymentNavigationStack( + type: type, + presenter: model.paymentSheetPresenter, + ) + } .sheet(isPresented: $model.isPresentingCreateWalletSheet) { CreateWalletNavigationStack( model: CreateWalletModel( diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index b0c6cb6b7d..d98b31e354 100644 --- a/ios/Gem/Services/AppResolver+Services.swift +++ b/ios/Gem/Services/AppResolver+Services.swift @@ -21,12 +21,14 @@ import NameService import NFTService import NodeService import NotificationService +import Payments import PerpetualService import PriceAlertService import PriceService +import Primitives import RewardsService -import ServiceStatusService import ScanService +import ServiceStatusService import StakeService import StreamService import SupportChatService @@ -77,6 +79,8 @@ extension AppResolver { let onstartAsyncService: OnstartAsyncService let onstartWalletService: OnstartWalletService let walletConnectorManager: WalletConnectorManager + let paymentLinkManager: PaymentLinkManager + let paymentSheetPresenter: PaymentSheetPresenter let perpetualService: PerpetualService let hyperliquidObserverService: any PerpetualObservable let nameService: NameService @@ -132,6 +136,8 @@ extension AppResolver { onstartAsyncService: OnstartAsyncService, onstartWalletService: OnstartWalletService, walletConnectorManager: WalletConnectorManager, + paymentLinkManager: PaymentLinkManager, + paymentSheetPresenter: PaymentSheetPresenter, perpetualService: PerpetualService, hyperliquidObserverService: any PerpetualObservable, nameService: NameService, @@ -187,6 +193,8 @@ extension AppResolver { self.onstartAsyncService = onstartAsyncService self.onstartWalletService = onstartWalletService self.walletConnectorManager = walletConnectorManager + self.paymentLinkManager = paymentLinkManager + self.paymentSheetPresenter = paymentSheetPresenter self.perpetualService = perpetualService self.hyperliquidObserverService = hyperliquidObserverService self.nameService = nameService diff --git a/ios/Gem/Services/AppResolver+ViewInjection.swift b/ios/Gem/Services/AppResolver+ViewInjection.swift index ef21e00709..722a9f5337 100644 --- a/ios/Gem/Services/AppResolver+ViewInjection.swift +++ b/ios/Gem/Services/AppResolver+ViewInjection.swift @@ -14,6 +14,7 @@ extension View { private func inject(services: AppResolver.Services) -> some View { environment(\.nodeService, services.nodeService) .environment(\.serviceStatusService, services.serviceStatusService) + .environment(\.payService, services.paymentLinkManager) .environment(\.walletService, services.walletService) .environment(\.walletSessionService, services.walletSessionService) .environment(\.assetsEnabler, services.assetsEnabler) diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index 3a8865762b..0a9fd94d28 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -22,13 +22,14 @@ import FiatService import Foundation import GemAPI import GemAPIDevice -import GemstonePrimitives import Keystore import NameService import NativeProviderService import NFTService import NodeService import NotificationService +import Payments +import PaymentService import PerpetualService import Preferences import PriceAlertService @@ -37,6 +38,7 @@ import Primitives import RewardsService import ScanService import ServiceStatusService +import SimulationService import StakeService import Store import StreamService @@ -148,6 +150,11 @@ struct ServicesFactory { assetsService: assetsService, addressStore: storeManager.addressStore, ) + let paymentService = PaymentService( + provider: nativeProvider, + appId: Constants.WalletConnect.projectId, + clientId: UUID().uuidString, + ) let transactionStateScheduler = Self.makeTransactionService( transactionStore: storeManager.transactionStore, gatewayService: gatewayService, @@ -155,6 +162,7 @@ struct ServicesFactory { earnService: earnService, nftService: nftService, balanceService: balanceService, + paymentStatusService: paymentService, ) let preferences = storages.observablePreferences.preferences @@ -233,6 +241,7 @@ struct ServicesFactory { connectionsStore: storeManager.connectionsStore, walletSessionService: walletSessionService, interactor: walletConnectorManager, + signingInteractor: presenter, nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider, ) @@ -295,6 +304,22 @@ struct ServicesFactory { let authService = AuthService(apiService: apiService, keystore: storages.keystore) let rewardsService = RewardsService(apiService: apiService, authService: authService) let eventPresenterService = EventPresenterService() + let paymentAssetsProvider = PaymentAssetsProvider(assetStore: storeManager.assetStore) + let paymentSheetPresenter = PaymentSheetPresenter() + let paymentLinkManager = PaymentLinkManager( + paymentManager: PaymentManager( + service: paymentService, + executor: PaymentActionExecutor( + interactor: paymentSheetPresenter, + simulator: SimulationService(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), + assetsProvider: paymentAssetsProvider, + ), + presenter: paymentSheetPresenter, + assetsProvider: paymentAssetsProvider, + transactionStateScheduler: transactionStateScheduler, + ), + eventPresenterService: eventPresenterService, + ) let walletSearchService = WalletSearchService( assetsService: assetsService, searchStore: storeManager.searchStore, @@ -388,6 +413,8 @@ struct ServicesFactory { onstartAsyncService: onstartAsyncService, onstartWalletService: onstartWalletService, walletConnectorManager: walletConnectorManager, + paymentLinkManager: paymentLinkManager, + paymentSheetPresenter: paymentSheetPresenter, perpetualService: perpetualService, hyperliquidObserverService: hyperliquidObserverService, nameService: nameService, @@ -440,6 +467,7 @@ extension ServicesFactory { earnService: EarnService, nftService: NFTService, balanceService: BalanceService, + paymentStatusService: any PaymentStatusServiceable, ) -> TransactionStateScheduler { let postProcessingService = TransactionPostProcessingService( transactionStore: transactionStore, @@ -452,6 +480,7 @@ extension ServicesFactory { transactionStore: transactionStore, gatewayService: gatewayService, postProcessingService: postProcessingService, + paymentStatusService: paymentStatusService, ) return TransactionStateScheduler( transactionStore: transactionStore, @@ -480,6 +509,7 @@ extension ServicesFactory { connectionsStore: ConnectionsStore, walletSessionService: WalletSessionService, interactor: any WalletConnectorInteractable, + signingInteractor: any SigningRequestInteractable, nodeProvider: any NodeURLFetchable, requestInterceptor: any RequestInterceptable, ) -> ConnectionsService { @@ -489,6 +519,7 @@ extension ServicesFactory { connectionsStore: connectionsStore, walletSessionService: walletSessionService, walletConnectorInteractor: interactor, + signingInteractor: signingInteractor, ), nodeProvider: nodeProvider, requestInterceptor: requestInterceptor, diff --git a/ios/Gem/Services/ViewModelFactory.swift b/ios/Gem/Services/ViewModelFactory.swift index 919e8139c4..b616d4328e 100644 --- a/ios/Gem/Services/ViewModelFactory.swift +++ b/ios/Gem/Services/ViewModelFactory.swift @@ -13,6 +13,7 @@ import FiatService import Foundation import Keystore import NameService +import PaymentService import PerpetualService import Preferences import PriceAlertService @@ -152,7 +153,7 @@ public struct ViewModelFactory: Sendable { public func confirmTransferScene( wallet: Wallet, data: TransferData, - confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate? = nil, + confirmTransferDelegate: StringResultAction? = nil, simulation: SimulationResult? = nil, onComplete: VoidAction, ) -> ConfirmTransferSceneViewModel { @@ -295,7 +296,7 @@ public struct ViewModelFactory: Sendable { @MainActor public func signMessageScene( payload: SignMessagePayload, - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, ) -> SignMessageSceneViewModel { SignMessageSceneViewModel( keystore: keystore, diff --git a/ios/Gem/Types/Environment.swift b/ios/Gem/Types/Environment.swift index 7ae2a6117b..9d3448d06f 100644 --- a/ios/Gem/Types/Environment.swift +++ b/ios/Gem/Types/Environment.swift @@ -25,6 +25,7 @@ import NotificationService import PerpetualService import PriceAlertService import PriceService +import Primitives import RewardsService import ServiceStatusService import ScanService @@ -49,6 +50,7 @@ extension EnvironmentValues { @Entry var explorerService: ExplorerService = AppResolver.main.services.explorerService @Entry var assetsEnabler: any AssetsEnabler = AppResolver.main.services.assetsEnabler @Entry var assetDiscoveryService: any AssetDiscoverable = AppResolver.main.services.assetDiscoveryService + @Entry var payService: any PaymentLinkPayable = AppResolver.main.services.paymentLinkManager @Entry var walletService: WalletService = AppResolver.main.services.walletService @Entry var walletSessionService: any WalletSessionManageable = AppResolver.main.services.walletSessionService @Entry var priceAlertService: PriceAlertService = AppResolver.main.services.priceAlertService diff --git a/ios/Gem/Types/Errors.swift b/ios/Gem/Types/Errors.swift index f384fd5f75..1c0bfa66cd 100644 --- a/ios/Gem/Types/Errors.swift +++ b/ios/Gem/Types/Errors.swift @@ -4,6 +4,7 @@ import Foundation import Gemstone import Localization import Primitives +import WalletConnector extension Gemstone.GatewayError: @retroactive LocalizedError { public var errorDescription: String? { @@ -36,6 +37,24 @@ extension Gemstone.SwapperError: @retroactive LocalizedError { } } +extension Gemstone.PaymentError: @retroactive LocalizedError { + public var errorDescription: String? { + switch self { + case .PaymentExpired, .QuoteExpired: + return Localized.Errors.paymentExpired + case .Rejected: + return Localized.Errors.paymentNotAllowed + case .PaymentNotFound, .RateLimited: + return Localized.Transaction.Status.failed + case .NoPaymentOptions, .UnsupportedAccounts, .NotSupported: + return Localized.Errors.notSupported + case let .InvalidRequest(message), let .Network(message): + debugLog("PaymentError \(message)") + return Localized.Errors.errorOccurred + } + } +} + extension Gemstone.AlienError: @retroactive LocalizedError { public var errorDescription: String? { switch self { diff --git a/ios/Gem/ViewModels/RootSceneViewModel.swift b/ios/Gem/ViewModels/RootSceneViewModel.swift index 213cdb75b6..f2b4b0b36c 100644 --- a/ios/Gem/ViewModels/RootSceneViewModel.swift +++ b/ios/Gem/ViewModels/RootSceneViewModel.swift @@ -12,6 +12,7 @@ import Localization import LockManager import NameService import Onboarding +import Payments import Preferences import Primitives import SwiftUI @@ -39,9 +40,11 @@ final class RootSceneViewModel { let walletSetupService: WalletSetupService let walletService: WalletService let walletSessionService: any WalletSessionManageable + let payService: any PaymentLinkPayable let nameService: NameService let avatarService: AvatarService let walletConnectorPresenter: WalletConnectorPresenter + let paymentSheetPresenter: PaymentSheetPresenter let lockManager: any LockWindowManageable var currentWallet: Wallet? { walletSessionService.currentWallet @@ -64,6 +67,11 @@ final class RootSceneViewModel { set { walletConnectorPresenter.isPresentingSheet = newValue } } + var isPresentingPaymentSheet: PaymentSheetType? { + get { paymentSheetPresenter.isPresentingSheet } + set { paymentSheetPresenter.isPresentingSheet = newValue } + } + var isPresentingConnectorBar: Bool { get { walletConnectorPresenter.isPresentingConnectionBar } set { walletConnectorPresenter.isPresentingConnectionBar = newValue } @@ -79,6 +87,7 @@ final class RootSceneViewModel { init( observablePreferences: ObservablePreferences, walletConnectorPresenter: WalletConnectorPresenter, + paymentSheetPresenter: PaymentSheetPresenter, onstartService: OnstartService, onstartWalletService: OnstartWalletService, transactionStateScheduler: TransactionStateScheduler, @@ -88,6 +97,7 @@ final class RootSceneViewModel { lockWindowManager: any LockWindowManageable, walletService: WalletService, walletSessionService: any WalletSessionManageable, + payService: any PaymentLinkPayable, walletSetupService: WalletSetupService, nameService: NameService, releaseAlertService: ReleaseAlertService, @@ -98,6 +108,7 @@ final class RootSceneViewModel { ) { self.observablePreferences = observablePreferences self.walletConnectorPresenter = walletConnectorPresenter + self.paymentSheetPresenter = paymentSheetPresenter self.onstartService = onstartService self.onstartWalletService = onstartWalletService self.transactionStateScheduler = transactionStateScheduler @@ -107,6 +118,7 @@ final class RootSceneViewModel { lockManager = lockWindowManager self.walletService = walletService self.walletSessionService = walletSessionService + self.payService = payService self.walletSetupService = walletSetupService self.nameService = nameService self.releaseAlertService = releaseAlertService @@ -159,6 +171,11 @@ extension RootSceneViewModel { switch action { case let .walletConnect(walletConnectAction): try await handleWalletConnect(walletConnectAction) + case let .payment(link): + guard observablePreferences.isDeveloperEnabled else { + throw AnyError(Localized.Errors.notSupported) + } + await handlePayment(link) case .deeplink: await navigationHandler.handle(action) } @@ -231,6 +248,13 @@ extension RootSceneViewModel { ) } + private func handlePayment(_ link: PaymentLink) async { + guard let wallet = currentWallet else { + return + } + await payService.pay(link: link, wallet: wallet) + } + private func handleWalletConnect(_ action: WalletConnectAction) async throws { isPresentingConnectorBar = true switch action { diff --git a/ios/Gem/Views/MainTabView.swift b/ios/Gem/Views/MainTabView.swift index 0e8cd1dd85..e4b91e32fb 100644 --- a/ios/Gem/Views/MainTabView.swift +++ b/ios/Gem/Views/MainTabView.swift @@ -19,6 +19,7 @@ struct MainTabView: View { @Environment(\.navigationState) private var navigationState @Environment(\.navigationPresenter) private var presenter @Environment(\.nftService) private var nftService + @Environment(\.payService) private var payService @Environment(\.priceService) private var priceService @Environment(\.observablePreferences) private var observablePreferences @Environment(\.walletSessionService) private var walletSessionService @@ -47,6 +48,7 @@ struct MainTabView: View { balanceService: balanceService, bannerService: bannerService, walletSessionService: walletSessionService, + payService: payService, nftService: nftService, observablePreferences: observablePreferences, wallet: model.wallet, diff --git a/ios/GemTests/unit_frameworks.xctestplan b/ios/GemTests/unit_frameworks.xctestplan index 078ffca49e..5e365a1476 100644 --- a/ios/GemTests/unit_frameworks.xctestplan +++ b/ios/GemTests/unit_frameworks.xctestplan @@ -175,6 +175,13 @@ "name" : "TransferTests" } }, + { + "target" : { + "containerPath" : "container:Features\/Payments", + "identifier" : "PaymentsTests", + "name" : "PaymentsTests" + } + }, { "target" : { "containerPath" : "container:Packages\/GemAPI", diff --git a/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift b/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift index 3aca22fe4f..815a815716 100644 --- a/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift +++ b/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift @@ -34,7 +34,7 @@ public extension ChainFeeRateFetchable { func defaultPriority(for type: TransferDataType) -> FeePriority { switch type { case let .swap(fromAsset, _, _): fromAsset.chain == .bitcoin ? .fast : .normal - case .tokenApprove, .stake, .transfer, .deposit, .transferNft, .generic, .account, .perpetual, .withdrawal, .earn: .normal + case .tokenApprove, .stake, .transfer, .deposit, .transferNft, .generic, .payment, .account, .perpetual, .withdrawal, .earn: .normal } } } diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 66a228f535..be8ff0900c 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -15,6 +15,8 @@ let package = Package( .library(name: "StakeServiceTestKit", targets: ["StakeServiceTestKit"]), .library(name: "NodeService", targets: ["NodeService"]), .library(name: "NodeServiceTestKit", targets: ["NodeServiceTestKit"]), + .library(name: "SimulationService", targets: ["SimulationService"]), + .library(name: "SimulationServiceTestKit", targets: ["SimulationServiceTestKit"]), .library(name: "WalletConnectorService", targets: ["WalletConnectorService"]), .library(name: "WalletConnectorServiceTestKit", targets: ["WalletConnectorServiceTestKit"]), .library(name: "ScanService", targets: ["ScanService"]), @@ -103,12 +105,31 @@ let package = Package( path: "NodeService/Tests", ), .target( - name: "WalletConnectorService", + name: "SimulationService", dependencies: [ "Primitives", "Gemstone", "GemstonePrimitives", "NativeProviderService", + ], + path: "SimulationService", + exclude: ["TestKit"], + ), + .target( + name: "SimulationServiceTestKit", + dependencies: [ + "SimulationService", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "SimulationService/TestKit", + ), + .target( + name: "WalletConnectorService", + dependencies: [ + "Primitives", + "SimulationService", + "Gemstone", + "NativeProviderService", .product(name: "WalletConnect", package: "reown-swift"), .product(name: "ReownWalletKit", package: "reown-swift"), .product(name: "WalletConnectNetworking", package: "reown-swift"), @@ -120,6 +141,7 @@ let package = Package( name: "WalletConnectorServiceTestKit", dependencies: [ "WalletConnectorService", + "SimulationService", .product(name: "PrimitivesTestKit", package: "Primitives"), ], path: "WalletConnectorService/TestKit", diff --git a/ios/Packages/ChainServices/ScanService/ScanService.swift b/ios/Packages/ChainServices/ScanService/ScanService.swift index 4d0fc8c906..f13b21437a 100644 --- a/ios/Packages/ChainServices/ScanService/ScanService.swift +++ b/ios/Packages/ChainServices/ScanService/ScanService.swift @@ -14,7 +14,7 @@ public struct ScanService: Sendable { let originAssetId = input.inputType.assetIds.first ?? input.inputType.chain.assetId let targetAssetId = input.inputType.assetIds.last ?? originAssetId let website: String? = switch input.inputType { - case let .generic(_, metadata, _): metadata.url + case let .generic(_, app, _): app.url default: nil } let payload = ScanTransactionPayload( diff --git a/ios/Packages/ChainServices/SimulationService/SimulationService.swift b/ios/Packages/ChainServices/SimulationService/SimulationService.swift new file mode 100644 index 0000000000..f82e891a7d --- /dev/null +++ b/ios/Packages/ChainServices/SimulationService/SimulationService.swift @@ -0,0 +1,43 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.Chain +import enum Gemstone.SignableTransactionType +import enum Gemstone.SignDigestType +import struct Gemstone.SignMessage +import class Gemstone.WalletConnectSimulationClient +import GemstonePrimitives +import NativeProviderService +import Primitives + +public protocol SimulationServiceable: Sendable { + func simulateSignMessage(chain: Gemstone.Chain, signType: SignDigestType, data: String, sessionDomain: String) async throws -> SimulationResult + func simulateSendTransaction(chain: Gemstone.Chain, transactionType: SignableTransactionType, data: String) async throws -> SimulationResult +} + +public extension SimulationServiceable { + func simulateSignMessage(message: SignMessage, sessionDomain: String) async throws -> SimulationResult { + try await simulateSignMessage( + chain: message.chain, + signType: message.signType, + data: String(decoding: message.data, as: UTF8.self), + sessionDomain: sessionDomain, + ) + } +} + +public final class SimulationService: SimulationServiceable, Sendable { + private let client: WalletConnectSimulationClient + + public init(nodeProvider: any NodeURLFetchable, requestInterceptor: any RequestInterceptable = EmptyRequestInterceptor()) { + client = WalletConnectSimulationClient(provider: NativeProvider(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor)) + } + + public func simulateSignMessage(chain: Gemstone.Chain, signType: SignDigestType, data: String, sessionDomain: String) async throws -> SimulationResult { + try await client.simulateSignMessage(chain: chain, signType: signType, data: data, sessionDomain: sessionDomain).map() + } + + public func simulateSendTransaction(chain: Gemstone.Chain, transactionType: SignableTransactionType, data: String) async throws -> SimulationResult { + try await client.simulateSendTransaction(chain: chain, transactionType: transactionType, data: data).map() + } +} diff --git a/ios/Packages/ChainServices/SimulationService/TestKit/SimulationServiceableMock.swift b/ios/Packages/ChainServices/SimulationService/TestKit/SimulationServiceableMock.swift new file mode 100644 index 0000000000..8d769f6e96 --- /dev/null +++ b/ios/Packages/ChainServices/SimulationService/TestKit/SimulationServiceableMock.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.Chain +import enum Gemstone.SignableTransactionType +import enum Gemstone.SignDigestType +import Primitives +import SimulationService + +public struct SimulationServiceableMock: SimulationServiceable { + private let result: SimulationResult + + public init(result: SimulationResult = .empty) { + self.result = result + } + + public func simulateSignMessage(chain _: Gemstone.Chain, signType _: SignDigestType, data _: String, sessionDomain _: String) async throws -> SimulationResult { + result + } + + public func simulateSendTransaction(chain _: Gemstone.Chain, transactionType _: SignableTransactionType, data _: String) async throws -> SimulationResult { + result + } +} diff --git a/ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift b/ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift deleted file mode 100644 index 8fdf772993..0000000000 --- a/ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import struct Gemstone.SignMessage -import Primitives -import PrimitivesTestKit -import WalletConnectorService - -public extension SignMessagePayload { - static func mock( - chain: Chain = .ethereum, - session: WalletConnectionSession = .mock(), - wallet: Wallet = .mock(), - message: SignMessage = SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), - simulation: SimulationResult = .mock(), - ) -> SignMessagePayload { - SignMessagePayload( - chain: chain, - session: session, - wallet: wallet, - message: message, - simulation: simulation, - ) - } -} diff --git a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift index e0c78f0604..73c9450ab3 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift @@ -51,11 +51,11 @@ public struct WalletConnectorSignableMock: WalletConnectorSignable { "" } - public func signTransaction(sessionId _: String, chain _: Chain, transaction _: WalletConnectorTransaction, simulation _: SimulationResult) async throws -> String { + public func signTransaction(sessionId _: String, chain _: Chain, transaction _: SignableTransaction, simulation _: SimulationResult) async throws -> String { "" } - public func sendTransaction(sessionId _: String, chain _: Chain, transaction _: WalletConnectorTransaction, simulation _: SimulationResult) async throws -> String { + public func sendTransaction(sessionId _: String, chain _: Chain, transaction _: SignableTransaction, simulation _: SimulationResult) async throws -> String { "" } diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift index e4aa0bb237..1c8934285c 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift @@ -2,6 +2,8 @@ import Foundation import Gemstone +import enum Gemstone.SignableTransaction +import enum Gemstone.SignableTransactionType import enum Gemstone.SignDigestType import struct Gemstone.SignMessage import class Gemstone.WalletConnect @@ -9,12 +11,10 @@ import enum Gemstone.WalletConnectAction import enum Gemstone.WalletConnectChainOperation import enum Gemstone.WalletConnectResponseType import class Gemstone.WalletConnectSimulationClient -import enum Gemstone.WalletConnectTransaction -import enum Gemstone.WalletConnectTransactionType -import GemstonePrimitives import NativeProviderService import Primitives @preconcurrency import ReownWalletKit +import SimulationService @preconcurrency import WalletConnectPairing public final class WalletConnectorService { @@ -22,7 +22,7 @@ public final class WalletConnectorService { private let signer: WalletConnectorSignable private let messageTracker = MessageTracker() private let walletConnect = WalletConnect() - private let simulationClient: WalletConnectSimulationClient + private let simulator: SimulationService public init( signer: WalletConnectorSignable, @@ -30,7 +30,7 @@ public final class WalletConnectorService { requestInterceptor: any RequestInterceptable = EmptyRequestInterceptor(), ) { self.signer = signer - simulationClient = WalletConnectSimulationClient(provider: NativeProvider(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor)) + simulator = SimulationService(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor) } } @@ -98,19 +98,15 @@ extension WalletConnectorService: WalletConnectorServiceable { extension WalletConnectorService { private func simulateSignMessage(chain: Gemstone.Chain, signType: SignDigestType, data: String, sessionDomain: String) async throws -> Primitives.SimulationResult { - try await simulationClient - .simulateSignMessage(chain: chain, signType: signType, data: data, sessionDomain: sessionDomain) - .map() + try await simulator.simulateSignMessage(chain: chain, signType: signType, data: data, sessionDomain: sessionDomain) } private func simulateSendTransaction( chain: Gemstone.Chain, - transactionType: WalletConnectTransactionType, + transactionType: SignableTransactionType, data: String, ) async throws -> Primitives.SimulationResult { - try await simulationClient - .simulateSendTransaction(chain: chain, transactionType: transactionType, data: data) - .map() + try await simulator.simulateSendTransaction(chain: chain, transactionType: transactionType, data: data) } private func handleSessions() async { diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift index 8b8d35a1bb..d8cca29656 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift @@ -21,7 +21,7 @@ public protocol WalletConnectorSignable: Sendable { func getEvents() -> [WalletConnectionEvents] func sessionApproval(payload: WCPairingProposal) async throws -> WalletId func signMessage(sessionId: String, chain: Chain, message: SignMessage, simulation: SimulationResult) async throws -> String - func signTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String - func sendTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String + func signTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String + func sendTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String func sendRawTransaction(sessionId: String, chain: Chain, transaction: String) async throws -> String } diff --git a/ios/Packages/Components/Sources/ListViews/Protocols/SelectableListAdoptable.swift b/ios/Packages/Components/Sources/ListViews/Protocols/SelectableListAdoptable.swift index ee51f27a9c..b81ea47fe2 100644 --- a/ios/Packages/Components/Sources/ListViews/Protocols/SelectableListAdoptable.swift +++ b/ios/Packages/Components/Sources/ListViews/Protocols/SelectableListAdoptable.swift @@ -27,7 +27,7 @@ public extension SelectableListAdoptable { var shouldResetOnToggle: Bool { switch selectionType { case .multiSelection: false - case .navigationLink, .checkmark: true + case .checkmark, .navigationLink: true } } @@ -60,7 +60,7 @@ public extension SelectableListAdoptable { return switch selectionType { case .multiSelection: nil - case .navigationLink, .checkmark: Array(selectedItems) + case .checkmark, .navigationLink: Array(selectedItems) } } } diff --git a/ios/Packages/Components/Sources/ListViews/SearchableSelectableListView.swift b/ios/Packages/Components/Sources/ListViews/SearchableSelectableListView.swift index 8f637addca..f57109723d 100644 --- a/ios/Packages/Components/Sources/ListViews/SearchableSelectableListView.swift +++ b/ios/Packages/Components/Sources/ListViews/SearchableSelectableListView.swift @@ -1,5 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. +import Style import SwiftUI public struct SearchableSelectableListView: View { diff --git a/ios/Packages/Components/Sources/ListViews/SelectableListView.swift b/ios/Packages/Components/Sources/ListViews/SelectableListView.swift index 4df2ddbdf6..ae1e401da9 100644 --- a/ios/Packages/Components/Sources/ListViews/SelectableListView.swift +++ b/ios/Packages/Components/Sources/ListViews/SelectableListView.swift @@ -1,5 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. +import Style import SwiftUI public struct SelectableListView: View { diff --git a/ios/Packages/Components/Sources/Lists/ListItemExpiryView.swift b/ios/Packages/Components/Sources/Lists/ListItemExpiryView.swift new file mode 100644 index 0000000000..06a3b57f59 --- /dev/null +++ b/ios/Packages/Components/Sources/Lists/ListItemExpiryView.swift @@ -0,0 +1,45 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Style +import SwiftUI + +public struct ListItemExpiryView: View { + private let title: String + private let expiresAt: Date + + public init(title: String, expiresAt: Date) { + self.title = title + self.expiresAt = expiresAt + } + + public var body: some View { + HStack { + Text(title) + .textStyle(.body) + Spacer() + Text(timerInterval: countdown, countsDown: true) + .multilineTextAlignment(.trailing) + .monospacedDigit() + .textStyle(.bodySecondary) + } + } +} + +// MARK: - Private + +extension ListItemExpiryView { + private var countdown: ClosedRange { + let now = Date.now + return now ... max(expiresAt, now) + } +} + +// MARK: - Previews + +#Preview { + List { + ListItemExpiryView(title: "Payment expires in", expiresAt: .now.addingTimeInterval(90)) + ListItemExpiryView(title: "Payment expires in", expiresAt: .now.addingTimeInterval(-90)) + } +} diff --git a/ios/Packages/Components/Sources/SelectableSheet.swift b/ios/Packages/Components/Sources/SelectableSheet.swift index ecfbba46f9..67c829c7d1 100644 --- a/ios/Packages/Components/Sources/SelectableSheet.swift +++ b/ios/Packages/Components/Sources/SelectableSheet.swift @@ -83,7 +83,7 @@ public struct SelectableSheet } .bold() } - case .navigationLink, .checkmark: + case .checkmark, .navigationLink: cancelToolbarItem() } } diff --git a/ios/Packages/Components/Sources/WebView/WebView.swift b/ios/Packages/Components/Sources/WebView/WebView.swift new file mode 100644 index 0000000000..7b697ac348 --- /dev/null +++ b/ios/Packages/Components/Sources/WebView/WebView.swift @@ -0,0 +1,98 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import WebKit + +public struct WebView: UIViewRepresentable { + private let url: URL + private let messageHandler: WebViewMessageHandler? + private let allowedHost: String + + public init( + url: URL, + allowedHost: String, + messageHandler: WebViewMessageHandler? = .none, + ) { + self.url = url + self.allowedHost = allowedHost + self.messageHandler = messageHandler + } + + public func makeCoordinator() -> Coordinator { + Coordinator(allowedHost: allowedHost, messageHandler: messageHandler) + } + + public func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + if let messageHandler { + configuration.userContentController.add(context.coordinator, name: messageHandler.name) + } + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.load(URLRequest(url: url)) + return webView + } + + public func updateUIView(_: WKWebView, context _: Context) {} +} + +public struct WebViewMessageHandler { + public let name: String + public let onMessage: ([String: Any]) -> Void + + public init(name: String, onMessage: @escaping ([String: Any]) -> Void) { + self.name = name + self.onMessage = onMessage + } +} + +public extension WebView { + final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + private let allowedHost: String + private let messageHandler: WebViewMessageHandler? + + init(allowedHost: String, messageHandler: WebViewMessageHandler?) { + self.allowedHost = allowedHost + self.messageHandler = messageHandler + } + + public func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = Self.payload(from: message.body) else { + return + } + messageHandler?.onMessage(payload) + } + + public func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url, Self.isWeb(url: url) else { + return decisionHandler(.cancel) + } + guard isAllowed(url: url) else { + UIApplication.shared.open(url) + return decisionHandler(.cancel) + } + decisionHandler(.allow) + } + + private func isAllowed(url: URL) -> Bool { + guard url.scheme == "https", let host = url.host() else { + return false + } + return host == allowedHost || host.hasSuffix(".\(allowedHost)") + } + + private static func isWeb(url: URL) -> Bool { + ["https", "http"].contains(url.scheme ?? "") + } + + private static func payload(from body: Any) -> [String: Any]? { + if let payload = body as? [String: Any] { + return payload + } + guard let text = body as? String, let data = text.data(using: .utf8) else { + return .none + } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index d72199eb82..19c09d6186 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -24,6 +24,8 @@ let package = Package( .library(name: "StreamServiceTestKit", targets: ["StreamServiceTestKit"]), .library(name: "PriceAlertService", targets: ["PriceAlertService"]), .library(name: "PriceAlertServiceTestKit", targets: ["PriceAlertServiceTestKit"]), + .library(name: "PaymentService", targets: ["PaymentService"]), + .library(name: "PaymentServiceTestKit", targets: ["PaymentServiceTestKit"]), .library(name: "TransactionStateService", targets: ["TransactionStateService"]), .library(name: "TransactionStateServiceTestKit", targets: ["TransactionStateServiceTestKit"]), .library(name: "TransactionsService", targets: ["TransactionsService"]), @@ -265,9 +267,30 @@ let package = Package( ], path: "PriceAlertService/TestKit", ), + .target( + name: "PaymentService", + dependencies: [ + "Primitives", + "Store", + .product(name: "GemstonePrimitives", package: "GemstonePrimitives"), + .product(name: "Gemstone", package: "Gemstone"), + .product(name: "NativeProviderService", package: "NativeProviderService"), + ], + path: "PaymentService", + exclude: ["TestKit"], + ), + .target( + name: "PaymentServiceTestKit", + dependencies: [ + "PaymentService", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "PaymentService/TestKit", + ), .target( name: "TransactionStateService", dependencies: [ + "PaymentService", "Primitives", "Store", "Blockchain", @@ -283,6 +306,8 @@ let package = Package( .target( name: "TransactionStateServiceTestKit", dependencies: [ + "PaymentServiceTestKit", + .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "StoreTestKit", package: "Store"), .product(name: "StakeServiceTestKit", package: "ChainServices"), .product(name: "PreferencesTestKit", package: "Preferences"), @@ -723,6 +748,7 @@ let package = Package( .testTarget( name: "TransactionStateServiceTests", dependencies: [ + "PaymentServiceTestKit", "TransactionStateService", "TransactionStateServiceTestKit", "BalanceServiceTestKit", diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift new file mode 100644 index 0000000000..01134c9ebe --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift @@ -0,0 +1,23 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import enum Gemstone.PaymentAction +import GemstonePrimitives +import Primitives + +typealias GemPaymentAction = Gemstone.PaymentAction + +public extension GemPaymentAction { + func map() throws -> PaymentAction { + switch self { + case let .signMessage(message): + try .signMessage(chain: message.chain.map(), message: message) + case let .signTransaction(chain, transaction): + try .signTransaction(chain: chain.map(), transaction: transaction.map()) + case let .sendTransaction(chain, transaction): + try .sendTransaction(chain: chain.map(), transaction: transaction.map()) + case let .approveToken(chain, approval): + try .approveToken(chain: chain.map(), approval: approval.map()) + } + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift new file mode 100644 index 0000000000..f15d5e8049 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import Primitives + +public enum PaymentAction: Sendable { + case signMessage(chain: Chain, message: SignMessage) + case signTransaction(chain: Chain, transaction: SignableTransaction) + case sendTransaction(chain: Chain, transaction: SignableTransaction) + case approveToken(chain: Chain, approval: ApprovalData) +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvidable.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvidable.swift new file mode 100644 index 0000000000..a817d8c96b --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvidable.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentAssetsProvidable: Sendable { + func assetsData(walletId: WalletId, assetIds: [AssetId]) throws -> [AssetData] +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvider.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvider.swift new file mode 100644 index 0000000000..4b75a2f276 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvider.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import Store + +public struct PaymentAssetsProvider: PaymentAssetsProvidable { + private let assetStore: AssetStore + + public init(assetStore: AssetStore) { + self.assetStore = assetStore + } + + public func assetsData(walletId: WalletId, assetIds: [AssetId]) throws -> [AssetData] { + try assetStore.getAssetsData(walletId: walletId, filters: [.chainsOrAssets([], assetIds.map(\.identifier))]) + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentDataCollectionRequest.swift b/ios/Packages/FeatureServices/PaymentService/PaymentDataCollectionRequest.swift new file mode 100644 index 0000000000..558fc88085 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentDataCollectionRequest.swift @@ -0,0 +1,13 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PaymentDataCollectionRequest: Identifiable, Sendable { + public let id: String + public let url: URL + + public init(id: String, url: URL) { + self.id = id + self.url = url + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentQuotesRequest.swift b/ios/Packages/FeatureServices/PaymentService/PaymentQuotesRequest.swift new file mode 100644 index 0000000000..486bf9b343 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentQuotesRequest.swift @@ -0,0 +1,22 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct PaymentQuotesRequest: Identifiable, Sendable { + public let id: String + public let quotes: PaymentQuotes + public let wallet: Wallet + public let assetsData: [AssetData] + + public init(id: String, quotes: PaymentQuotes, wallet: Wallet, assetsData: [AssetData]) { + self.id = id + self.quotes = quotes + self.wallet = wallet + self.assetsData = assetsData + } + + public func assetData(for quote: PaymentQuote) -> AssetData? { + assetsData.first { $0.asset.id == quote.amount.assetId } + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift new file mode 100644 index 0000000000..0d9b18d82a --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import GemstonePrimitives +import NativeProviderService +import Primitives + +public protocol PaymentServiceable: PaymentStatusServiceable { + func getPaymentOptions(link: PaymentLink, wallet: Wallet) async throws -> PaymentOptions + func getPreparedPayment(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PreparedPayment + func confirmPayment(provider: PaymentProviderName, quote: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome +} + +public final class PaymentService: PaymentServiceable { + private let service: Gemstone.GemPaymentService + + public init(provider: NativeProvider, appId: String, clientId: String) { + service = Gemstone.GemPaymentService( + provider: provider, + config: GemPaymentConfig(walletConnectPay: GemWalletConnectPayAuth(appId: appId, clientId: clientId)), + ) + } + + public func getPaymentOptions(link: PaymentLink, wallet: Wallet) async throws -> PaymentOptions { + try await service.getPaymentOptions(link: link.map(), addresses: wallet.chainAddresses.map { $0.map() }).map() + } + + public func getPreparedPayment(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PreparedPayment { + let payment = try await service.getPreparedPayment( + provider: provider.map(), + quotes: quotes.map(), + quote: quote.map(), + addresses: wallet.chainAddresses.map { $0.map() }, + ) + return try PreparedPayment( + quotes: payment.quotes.map(), + quote: payment.quote.map(), + actions: payment.actions.map { try $0.map() }, + isRelayed: payment.isRelayed, + ) + } + + public func confirmPayment(provider: PaymentProviderName, quote: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome { + try await service.confirmPayment(provider: provider.map(), quote: quote.map(), actionResults: actionResults).map() + } + + public func hasStatus(provider: PaymentProviderName) -> Bool { + Gemstone.paymentProviderHasStatus(provider: provider.map()) + } + + public func getPaymentStatus(provider: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome { + try await service.getPaymentStatus(provider: provider.map(), paymentId: paymentId).map() + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift b/ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift new file mode 100644 index 0000000000..d5b516c01f --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentStatusServiceable: Sendable { + func hasStatus(provider: PaymentProviderName) -> Bool + func getPaymentStatus(provider: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome +} diff --git a/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift b/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift new file mode 100644 index 0000000000..5117786a48 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift @@ -0,0 +1,18 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct PreparedPayment: Sendable { + public let quotes: PaymentQuotes + public let quote: PaymentQuote + public let actions: [PaymentAction] + public let isRelayed: Bool + + public init(quotes: PaymentQuotes, quote: PaymentQuote, actions: [PaymentAction], isRelayed: Bool) { + self.quotes = quotes + self.quote = quote + self.actions = actions + self.isRelayed = isRelayed + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAction+TestKit.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAction+TestKit.swift new file mode 100644 index 0000000000..2c002a1e1c --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAction+TestKit.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import PaymentService +import Primitives + +public extension PaymentAction { + static func mockSignMessage(chain: Chain = .ethereum, data: Data = Data("pay".utf8)) -> PaymentAction { + .signMessage(chain: chain, message: SignMessage(chain: chain.rawValue, signType: .eip712, data: data)) + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift new file mode 100644 index 0000000000..ef1dea8421 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives + +public struct PaymentAssetsProvidableMock: PaymentAssetsProvidable { + private let assetsData: [AssetData] + + public init(assetsData: [AssetData] = []) { + self.assetsData = assetsData + } + + public func assetsData(walletId _: WalletId, assetIds: [AssetId]) throws -> [AssetData] { + assetsData.filter { assetIds.contains($0.asset.id) } + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift new file mode 100644 index 0000000000..3a9847b6f5 --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift @@ -0,0 +1,67 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import PrimitivesTestKit + +public actor PaymentServiceableMock: PaymentServiceable { + private var options: [PaymentOptions] + private let actions: [PaymentAction] + private let isRelayed: Bool + private let confirmOutcome: PaymentOutcome + private let confirmError: (any Error)? + private let statusOutcome: PaymentOutcome + + public private(set) var confirmedResults: [[String]] = [] + public private(set) var requestedQuotes: [PaymentQuote] = [] + + public init( + options: [PaymentOptions], + actions: [PaymentAction] = [], + isRelayed: Bool = true, + confirmOutcome: PaymentOutcome = .mock(), + confirmError: (any Error)? = .none, + statusOutcome: PaymentOutcome = .mock(), + ) { + self.options = options + self.actions = actions + self.isRelayed = isRelayed + self.confirmOutcome = confirmOutcome + self.confirmError = confirmError + self.statusOutcome = statusOutcome + } + + public func getPaymentOptions(link _: PaymentLink, wallet _: Wallet) async throws -> PaymentOptions { + guard !options.isEmpty else { + throw AnyError("Unexpected payment options request") + } + return options.removeFirst() + } + + public func getPreparedPayment(provider _: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet _: Wallet) async throws -> PreparedPayment { + requestedQuotes.append(quote) + return PreparedPayment( + quotes: quotes, + quote: quote, + actions: actions, + isRelayed: isRelayed, + ) + } + + public func confirmPayment(provider _: PaymentProviderName, quote _: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome { + confirmedResults.append(actionResults) + if let confirmError { + throw confirmError + } + return confirmOutcome + } + + public nonisolated func hasStatus(provider _: PaymentProviderName) -> Bool { + true + } + + public func getPaymentStatus(provider _: PaymentProviderName, paymentId _: String) async throws -> PaymentOutcome { + statusOutcome + } +} diff --git a/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift new file mode 100644 index 0000000000..4a81bc482f --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import PrimitivesTestKit + +public actor PaymentStatusServiceableMock: PaymentStatusServiceable { + private let result: PaymentOutcome + private let providerHasStatus: Bool + private var requestedPaymentIds: [String] = [] + + public init(result: PaymentOutcome = .mock(), providerHasStatus: Bool = true) { + self.result = result + self.providerHasStatus = providerHasStatus + } + + public nonisolated func hasStatus(provider _: PaymentProviderName) -> Bool { + providerHasStatus + } + + public func getPaymentStatus(provider _: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome { + requestedPaymentIds.append(paymentId) + return result + } + + public func paymentIds() -> [String] { + requestedPaymentIds + } +} diff --git a/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift b/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift index faff54a3a1..f4dc50739d 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift @@ -8,6 +8,8 @@ import Foundation import NativeProviderService import NFTService import NFTServiceTestKit +import PaymentService +import PaymentServiceTestKit import Primitives import StakeService import StakeServiceTestKit @@ -22,6 +24,7 @@ public extension TransactionStateScheduler { stakeService: StakeService = .mock(), earnService: EarnService = .mock(), nftService: NFTService = .mock(), + paymentStatusService: any PaymentStatusServiceable = PaymentStatusServiceableMock(), ) -> TransactionStateScheduler { let postProcessingService = TransactionPostProcessingService( transactionStore: transactionStore, @@ -34,6 +37,7 @@ public extension TransactionStateScheduler { transactionStore: transactionStore, gatewayService: gatewayService, postProcessingService: postProcessingService, + paymentStatusService: paymentStatusService, ) return TransactionStateScheduler( transactionStore: transactionStore, diff --git a/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift b/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift index 9226e06eb0..0f9dceab73 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift @@ -5,11 +5,13 @@ import EarnService import Foundation import GemAPITestKit import NFTServiceTestKit +import PaymentService import Primitives import PrimitivesTestKit import StakeServiceTestKit import Store import StoreTestKit +import PaymentServiceTestKit import Testing @testable import TransactionStateService import TransactionStateServiceTestKit @@ -215,6 +217,54 @@ struct TransactionStateServiceTests { #expect(swapRequests.map(\.state) == [TransactionState.pending, .inTransit]) } + @Test + func paymentSettlesWithGatewayTransactionHash() async throws { + let statusService = TransactionStatusServiceMock(stateChanges: TransactionChanges(state: .confirmed)) + let paymentStatusService = PaymentStatusServiceableMock(result: .mock(status: .succeeded, transactionId: "0xsettled")) + let fixture = try makeFixture( + statusService: statusService, + paymentStatusService: paymentStatusService, + transaction: makePaymentTransaction(paymentId: "pay_1"), + ) + + let status = await fixture.service.update(for: fixture.transaction).status + + expectComplete(status) + #expect(await paymentStatusService.paymentIds() == ["pay_1"]) + #expect(await statusService.regularRequestCount() == 0) + let saved = try #require(fixture.store.getTransactions(states: [.confirmed]).first) + #expect(saved.id.hash == "0xsettled") + } + + @Test + func paymentKeepsPendingWhileGatewayProcesses() async throws { + let fixture = try makeFixture( + statusService: TransactionStatusServiceMock(stateChanges: TransactionChanges(state: .confirmed)), + paymentStatusService: PaymentStatusServiceableMock(result: .mock(status: .processing)), + transaction: makePaymentTransaction(paymentId: "pay_1"), + ) + + let status = await fixture.service.update(for: fixture.transaction).status + + expectRetry(status) + let saved = try #require(fixture.store.getTransactions(states: [.pending]).first) + #expect(saved.id.hash == "pay_1") + } + + @Test + func paymentFailsWhenGatewayExpiresIt() async throws { + let fixture = try makeFixture( + statusService: TransactionStatusServiceMock(stateChanges: TransactionChanges(state: .confirmed)), + paymentStatusService: PaymentStatusServiceableMock(result: .mock(status: .expired)), + transaction: makePaymentTransaction(paymentId: "pay_1"), + ) + + let status = await fixture.service.update(for: fixture.transaction).status + + expectComplete(status) + #expect(try fixture.store.getTransactions(states: [.failed]).count == 1) + } + @Test func postProcessingRefreshesSwapBalances() async throws { let fromAsset = AssetId.mock(.bitcoin) @@ -297,6 +347,8 @@ private extension TransactionStateServiceTests { state: TransactionState = .pending, provider: SwapProvider? = .thorchain, statusService: any TransactionStatusServiceable, + paymentStatusService: any PaymentStatusServiceable = PaymentStatusServiceableMock(), + transaction: Transaction? = .none, ) throws -> Fixture { let fromAsset = AssetId.mock(.bitcoin) let toAsset = AssetId.mock(.ethereum) @@ -307,7 +359,7 @@ private extension TransactionStateServiceTests { let store = TransactionStore.mock(db: db) let wallet = Wallet.mock() let walletId = wallet.id - let transaction = try makeSwapTransaction( + let transaction = try transaction ?? makeSwapTransaction( fromAsset: fromAsset, toAsset: toAsset, state: state, @@ -326,6 +378,7 @@ private extension TransactionStateServiceTests { transactionStore: store, postProcessingService: postProcessingService, statusService: statusService, + paymentStatusService: paymentStatusService, ) return Fixture(store: store, walletId: walletId, wallet: wallet, transaction: transaction, service: service) } @@ -366,6 +419,31 @@ private extension TransactionStateServiceTests { ) } + func makePaymentTransaction(paymentId: String) -> Transaction { + let assetId = AssetId.mock(.ethereum) + let metadata = AnyCodableValue.encode(TransactionPaymentMetadata(paymentId: paymentId, merchant: .mock(), provider: .walletConnectPay)) + return Transaction( + id: TransactionId(chain: assetId.chain, hash: paymentId), + assetId: assetId, + from: "sender", + to: .empty, + contract: nil, + type: .transfer, + state: .pending, + blockNumber: nil, + sequence: nil, + fee: .zero, + feeAssetId: assetId.chain.assetId, + value: "10000", + memo: nil, + direction: .outgoing, + utxoInputs: [], + utxoOutputs: [], + metadata: metadata, + createdAt: Date(timeIntervalSince1970: 1234), + ) + } + func fetchNFTs(db: DB, walletId: WalletId) throws -> [NFTData] { try db.dbQueue.read { database in try NFTRequest(walletId: walletId, filter: .all).fetch(database) diff --git a/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift b/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift index 979b1ea9f3..beb38471c0 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift @@ -2,6 +2,7 @@ import Blockchain import Foundation +import PaymentService import Primitives import Store @@ -26,16 +27,19 @@ public struct TransactionStateService: Sendable { private let transactionStore: TransactionStore private let postProcessingService: TransactionPostProcessingService private let statusService: any TransactionStatusServiceable + private let paymentStatusService: any PaymentStatusServiceable public init( transactionStore: TransactionStore, gatewayService: GatewayService, postProcessingService: TransactionPostProcessingService, + paymentStatusService: any PaymentStatusServiceable, ) { self.init( transactionStore: transactionStore, postProcessingService: postProcessingService, statusService: gatewayService, + paymentStatusService: paymentStatusService, ) } @@ -43,10 +47,12 @@ public struct TransactionStateService: Sendable { transactionStore: TransactionStore, postProcessingService: TransactionPostProcessingService, statusService: any TransactionStatusServiceable, + paymentStatusService: any PaymentStatusServiceable, ) { self.transactionStore = transactionStore self.postProcessingService = postProcessingService self.statusService = statusService + self.paymentStatusService = paymentStatusService } func update(for transaction: Transaction) async -> TransactionStateUpdateResult { @@ -77,6 +83,9 @@ public struct TransactionStateService: Sendable { extension TransactionStateService { private func fetchStateChanges(for transaction: Transaction) async throws -> TransactionChanges { + if let payment = transaction.paymentMetadata, paymentStatusService.hasStatus(provider: payment.provider) { + return try await paymentStateChanges(payment: payment, transaction: transaction) + } let request = transactionStateRequest(for: transaction) if let swapRequest = transactionSwapStateRequest(for: transaction, transactionRequest: request) { return try await statusService.transactionSwapStatus( @@ -93,6 +102,21 @@ extension TransactionStateService { ) } + private func paymentStateChanges(payment: TransactionPaymentMetadata, transaction: Transaction) async throws -> TransactionChanges { + let result = try await paymentStatusService.getPaymentStatus(provider: payment.provider, paymentId: payment.paymentId) + switch result.status { + case .succeeded: + guard let hash = result.transactionId, transaction.isAwaitingPaymentHash else { + return TransactionChanges(state: .confirmed) + } + return TransactionChanges(state: .confirmed, changes: [.hashChange(old: transaction.id.hash, new: hash)]) + case .failed, .expired, .cancelled: + return TransactionChanges(state: .failed) + case .processing, .requiresAction: + return TransactionChanges(state: transaction.state) + } + } + private func transactionStateRequest(for transaction: Transaction) -> TransactionStateRequest { TransactionStateRequest( id: transaction.id.hash, diff --git a/ios/Packages/Gemstone/Package.swift b/ios/Packages/Gemstone/Package.swift index 8c8cae6cc1..266c418fc1 100644 --- a/ios/Packages/Gemstone/Package.swift +++ b/ios/Packages/Gemstone/Package.swift @@ -14,11 +14,17 @@ let package = Package( ) ], dependencies: [ + .package(name: "Primitives", path: "../Primitives"), + .package(name: "BigInt", path: "../../Submodules/BigInt"), ], targets: [ .target( name: "Gemstone", - dependencies: ["GemstoneFFI"], + dependencies: [ + "GemstoneFFI", + "Primitives", + .product(name: "BigInt", package: "BigInt"), + ], swiftSettings: [ .swiftLanguageMode(.v5) // TODO: - remove when GemstoneFFI will support swift6 fully ] diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift new file mode 100644 index 0000000000..7d631deaf8 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension Primitives.ChainAddress { + func map() -> Gemstone.ChainAddress { + Gemstone.ChainAddress(chain: chain.rawValue, address: address) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift new file mode 100644 index 0000000000..85dd8c1f3e --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemPayment { + func map() throws -> Payment { + switch self { + case let .request(request): try .request(request.map()) + case let .link(link): .link(link.map()) + } + } +} + +public extension GemPaymentRequest { + func map() throws -> PaymentRequest { + try PaymentRequest( + address: address, + amount: amount, + memo: memo, + assetId: assetId.map { try AssetId(id: $0) }, + ) + } +} + +public extension GemPaymentLink { + func map() -> PaymentLink { + PaymentLink(provider: provider.map(), id: id) + } +} + +public extension PaymentLink { + func map() -> GemPaymentLink { + GemPaymentLink(provider: provider.map(), id: id) + } +} + +public extension PaymentProviderName { + func map() -> GemPaymentProviderName { + switch self { + case .solanaPay: .solanaPay + case .walletConnectPay: .walletConnectPay + } + } +} + +public extension GemPaymentProviderName { + func map() -> PaymentProviderName { + switch self { + case .solanaPay: .solanaPay + case .walletConnectPay: .walletConnectPay + } + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift new file mode 100644 index 0000000000..74bb55626b --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift @@ -0,0 +1,117 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemPaymentOptions { + func map() throws -> PaymentOptions { + switch self { + case let .quotes(quotes): try .quotes(quotes.map()) + case let .outcome(outcome): .outcome(outcome.map()) + } + } +} + +public extension PaymentQuotes { + func map() -> GemPaymentQuotes { + GemPaymentQuotes( + merchant: merchant.map(), + price: price?.map(), + expiresAt: expiresAt.map { Int64($0.timeIntervalSince1970) }, + quotes: quotes.map { $0.map() }, + ) + } +} + +public extension PaymentMerchant { + func map() -> GemPaymentMerchant { + GemPaymentMerchant(name: name, iconUrl: iconUrl) + } +} + +public extension PaymentPrice { + func map() -> GemPaymentPrice { + GemPaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} + +public extension GemPaymentQuotes { + func map() throws -> PaymentQuotes { + try PaymentQuotes( + merchant: merchant.map(), + price: price?.map(), + expiresAt: expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + quotes: quotes.map { try $0.map() }, + ) + } +} + +public extension GemPaymentQuote { + func map() throws -> PaymentQuote { + try PaymentQuote( + id: id, + paymentId: paymentId, + amount: amount.map(), + expiresAt: expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + collectDataUrl: collectDataUrl, + providerData: providerData, + ) + } +} + +public extension PaymentQuote { + func map() -> GemPaymentQuote { + GemPaymentQuote( + id: id, + paymentId: paymentId, + amount: amount.map(), + expiresAt: expiresAt.map { Int64($0.timeIntervalSince1970) }, + collectDataUrl: collectDataUrl, + providerData: providerData, + ) + } +} + +public extension PaymentAmount { + func map() -> GemPaymentAmount { + GemPaymentAmount(assetId: assetId.identifier, value: value, symbol: symbol, decimals: decimals) + } +} + +public extension GemPaymentAmount { + func map() throws -> PaymentAmount { + try PaymentAmount(assetId: AssetId(id: assetId), value: value, symbol: symbol, decimals: decimals) + } +} + +public extension GemPaymentMerchant { + func map() -> PaymentMerchant { + PaymentMerchant(name: name, iconUrl: iconUrl) + } +} + +public extension GemPaymentOutcome { + func map() -> PaymentOutcome { + PaymentOutcome(status: status.map(), transactionId: transactionId) + } +} + +public extension GemPaymentStatus { + func map() -> PaymentStatus { + switch self { + case .requiresAction: .requiresAction + case .processing: .processing + case .succeeded: .succeeded + case .failed: .failed + case .expired: .expired + case .cancelled: .cancelled + } + } +} + +public extension GemPaymentPrice { + func map() -> PaymentPrice { + PaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift index c5ee1a0be0..d4990bcf4b 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift @@ -38,8 +38,8 @@ public extension GemTransactionInputType { try TransferDataType.stake(asset.map(), type.map()) case let .tokenApprove(asset, approvalData): try TransferDataType.tokenApprove(asset.map(), approvalData.map()) - case let .generic(asset, metadata, extra): - try TransferDataType.generic(asset: asset.map(), metadata: metadata.map(), extra: extra.map()) + case let .generic(asset, appMetadata, extra): + try TransferDataType.generic(asset: asset.map(), appMetadata: appMetadata.map(), extra: extra.map()) case let .account(asset, accountType): try TransferDataType.account(asset.map(), accountType.map()) case let .perpetual(asset: asset, perpetualType: perpetualType): @@ -67,8 +67,10 @@ public extension TransferDataType { return .stake(asset: asset.map(), stakeType: stakeType.map()) case let .tokenApprove(asset, approvalData): return .tokenApprove(asset: asset.map(), approvalData: approvalData.map()) - case let .generic(asset, metadata, extra): - return .generic(asset: asset.map(), metadata: metadata.map(), extra: extra.map()) + case let .generic(asset, appMetadata, extra): + return .generic(asset: asset.map(), appMetadata: appMetadata.map(), extra: extra.map()) + case let .payment(asset, payment, extra): + return .generic(asset: asset.map(), appMetadata: TransactionAppMetadata(merchant: payment.merchant).map(), extra: extra.map()) case let .withdrawal(asset): if asset.chain == .hyperCore { return .withdrawal(asset: asset.map()) diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift index 0e9e2aa71b..d7efb8bb1f 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift @@ -26,6 +26,6 @@ public extension WalletConnectionSessionAppMetadata { } var shortName: String { - Gemstone.walletConnectAppShortName(metadata: map()) + Gemstone.walletConnectAppShortName(name: name) } } diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift new file mode 100644 index 0000000000..2645426937 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift @@ -0,0 +1,53 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension Gemstone.SignMessage { + func map() -> Primitives.SignMessage { + Primitives.SignMessage( + chain: chain, + signType: signType.map(), + data: data, + ) + } +} + +public extension Primitives.SignMessage { + func map() -> Gemstone.SignMessage { + Gemstone.SignMessage( + chain: chain, + signType: signType.map(), + data: data, + ) + } +} + +public extension Gemstone.SignDigestType { + func map() -> Primitives.SignDigestType { + switch self { + case .eip191: .eip191 + case .eip712: .eip712 + case .base58: .base58 + case .suiPersonal: .suiPersonal + case .siwe: .siwe + case .tonPersonal: .tonPersonal + case .tronPersonal: .tronPersonal + } + } +} + +public extension Primitives.SignDigestType { + func map() -> Gemstone.SignDigestType { + switch self { + case .eip191: .eip191 + case .eip712: .eip712 + case .base58: .base58 + case .suiPersonal: .suiPersonal + case .siwe: .siwe + case .tonPersonal: .tonPersonal + case .tronPersonal: .tronPersonal + } + } +} diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift similarity index 69% rename from ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift rename to ios/Packages/GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift index be9773e158..b155be9d09 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift @@ -1,14 +1,11 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import enum Gemstone.WalletConnectTransaction -import struct Gemstone.WcEthereumTransactionData -import struct Gemstone.WcSolanaTransactionData -import struct Gemstone.WcSuiTransactionData +import Gemstone import Primitives -extension WalletConnectTransaction { - func map() -> WalletConnectorTransaction { +public extension Gemstone.SignableTransaction { + func map() -> Primitives.SignableTransaction { switch self { case let .ethereum(data, transactionType): .ethereum(data.map(), transactionType.map()) case let .solana(data, outputType): .solana(data.transaction, outputType.map()) @@ -19,9 +16,9 @@ extension WalletConnectTransaction { } } -extension WcEthereumTransactionData { - func map() -> WCEthereumTransaction { - WCEthereumTransaction( +extension Gemstone.EthereumTransactionData { + func map() -> Primitives.EthereumTransactionData { + Primitives.EthereumTransactionData( chainId: chainId, from: from, to: to, diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift new file mode 100644 index 0000000000..be786334c6 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemTransactionAppMetadata { + func map() -> TransactionAppMetadata { + TransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } +} + +public extension TransactionAppMetadata { + func map() -> GemTransactionAppMetadata { + GemTransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } + + var shortName: String { + Gemstone.walletConnectAppShortName(name: name) + } +} + +public extension TransactionAppMetadata { + init(merchant: PaymentMerchant) { + self.init(name: merchant.name, description: .none, url: Gemstone.paymentWalletConnectUrl(), icon: merchant.iconUrl) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift index 1f8bb4dbf5..325fa70774 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift @@ -15,7 +15,8 @@ public extension TransferDataType { let .perpetual(asset, _), let .earn(asset, _, _), let .tokenApprove(asset, _), - let .generic(asset, _, _): + let .generic(asset, _, _), + let .payment(asset, _, _): asset case let .transferNft(asset): asset.chain.asset diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift index 66d181418c..4552ebf395 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift @@ -7,6 +7,7 @@ public extension Gemstone.UrlAction { func map() throws -> Primitives.URLAction { switch self { case let .deeplink(deeplink): try .deeplink(deeplink.map()) + case let .payment(link): .payment(link.map()) case let .walletConnect(link): .walletConnect(link.map()) } } diff --git a/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift b/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift index c7411f8139..dcc007755f 100644 --- a/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift +++ b/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift @@ -2,10 +2,10 @@ import Foundation import func Gemstone.paymentDecodeUrl -import struct Gemstone.PaymentWrapper +import Primitives public enum PaymentURLDecoder { - public static func decode(_ string: String) throws -> PaymentWrapper { - try paymentDecodeUrl(string: string) + public static func decode(_ string: String) throws -> Payment { + try paymentDecodeUrl(string: string).map() } } diff --git a/ios/Packages/GemstonePrimitives/Sources/URLParser.swift b/ios/Packages/GemstonePrimitives/Sources/URLParser.swift index 8fe4e73261..890ecb6267 100644 --- a/ios/Packages/GemstonePrimitives/Sources/URLParser.swift +++ b/ios/Packages/GemstonePrimitives/Sources/URLParser.swift @@ -6,13 +6,17 @@ import func Gemstone.urlAction import Primitives enum URLParserError: Error { - case invalidURL(URL) + case unsupported(String) } public enum URLParser { public static func from(url: URL) throws -> URLAction { - guard let action = urlAction(url: url.absoluteString) else { - throw URLParserError.invalidURL(url) + try from(string: url.absoluteString) + } + + public static func from(string: String) throws -> URLAction { + guard let action = urlAction(url: string) else { + throw URLParserError.unsupported(string) } return try action.map() } diff --git a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift new file mode 100644 index 0000000000..aff1897889 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift @@ -0,0 +1,37 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +@testable import GemstonePrimitives +import Primitives +import Testing + +final class GemPaymentQuoteTests { + private let expiresAtSeconds: Int64 = 1_700_000_000 + + private func gemQuote(assetId: String = "ethereum_0xtoken") -> GemPaymentQuote { + GemPaymentQuote( + id: "option_1", + paymentId: "pay_1", + amount: GemPaymentAmount(assetId: assetId, value: "10", symbol: "USDT", decimals: 6), + expiresAt: expiresAtSeconds, + collectDataUrl: .none, + providerData: #"{"opaque":true}"#, + ) + } + + @Test + func gatewaySecondsBecomeADate() throws { + let quote = try gemQuote().map() + + #expect(quote.expiresAt == Date(timeIntervalSince1970: TimeInterval(expiresAtSeconds))) + #expect(quote.amount.assetId.chain == .ethereum) + #expect(quote.amount.assetId.tokenId == "0xtoken") + } + + @Test + func quoteReturnsToTheGatewayUnchanged() throws { + #expect(try gemQuote().map().map() == gemQuote()) + #expect(try gemQuote(assetId: "ethereum").map().map() == gemQuote(assetId: "ethereum")) + } +} diff --git a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift index 1971ad2231..5e7bf9bf54 100644 --- a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift +++ b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift @@ -1,40 +1,37 @@ // Copyright (c). Gem Wallet. All rights reserved. -import struct Gemstone.PaymentWrapper @testable import GemstonePrimitives +import Primitives +import PrimitivesTestKit import Testing final class PaymentURLDecoderTests { @Test func testAddress() throws { let result = try PaymentURLDecoder.decode("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326") - #expect(result == PaymentWrapper( - address: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326", - amount: .none, - memo: .none, - assetId: .none, - paymentLink: .none, - )) + #expect(result == .request(.mock(address: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"))) } @Test func solana() throws { let result1 = try PaymentURLDecoder.decode("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5") - #expect(result1 == PaymentWrapper( - address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5", - amount: .none, - memo: .none, - assetId: .none, - paymentLink: .none, - )) + #expect(result1 == .request(.mock(address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5"))) let result2 = try PaymentURLDecoder.decode("solana:HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5?amount=0.266232") - #expect(result2 == PaymentWrapper( + #expect(result2 == .request(.mock( address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5", amount: "0.266232", - memo: .none, - assetId: "solana", - paymentLink: .none, - )) + assetId: .mockSolana(), + ))) + } + + @Test + func links() throws { + let solanaPay = "https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1" + let result1 = try PaymentURLDecoder.decode("solana:https%3A%2F%2Fapi.spherepay.co%2Fv1%2Fpublic%2FpaymentLink%2Fpay%2FpaymentLink_1") + #expect(result1 == .link(PaymentLink(provider: .solanaPay, id: solanaPay))) + + let result2 = try PaymentURLDecoder.decode("https://pay.walletconnect.com/?pid=pay_123") + #expect(result2 == .link(PaymentLink(provider: .walletConnectPay, id: "pay_123"))) } } diff --git a/ios/Packages/Localization/Sources/Localized.swift b/ios/Packages/Localization/Sources/Localized.swift index d60882a133..d83b842345 100644 --- a/ios/Packages/Localization/Sources/Localized.swift +++ b/ios/Packages/Localization/Sources/Localized.swift @@ -441,6 +441,10 @@ public enum Localized { public static let notSupported = Localized.tr("Localizable", "errors.not_supported", fallback: "Not Supported") /// This device does not support QR code scanning. You can only select QR code image from library. public static let notSupportedQr = Localized.tr("Localizable", "errors.not_supported_qr", fallback: "This device does not support QR code scanning. You can only select QR code image from library.") + /// Payment Expired + public static let paymentExpired = Localized.tr("Localizable", "errors.payment_expired", fallback: "Payment Expired") + /// Payment not allowed + public static let paymentNotAllowed = Localized.tr("Localizable", "errors.payment_not_allowed", fallback: "Payment not allowed") /// Permissions Not Granted public static let permissionsNotGranted = Localized.tr("Localizable", "errors.permissions_not_granted", fallback: "Permissions Not Granted") /// %@ is required @@ -1476,6 +1480,8 @@ public enum Localized { } /// Memo public static let memo = Localized.tr("Localizable", "transfer.memo", fallback: "Memo") + /// Merchant + public static let merchant = Localized.tr("Localizable", "transfer.merchant", fallback: "Merchant") /// A minimum %@ balance must remain after this, unless you're using your full balance. public static func minimumAccountBalance(_ p1: Any) -> String { return Localized.tr("Localizable", "transfer.minimum_account_balance", String(describing: p1), fallback: "A minimum %@ balance must remain after this, unless you're using your full balance.") @@ -1488,6 +1494,12 @@ public enum Localized { public static let network = Localized.tr("Localizable", "transfer.network", fallback: "Network") /// Network Fee public static let networkFee = Localized.tr("Localizable", "transfer.network_fee", fallback: "Network Fee") + /// Pay with + public static let payWith = Localized.tr("Localizable", "transfer.pay_with", fallback: "Pay with") + /// Payment expires in + public static let paymentExpiresIn = Localized.tr("Localizable", "transfer.payment_expires_in", fallback: "Payment expires in") + /// Payment + public static let paymentTitle = Localized.tr("Localizable", "transfer.payment_title", fallback: "Payment") /// We've left %@ in your balance to cover future network fees. public static func reservedFees(_ p1: Any) -> String { return Localized.tr("Localizable", "transfer.reserved_fees", String(describing: p1), fallback: "We've left %@ in your balance to cover future network fees.") diff --git a/ios/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings index f72a041193..4ea1a04ad8 100644 --- a/ios/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "الأذونات"; "wallet_connect.permissions.view_balance" = "اطلع على رصيدك ونشاطك"; "wallet_connect.permissions.approval_requests" = "إرسال طلبات الموافقة"; +"errors.payment_expired" = "انتهت صلاحية الدفعة"; +"errors.payment_not_allowed" = "الدفع غير مسموح به"; +"transfer.payment_expires_in" = "تنتهي صلاحية الدفعة خلال"; +"transfer.pay_with" = "ادفع بواسطة"; +"transfer.payment_title" = "الدفع"; +"transfer.merchant" = "التاجر"; "swap.you_pay" = "انت تدفع"; "swap.you_receive" = "انت تستقبل"; "swap.price_impact" = "تأثير السعر"; diff --git a/ios/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings index 477a2a8adc..9f0c3a0c50 100644 --- a/ios/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "অনুমতি"; "wallet_connect.permissions.view_balance" = "আপনার ব্যালেন্স এবং কার্যকলাপ দেখুন"; "wallet_connect.permissions.approval_requests" = "অনুমোদনের অনুরোধ পাঠান"; +"errors.payment_expired" = "পেমেন্টের মেয়াদ শেষ"; +"errors.payment_not_allowed" = "পেমেন্ট অনুমোদিত নয়"; +"transfer.payment_expires_in" = "পেমেন্টের মেয়াদ শেষ হবে"; +"transfer.pay_with" = "যা দিয়ে পরিশোধ করবেন"; +"transfer.payment_title" = "পেমেন্ট"; +"transfer.merchant" = "বিক্রেতা"; "swap.you_pay" = "আপনি পরিশোধ করেন"; "swap.you_receive" = "আপনি পাবেন"; "swap.price_impact" = "দামের প্রভাব"; diff --git a/ios/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings index 8418eacf38..38d7bfe42c 100644 --- a/ios/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Oprávnění"; "wallet_connect.permissions.view_balance" = "Zobrazení zůstatku a aktivity"; "wallet_connect.permissions.approval_requests" = "Odeslat žádosti o schválení"; +"errors.payment_expired" = "Platba vypršela"; +"errors.payment_not_allowed" = "Platba není povolena"; +"transfer.payment_expires_in" = "Platba vyprší za"; +"transfer.pay_with" = "Zaplatit pomocí"; +"transfer.payment_title" = "Platba"; +"transfer.merchant" = "Obchodník"; "swap.you_pay" = "Vy platíte"; "swap.you_receive" = "Přijímáte"; "swap.price_impact" = "Vliv ceny"; diff --git a/ios/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings index 510198b419..f9a5567e4f 100644 --- a/ios/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Tilladelser"; "wallet_connect.permissions.view_balance" = "Se din saldo og aktivitet"; "wallet_connect.permissions.approval_requests" = "Send godkendelsesanmodninger"; +"errors.payment_expired" = "Betaling udløbet"; +"errors.payment_not_allowed" = "Betaling ikke tilladt"; +"transfer.payment_expires_in" = "Betaling udløber om"; +"transfer.pay_with" = "Betal med"; +"transfer.payment_title" = "Betaling"; +"transfer.merchant" = "Forhandler"; "swap.you_pay" = "Du betaler"; "swap.you_receive" = "Du modtager"; "swap.price_impact" = "Prispåvirkning"; diff --git a/ios/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings index 082b50584e..9715d8b610 100644 --- a/ios/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Berechtigungen"; "wallet_connect.permissions.view_balance" = "Sehen Sie Ihren Kontostand und Ihre Aktivitäten ein."; "wallet_connect.permissions.approval_requests" = "Genehmigungsanfragen senden"; +"errors.payment_expired" = "Zahlung abgelaufen"; +"errors.payment_not_allowed" = "Zahlung nicht zulässig"; +"transfer.payment_expires_in" = "Zahlung läuft ab in"; +"transfer.pay_with" = "Bezahlen mit"; +"transfer.payment_title" = "Zahlung"; +"transfer.merchant" = "Händler"; "swap.you_pay" = "Sie bezahlen"; "swap.you_receive" = "Sie erhalten"; "swap.price_impact" = "Preisauswirkungen"; diff --git a/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings index 94deef05a9..a36809ea87 100644 --- a/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Permissions"; "wallet_connect.permissions.view_balance" = "View your balance and activity"; "wallet_connect.permissions.approval_requests" = "Send approval requests"; +"errors.payment_expired" = "Payment Expired"; +"errors.payment_not_allowed" = "Payment not allowed"; +"transfer.payment_expires_in" = "Payment expires in"; +"transfer.pay_with" = "Pay with"; +"transfer.payment_title" = "Payment"; +"transfer.merchant" = "Merchant"; "swap.you_pay" = "You Pay"; "swap.you_receive" = "You Receive"; "swap.price_impact" = "Price Impact"; diff --git a/ios/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings index 7579bc802f..64c3c718c4 100644 --- a/ios/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Permisos"; "wallet_connect.permissions.view_balance" = "Consulta tu saldo y actividad."; "wallet_connect.permissions.approval_requests" = "Enviar solicitudes de aprobación"; +"errors.payment_expired" = "Pago caducado"; +"errors.payment_not_allowed" = "Pago no permitido"; +"transfer.payment_expires_in" = "El pago caduca en"; +"transfer.pay_with" = "Pagar con"; +"transfer.payment_title" = "Pago"; +"transfer.merchant" = "Comercio"; "swap.you_pay" = "Tu pagas"; "swap.you_receive" = "Recibes"; "swap.price_impact" = "Impacto en el precio"; diff --git a/ios/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings index c996356501..e92a576922 100644 --- a/ios/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "مجوزها"; "wallet_connect.permissions.view_balance" = "مشاهده موجودی و فعالیت خود"; "wallet_connect.permissions.approval_requests" = "ارسال درخواست‌های تأیید"; +"errors.payment_expired" = "پرداخت منقضی شد"; +"errors.payment_not_allowed" = "پرداخت مجاز نیست"; +"transfer.payment_expires_in" = "انقضای پرداخت تا"; +"transfer.pay_with" = "پرداخت با"; +"transfer.payment_title" = "پرداخت"; +"transfer.merchant" = "فروشنده"; "swap.you_pay" = "شما پرداخت میکنید"; "swap.you_receive" = "شما دریافت میکنید"; "swap.price_impact" = "تاثیر قیمت"; diff --git a/ios/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings index ec92384800..1ce085fbde 100644 --- a/ios/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Mga Pahintulot"; "wallet_connect.permissions.view_balance" = "Tingnan ang iyong balanse at aktibidad"; "wallet_connect.permissions.approval_requests" = "Magpadala ng mga kahilingan sa pag-apruba"; +"errors.payment_expired" = "Nag-expire ang bayad"; +"errors.payment_not_allowed" = "Hindi pinapayagan ang bayad"; +"transfer.payment_expires_in" = "Mag-e-expire ang bayad sa"; +"transfer.pay_with" = "Bayaran gamit ang"; +"transfer.payment_title" = "Bayad"; +"transfer.merchant" = "Merchant"; "swap.you_pay" = "Babayaran Mo"; "swap.you_receive" = "Matatanggap Mo"; "swap.price_impact" = "Epekto sa Presyo"; diff --git a/ios/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings index 0519b972f5..af8dcfea53 100644 --- a/ios/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Autorisations"; "wallet_connect.permissions.view_balance" = "Consultez votre solde et votre activité"; "wallet_connect.permissions.approval_requests" = "Envoyer les demandes d'approbation"; +"errors.payment_expired" = "Paiement expiré"; +"errors.payment_not_allowed" = "Paiement non autorisé"; +"transfer.payment_expires_in" = "Le paiement expire dans"; +"transfer.pay_with" = "Payer avec"; +"transfer.payment_title" = "Paiement"; +"transfer.merchant" = "Marchand"; "swap.you_pay" = "Vous payez"; "swap.you_receive" = "Vous recevez"; "swap.price_impact" = "Impact sur les prix"; diff --git a/ios/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings index 58e466a993..7b30ec3ec8 100644 --- a/ios/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Izini"; "wallet_connect.permissions.view_balance" = "Duba ma'aunin ku da ayyukan ku"; "wallet_connect.permissions.approval_requests" = "Aika buƙatun amincewa"; +"errors.payment_expired" = "Biyan ya ƙare"; +"errors.payment_not_allowed" = "Ba a yarda da biyan ba"; +"transfer.payment_expires_in" = "Biyan zai ƙare cikin"; +"transfer.pay_with" = "Biya da"; +"transfer.payment_title" = "Biya"; +"transfer.merchant" = "Ɗan kasuwa"; "swap.you_pay" = "Kuna Biya"; "swap.you_receive" = "Kuna karba"; "swap.price_impact" = "Tasirin Farashin"; diff --git a/ios/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings index 20d176e9b0..395a0b6ea8 100644 --- a/ios/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "הרשאות"; "wallet_connect.permissions.view_balance" = "צפה ביתרה ובפעילות שלך"; "wallet_connect.permissions.approval_requests" = "שלח בקשות אישור"; +"errors.payment_expired" = "התשלום פג תוקף"; +"errors.payment_not_allowed" = "התשלום אינו מורשה"; +"transfer.payment_expires_in" = "התשלום יפוג בעוד"; +"transfer.pay_with" = "שלם באמצעות"; +"transfer.payment_title" = "תשלום"; +"transfer.merchant" = "בית עסק"; "swap.you_pay" = "אתה משלם"; "swap.you_receive" = "אתה מקבל"; "swap.price_impact" = "השפעת המחיר"; diff --git a/ios/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings index 230ef77121..6f8e80ec28 100644 --- a/ios/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "अनुमतियां"; "wallet_connect.permissions.view_balance" = "अपना बैलेंस और गतिविधि देखें"; "wallet_connect.permissions.approval_requests" = "अनुमोदन अनुरोध भेजें"; +"errors.payment_expired" = "भुगतान समाप्त"; +"errors.payment_not_allowed" = "भुगतान की अनुमति नहीं है"; +"transfer.payment_expires_in" = "भुगतान समाप्त होने में"; +"transfer.pay_with" = "इससे भुगतान करें"; +"transfer.payment_title" = "भुगतान"; +"transfer.merchant" = "व्यापारी"; "swap.you_pay" = "आप भुगतान करें"; "swap.you_receive" = "आप प्राप्त करें"; "swap.price_impact" = "मूल्य प्रभाव"; diff --git a/ios/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings index cfb0a506b0..75b9115832 100644 --- a/ios/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Izin"; "wallet_connect.permissions.view_balance" = "Lihat saldo dan aktivitas Anda"; "wallet_connect.permissions.approval_requests" = "Kirim permintaan persetujuan"; +"errors.payment_expired" = "Pembayaran kedaluwarsa"; +"errors.payment_not_allowed" = "Pembayaran tidak diizinkan"; +"transfer.payment_expires_in" = "Pembayaran kedaluwarsa dalam"; +"transfer.pay_with" = "Bayar dengan"; +"transfer.payment_title" = "Pembayaran"; +"transfer.merchant" = "Pedagang"; "swap.you_pay" = "Kamu Membayar"; "swap.you_receive" = "Kamu Menerima"; "swap.price_impact" = "Dampak Harga"; diff --git a/ios/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings index 09509cb895..7a3da04f76 100644 --- a/ios/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Autorizzazioni"; "wallet_connect.permissions.view_balance" = "Visualizza il tuo saldo e la tua attività"; "wallet_connect.permissions.approval_requests" = "Invia richieste di approvazione"; +"errors.payment_expired" = "Pagamento scaduto"; +"errors.payment_not_allowed" = "Pagamento non consentito"; +"transfer.payment_expires_in" = "Il pagamento scade tra"; +"transfer.pay_with" = "Paga con"; +"transfer.payment_title" = "Pagamento"; +"transfer.merchant" = "Esercente"; "swap.you_pay" = "Paghi tu"; "swap.you_receive" = "Ricevi"; "swap.price_impact" = "Impatto sul prezzo"; diff --git a/ios/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings index 50e282a35e..03d125e49b 100644 --- a/ios/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "権限"; "wallet_connect.permissions.view_balance" = "残高とアクティビティを確認する"; "wallet_connect.permissions.approval_requests" = "承認依頼を送信する"; +"errors.payment_expired" = "支払いの期限切れ"; +"errors.payment_not_allowed" = "支払いは許可されていません"; +"transfer.payment_expires_in" = "支払い期限まで"; +"transfer.pay_with" = "支払い方法"; +"transfer.payment_title" = "支払い"; +"transfer.merchant" = "加盟店"; "swap.you_pay" = "あなたが支払う"; "swap.you_receive" = "受け取るもの"; "swap.price_impact" = "価格の影響"; diff --git a/ios/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings index 51774f0eb7..2cb2239602 100644 --- a/ios/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "권한"; "wallet_connect.permissions.view_balance" = "잔액과 활동 내역을 확인하세요"; "wallet_connect.permissions.approval_requests" = "승인 요청 보내기"; +"errors.payment_expired" = "결제 만료됨"; +"errors.payment_not_allowed" = "결제가 허용되지 않음"; +"transfer.payment_expires_in" = "결제 만료까지"; +"transfer.pay_with" = "결제 수단"; +"transfer.payment_title" = "결제"; +"transfer.merchant" = "가맹점"; "swap.you_pay" = "지불 금액"; "swap.you_receive" = "수령 금액"; "swap.price_impact" = "가격 영향"; diff --git a/ios/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings index 33a160cbb0..fe087efad8 100644 --- a/ios/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Kebenaran"; "wallet_connect.permissions.view_balance" = "Lihat baki dan aktiviti anda"; "wallet_connect.permissions.approval_requests" = "Hantar permintaan kelulusan"; +"errors.payment_expired" = "Pembayaran tamat tempoh"; +"errors.payment_not_allowed" = "Pembayaran tidak dibenarkan"; +"transfer.payment_expires_in" = "Pembayaran tamat tempoh dalam"; +"transfer.pay_with" = "Bayar dengan"; +"transfer.payment_title" = "Pembayaran"; +"transfer.merchant" = "Peniaga"; "swap.you_pay" = "Anda Bayar"; "swap.you_receive" = "Anda Terima"; "swap.price_impact" = "Kesan Harga"; diff --git a/ios/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings index 59f0dfbf32..3976de2c49 100644 --- a/ios/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Toestemmingen"; "wallet_connect.permissions.view_balance" = "Bekijk je saldo en activiteit"; "wallet_connect.permissions.approval_requests" = "Verzoeken om goedkeuring verzenden"; +"errors.payment_expired" = "Betaling verlopen"; +"errors.payment_not_allowed" = "Betaling niet toegestaan"; +"transfer.payment_expires_in" = "Betaling verloopt over"; +"transfer.pay_with" = "Betalen met"; +"transfer.payment_title" = "Betaling"; +"transfer.merchant" = "Verkoper"; "swap.you_pay" = "Jij betaalt"; "swap.you_receive" = "Jij ontvangt"; "swap.price_impact" = "Prijsimpact"; diff --git a/ios/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings index 1332ea0501..ce69c99bf0 100644 --- a/ios/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Uprawnienia"; "wallet_connect.permissions.view_balance" = "Wyświetl swoje saldo i aktywność"; "wallet_connect.permissions.approval_requests" = "Wyślij prośby o zatwierdzenie"; +"errors.payment_expired" = "Płatność wygasła"; +"errors.payment_not_allowed" = "Płatność niedozwolona"; +"transfer.payment_expires_in" = "Płatność wygasa za"; +"transfer.pay_with" = "Zapłać za pomocą"; +"transfer.payment_title" = "Płatność"; +"transfer.merchant" = "Sprzedawca"; "swap.you_pay" = "Ty płacisz"; "swap.you_receive" = "Otrzymujesz"; "swap.price_impact" = "Wpływ na cenę"; diff --git a/ios/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings index 24c59b2820..f23406d09b 100644 --- a/ios/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Permissões"; "wallet_connect.permissions.view_balance" = "Veja seu saldo e atividade"; "wallet_connect.permissions.approval_requests" = "Enviar solicitações de aprovação"; +"errors.payment_expired" = "Pagamento expirado"; +"errors.payment_not_allowed" = "Pagamento não permitido"; +"transfer.payment_expires_in" = "O pagamento expira em"; +"transfer.pay_with" = "Pagar com"; +"transfer.payment_title" = "Pagamento"; +"transfer.merchant" = "Comerciante"; "swap.you_pay" = "Você paga"; "swap.you_receive" = "Você recebe"; "swap.price_impact" = "Impacto no preço"; diff --git a/ios/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings index 5059361ee8..630b4e63eb 100644 --- a/ios/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Permisiuni"; "wallet_connect.permissions.view_balance" = "Vizualizați soldul și activitatea dvs."; "wallet_connect.permissions.approval_requests" = "Trimiteți cereri de aprobare"; +"errors.payment_expired" = "Plată expirată"; +"errors.payment_not_allowed" = "Plată nepermisă"; +"transfer.payment_expires_in" = "Plata expiră în"; +"transfer.pay_with" = "Plătește cu"; +"transfer.payment_title" = "Plată"; +"transfer.merchant" = "Comerciant"; "swap.you_pay" = "Tu plătești"; "swap.you_receive" = "Tu Primești"; "swap.price_impact" = "Impactul prețului"; diff --git a/ios/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings index a62c236e3f..9075511780 100644 --- a/ios/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Разрешения"; "wallet_connect.permissions.view_balance" = "Просматривать баланс и активность"; "wallet_connect.permissions.approval_requests" = "Отправлять запросы на подтверждение"; +"errors.payment_expired" = "Срок платежа истёк"; +"errors.payment_not_allowed" = "Платёж не разрешён"; +"transfer.payment_expires_in" = "Платёж истекает через"; +"transfer.pay_with" = "Оплатить с помощью"; +"transfer.payment_title" = "Платёж"; +"transfer.merchant" = "Продавец"; "swap.you_pay" = "Вы платите"; "swap.you_receive" = "Вы получаете"; "swap.price_impact" = "Влияние цены"; diff --git a/ios/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings index bdb2f3c2aa..9563cba3d6 100644 --- a/ios/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Ruhusa"; "wallet_connect.permissions.view_balance" = "Tazama salio na shughuli zako"; "wallet_connect.permissions.approval_requests" = "Tuma maombi ya idhini"; +"errors.payment_expired" = "Malipo yamekwisha muda"; +"errors.payment_not_allowed" = "Malipo hayaruhusiwi"; +"transfer.payment_expires_in" = "Malipo yataisha baada ya"; +"transfer.pay_with" = "Lipa kwa"; +"transfer.payment_title" = "Malipo"; +"transfer.merchant" = "Mfanyabiashara"; "swap.you_pay" = "Unalipa"; "swap.you_receive" = "Unapokea"; "swap.price_impact" = "Athari ya Bei"; diff --git a/ios/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings index 8fec797859..3089ede769 100644 --- a/ios/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "สิทธิ์การเข้าถึง"; "wallet_connect.permissions.view_balance" = "ตรวจสอบยอดเงินคงเหลือและกิจกรรมของคุณ"; "wallet_connect.permissions.approval_requests" = "ส่งคำขออนุมัติ"; +"errors.payment_expired" = "การชำระเงินหมดอายุ"; +"errors.payment_not_allowed" = "ไม่อนุญาตให้ชำระเงิน"; +"transfer.payment_expires_in" = "การชำระเงินหมดอายุใน"; +"transfer.pay_with" = "ชำระด้วย"; +"transfer.payment_title" = "การชำระเงิน"; +"transfer.merchant" = "ร้านค้า"; "swap.you_pay" = "คุณจ่าย"; "swap.you_receive" = "คุณได้รับ"; "swap.price_impact" = "ผลกระทบต่อราคา"; diff --git a/ios/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings index 4e4516eeb3..9f6c34c265 100644 --- a/ios/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "İzinler"; "wallet_connect.permissions.view_balance" = "Bakiyenizi ve işlemlerinizi görüntüleyin."; "wallet_connect.permissions.approval_requests" = "Onay isteklerini gönderin"; +"errors.payment_expired" = "Ödeme süresi doldu"; +"errors.payment_not_allowed" = "Ödemeye izin verilmiyor"; +"transfer.payment_expires_in" = "Ödemenin süresi doluyor"; +"transfer.pay_with" = "Şununla öde"; +"transfer.payment_title" = "Ödeme"; +"transfer.merchant" = "Satıcı"; "swap.you_pay" = "Öde"; "swap.you_receive" = "Alacağın"; "swap.price_impact" = "Fiyat Etkisi"; diff --git a/ios/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings index f6be923d90..f261ae2a43 100644 --- a/ios/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Дозволи"; "wallet_connect.permissions.view_balance" = "Перегляд вашого балансу та активності"; "wallet_connect.permissions.approval_requests" = "Надсилати запити на схвалення"; +"errors.payment_expired" = "Термін платежу минув"; +"errors.payment_not_allowed" = "Платіж не дозволено"; +"transfer.payment_expires_in" = "Платіж спливає через"; +"transfer.pay_with" = "Оплатити за допомогою"; +"transfer.payment_title" = "Платіж"; +"transfer.merchant" = "Продавець"; "swap.you_pay" = "Ви платите"; "swap.you_receive" = "Ви отримуєте"; "swap.price_impact" = "Вплив ціни"; diff --git a/ios/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings index e8b0c15c16..c988daf8d9 100644 --- a/ios/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "اجازتیں"; "wallet_connect.permissions.view_balance" = "اپنا توازن اور سرگرمی دیکھیں"; "wallet_connect.permissions.approval_requests" = "منظوری کی درخواستیں بھیجیں۔"; +"errors.payment_expired" = "ادائیگی کی میعاد ختم ہو گئی"; +"errors.payment_not_allowed" = "ادائیگی کی اجازت نہیں"; +"transfer.payment_expires_in" = "ادائیگی ختم ہونے میں"; +"transfer.pay_with" = "اس سے ادائیگی کریں"; +"transfer.payment_title" = "ادائیگی"; +"transfer.merchant" = "تاجر"; "swap.you_pay" = "آپ ادا کرتے ہیں"; "swap.you_receive" = "آپ وصول کرتے ہیں"; "swap.price_impact" = "قیمت کا اثر"; diff --git a/ios/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings index 25bd69fbb0..1e609b35ca 100644 --- a/ios/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "Quyền hạn"; "wallet_connect.permissions.view_balance" = "Xem số dư và hoạt động của bạn"; "wallet_connect.permissions.approval_requests" = "Gửi yêu cầu phê duyệt"; +"errors.payment_expired" = "Thanh toán đã hết hạn"; +"errors.payment_not_allowed" = "Thanh toán không được phép"; +"transfer.payment_expires_in" = "Thanh toán hết hạn sau"; +"transfer.pay_with" = "Thanh toán bằng"; +"transfer.payment_title" = "Thanh toán"; +"transfer.merchant" = "Người bán"; "swap.you_pay" = "Bạn trả"; "swap.you_receive" = "Bạn nhận được"; "swap.price_impact" = "Tác động giá"; diff --git a/ios/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings index 37f25caba3..b3f368b8a2 100644 --- a/ios/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "权限"; "wallet_connect.permissions.view_balance" = "查看您的余额和活动"; "wallet_connect.permissions.approval_requests" = "发送审批请求"; +"errors.payment_expired" = "支付已过期"; +"errors.payment_not_allowed" = "不允许支付"; +"transfer.payment_expires_in" = "支付将于以下时间过期"; +"transfer.pay_with" = "支付方式"; +"transfer.payment_title" = "支付"; +"transfer.merchant" = "商户"; "swap.you_pay" = "将支付"; "swap.you_receive" = "将收到"; "swap.price_impact" = "价格影响"; diff --git a/ios/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings b/ios/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings index 1becd2649c..17ffb1393f 100644 --- a/ios/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings +++ b/ios/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings @@ -288,6 +288,12 @@ "wallet_connect.permissions.title" = "權限"; "wallet_connect.permissions.view_balance" = "查看您的餘額和活動"; "wallet_connect.permissions.approval_requests" = "發送審批請求"; +"errors.payment_expired" = "付款已過期"; +"errors.payment_not_allowed" = "不允許付款"; +"transfer.payment_expires_in" = "付款將於以下時間過期"; +"transfer.pay_with" = "付款方式"; +"transfer.payment_title" = "付款"; +"transfer.merchant" = "商戶"; "swap.you_pay" = "將支付"; "swap.you_receive" = "將收到"; "swap.price_impact" = "價格影響"; diff --git a/ios/Packages/Primitives/Sources/Action.swift b/ios/Packages/Primitives/Sources/Action.swift index 16750cb856..1a157137b4 100644 --- a/ios/Packages/Primitives/Sources/Action.swift +++ b/ios/Packages/Primitives/Sources/Action.swift @@ -4,6 +4,7 @@ import Foundation public typealias VoidAction = (() -> Void)? public typealias StringAction = ((String) -> Void)? +public typealias StringResultAction = @Sendable (Result) -> Void public typealias AssetAction = ((Asset) -> Void)? public typealias AssetIdAction = ((AssetId) -> Void)? public typealias AssetBoolAction = ((Asset, Bool) -> Void)? diff --git a/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift index e9edade9e1..d4819d0f21 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift @@ -7,3 +7,9 @@ extension Account: Identifiable { "\(chain)\(address)" } } + +public extension Account { + var chainAddress: ChainAddress { + ChainAddress(chain: chain, address: address) + } +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift index 0d5f80b0fe..7854cedb28 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift @@ -25,4 +25,12 @@ public extension Date { var millisecondsSince1970: Int64 { Int64(timeIntervalSince1970 * 1000) } + + func sleepUntil() async { + let remaining = timeIntervalSinceNow + guard remaining > 0 else { + return + } + try? await Task.sleep(for: .seconds(remaining)) + } } diff --git a/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift new file mode 100644 index 0000000000..a9927b3e1d --- /dev/null +++ b/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public extension SimulationResult { + static let empty = SimulationResult( + warnings: [], + balanceChanges: [], + payload: [], + header: .none, + ) +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift index 82cefb55db..c3325945aa 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift @@ -51,6 +51,17 @@ public extension Transaction { var swapProvider: String? { metadata?.decode(TransactionSwapMetadata.self)?.provider } + + var paymentMetadata: TransactionPaymentMetadata? { + metadata?.decode(TransactionPaymentMetadata.self) + } + + var isAwaitingPaymentHash: Bool { + guard let paymentMetadata else { + return false + } + return id.hash == paymentMetadata.paymentId + } } extension Transaction: Identifiable {} diff --git a/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift new file mode 100644 index 0000000000..d5dfb04575 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public extension WalletConnectionSessionAppMetadata { + var transactionAppMetadata: TransactionAppMetadata { + TransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } +} + +public extension TransactionAppMetadata { + var iconURL: URL? { + guard let icon, let iconURL = icon.asURL else { return .none } + guard iconURL.host() == nil else { return iconURL } + return url.flatMap { ($0 + icon).asURL } + } +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift index ace48a61e1..cddc68370a 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift @@ -17,6 +17,10 @@ public extension Wallet { type == .multicoin } + var chainAddresses: [ChainAddress] { + accounts.map(\.chainAddress) + } + var addressChains: [AddressChains] { Dictionary(grouping: accounts, by: \.address) .map { AddressChains(address: $0.key, chains: Set($0.value.map(\.chain)).sorted()) } diff --git a/ios/Packages/Primitives/Sources/Generated/Payment.swift b/ios/Packages/Primitives/Sources/Generated/Payment.swift new file mode 100644 index 0000000000..94aa6dbd68 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/Payment.swift @@ -0,0 +1,211 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct PaymentAmount: Codable, Equatable, Hashable, Sendable { + public let assetId: AssetId + public let value: String + public let symbol: String + public let decimals: Int32 + + public init(assetId: AssetId, value: String, symbol: String, decimals: Int32) { + self.assetId = assetId + self.value = value + self.symbol = symbol + self.decimals = decimals + } +} + +public enum PaymentProviderName: String, Codable, Equatable, Hashable, Sendable { + case solanaPay + case walletConnectPay +} + +public struct PaymentLink: Codable, Equatable, Hashable, Sendable { + public let provider: PaymentProviderName + public let id: String + + public init(provider: PaymentProviderName, id: String) { + self.provider = provider + self.id = id + } +} + +public struct PaymentMerchant: Codable, Equatable, Hashable, Sendable { + public let name: String + public let iconUrl: String? + + public init(name: String, iconUrl: String?) { + self.name = name + self.iconUrl = iconUrl + } +} + +public enum PaymentStatus: String, Codable, Equatable, Hashable, Sendable { + case requiresAction = "requires_action" + case processing + case succeeded + case failed + case expired + case cancelled +} + +public struct PaymentOutcome: Codable, Equatable, Hashable, Sendable { + public let status: PaymentStatus + public let transactionId: String? + + public init(status: PaymentStatus, transactionId: String?) { + self.status = status + self.transactionId = transactionId + } +} + +public struct PaymentPrice: Codable, Equatable, Hashable, Sendable { + public let symbol: String + public let value: String + public let decimals: Int32 + + public init(symbol: String, value: String, decimals: Int32) { + self.symbol = symbol + self.value = value + self.decimals = decimals + } +} + +public struct PaymentQuote: Codable, Equatable, Hashable, Sendable { + public let id: String + public let paymentId: String + public let amount: PaymentAmount + public let expiresAt: Date? + public let collectDataUrl: String? + public let providerData: String + + public init(id: String, paymentId: String, amount: PaymentAmount, expiresAt: Date?, collectDataUrl: String?, providerData: String) { + self.id = id + self.paymentId = paymentId + self.amount = amount + self.expiresAt = expiresAt + self.collectDataUrl = collectDataUrl + self.providerData = providerData + } +} + +public struct PaymentQuotes: Codable, Equatable, Hashable, Sendable { + public let merchant: PaymentMerchant + public let price: PaymentPrice? + public let expiresAt: Date? + public let quotes: [PaymentQuote] + + public init(merchant: PaymentMerchant, price: PaymentPrice?, expiresAt: Date?, quotes: [PaymentQuote]) { + self.merchant = merchant + self.price = price + self.expiresAt = expiresAt + self.quotes = quotes + } +} + +public struct PaymentRequest: Codable, Equatable, Hashable, Sendable { + public let address: String + public let amount: String? + public let memo: String? + public let assetId: AssetId? + + public init(address: String, amount: String?, memo: String?, assetId: AssetId?) { + self.address = address + self.amount = amount + self.memo = memo + self.assetId = assetId + } +} + +public enum Payment: Codable, Equatable, Hashable, Sendable { + case request(PaymentRequest) + case link(PaymentLink) + + enum CodingKeys: String, CodingKey, Codable { + case request, + link + } + + private enum ContainerCodingKeys: String, CodingKey { + case type, content + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ContainerCodingKeys.self) + if let type = try? container.decode(CodingKeys.self, forKey: .type) { + switch type { + case .request: + if let content = try? container.decode(PaymentRequest.self, forKey: .content) { + self = .request(content) + return + } + case .link: + if let content = try? container.decode(PaymentLink.self, forKey: .content) { + self = .link(content) + return + } + } + } + throw DecodingError.typeMismatch(Payment.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for Payment")) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ContainerCodingKeys.self) + switch self { + case .request(let content): + try container.encode(CodingKeys.request, forKey: .type) + try container.encode(content, forKey: .content) + case .link(let content): + try container.encode(CodingKeys.link, forKey: .type) + try container.encode(content, forKey: .content) + } + } +} + +public enum PaymentOptions: Codable, Equatable, Hashable, Sendable { + case quotes(PaymentQuotes) + case outcome(PaymentOutcome) + + enum CodingKeys: String, CodingKey, Codable { + case quotes, + outcome + } + + private enum ContainerCodingKeys: String, CodingKey { + case type, content + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ContainerCodingKeys.self) + if let type = try? container.decode(CodingKeys.self, forKey: .type) { + switch type { + case .quotes: + if let content = try? container.decode(PaymentQuotes.self, forKey: .content) { + self = .quotes(content) + return + } + case .outcome: + if let content = try? container.decode(PaymentOutcome.self, forKey: .content) { + self = .outcome(content) + return + } + } + } + throw DecodingError.typeMismatch(PaymentOptions.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for PaymentOptions")) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ContainerCodingKeys.self) + switch self { + case .quotes(let content): + try container.encode(CodingKeys.quotes, forKey: .type) + try container.encode(content, forKey: .content) + case .outcome(let content): + try container.encode(CodingKeys.outcome, forKey: .type) + try container.encode(content, forKey: .content) + } + } +} diff --git a/ios/Packages/Primitives/Sources/Generated/Signing.swift b/ios/Packages/Primitives/Sources/Generated/Signing.swift new file mode 100644 index 0000000000..100b6e8d38 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/Signing.swift @@ -0,0 +1,43 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct EthereumTransactionData: Codable, Equatable, Hashable, Sendable { + public let chainId: UInt64? + public let from: String + public let to: String + public let value: String? + public let gas: String? + public let gasLimit: String? + public let gasPrice: String? + public let maxFeePerGas: String? + public let maxPriorityFeePerGas: String? + public let nonce: String? + public let data: String? + + public init(chainId: UInt64?, from: String, to: String, value: String?, gas: String?, gasLimit: String?, gasPrice: String?, maxFeePerGas: String?, maxPriorityFeePerGas: String?, nonce: String?, data: String?) { + self.chainId = chainId + self.from = from + self.to = to + self.value = value + self.gas = gas + self.gasLimit = gasLimit + self.gasPrice = gasPrice + self.maxFeePerGas = maxFeePerGas + self.maxPriorityFeePerGas = maxPriorityFeePerGas + self.nonce = nonce + self.data = data + } +} + +public enum SignDigestType: String, Codable, Equatable, Hashable, Sendable { + case eip191 + case eip712 + case base58 + case suiPersonal + case siwe + case tonPersonal + case tronPersonal +} diff --git a/ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift b/ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift new file mode 100644 index 0000000000..2f5e54c977 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift @@ -0,0 +1,19 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct TransactionAppMetadata: Codable, Equatable, Hashable, Sendable { + public let name: String + public let description: String? + public let url: String? + public let icon: String? + + public init(name: String, description: String?, url: String?, icon: String?) { + self.name = name + self.description = description + self.url = url + self.icon = icon + } +} diff --git a/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift b/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift index 7670286bd6..b7918b6f5f 100644 --- a/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift +++ b/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift @@ -14,6 +14,18 @@ public struct TransactionNFTTransferMetadata: Codable, Sendable { } } +public struct TransactionPaymentMetadata: Codable, Equatable, Hashable, Sendable { + public let paymentId: String + public let merchant: PaymentMerchant + public let provider: PaymentProviderName + + public init(paymentId: String, merchant: PaymentMerchant, provider: PaymentProviderName) { + self.paymentId = paymentId + self.merchant = merchant + self.provider = provider + } +} + public struct TransactionPerpetualMetadata: Codable, Sendable { public let pnl: Double public let price: Double diff --git a/ios/Packages/Primitives/Sources/PaymentData.swift b/ios/Packages/Primitives/Sources/PaymentData.swift new file mode 100644 index 0000000000..3dd1fd4b82 --- /dev/null +++ b/ios/Packages/Primitives/Sources/PaymentData.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PaymentData: Hashable, Equatable, Sendable { + public let provider: PaymentProviderName + public let quotes: PaymentQuotes + public let quote: PaymentQuote + + public init(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote) { + self.provider = provider + self.quotes = quotes + self.quote = quote + } + + public var merchant: PaymentMerchant { + quotes.merchant + } + + public var price: PaymentPrice? { + quotes.price + } + + public var expiresAt: Date? { + quotes.expiresAt + } +} diff --git a/ios/Packages/Primitives/Sources/Protocols/PaymentLinkPayable.swift b/ios/Packages/Primitives/Sources/Protocols/PaymentLinkPayable.swift new file mode 100644 index 0000000000..4b781bb089 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Protocols/PaymentLinkPayable.swift @@ -0,0 +1,7 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public protocol PaymentLinkPayable: Sendable { + func pay(link: PaymentLink, wallet: Wallet) async +} diff --git a/ios/Packages/Primitives/Sources/SignMessage.swift b/ios/Packages/Primitives/Sources/SignMessage.swift new file mode 100644 index 0000000000..d0b3d92da4 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SignMessage.swift @@ -0,0 +1,19 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct SignMessage: Sendable, Equatable, Hashable { + public let chain: String + public let signType: SignDigestType + public let data: Data + + public init( + chain: String, + signType: SignDigestType, + data: Data, + ) { + self.chain = chain + self.signType = signType + self.data = data + } +} diff --git a/ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift b/ios/Packages/Primitives/Sources/SignMessagePayload.swift similarity index 50% rename from ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift rename to ios/Packages/Primitives/Sources/SignMessagePayload.swift index 56493bb979..85726c04e7 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift +++ b/ios/Packages/Primitives/Sources/SignMessagePayload.swift @@ -1,33 +1,34 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import struct Gemstone.SignMessage -import Primitives -public struct SignMessagePayload: Sendable { +public struct SignMessagePayload: Sendable, Identifiable { + public let id: String public let chain: Chain - public let session: WalletConnectionSession + public let appMetadata: TransactionAppMetadata public let wallet: Wallet public let message: SignMessage public let simulation: SimulationResult + public let payment: PaymentData? + public let expiresAt: Date? public init( + id: String, chain: Chain, - session: WalletConnectionSession, + appMetadata: TransactionAppMetadata, wallet: Wallet, message: SignMessage, simulation: SimulationResult, + payment: PaymentData? = .none, + expiresAt: Date? = .none, ) { + self.id = id self.chain = chain + self.appMetadata = appMetadata self.wallet = wallet - self.session = session self.message = message self.simulation = simulation - } -} - -extension SignMessagePayload: Identifiable { - public var id: String { - session.id + self.payment = payment + self.expiresAt = expiresAt } } diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift b/ios/Packages/Primitives/Sources/SignableTransaction.swift similarity index 68% rename from ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift rename to ios/Packages/Primitives/Sources/SignableTransaction.swift index b6fa0e5424..91fec7eec2 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift +++ b/ios/Packages/Primitives/Sources/SignableTransaction.swift @@ -1,10 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives -public enum WalletConnectorTransaction { - case ethereum(WCEthereumTransaction, TransactionType) +public enum SignableTransaction: Sendable { + case ethereum(EthereumTransactionData, TransactionType) case solana(String, TransferDataOutputType) case sui(String, TransferDataOutputType) case ton(String, TransferDataOutputType) diff --git a/ios/Packages/Primitives/Sources/SigningRequestError.swift b/ios/Packages/Primitives/Sources/SigningRequestError.swift new file mode 100644 index 0000000000..b1af4b7b89 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SigningRequestError.swift @@ -0,0 +1,7 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum SigningRequestError: Error, Equatable { + case userCancelled +} diff --git a/ios/Packages/Primitives/Sources/SigningRequestInteractable.swift b/ios/Packages/Primitives/Sources/SigningRequestInteractable.swift new file mode 100644 index 0000000000..83d07e5880 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SigningRequestInteractable.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public protocol SigningRequestInteractable: Sendable { + func signMessage(payload: SignMessagePayload) async throws -> String + func signTransaction(transferData: SigningTransferData) async throws -> String + func sendTransaction(transferData: SigningTransferData) async throws -> String +} diff --git a/ios/Packages/Primitives/Sources/SigningTransferData.swift b/ios/Packages/Primitives/Sources/SigningTransferData.swift new file mode 100644 index 0000000000..3c52ce8080 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SigningTransferData.swift @@ -0,0 +1,19 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct SigningTransferData: Identifiable, Sendable { + public let transferData: TransferData + public let wallet: Wallet + public let simulation: SimulationResult + + public init(transferData: TransferData, wallet: Wallet, simulation: SimulationResult) { + self.transferData = transferData + self.wallet = wallet + self.simulation = simulation + } + + public var id: String { + wallet.id.id + } +} diff --git a/ios/Packages/Primitives/Sources/SigningTransferDataFactory.swift b/ios/Packages/Primitives/Sources/SigningTransferDataFactory.swift new file mode 100644 index 0000000000..3c4ab55bf7 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SigningTransferDataFactory.swift @@ -0,0 +1,115 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation + +public enum SigningTransferDataFactory { + public static func transferData( + asset: Asset, + appMetadata: TransactionAppMetadata, + transaction: SignableTransaction, + outputAction: TransferDataOutputAction, + payment: PaymentData? = .none, + ) throws -> TransferData { + switch transaction { + case let .ethereum(transaction, transactionType): + try ethereumTransferData(asset: asset, appMetadata: appMetadata, transaction: transaction, transactionType: transactionType, payment: payment) + case let .solana(transaction, outputType), + let .sui(transaction, outputType), + let .ton(transaction, outputType), + let .tron(transaction, outputType): + encodedTransferData(asset: asset, appMetadata: appMetadata, transaction: transaction, outputType: outputType, outputAction: outputAction, payment: payment) + } + } + + public static func ethereumTransferData( + asset: Asset, + appMetadata: TransactionAppMetadata, + transaction: EthereumTransactionData, + transactionType: TransactionType, + payment: PaymentData? = .none, + ) throws -> TransferData { + let address = transaction.to + let value = try BigInt.fromHex(transaction.value ?? .zero) + let gasLimit: BigInt? = { + if let value = transaction.gasLimit { + return BigInt(hex: value) + } else if let gas = transaction.gas { + return BigInt(hex: gas) + } + return .none + }() + + let gasPrice: GasPriceType? = { + if let maxFeePerGas = transaction.maxFeePerGas, + let maxPriorityFeePerGas = transaction.maxPriorityFeePerGas, + let maxFeePerGasBigInt = BigInt(hex: maxFeePerGas), + let maxPriorityFeePerGasBigInt = BigInt(hex: maxPriorityFeePerGas) + { + return .eip1559(gasPrice: maxFeePerGasBigInt, priorityFee: maxPriorityFeePerGasBigInt) + } + return .none + }() + + let data: Data? = { + if let data = transaction.data { + return Data(fromHex: data) + } + return .none + }() + + return TransferData( + type: Self.type(asset: asset, appMetadata: appMetadata, payment: payment, extra: TransferDataExtra( + to: address, + gasLimit: gasLimit, + gasPrice: gasPrice, + data: data, + transactionType: transactionType, + )), + recipientData: RecipientData( + recipient: Recipient(name: .none, address: address, memo: .none), + amount: .none, + ), + amount: .exact(value), + ) + } + + public static func encodedTransferData( + asset: Asset, + appMetadata: TransactionAppMetadata, + transaction: String, + outputType: TransferDataOutputType, + outputAction: TransferDataOutputAction, + payment: PaymentData? = .none, + ) -> TransferData { + TransferData( + type: type( + asset: asset, + appMetadata: appMetadata, + payment: payment, + extra: TransferDataExtra( + to: "", + data: transaction.data(using: .utf8), + outputType: outputType, + outputAction: outputAction, + ), + ), + recipientData: RecipientData( + recipient: Recipient(name: .none, address: "", memo: .none), + amount: .none, + ), + amount: .exact(.zero), + ) + } +} + +// MARK: - Private + +private extension SigningTransferDataFactory { + static func type(asset: Asset, appMetadata: TransactionAppMetadata, payment: PaymentData?, extra: TransferDataExtra) -> TransferDataType { + guard let payment else { + return .generic(asset: asset, appMetadata: appMetadata, extra: extra) + } + return .payment(asset: asset, payment: payment, extra: extra) + } +} diff --git a/ios/Packages/Primitives/Sources/TransactionExtendedMetadata.swift b/ios/Packages/Primitives/Sources/TransactionExtendedMetadata.swift index 48c6c1b054..c4ea696094 100644 --- a/ios/Packages/Primitives/Sources/TransactionExtendedMetadata.swift +++ b/ios/Packages/Primitives/Sources/TransactionExtendedMetadata.swift @@ -32,4 +32,8 @@ public struct TransactionExtendedMetadata: Sendable { public var nftMetadata: TransactionNFTTransferMetadata? { metadata?.decode(TransactionNFTTransferMetadata.self) } + + public var paymentMetadata: TransactionPaymentMetadata? { + metadata?.decode(TransactionPaymentMetadata.self) + } } diff --git a/ios/Packages/Primitives/Sources/TransferDataType.swift b/ios/Packages/Primitives/Sources/TransferDataType.swift index 9b7dc18ada..52ce28953e 100644 --- a/ios/Packages/Primitives/Sources/TransferDataType.swift +++ b/ios/Packages/Primitives/Sources/TransferDataType.swift @@ -13,14 +13,23 @@ public enum TransferDataType: Hashable, Equatable, Sendable { case account(Asset, AccountDataType) case perpetual(Asset, PerpetualType) case earn(Asset, EarnType, ContractCallData) - case generic(asset: Asset, metadata: WalletConnectionSessionAppMetadata, extra: TransferDataExtra) + case generic(asset: Asset, appMetadata: TransactionAppMetadata, extra: TransferDataExtra) + case payment(asset: Asset, payment: PaymentData, extra: TransferDataExtra) + + public var payment: PaymentData? { + switch self { + case let .payment(_, payment, _): payment + case .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, + .stake, .account, .perpetual, .earn, .generic: .none + } + } public var transactionType: TransactionType { switch self { case .transfer: .transfer case .deposit: .transfer case .withdrawal: .transfer - case let .generic(_, _, extra): extra.transactionType + case let .generic(_, _, extra), let .payment(_, _, extra): extra.transactionType case .transferNft: .transferNFT case .tokenApprove: .tokenApproval case .swap: .swap @@ -60,7 +69,8 @@ public enum TransferDataType: Hashable, Equatable, Sendable { let .perpetual(asset, _), let .earn(asset, _, _), let .tokenApprove(asset, _), - let .generic(asset, _, _): asset.chain + let .generic(asset, _, _), + let .payment(asset, _, _): asset.chain case let .transferNft(asset): asset.chain } } @@ -91,6 +101,8 @@ public enum TransferDataType: Hashable, Equatable, Sendable { } case let .generic(_, _, extra): return .encode(TransactionWalletConnectMetadata(outputAction: extra.outputAction)) + case let .payment(_, payment, _): + return .encode(TransactionPaymentMetadata(paymentId: payment.quote.paymentId, merchant: payment.merchant, provider: payment.provider)) case .transfer, .deposit, .withdrawal, @@ -109,6 +121,7 @@ public enum TransferDataType: Hashable, Equatable, Sendable { let .tokenApprove(asset, _), let .stake(asset, _), let .generic(asset, _, _), + let .payment(asset, _, _), let .account(asset, _), let .perpetual(asset, _), let .earn(asset, _, _): [asset.id] @@ -119,14 +132,14 @@ public enum TransferDataType: Hashable, Equatable, Sendable { public var outputType: TransferDataOutputType { switch self { - case let .generic(_, _, extra): extra.outputType + case let .generic(_, _, extra), let .payment(_, _, extra): extra.outputType default: .encodedTransaction } } public var outputAction: TransferDataOutputAction { switch self { - case let .generic(_, _, extra): extra.outputAction + case let .generic(_, _, extra), let .payment(_, _, extra): extra.outputAction default: .send } } @@ -148,7 +161,7 @@ public enum TransferDataType: Hashable, Equatable, Sendable { public var shouldIgnoreValueCheck: Bool { switch self { case .transferNft, .stake, .account, .tokenApprove, .perpetual, .earn: true - case .transfer, .deposit, .withdrawal, .swap, .generic: false + case .transfer, .deposit, .withdrawal, .swap, .generic, .payment: false } } diff --git a/ios/Packages/Primitives/Sources/URLAction.swift b/ios/Packages/Primitives/Sources/URLAction.swift index 141cfacd0d..052a3a40e5 100644 --- a/ios/Packages/Primitives/Sources/URLAction.swift +++ b/ios/Packages/Primitives/Sources/URLAction.swift @@ -4,6 +4,7 @@ import Foundation public enum URLAction: Equatable { case deeplink(DeepLink) + case payment(PaymentLink) case walletConnect(WalletConnectAction) } diff --git a/ios/Packages/Primitives/TestKit/Payment+PrimitivesTestKit.swift b/ios/Packages/Primitives/TestKit/Payment+PrimitivesTestKit.swift new file mode 100644 index 0000000000..9e50fa9a02 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/Payment+PrimitivesTestKit.swift @@ -0,0 +1,92 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension PaymentRequest { + static func mock( + address: String = .empty, + amount: String? = .none, + memo: String? = .none, + assetId: AssetId? = .none, + ) -> Self { + .init( + address: address, + amount: amount, + memo: memo, + assetId: assetId, + ) + } +} + +public extension PaymentLink { + static func mock( + provider: PaymentProviderName = .walletConnectPay, + id: String = "pay_1", + ) -> Self { + .init(provider: provider, id: id) + } +} + +public extension PaymentMerchant { + static func mock( + name: String = "Test Merchant", + iconUrl: String? = .none, + ) -> Self { + .init(name: name, iconUrl: iconUrl) + } +} + +public extension PaymentOutcome { + static func mock( + status: PaymentStatus = .succeeded, + transactionId: String? = .none, + ) -> Self { + .init(status: status, transactionId: transactionId) + } +} + +public extension PaymentQuotes { + static func mock( + merchant: PaymentMerchant = .mock(), + price: PaymentPrice = .mock(), + expiresAt: Date = Date(timeIntervalSinceNow: 900), + quotes: [PaymentQuote] = [.mock()], + ) -> Self { + .init(merchant: merchant, price: price, expiresAt: expiresAt, quotes: quotes) + } +} + +public extension PaymentQuote { + static func mock( + paymentId: String = "pay_1", + amount: PaymentAmount = .mock(), + expiresAt: Date? = .none, + collectDataUrl: String? = .none, + providerData: String = "{}", + id: String = "option_1", + ) -> Self { + .init(id: id, paymentId: paymentId, amount: amount, expiresAt: expiresAt, collectDataUrl: collectDataUrl, providerData: providerData) + } +} + +public extension PaymentAmount { + static func mock( + assetId: AssetId = .mock(), + value: String = "10000", + symbol: String = "USDC", + decimals: Int32 = 6, + ) -> Self { + .init(assetId: assetId, value: value, symbol: symbol, decimals: decimals) + } +} + +public extension PaymentPrice { + static func mock( + symbol: String = "USD", + value: String = "1", + decimals: Int32 = 2, + ) -> PaymentPrice { + PaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} diff --git a/ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift b/ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift new file mode 100644 index 0000000000..9c077fdd14 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift @@ -0,0 +1,16 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct PaymentLinkPayableMock: PaymentLinkPayable { + public init() {} + + public func pay(link _: PaymentLink, wallet _: Wallet) async {} +} + +public extension PaymentLinkPayable where Self == PaymentLinkPayableMock { + static func mock() -> PaymentLinkPayableMock { + PaymentLinkPayableMock() + } +} diff --git a/ios/Packages/Primitives/TestKit/SignMessagePayload+TestKit.swift b/ios/Packages/Primitives/TestKit/SignMessagePayload+TestKit.swift new file mode 100644 index 0000000000..14442c36a4 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/SignMessagePayload+TestKit.swift @@ -0,0 +1,43 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension SignMessagePayload { + static func mock( + id: String = .empty, + chain: Chain = .ethereum, + appMetadata: TransactionAppMetadata = .mock(), + wallet: Wallet = .mock(), + message: SignMessage = SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), + simulation: SimulationResult = .mock(), + payment: PaymentData? = .none, + expiresAt: Date? = .none, + ) -> SignMessagePayload { + SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message, + simulation: simulation, + payment: payment, + expiresAt: expiresAt, + ) + } +} + +public extension PaymentData { + static func mock( + provider: PaymentProviderName = .walletConnectPay, + quote: PaymentQuote = .mock(), + quotes: [PaymentQuote]? = .none, + expiresAt: Date = Date(timeIntervalSinceNow: 900), + ) -> PaymentData { + PaymentData( + provider: provider, + quotes: .mock(expiresAt: expiresAt, quotes: quotes ?? [quote]), + quote: quote, + ) + } +} diff --git a/ios/Packages/Primitives/TestKit/SigningRequestInteractableMock.swift b/ios/Packages/Primitives/TestKit/SigningRequestInteractableMock.swift new file mode 100644 index 0000000000..b06d59bb79 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/SigningRequestInteractableMock.swift @@ -0,0 +1,34 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public final class SigningRequestInteractableMock: SigningRequestInteractable, @unchecked Sendable { + public init() {} + + public var transactionHash = "1" + public var signature = "signature" + public var signMessageError: Error? + private var failedSignOnce = false + + public private(set) var signMessagePayloads: [SignMessagePayload] = [] + public private(set) var sentTransferData: [SigningTransferData] = [] + + public func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + if let signMessageError, !failedSignOnce { + failedSignOnce = true + throw signMessageError + } + return signature + } + + public func signTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + sentTransferData.append(transferData) + return transactionHash + } +} diff --git a/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift b/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift index 03d99335fe..04b34f8c62 100644 --- a/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift +++ b/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift @@ -5,6 +5,7 @@ import Primitives public extension Transaction { static func mock( + hash: String = "1", type: TransactionType = .transfer, state: TransactionState = .confirmed, direction: TransactionDirection = .incoming, @@ -16,7 +17,7 @@ public extension Transaction { metadata: AnyCodableValue? = nil, ) -> Transaction { Transaction( - id: TransactionId(chain: .ethereum, hash: "1"), + id: TransactionId(chain: .ethereum, hash: hash), assetId: assetId, from: from, to: to, diff --git a/ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift b/ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift new file mode 100644 index 0000000000..ca67852544 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension TransactionAppMetadata { + static func mock( + name: String = "Test Dapp", + description: String? = .none, + url: String? = "https://example.com", + icon: String? = "https://example.com/icon.png", + ) -> Self { + .init(name: name, description: description, url: url, icon: icon) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift b/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift new file mode 100644 index 0000000000..7279a83a2c --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift @@ -0,0 +1,61 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol SheetPresenting: Sendable { + associatedtype Sheet: SheetRejectable & Identifiable where Sheet.ID == String + + var sheets: SheetPresenter { get } +} + +public extension SheetPresenting { + @MainActor + var isPresentingSheet: Sheet? { + get { sheets.isPresentingSheet } + nonmutating set { sheets.isPresentingSheet = newValue } + } + + @MainActor + func complete(type: Sheet) { + sheets.dismiss(id: type.id) + } + + @MainActor + func cancelSheet(type: Sheet) { + guard sheets.isPresentingSheet?.id == type.id else { + return + } + type.reject(SheetDismissal.cancelled) + sheets.dismiss(id: type.id) + } + + @MainActor + func onSheetDismiss() { + sheets.onSheetDismiss() + } + + func present( + payload: Payload, + sheet: @Sendable @escaping (SheetCallback) -> Sheet, + ) async throws -> String where Payload.ID == String { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) + let callback = SheetCallback(payload: payload) { + continuation.yield(with: $0) + continuation.finish() + } + await sheets.show(sheet: sheet(callback)) + + do { + for try await value in stream { + await sheets.dismissPresented() + return value + } + } catch { + await sheets.dismissPresented() + throw error + } + await sheets.dismissPresented() + throw SheetDismissal.cancelled + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift b/ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift new file mode 100644 index 0000000000..7a970ed762 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol SheetRejectable: Sendable { + var id: String { get } + func reject(_ error: any Error) +} + +public final class SheetCallback: SheetRejectable, Identifiable where T.ID == String { + public let payload: T + public let delegate: StringResultAction + + public init( + payload: T, + delegate: @escaping StringResultAction, + ) { + self.payload = payload + self.delegate = delegate + } + + public var id: String { + payload.id + } + + public func reject(_ error: any Error) { + delegate(.failure(error)) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift b/ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift new file mode 100644 index 0000000000..1d307d2d96 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +/// Thrown when a sheet closes without producing a value. +public enum SheetDismissal: Error, Equatable { + case cancelled +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift b/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift index f36c36d97e..afa4204eae 100644 --- a/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift +++ b/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift @@ -56,7 +56,8 @@ public enum TransactionHeaderTypeBuilder { .deposit, .withdrawal, .stake, - .generic: + .generic, + .payment: return .amount(showFiat: true) case .tokenApprove: return .assetImage diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift new file mode 100644 index 0000000000..81510a9cc2 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components + +public struct AppPreviewModel: AssetPreviewable { + public let assetImage: AssetImage + public let name: String + public let subtitleSymbol: String? + + public init(assetImage: AssetImage, name: String, subtitleSymbol: String?) { + self.assetImage = assetImage + self.name = name + self.subtitleSymbol = subtitleSymbol + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift new file mode 100644 index 0000000000..16899c1903 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift @@ -0,0 +1,23 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +@Observable +@MainActor +public final class ExpiryCountdown { + public private(set) var isExpired: Bool = false + + private let expiresAt: Date? + + public init(expiresAt: Date?) { + self.expiresAt = expiresAt + } + + public func start() async { + guard let expiresAt else { + return + } + await expiresAt.sleepUntil() + isExpired = true + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift new file mode 100644 index 0000000000..5a90c3389b --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift @@ -0,0 +1,92 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Components +import Formatters +import Foundation +import Primitives +import Style +import SwiftUI + +public struct PaymentQuoteItem: Identifiable, Sendable { + public let quote: PaymentQuote + + private let assetData: AssetData? + private let formatter: ValueFormatter + + public init( + quote: PaymentQuote, + assetData: AssetData? = .none, + formatter: ValueFormatter, + ) { + self.quote = quote + self.assetData = assetData + self.formatter = formatter + } + + public var id: String { + quote.id + } + + public var amountText: String { + guard let value = BigInt(quote.amount.value) else { + return quote.amount.symbol + } + return formatter.string(value, decimals: quote.amount.decimals.asInt, currency: quote.amount.symbol) + } +} + +// MARK: - ListAssetItemViewable + +extension PaymentQuoteItem: ListAssetItemViewable { + public var name: String { + assetData?.asset.name ?? quote.amount.symbol + } + + public var symbol: String? { + .none + } + + public var assetImage: AssetImage { + AssetIdViewModel(assetId: quote.amount.assetId).assetImage + } + + public var subtitleView: ListAssetItemSubtitleView { + .type(TextValue(text: quote.amount.assetId.chain.networkName, style: .calloutSecondary)) + } + + public var rightView: ListAssetItemRightView { + .balance( + balance: TextValue(text: amountText, style: TextStyle(font: .callout, color: Colors.black, fontWeight: .semibold)), + totalFiat: TextValue(text: balanceText, style: TextStyle(font: .footnote, color: Colors.gray)), + ) + } + + public var action: ((ListAssetItemAction) -> Void)? { + get { .none } + set {} + } +} + +// MARK: - Hashable + +extension PaymentQuoteItem: Hashable { + public static func == (lhs: PaymentQuoteItem, rhs: PaymentQuoteItem) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Private + +extension PaymentQuoteItem { + private var balanceText: String { + guard let assetData else { + return .empty + } + return formatter.string(assetData.balance.available, decimals: assetData.asset.decimals.asInt, currency: assetData.asset.symbol) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift new file mode 100644 index 0000000000..d7eccb02fb --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift @@ -0,0 +1,68 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +@Observable +public final class SheetPresenter: Sendable where Sheet.ID == String { + @MainActor + public var isPresentingSheet: Sheet? + @MainActor + private var isDismissingSheet: Bool = false + @MainActor + private var dismissals: [CheckedContinuation] = [] + + public init() {} + + @MainActor + public func onSheetDismiss() { + isDismissingSheet = false + let waiting = dismissals + dismissals = [] + waiting.forEach { $0.resume() } + } +} + +// MARK: - Internal + +extension SheetPresenter { + @MainActor + func show(sheet: Sheet) async { + await waitForDismiss() + isPresentingSheet = sheet + } + + @MainActor + func dismissPresented() async { + dismiss() + await waitForDismiss() + } + + @MainActor + func dismiss(id: String) { + guard isPresentingSheet?.id == id else { + return + } + dismiss() + } +} + +// MARK: - Private + +extension SheetPresenter { + @MainActor + private func dismiss() { + guard isPresentingSheet != nil else { + return + } + isDismissingSheet = true + isPresentingSheet = .none + } + + @MainActor + private func waitForDismiss() async { + guard isPresentingSheet != nil || isDismissingSheet else { + return + } + await withCheckedContinuation { dismissals.append($0) } + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift index 6ae1191e83..f633be1d8b 100644 --- a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift @@ -106,6 +106,9 @@ public struct TransactionViewModel: Sendable { return Localized.Transfer.title } case .smartContractCall: + if paymentMerchantName != nil { + return Localized.Transfer.paymentTitle + } return Localized.Transfer.SmartContract.title case .swap: return Localized.Wallet.swap @@ -178,6 +181,9 @@ public struct TransactionViewModel: Sendable { let chain = assetId.chain switch transaction.transaction.type { case .transfer, .transferNFT, .tokenApproval, .smartContractCall: + if let merchant = paymentMerchantName { + return String(format: "%@ %@", Localized.Transfer.to, merchant) + } switch transaction.transaction.direction { case .incoming: return participantTitle(prefix: Localized.Transfer.from, address: transaction.transaction.from, chain: chain) @@ -350,6 +356,10 @@ public struct TransactionViewModel: Sendable { return String(format: "%@ %@", prefix, value) } + private var paymentMerchantName: String? { + transaction.transaction.paymentMetadata?.merchant.name + } + private func getResourceTitle() -> String? { guard let resourceType = transaction.transaction.metadata?.decode(TransactionResourceTypeMetadata.self)?.resourceType else { return nil } return ResourceViewModel(resource: resourceType).title diff --git a/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift b/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift new file mode 100644 index 0000000000..759ef0b1cd --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization +import Primitives +@testable import PrimitivesComponents +import PrimitivesTestKit +import Testing + +struct TransactionViewModelTests { + @Test + func smartContractCallTitle() { + let payment = TransactionPaymentMetadata( + paymentId: "pay_1", + merchant: PaymentMerchant(name: "Gem Wallet Test Merchant", iconUrl: .none), + provider: .walletConnectPay, + ) + + #expect(model(metadata: AnyCodableValue.encode(payment)).titleTextValue.text == Localized.Transfer.paymentTitle) + #expect(model(metadata: .none).titleTextValue.text == Localized.Transfer.SmartContract.title) + } + + private func model(metadata: AnyCodableValue?) -> TransactionViewModel { + TransactionViewModel( + explorerService: MockExplorerLink(), + transaction: .mock(transaction: .mock(type: .smartContractCall, metadata: metadata)), + currency: "USD", + ) + } +} diff --git a/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift new file mode 100644 index 0000000000..1e89581b26 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift @@ -0,0 +1,130 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +@testable import PrimitivesComponents +import Testing + +struct SheetPresenterTests { + @Test + @MainActor + func completeDismissesThePresentedSheet() { + let presenter = TestPresenter() + let type = TestSheetType.request(SheetCallback(payload: TestPayload(id: "request"), delegate: { _ in })) + + presenter.sheets.isPresentingSheet = type + presenter.complete(type: type) + + #expect(presenter.sheets.isPresentingSheet == nil) + } + + @Test + @MainActor + func presentReturnsTheAnswerOnlyAfterTheSheetReportsItClosed() async throws { + let presenter = TestPresenter() + let answer = Task { @MainActor in + try await presenter.present(payload: TestPayload(id: "request")) { .request($0) } + } + try await Self.wait { presenter.sheets.isPresentingSheet != nil } + + guard case let .request(callback) = presenter.sheets.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + callback.delegate(.success("signature")) + try await Self.wait { presenter.sheets.isPresentingSheet == nil } + presenter.onSheetDismiss() + + #expect(try await answer.value == "signature") + } + + @Test + @MainActor + func presentQueuesBehindTheSheetThatIsStillClosing() async throws { + let presenter = TestPresenter() + let first = Task { @MainActor in + try await presenter.present(payload: TestPayload(id: "first")) { .request($0) } + } + try await Self.wait { presenter.sheets.isPresentingSheet?.id == "first" } + + guard case let .request(callback) = presenter.sheets.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + let second = Task { @MainActor in + try await presenter.present(payload: TestPayload(id: "second")) { .request($0) } + } + callback.delegate(.success("signature")) + try await Self.wait { presenter.sheets.isPresentingSheet == nil } + + #expect(presenter.sheets.isPresentingSheet == nil) + + presenter.onSheetDismiss() + try await Self.wait { presenter.sheets.isPresentingSheet?.id == "second" } + + #expect(try await first.value == "signature") + + guard let sheet = presenter.sheets.isPresentingSheet else { + Issue.record("queued sheet is not presented") + return + } + presenter.cancelSheet(type: sheet) + presenter.onSheetDismiss() + _ = try? await second.value + } + + @Test + @MainActor + func cancelSheetFailsTheRequestWithUserCancelled() async throws { + let presenter = TestPresenter() + let answer = Task { @MainActor in + try await presenter.present(payload: TestPayload(id: "request")) { .request($0) } + } + try await Self.wait { presenter.sheets.isPresentingSheet != nil } + + guard let sheet = presenter.sheets.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + presenter.cancelSheet(type: sheet) + presenter.onSheetDismiss() + + await #expect(throws: SheetDismissal.cancelled) { + try await answer.value + } + } + + private static func wait(until condition: @MainActor () -> Bool) async throws { + for _ in 0 ..< 100 { + if await condition() { + return + } + await Task.yield() + } + throw AnyError("condition never became true") + } +} + +private struct TestPayload: Identifiable { + let id: String +} + +private struct TestPresenter: SheetPresenting { + let sheets = SheetPresenter() +} + +private enum TestSheetType: Identifiable, SheetRejectable { + case request(SheetCallback) + + var id: String { + switch self { + case let .request(callback): callback.id + } + } + + func reject(_ error: any Error) { + switch self { + case let .request(callback): callback.reject(error) + } + } +} diff --git a/localization/app/ar.ftl b/localization/app/ar.ftl index 823d4fca95..2b81e8f003 100644 --- a/localization/app/ar.ftl +++ b/localization/app/ar.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = الأذونات wallet_connect_permissions_view_balance = اطلع على رصيدك ونشاطك wallet_connect_permissions_approval_requests = إرسال طلبات الموافقة +# Payment + +errors_payment_expired = انتهت صلاحية الدفعة +errors_payment_not_allowed = الدفع غير مسموح به +transfer_payment_expires_in = تنتهي صلاحية الدفعة خلال +transfer_pay_with = ادفع بواسطة +transfer_payment_title = الدفع +transfer_merchant = التاجر + # Swap swap_you_pay = انت تدفع diff --git a/localization/app/bn.ftl b/localization/app/bn.ftl index da75935875..07a889ede8 100644 --- a/localization/app/bn.ftl +++ b/localization/app/bn.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = অনুমতি wallet_connect_permissions_view_balance = আপনার ব্যালেন্স এবং কার্যকলাপ দেখুন wallet_connect_permissions_approval_requests = অনুমোদনের অনুরোধ পাঠান +# Payment + +errors_payment_expired = পেমেন্টের মেয়াদ শেষ +errors_payment_not_allowed = পেমেন্ট অনুমোদিত নয় +transfer_payment_expires_in = পেমেন্টের মেয়াদ শেষ হবে +transfer_pay_with = যা দিয়ে পরিশোধ করবেন +transfer_payment_title = পেমেন্ট +transfer_merchant = বিক্রেতা + # Swap swap_you_pay = আপনি পরিশোধ করেন diff --git a/localization/app/cs.ftl b/localization/app/cs.ftl index 04f7cd1d4a..412c6cd6a9 100644 --- a/localization/app/cs.ftl +++ b/localization/app/cs.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Oprávnění wallet_connect_permissions_view_balance = Zobrazení zůstatku a aktivity wallet_connect_permissions_approval_requests = Odeslat žádosti o schválení +# Payment + +errors_payment_expired = Platba vypršela +errors_payment_not_allowed = Platba není povolena +transfer_payment_expires_in = Platba vyprší za +transfer_pay_with = Zaplatit pomocí +transfer_payment_title = Platba +transfer_merchant = Obchodník + # Swap swap_you_pay = Vy platíte diff --git a/localization/app/da.ftl b/localization/app/da.ftl index 0e16d81779..63fa347846 100644 --- a/localization/app/da.ftl +++ b/localization/app/da.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Tilladelser wallet_connect_permissions_view_balance = Se din saldo og aktivitet wallet_connect_permissions_approval_requests = Send godkendelsesanmodninger +# Payment + +errors_payment_expired = Betaling udløbet +errors_payment_not_allowed = Betaling ikke tilladt +transfer_payment_expires_in = Betaling udløber om +transfer_pay_with = Betal med +transfer_payment_title = Betaling +transfer_merchant = Forhandler + # Swap swap_you_pay = Du betaler diff --git a/localization/app/de.ftl b/localization/app/de.ftl index 8ee6f9717a..2d48191cb1 100644 --- a/localization/app/de.ftl +++ b/localization/app/de.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Berechtigungen wallet_connect_permissions_view_balance = Sehen Sie Ihren Kontostand und Ihre Aktivitäten ein. wallet_connect_permissions_approval_requests = Genehmigungsanfragen senden +# Payment + +errors_payment_expired = Zahlung abgelaufen +errors_payment_not_allowed = Zahlung nicht zulässig +transfer_payment_expires_in = Zahlung läuft ab in +transfer_pay_with = Bezahlen mit +transfer_payment_title = Zahlung +transfer_merchant = Händler + # Swap swap_you_pay = Sie bezahlen diff --git a/localization/app/en.ftl b/localization/app/en.ftl index 7f1e4aec4c..225e4e508a 100644 --- a/localization/app/en.ftl +++ b/localization/app/en.ftl @@ -669,6 +669,21 @@ wallet_connect_permissions_view_balance = View your balance and activity # Used in WalletConnect connection and request screens for the permissions approval requests label. wallet_connect_permissions_approval_requests = Send approval requests +# Payment + +# Used in payment link flow when the payment or its quote is no longer valid. +errors_payment_expired = Payment Expired +# Used in payment link flow when the gateway refuses the payment. +errors_payment_not_allowed = Payment not allowed +# Used in payment confirmation as the label before the expiry countdown. +transfer_payment_expires_in = Payment expires in +# Used in payment confirmation as the label for the asset the payment is made with. +transfer_pay_with = Pay with +# Used in payment confirmation as the screen title. +transfer_payment_title = Payment +# Used in payment confirmation as the label for the merchant being paid. +transfer_merchant = Merchant + # Swap # Used in Swap flow and swap details screen for the you pay label. diff --git a/localization/app/es.ftl b/localization/app/es.ftl index fb08b330c5..3c7078aec8 100644 --- a/localization/app/es.ftl +++ b/localization/app/es.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Permisos wallet_connect_permissions_view_balance = Consulta tu saldo y actividad. wallet_connect_permissions_approval_requests = Enviar solicitudes de aprobación +# Payment + +errors_payment_expired = Pago caducado +errors_payment_not_allowed = Pago no permitido +transfer_payment_expires_in = El pago caduca en +transfer_pay_with = Pagar con +transfer_payment_title = Pago +transfer_merchant = Comercio + # Swap swap_you_pay = Tu pagas diff --git a/localization/app/fa.ftl b/localization/app/fa.ftl index 9e4f086339..fcd9ba271d 100644 --- a/localization/app/fa.ftl +++ b/localization/app/fa.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = مجوزها wallet_connect_permissions_view_balance = مشاهده موجودی و فعالیت خود wallet_connect_permissions_approval_requests = ارسال درخواست‌های تأیید +# Payment + +errors_payment_expired = پرداخت منقضی شد +errors_payment_not_allowed = پرداخت مجاز نیست +transfer_payment_expires_in = انقضای پرداخت تا +transfer_pay_with = پرداخت با +transfer_payment_title = پرداخت +transfer_merchant = فروشنده + # Swap swap_you_pay = شما پرداخت میکنید diff --git a/localization/app/fil.ftl b/localization/app/fil.ftl index 1bc89b093b..893e17f06b 100644 --- a/localization/app/fil.ftl +++ b/localization/app/fil.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Mga Pahintulot wallet_connect_permissions_view_balance = Tingnan ang iyong balanse at aktibidad wallet_connect_permissions_approval_requests = Magpadala ng mga kahilingan sa pag-apruba +# Payment + +errors_payment_expired = Nag-expire ang bayad +errors_payment_not_allowed = Hindi pinapayagan ang bayad +transfer_payment_expires_in = Mag-e-expire ang bayad sa +transfer_pay_with = Bayaran gamit ang +transfer_payment_title = Bayad +transfer_merchant = Merchant + # Swap swap_you_pay = Babayaran Mo diff --git a/localization/app/fr.ftl b/localization/app/fr.ftl index 62160c511c..37e2eaa322 100644 --- a/localization/app/fr.ftl +++ b/localization/app/fr.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Autorisations wallet_connect_permissions_view_balance = Consultez votre solde et votre activité wallet_connect_permissions_approval_requests = Envoyer les demandes d'approbation +# Payment + +errors_payment_expired = Paiement expiré +errors_payment_not_allowed = Paiement non autorisé +transfer_payment_expires_in = Le paiement expire dans +transfer_pay_with = Payer avec +transfer_payment_title = Paiement +transfer_merchant = Marchand + # Swap swap_you_pay = Vous payez diff --git a/localization/app/ha.ftl b/localization/app/ha.ftl index a3ff24e123..138eae5814 100644 --- a/localization/app/ha.ftl +++ b/localization/app/ha.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Izini wallet_connect_permissions_view_balance = Duba ma'aunin ku da ayyukan ku wallet_connect_permissions_approval_requests = Aika buƙatun amincewa +# Payment + +errors_payment_expired = Biyan ya ƙare +errors_payment_not_allowed = Ba a yarda da biyan ba +transfer_payment_expires_in = Biyan zai ƙare cikin +transfer_pay_with = Biya da +transfer_payment_title = Biya +transfer_merchant = Ɗan kasuwa + # Swap swap_you_pay = Kuna Biya diff --git a/localization/app/he.ftl b/localization/app/he.ftl index 97b4451641..8fd44c2fe9 100644 --- a/localization/app/he.ftl +++ b/localization/app/he.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = הרשאות wallet_connect_permissions_view_balance = צפה ביתרה ובפעילות שלך wallet_connect_permissions_approval_requests = שלח בקשות אישור +# Payment + +errors_payment_expired = התשלום פג תוקף +errors_payment_not_allowed = התשלום אינו מורשה +transfer_payment_expires_in = התשלום יפוג בעוד +transfer_pay_with = שלם באמצעות +transfer_payment_title = תשלום +transfer_merchant = בית עסק + # Swap swap_you_pay = אתה משלם diff --git a/localization/app/hi.ftl b/localization/app/hi.ftl index 2a5d99e05a..a6a6286f52 100644 --- a/localization/app/hi.ftl +++ b/localization/app/hi.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = अनुमतियां wallet_connect_permissions_view_balance = अपना बैलेंस और गतिविधि देखें wallet_connect_permissions_approval_requests = अनुमोदन अनुरोध भेजें +# Payment + +errors_payment_expired = भुगतान समाप्त +errors_payment_not_allowed = भुगतान की अनुमति नहीं है +transfer_payment_expires_in = भुगतान समाप्त होने में +transfer_pay_with = इससे भुगतान करें +transfer_payment_title = भुगतान +transfer_merchant = व्यापारी + # Swap swap_you_pay = आप भुगतान करें diff --git a/localization/app/id.ftl b/localization/app/id.ftl index 185f494639..ddddf0a1a5 100644 --- a/localization/app/id.ftl +++ b/localization/app/id.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Izin wallet_connect_permissions_view_balance = Lihat saldo dan aktivitas Anda wallet_connect_permissions_approval_requests = Kirim permintaan persetujuan +# Payment + +errors_payment_expired = Pembayaran kedaluwarsa +errors_payment_not_allowed = Pembayaran tidak diizinkan +transfer_payment_expires_in = Pembayaran kedaluwarsa dalam +transfer_pay_with = Bayar dengan +transfer_payment_title = Pembayaran +transfer_merchant = Pedagang + # Swap swap_you_pay = Kamu Membayar diff --git a/localization/app/it.ftl b/localization/app/it.ftl index cf5564bde5..e901ea0e49 100644 --- a/localization/app/it.ftl +++ b/localization/app/it.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Autorizzazioni wallet_connect_permissions_view_balance = Visualizza il tuo saldo e la tua attività wallet_connect_permissions_approval_requests = Invia richieste di approvazione +# Payment + +errors_payment_expired = Pagamento scaduto +errors_payment_not_allowed = Pagamento non consentito +transfer_payment_expires_in = Il pagamento scade tra +transfer_pay_with = Paga con +transfer_payment_title = Pagamento +transfer_merchant = Esercente + # Swap swap_you_pay = Paghi tu diff --git a/localization/app/ja.ftl b/localization/app/ja.ftl index 30a8833e71..a5fd1f4131 100644 --- a/localization/app/ja.ftl +++ b/localization/app/ja.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = 権限 wallet_connect_permissions_view_balance = 残高とアクティビティを確認する wallet_connect_permissions_approval_requests = 承認依頼を送信する +# Payment + +errors_payment_expired = 支払いの期限切れ +errors_payment_not_allowed = 支払いは許可されていません +transfer_payment_expires_in = 支払い期限まで +transfer_pay_with = 支払い方法 +transfer_payment_title = 支払い +transfer_merchant = 加盟店 + # Swap swap_you_pay = あなたが支払う diff --git a/localization/app/ko.ftl b/localization/app/ko.ftl index 13183e9e6c..7076ec02de 100644 --- a/localization/app/ko.ftl +++ b/localization/app/ko.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = 권한 wallet_connect_permissions_view_balance = 잔액과 활동 내역을 확인하세요 wallet_connect_permissions_approval_requests = 승인 요청 보내기 +# Payment + +errors_payment_expired = 결제 만료됨 +errors_payment_not_allowed = 결제가 허용되지 않음 +transfer_payment_expires_in = 결제 만료까지 +transfer_pay_with = 결제 수단 +transfer_payment_title = 결제 +transfer_merchant = 가맹점 + # Swap swap_you_pay = 지불 금액 diff --git a/localization/app/ms.ftl b/localization/app/ms.ftl index 0d87a24c0f..726c3fc2cd 100644 --- a/localization/app/ms.ftl +++ b/localization/app/ms.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Kebenaran wallet_connect_permissions_view_balance = Lihat baki dan aktiviti anda wallet_connect_permissions_approval_requests = Hantar permintaan kelulusan +# Payment + +errors_payment_expired = Pembayaran tamat tempoh +errors_payment_not_allowed = Pembayaran tidak dibenarkan +transfer_payment_expires_in = Pembayaran tamat tempoh dalam +transfer_pay_with = Bayar dengan +transfer_payment_title = Pembayaran +transfer_merchant = Peniaga + # Swap swap_you_pay = Anda Bayar diff --git a/localization/app/nl.ftl b/localization/app/nl.ftl index bab3469b55..24cd758b4a 100644 --- a/localization/app/nl.ftl +++ b/localization/app/nl.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Toestemmingen wallet_connect_permissions_view_balance = Bekijk je saldo en activiteit wallet_connect_permissions_approval_requests = Verzoeken om goedkeuring verzenden +# Payment + +errors_payment_expired = Betaling verlopen +errors_payment_not_allowed = Betaling niet toegestaan +transfer_payment_expires_in = Betaling verloopt over +transfer_pay_with = Betalen met +transfer_payment_title = Betaling +transfer_merchant = Verkoper + # Swap swap_you_pay = Jij betaalt diff --git a/localization/app/pl.ftl b/localization/app/pl.ftl index b3ddbdb079..fe56ee15bc 100644 --- a/localization/app/pl.ftl +++ b/localization/app/pl.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Uprawnienia wallet_connect_permissions_view_balance = Wyświetl swoje saldo i aktywność wallet_connect_permissions_approval_requests = Wyślij prośby o zatwierdzenie +# Payment + +errors_payment_expired = Płatność wygasła +errors_payment_not_allowed = Płatność niedozwolona +transfer_payment_expires_in = Płatność wygasa za +transfer_pay_with = Zapłać za pomocą +transfer_payment_title = Płatność +transfer_merchant = Sprzedawca + # Swap swap_you_pay = Ty płacisz diff --git a/localization/app/pt-BR.ftl b/localization/app/pt-BR.ftl index 5d022d3a32..f1f1f7f000 100644 --- a/localization/app/pt-BR.ftl +++ b/localization/app/pt-BR.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Permissões wallet_connect_permissions_view_balance = Veja seu saldo e atividade wallet_connect_permissions_approval_requests = Enviar solicitações de aprovação +# Payment + +errors_payment_expired = Pagamento expirado +errors_payment_not_allowed = Pagamento não permitido +transfer_payment_expires_in = O pagamento expira em +transfer_pay_with = Pagar com +transfer_payment_title = Pagamento +transfer_merchant = Comerciante + # Swap swap_you_pay = Você paga diff --git a/localization/app/ro.ftl b/localization/app/ro.ftl index 0749807a78..7088d8848c 100644 --- a/localization/app/ro.ftl +++ b/localization/app/ro.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Permisiuni wallet_connect_permissions_view_balance = Vizualizați soldul și activitatea dvs. wallet_connect_permissions_approval_requests = Trimiteți cereri de aprobare +# Payment + +errors_payment_expired = Plată expirată +errors_payment_not_allowed = Plată nepermisă +transfer_payment_expires_in = Plata expiră în +transfer_pay_with = Plătește cu +transfer_payment_title = Plată +transfer_merchant = Comerciant + # Swap swap_you_pay = Tu plătești diff --git a/localization/app/ru.ftl b/localization/app/ru.ftl index c2f59cafe1..3ef89ad755 100644 --- a/localization/app/ru.ftl +++ b/localization/app/ru.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Разрешения wallet_connect_permissions_view_balance = Просматривать баланс и активность wallet_connect_permissions_approval_requests = Отправлять запросы на подтверждение +# Payment + +errors_payment_expired = Срок платежа истёк +errors_payment_not_allowed = Платёж не разрешён +transfer_payment_expires_in = Платёж истекает через +transfer_pay_with = Оплатить с помощью +transfer_payment_title = Платёж +transfer_merchant = Продавец + # Swap swap_you_pay = Вы платите diff --git a/localization/app/sw.ftl b/localization/app/sw.ftl index f445fa9b5f..a6dbcbe510 100644 --- a/localization/app/sw.ftl +++ b/localization/app/sw.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Ruhusa wallet_connect_permissions_view_balance = Tazama salio na shughuli zako wallet_connect_permissions_approval_requests = Tuma maombi ya idhini +# Payment + +errors_payment_expired = Malipo yamekwisha muda +errors_payment_not_allowed = Malipo hayaruhusiwi +transfer_payment_expires_in = Malipo yataisha baada ya +transfer_pay_with = Lipa kwa +transfer_payment_title = Malipo +transfer_merchant = Mfanyabiashara + # Swap swap_you_pay = Unalipa diff --git a/localization/app/th.ftl b/localization/app/th.ftl index c78bac243a..318f500f20 100644 --- a/localization/app/th.ftl +++ b/localization/app/th.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = สิทธิ์การเข้าถึ wallet_connect_permissions_view_balance = ตรวจสอบยอดเงินคงเหลือและกิจกรรมของคุณ wallet_connect_permissions_approval_requests = ส่งคำขออนุมัติ +# Payment + +errors_payment_expired = การชำระเงินหมดอายุ +errors_payment_not_allowed = ไม่อนุญาตให้ชำระเงิน +transfer_payment_expires_in = การชำระเงินหมดอายุใน +transfer_pay_with = ชำระด้วย +transfer_payment_title = การชำระเงิน +transfer_merchant = ร้านค้า + # Swap swap_you_pay = คุณจ่าย diff --git a/localization/app/tr.ftl b/localization/app/tr.ftl index db2cb5d0e0..635839dde5 100644 --- a/localization/app/tr.ftl +++ b/localization/app/tr.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = İzinler wallet_connect_permissions_view_balance = Bakiyenizi ve işlemlerinizi görüntüleyin. wallet_connect_permissions_approval_requests = Onay isteklerini gönderin +# Payment + +errors_payment_expired = Ödeme süresi doldu +errors_payment_not_allowed = Ödemeye izin verilmiyor +transfer_payment_expires_in = Ödemenin süresi doluyor +transfer_pay_with = Şununla öde +transfer_payment_title = Ödeme +transfer_merchant = Satıcı + # Swap swap_you_pay = Öde diff --git a/localization/app/uk.ftl b/localization/app/uk.ftl index 81febd72ed..2f09bf09d4 100644 --- a/localization/app/uk.ftl +++ b/localization/app/uk.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Дозволи wallet_connect_permissions_view_balance = Перегляд вашого балансу та активності wallet_connect_permissions_approval_requests = Надсилати запити на схвалення +# Payment + +errors_payment_expired = Термін платежу минув +errors_payment_not_allowed = Платіж не дозволено +transfer_payment_expires_in = Платіж спливає через +transfer_pay_with = Оплатити за допомогою +transfer_payment_title = Платіж +transfer_merchant = Продавець + # Swap swap_you_pay = Ви платите diff --git a/localization/app/ur.ftl b/localization/app/ur.ftl index 534a928532..0b37ea8389 100644 --- a/localization/app/ur.ftl +++ b/localization/app/ur.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = اجازتیں wallet_connect_permissions_view_balance = اپنا توازن اور سرگرمی دیکھیں wallet_connect_permissions_approval_requests = منظوری کی درخواستیں بھیجیں۔ +# Payment + +errors_payment_expired = ادائیگی کی میعاد ختم ہو گئی +errors_payment_not_allowed = ادائیگی کی اجازت نہیں +transfer_payment_expires_in = ادائیگی ختم ہونے میں +transfer_pay_with = اس سے ادائیگی کریں +transfer_payment_title = ادائیگی +transfer_merchant = تاجر + # Swap swap_you_pay = آپ ادا کرتے ہیں diff --git a/localization/app/vi.ftl b/localization/app/vi.ftl index 693c98c5fb..ef98903936 100644 --- a/localization/app/vi.ftl +++ b/localization/app/vi.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = Quyền hạn wallet_connect_permissions_view_balance = Xem số dư và hoạt động của bạn wallet_connect_permissions_approval_requests = Gửi yêu cầu phê duyệt +# Payment + +errors_payment_expired = Thanh toán đã hết hạn +errors_payment_not_allowed = Thanh toán không được phép +transfer_payment_expires_in = Thanh toán hết hạn sau +transfer_pay_with = Thanh toán bằng +transfer_payment_title = Thanh toán +transfer_merchant = Người bán + # Swap swap_you_pay = Bạn trả diff --git a/localization/app/zh-Hans.ftl b/localization/app/zh-Hans.ftl index 6b186943e5..041937a1f3 100644 --- a/localization/app/zh-Hans.ftl +++ b/localization/app/zh-Hans.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = 权限 wallet_connect_permissions_view_balance = 查看您的余额和活动 wallet_connect_permissions_approval_requests = 发送审批请求 +# Payment + +errors_payment_expired = 支付已过期 +errors_payment_not_allowed = 不允许支付 +transfer_payment_expires_in = 支付将于以下时间过期 +transfer_pay_with = 支付方式 +transfer_payment_title = 支付 +transfer_merchant = 商户 + # Swap swap_you_pay = 将支付 diff --git a/localization/app/zh-Hant.ftl b/localization/app/zh-Hant.ftl index d14325849f..9cab587139 100644 --- a/localization/app/zh-Hant.ftl +++ b/localization/app/zh-Hant.ftl @@ -355,6 +355,15 @@ wallet_connect_permissions_title = 權限 wallet_connect_permissions_view_balance = 查看您的餘額和活動 wallet_connect_permissions_approval_requests = 發送審批請求 +# Payment + +errors_payment_expired = 付款已過期 +errors_payment_not_allowed = 不允許付款 +transfer_payment_expires_in = 付款將於以下時間過期 +transfer_pay_with = 付款方式 +transfer_payment_title = 付款 +transfer_merchant = 商戶 + # Swap swap_you_pay = 將支付 diff --git a/skills/decisions.md b/skills/decisions.md index 0fe30969d9..f94e98b4d3 100644 --- a/skills/decisions.md +++ b/skills/decisions.md @@ -26,6 +26,10 @@ Google, Huawei, Samsung, Solana, and Universal flavors exist to satisfy differen `core/` is tracked source in this repository, not a Git submodule. Changes to Core and the mobile apps should land together when shared behavior, generated models, or bindings need to stay aligned. +## Payment protocol lives in Core, not in the WalletConnect SDK + +Payment link decoding and the provider client are implemented in Core (`payment_decoder`, the `payment` crate) rather than delegated to Reown's WalletKit Pay APIs. Both apps therefore share one implementation, one set of validation rules, and one test suite, and neither depends on the iOS Reown fork gaining Pay support. `PaymentProviderName` is the only provider surface the apps see, so a second provider is a Core change alone. Do not move detection or the client back behind a platform SDK — it would split the rules across two codebases, which is what this design exists to avoid. See [Payments](../docs/PAYMENTS.md). + ## Number parsing: human input vs machine strings Amount strings come from two sources that must be parsed differently. Confusing them silently corrupts amounts on locales that group thousands with a dot (de, it, es, nl, pt-BR, da), where `"1.234"` means 1234, not 1.234. Pick the parser by the source of the string, never by convenience.