diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b2e1c2b20a..8b81cfb04a 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/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7fb6394d6a..c4a5535e5d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -65,6 +65,14 @@ + + + + + + + + { + replace(pendingIntent, PendingNavigation.Route(PaymentRoute(action.link.provider.name, action.link.id))) + 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..bdb3dee9b1 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 @@ -26,6 +26,10 @@ import uniffi.gemstone.GemPreferences import uniffi.gemstone.GemServiceStatus import uniffi.gemstone.serviceStatusTimeoutSeconds import uniffi.gemstone.WalletConnectSimulationClient +import com.gemwallet.android.application.device.coordinators.GetDeviceId +import com.gemwallet.android.data.service.walletconnect.reown.ReownWalletConnectClient +import kotlinx.coroutines.runBlocking +import uniffi.gemstone.GemPaymentService import uniffi.gemstone.WalletConnectSimulationClientInterface import javax.inject.Singleton @@ -105,6 +109,17 @@ object GatewayModule { return ServiceStatusService(GemServiceStatus(provider)) } + @Provides + @Singleton + fun provideGemPaymentService( + alienProvider: AlienProvider, + getDeviceId: GetDeviceId, + ): GemPaymentService = GemPaymentService( + provider = alienProvider, + appId = ReownWalletConnectClient.PROJECT_ID, + clientId = runBlocking { getDeviceId.getDeviceId() }, + ) + @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..40dcac175f 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,11 @@ 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.ui.components.QrCodeScannerModal +import uniffi.gemstone.UrlAction +import uniffi.gemstone.urlAction @Composable fun MainScreen( @@ -66,6 +71,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) + } + }, + ) BackHandler(isRootRouteActive && currentTab.value != assetsRoute) { currentTab.value = assetsRoute @@ -185,6 +202,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..ee90e2cb75 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 uniffi.gemstone.GemPaymentLink 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: GemPaymentLink) = push(PaymentRoute(link.provider.name, 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..d46be51edb 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,8 @@ fun WalletNavGraph( cancelAction = onCancel, ) + payment(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..96252a8038 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt @@ -0,0 +1,21 @@ +package com.gemwallet.android.ui.navigation.routes + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.gemwallet.android.features.payment.presents.PaymentScene +import com.gemwallet.android.ui.models.actions.CancelAction +import kotlinx.serialization.Serializable +import uniffi.gemstone.GemPaymentProviderName + +@Serializable +data class PaymentRoute(val provider: String, val paymentId: String) : NavKey + +fun EntryProviderScope.payment(cancelAction: CancelAction) { + entry { key -> + PaymentScene( + provider = GemPaymentProviderName.valueOf(key.provider), + paymentId = key.paymentId, + onCancel = { cancelAction() }, + ) + } +} 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..09a4c35b40 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 @@ -4,7 +4,7 @@ import com.gemwallet.android.blockchain.gemstone.toPrimitives import com.wallet.core.primitives.SimulationResult import uniffi.gemstone.SignDigestType import uniffi.gemstone.WalletConnectSimulationClientInterface -import uniffi.gemstone.WalletConnectTransactionType +import uniffi.gemstone.SignableTransactionType class WalletConnectSimulationService( private val client: WalletConnectSimulationClientInterface, @@ -12,6 +12,6 @@ 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 simulateSendTransaction(chain: String, transactionType: SignableTransactionType, data: String): SimulationResult = client.simulateSendTransaction(chain = chain, transactionType = transactionType, data = data).toPrimitives() } 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/bridge/WalletConnectClient.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt index d0a529163c..5faf95f461 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt @@ -1,6 +1,6 @@ package com.gemwallet.android.data.repositories.bridge -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import kotlinx.coroutines.flow.Flow interface WalletConnectClient { @@ -54,7 +54,7 @@ sealed interface WalletConnectEvent { data class WalletConnectSession( val topic: String, val expiry: Long, - val metadata: WalletConnectionSessionAppMetadata?, + val metadata: WalletConnectAppMetadata?, val namespaces: Map, val redirect: String?, ) @@ -95,7 +95,7 @@ data class WalletConnectSessionRequest( data class WalletConnectAuthenticationRequest( val id: Long, - val metadata: WalletConnectionSessionAppMetadata?, + val metadata: WalletConnectAppMetadata?, val payloadParams: WalletConnectAuthPayloadParams, ) 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..e16f2f5c94 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 @@ -9,6 +9,7 @@ import com.gemwallet.android.cases.transactions.SaveTransactions import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.repositories.transactions.TransactionRepository import com.gemwallet.android.data.repositories.transactions.TransactionsRepositoryImpl +import uniffi.gemstone.GemPaymentService import com.gemwallet.android.data.service.store.database.TransactionsDao import dagger.Module import dagger.Provides @@ -27,12 +28,14 @@ object TransactionsModule { sessionRepository: SessionRepository, transactionsDao: TransactionsDao, gateway: GemGateway, + paymentService: GemPaymentService, ): 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..8528df6277 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 @@ -17,6 +17,11 @@ 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.toGem +import com.wallet.core.primitives.TransactionPaymentMetadata +import uniffi.gemstone.GemPaymentService +import uniffi.gemstone.GemPaymentStatus import com.gemwallet.android.ext.getTransactionSwapMetadata import com.gemwallet.android.ext.isCompleted import com.gemwallet.android.ext.toIdentifier @@ -44,6 +49,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 +69,7 @@ class TransactionsRepositoryImpl( private val sessionRepository: SessionRepository, private val transactionsDao: TransactionsDao, private val transactionStatusService: TransactionStatusService, + private val paymentService: GemPaymentService, private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) : TransactionRepository, GetChangedTransactions, @@ -258,9 +265,59 @@ class TransactionsRepositoryImpl( return ((sourceTimeout + destinationTimeout) * 3).coerceAtLeast(DateUtils.DAY_IN_MILLIS) } + private suspend fun checkPayment( + transaction: DbTransactionExtended, + metadata: TransactionPaymentMetadata, + ): DbTransactionExtended? { + val outcome = try { + paymentService.getPaymentStatus(metadata.provider.toGem(), metadata.paymentId) + } catch (_: Throwable) { + return transaction.copy(transaction = transaction.transaction.copy(updatedAt = System.currentTimeMillis())) + } + val state = when (outcome.status) { + GemPaymentStatus.SUCCEEDED -> TransactionState.Confirmed + GemPaymentStatus.FAILED, GemPaymentStatus.EXPIRED, GemPaymentStatus.CANCELLED -> TransactionState.Failed + GemPaymentStatus.PROCESSING, GemPaymentStatus.REQUIRES_ACTION -> return transaction.copy( + transaction = transaction.transaction.copy(updatedAt = System.currentTimeMillis()), + ) + } + val isAwaitingPaymentHash = transaction.transaction.hash == metadata.paymentId + if (state == TransactionState.Confirmed && isAwaitingPaymentHash) { + val settledHash = outcome.transactionId + ?: return transaction.copy(transaction = transaction.transaction.copy(updatedAt = System.currentTimeMillis())) + val walletId = transaction.transaction.walletId + val settledId = TransactionId(transaction.transaction.assetId.chain, settledHash) + if (transactionsDao.getTransactionState(settledId, walletId) != null) { + transactionsDao.delete(transaction.transaction.id, walletId) + return transactionsDao.getExtendedTransaction(walletId, settledId).firstOrNull() + } + transactionsDao.updateTransactionId( + oldId = transaction.transaction.id, + newId = settledId, + walletId = walletId, + hash = settledHash, + ) + return transaction.copy( + transaction = transaction.transaction.copy( + id = settledId, + hash = settledHash, + state = state, + updatedAt = System.currentTimeMillis(), + ), + ) + } + return transaction.copy( + transaction = transaction.transaction.copy(state = state, updatedAt = System.currentTimeMillis()), + ) + } + 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) { + return checkPayment(transaction, paymentMetadata) + } val swapMetadata = getTransactionSwapMetadata(transactionRecord.type, transactionRecord.metadata) val swapProvider = swapMetadata?.provider?.toSwapProvider() if (transactionRecord.type == TransactionType.Swap && transactionRecord.state == TransactionState.InTransit && swapProvider == null) { diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt index bba1fd5d99..180fb6e127 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt @@ -8,8 +8,8 @@ import androidx.room.PrimaryKey import com.wallet.core.primitives.Chain import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletConnection -import com.wallet.core.primitives.WalletConnectionSession -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectSession +import com.wallet.core.primitives.WalletConnectAppMetadata import com.wallet.core.primitives.WalletConnectionState @Entity( @@ -38,14 +38,14 @@ data class DbConnection( fun DbConnection.toDTO(wallet: Wallet): WalletConnection { return WalletConnection( wallet = wallet, - session = WalletConnectionSession( + session = WalletConnectSession( id = id, sessionId = sessionId, state = state, createdAt = createdAt, expireAt = expireAt, chains = chains, - metadata = WalletConnectionSessionAppMetadata( + metadata = WalletConnectAppMetadata( name = appName, description = appDescription, icon = appIcon, 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..c7f3d82d35 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 @@ -23,7 +23,7 @@ import com.reown.android.CoreClient import com.reown.android.relay.ConnectionType import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import dagger.hilt.android.qualifiers.ApplicationContext import java.net.URI import kotlinx.coroutines.channels.BufferOverflow @@ -284,7 +284,7 @@ class ReownWalletConnectClient @Inject constructor( private data class ReownAuthObject(val value: Wallet.Model.Cacao) : WalletConnectAuthObject - private companion object { + companion object { const val TAG = "WalletConnect" const val PROJECT_ID = "3bc07cd7179d11ea65335fb9377702b6" } @@ -294,14 +294,14 @@ private fun Wallet.Model.Session.toWalletConnectSession(): WalletConnectSession return WalletConnectSession( topic = topic, expiry = expiry, - metadata = metaData?.toWalletConnectionSessionAppMetadata(), + metadata = metaData?.toWalletConnectAppMetadata(), namespaces = namespaces.mapValues { it.value.toWalletConnectSessionNamespace() }, redirect = redirect, ) } -private fun Core.Model.AppMetaData.toWalletConnectionSessionAppMetadata(): WalletConnectionSessionAppMetadata { - return WalletConnectionSessionAppMetadata( +private fun Core.Model.AppMetaData.toWalletConnectAppMetadata(): WalletConnectAppMetadata { + return WalletConnectAppMetadata( name = name, description = description, url = url, @@ -359,7 +359,7 @@ private fun Wallet.Model.SessionRequest.toWalletConnectSessionRequest(): WalletC private fun Wallet.Model.SessionAuthenticate.toWalletConnectAuthenticationRequest(): WalletConnectAuthenticationRequest { return WalletConnectAuthenticationRequest( id = id, - metadata = participant.metadata?.toWalletConnectionSessionAppMetadata(), + metadata = participant.metadata?.toWalletConnectAppMetadata(), payloadParams = payloadParams.toWalletConnectAuthPayloadParams(), ) } 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..a421be9e6f 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) }, + ) + }, 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..ca60fe5566 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 @@ -31,6 +31,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 +63,18 @@ internal fun AssetsTopBar( } } }, + navigationIcon = { + IconButton( + onClick = onScan, + Modifier.testTag("assetsScanAction") + ) { + Icon( + imageVector = AppIcons.QrCodeScanner, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "scan_payment", + ) + } + }, actions = { IconButton( onClick = onSearch, 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/ProposalSceneViewModel.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt index cc81cd08b4..776e99d27f 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt @@ -13,7 +13,7 @@ import com.gemwallet.android.features.bridge.viewmodels.model.WalletConnectOrigi import com.gemwallet.android.features.bridge.viewmodels.model.toSessionUI import com.gemwallet.android.ui.models.ButtonState import com.gemwallet.android.ui.models.buttonState -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import com.wallet.core.primitives.WalletId import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -48,7 +48,7 @@ class ProposalSceneViewModel @Inject constructor( val proposal = _proposal.map { it ?: return@map null val icons = it.icons - WalletConnectionSessionAppMetadata( + WalletConnectAppMetadata( name = walletConnectAppName(it.name, it.url), description = it.description, url = it.url, diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt index a3deed347d..a217db3103 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt @@ -26,7 +26,7 @@ import com.wallet.core.primitives.Account import com.wallet.core.primitives.Chain import com.wallet.core.primitives.ChainType import com.wallet.core.primitives.Wallet -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import com.wallet.core.primitives.WalletId import com.wallet.core.primitives.WalletType import dagger.hilt.android.lifecycle.HiltViewModel @@ -316,7 +316,7 @@ class WCAuthViewModel @Inject constructor( } private fun WalletConnectAuthenticationRequest.toSessionUI(): SessionUI { - return WalletConnectionSessionAppMetadata( + return WalletConnectAppMetadata( name = walletConnectAppName(metadata?.name, metadata?.url), description = metadata?.description ?: "", url = metadata?.url ?: "", diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt index 3c0ba8a324..2cb3262186 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt @@ -25,8 +25,8 @@ import com.gemwallet.android.ui.models.hasCriticalWarning import com.wallet.core.primitives.Account import com.wallet.core.primitives.Chain import com.wallet.core.primitives.WalletConnection -import com.wallet.core.primitives.WalletConnectionSession -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectSession +import com.wallet.core.primitives.WalletConnectAppMetadata import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -189,7 +189,7 @@ class WCRequestViewModel @Inject constructor( action: WalletConnectAction, sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, chain: Chain, sessionDomain: String, ): WCRequest = when (action) { @@ -354,7 +354,7 @@ class WCRequestViewModel @Inject constructor( state.update { RequestViewModelState() } } - private fun validateChain(chain: Chain, session: WalletConnectionSession) { + private fun validateChain(chain: Chain, session: WalletConnectSession) { if (!session.chains.contains(chain)) { throw BridgeRequestError.UnresolvedChainId } diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt index f2a6f783a2..cb09658019 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt @@ -2,7 +2,7 @@ package com.gemwallet.android.features.bridge.viewmodels.model import com.gemwallet.android.ext.getShortUrl import com.gemwallet.android.ext.shortName -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata data class SessionUI( val icon: String = "", @@ -10,7 +10,7 @@ data class SessionUI( val uri: String = "", ) -fun WalletConnectionSessionAppMetadata.toSessionUI(): SessionUI { +fun WalletConnectAppMetadata.toSessionUI(): SessionUI { return SessionUI( icon = icon, name = shortName, 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..54df69677e 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,8 @@ 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.SigningRequestApp +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 @@ -13,7 +15,7 @@ import com.gemwallet.android.ui.models.withExplorerLinks import com.gemwallet.android.data.repositories.bridge.WalletConnectSessionRequest import com.wallet.core.primitives.Account import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import uniffi.gemstone.MessageSigner import com.gemwallet.android.blockchain.services.GemSignMessageOperator import com.gemwallet.android.blockchain.gemstone.toGem @@ -23,14 +25,14 @@ 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( internal val sessionRequest: WalletConnectSessionRequest, internal val account: Account, - private val appMetadata: WalletConnectionSessionAppMetadata, + private val appMetadata: WalletConnectAppMetadata, ) { internal val walletConnect = WalletConnect() @@ -49,7 +51,7 @@ sealed class WCRequest( class SignMessage( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val action: WalletConnectAction.SignMessage, val simulation: SimulationResult, private val explorerName: String?, @@ -100,10 +102,10 @@ sealed class WCRequest( abstract class Transaction( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val isSendable: Boolean, val inputType: ConfirmParams.TransferParams.InputType, - val transactionType: WalletConnectTransactionType, + val transactionType: SignableTransactionType, val data: String, val simulation: SimulationResult, ) : WCRequest(sessionRequest, account, appMetadata) { @@ -116,8 +118,8 @@ sealed class WCRequest( abstract class Signing( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, - transactionType: WalletConnectTransactionType, + appMetadata: WalletConnectAppMetadata, + transactionType: SignableTransactionType, data: String, simulation: SimulationResult, ) : Transaction( @@ -134,7 +136,7 @@ sealed class WCRequest( class SignTransaction( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val action: WalletConnectAction.SignTransaction, simulation: SimulationResult, ) : Signing( @@ -153,8 +155,8 @@ sealed class WCRequest( class SignAllTransactions( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, - transactionType: WalletConnectTransactionType, + appMetadata: WalletConnectAppMetadata, + transactionType: SignableTransactionType, data: String, simulation: SimulationResult, ) : Signing( @@ -173,7 +175,7 @@ sealed class WCRequest( class SendTransaction( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val action: WalletConnectAction.SendTransaction, simulation: SimulationResult, ) : Transaction( @@ -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, + app = SigningRequestApp( + name = request.name, + description = request.description, + url = request.url, + icon = request.icon, + ), + isSendable = isSendable, + inputType = request.inputType, +) 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 589242047a..92dc8739a6 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,5 +1,6 @@ package com.gemwallet.android.features.confirm.viewmodels +import android.util.Log import com.gemwallet.android.domains.confirm.ConfirmError import com.gemwallet.android.ext.toGemNetworkError import com.gemwallet.android.model.GemPlatformErrors @@ -12,9 +13,11 @@ internal fun Throwable.toPreloadConfirmError(chain: Chain): ConfirmError { } return toGemNetworkError() ?.let { ConfirmError.NetworkError(it) } - ?: ConfirmError.PreloadError + ?: ConfirmError.PreloadError.also { Log.e(TAG, "Preload failed on $chain", this) } } +private const val TAG = "ConfirmPreload" + internal fun Throwable.toBroadcastConfirmError(): ConfirmError = when (this) { is ConfirmError -> this else -> toGemNetworkError() diff --git a/android/features/payment/presents/build.gradle.kts b/android/features/payment/presents/build.gradle.kts new file mode 100644 index 0000000000..be829de046 --- /dev/null +++ b/android/features/payment/presents/build.gradle.kts @@ -0,0 +1,71 @@ +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")) + api(project(":data:repositories")) + 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..22b4fbf124 --- /dev/null +++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt @@ -0,0 +1,110 @@ +package com.gemwallet.android.features.payment.presents + +import android.annotation.SuppressLint +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +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.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.screen.Scene +import org.json.JSONObject + +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 ALLOWED_HOST = "walletconnect.com" + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun PaymentDataCollectionScene( + url: String, + onComplete: () -> Unit, + onError: (String?) -> Unit, + onCancel: () -> Unit, +) { + var webView by remember { mutableStateOf(null) } + + BackHandler { + val view = webView + if (view != null && view.canGoBack()) view.goBack() else onCancel() + } + + Scene( + title = stringResource(R.string.transfer_payment_title), + onClose = onCancel, + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = AllowedHostWebViewClient() + addJavascriptInterface(CollectDataBridge(onComplete, onError), MESSAGE_HANDLER) + loadUrl(url) + webView = this + } + }, + ) + } +} + +private class CollectDataBridge( + private val onComplete: () -> Unit, + private val onError: (String?) -> Unit, +) { + @JavascriptInterface + fun postMessage(payload: String) { + val message = runCatching { JSONObject(payload) }.getOrNull() ?: return + when (message.optString(MESSAGE_TYPE_KEY)) { + COMPLETE -> onComplete() + ERROR -> onError(message.optString(MESSAGE_ERROR_KEY).takeIf { it.isNotEmpty() }) + } + } +} + +private class AllowedHostWebViewClient : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: android.webkit.WebResourceRequest?): Boolean { + val uri = request?.url ?: return true + val host = uri.host?.lowercase() ?: return true + val allowed = uri.scheme == "https" && (host == ALLOWED_HOST || host.endsWith(".$ALLOWED_HOST")) + return !allowed + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + view?.evaluateJavascript(BRIDGE_SHIM, null) + } + + override fun onPageFinished(view: WebView?, url: String?) { + view?.evaluateJavascript(BRIDGE_SHIM, null) + } +} + +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/PaymentScene.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt new file mode 100644 index 0000000000..ee085c0a39 --- /dev/null +++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt @@ -0,0 +1,369 @@ +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.mutableLongStateOf +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.ConfirmScreen +import com.gemwallet.android.features.payment.viewmodels.PaymentError +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.gemwallet.android.ui.R +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_item.ListItem +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.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.models.ButtonState +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 kotlinx.coroutines.delay +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.PaymentException + +@Composable +fun PaymentScene( + provider: GemPaymentProviderName, + paymentId: String, + onCancel: () -> Unit, + viewModel: PaymentViewModel = hiltViewModel(), +) { + val state by viewModel.sceneState.collectAsStateWithLifecycle() + + LaunchedEffect(paymentId) { viewModel.onPayment(provider, paymentId) } + + when (val sceneState = state) { + PaymentSceneState.Loading -> LoadingScene( + title = stringResource(R.string.transfer_payment_title), + onCancel = onCancel, + ) + is PaymentSceneState.Quotes -> PaymentQuotesScene( + state = sceneState, + onSelect = viewModel::onSelectQuote, + onConfirm = viewModel::onConfirmQuote, + onCancel = onCancel, + ) + is PaymentSceneState.CollectData -> PaymentDataCollectionScene( + url = sceneState.url, + onComplete = viewModel::onDataCollected, + onError = viewModel::onDataCollectionError, + onCancel = onCancel, + ) + is PaymentSceneState.Approve -> ConfirmScreen( + params = sceneState.params, + finishAction = { hash -> viewModel.onActionResult(hash) }, + cancelAction = onCancel, + onAcquireAsset = { _, _ -> }, + ) + is PaymentSceneState.Confirm -> ConfirmScreen( + params = sceneState.params, + finishAction = { hash -> viewModel.onActionResult(hash) }, + cancelAction = onCancel, + onAcquireAsset = { _, _ -> }, + ) + is PaymentSceneState.SignMessage -> PaymentSignMessageScene( + state = sceneState, + onApprove = viewModel::onSign, + onCancel = onCancel, + ) + is PaymentSceneState.Outcome -> PaymentToastEffect(sceneState.outcome.messageRes(), onCancel) + is PaymentSceneState.Error -> PaymentToastEffect(sceneState.error.messageRes(), onCancel) + } +} + +@Composable +private fun PaymentQuotesScene( + state: PaymentSceneState.Quotes, + onSelect: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + var isSelectingQuote by remember { mutableStateOf(false) } + + Scene( + title = stringResource(R.string.transfer_payment_title), + backHandle = true, + onClose = onCancel, + mainAction = { + MainActionButton( + title = stringResource(R.string.common_continue), + state = if (state.expired || state.selected == null) ButtonState.Disabled else ButtonState.Enabled, + onClick = onConfirm, + ) + }, + ) { + 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 = { + onSelect(it) + isSelectingQuote = false + }, + onDismissRequest = { isSelectingQuote = false }, + ) +} + +@Composable +private fun PaymentQuotesSelectModal( + isVisible: Boolean, + quotes: List, + selected: String?, + onSelect: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + ModalBottomSheet( + isVisible = isVisible, + onDismissRequest = onDismissRequest, + ) { + LazyColumn { + itemsIndexed(quotes) { index, quote -> + ListItem( + modifier = Modifier.clickable { onSelect(quote.id) }, + leading = { + if (quote.id == selected) { + IconWithBadge( + icon = quote.iconUrl, + placeholder = quote.symbol, + badge = { SelectionCheckmark() }, + ) + } else { + IconWithBadge( + icon = quote.iconUrl, + placeholder = quote.symbol, + supportIcon = quote.supportIconUrl, + ) + } + }, + title = { ListItemTitleText(quote.symbol) }, + trailing = { ListItemSupportText(quote.amount) }, + listPosition = ListPosition.getPosition(index, quotes.size), + ) + } + } + } +} + +@Composable +private fun PropertyExpiryItem( + title: String, + expiresAt: Long, + listPosition: ListPosition, +) { + var remaining by remember(expiresAt) { mutableLongStateOf(expiresAt - System.currentTimeMillis()) } + LaunchedEffect(expiresAt) { + while (remaining > 0) { + delay(1000) + remaining = expiresAt - System.currentTimeMillis() + } + } + val seconds = (remaining / 1000).coerceAtLeast(0) + PropertyItem( + title = title, + data = "%d:%02d".format(seconds / 60, seconds % 60), + listPosition = listPosition, + ) +} + +@Composable +private fun PaymentSignMessageScene( + state: PaymentSceneState.SignMessage, + onApprove: () -> Unit, + onCancel: () -> Unit, +) { + val context = LocalContext.current + var sheetType by remember { mutableStateOf(null) } + + Scene( + title = stringResource(R.string.transfer_review_request), + backHandle = true, + closeIcon = true, + onClose = onCancel, + mainAction = { + MainActionButton(title = stringResource(R.string.transfer_confirm)) { + context.requestAuth(AuthRequest.Confirmation) { onApprove() } + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = paddingValues.calculateBottomPadding() + paddingDefault), + ) { + item { + CenteredListHead( + icon = state.merchant.iconUrl, + title = state.merchant.name, + placeholderText = state.merchant.name.firstOrNull()?.uppercaseChar()?.toString(), + ) + } + item { PropertyItem(R.string.common_wallet, state.walletName, listPosition = ListPosition.First) } + item { PropertyNetworkItem(state.chain, listPosition = ListPosition.Last) } + 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 PaymentError.messageRes(): Int = when (this) { + PaymentError.NoWallet, + PaymentError.NoQuotes, + PaymentError.QuoteUnavailable, + PaymentError.NoAccount -> R.string.errors_not_supported + PaymentError.WatchWallet -> R.string.wallet_watch_tooltip_title + PaymentError.DataCollection -> R.string.errors_error_occurred + PaymentError.UnknownAsset -> R.string.errors_error_occurred + is PaymentError.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..bcdba43c48 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt @@ -0,0 +1,53 @@ +package com.gemwallet.android.features.payment.viewmodels + +import com.wallet.core.primitives.Wallet +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.PaymentAction + +internal data class ActivePayment( + val provider: GemPaymentProviderName, + val quotes: GemPaymentQuotes, + val wallet: Wallet, + val quote: GemPaymentQuote? = null, + val collecting: GemPaymentQuote? = null, + val actions: List = emptyList(), + val order: List = emptyList(), + val results: List = emptyList(), + val completed: Int = 0, +) { + val step: Step? + get() = order.getOrNull(completed)?.let { Step(actions[it], it) } + + fun collecting(quote: GemPaymentQuote) = copy(collecting = quote) + + fun prepared(quote: GemPaymentQuote, actions: List) = copy( + quote = quote, + collecting = null, + actions = actions, + order = signaturesFirst(actions), + results = List(actions.size) { "" }, + completed = 0, + ) + + fun completing(result: String): ActivePayment { + val index = order.getOrNull(completed) ?: return this + return copy( + results = results.toMutableList().also { it[index] = result }, + completed = completed + 1, + ) + } + + val isRelayed: Boolean + get() = actions.none { it is PaymentAction.SendTransaction } + + data class Step(val action: PaymentAction, val index: Int) +} + +private fun signaturesFirst(actions: List): List { + val signatures = actions.indices.filter { + actions[it] is PaymentAction.SignMessage || actions[it] is PaymentAction.SignTransaction + } + return signatures + actions.indices.filter { it !in signatures } +} 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..5e36302e04 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt @@ -0,0 +1,66 @@ +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.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 plainMessage: String, + val primaryPayloadFields: List, + val secondaryPayloadFields: List, + ) : PaymentSceneState { + val hasPayload: Boolean + get() = primaryPayloadFields.isNotEmpty() || secondaryPayloadFields.isNotEmpty() + } + + data class Outcome(val outcome: PaymentOutcomeUIModel) : PaymentSceneState + + data class Error(val error: PaymentError) : PaymentSceneState +} + +sealed interface PaymentError { + data object NoWallet : PaymentError + data object WatchWallet : PaymentError + data object NoQuotes : PaymentError + data object QuoteUnavailable : PaymentError + data object NoAccount : PaymentError + data object DataCollection : PaymentError + data object UnknownAsset : PaymentError + data class Gateway(val error: PaymentException?) : PaymentError +} 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..bfc08b2afb --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt @@ -0,0 +1,316 @@ +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.toPrimitives +import com.gemwallet.android.blockchain.services.GemSignMessageOperator +import com.gemwallet.android.cases.tokens.SearchTokensCase +import com.gemwallet.android.data.repositories.session.SessionRepository +import com.gemwallet.android.ext.SigningRequestApp +import com.gemwallet.android.ext.runCatchingCancellable +import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.ext.toChain +import com.gemwallet.android.ext.toConfirmParams +import com.gemwallet.android.ext.toPrimitives +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.ConfirmParams +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.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.ChainAddress as GemChainAddress +import uniffi.gemstone.GemPaymentLink +import uniffi.gemstone.GemPaymentOptions +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.GemPaymentService +import uniffi.gemstone.MessageSigner +import uniffi.gemstone.PaymentAction +import uniffi.gemstone.PaymentException +import uniffi.gemstone.SignableTransaction + +@HiltViewModel +class PaymentViewModel @Inject constructor( + private val paymentService: GemPaymentService, + 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: GemPaymentProviderName, paymentId: String) { + val link = GemPaymentLink(provider = provider, id = paymentId) + state.value = PaymentSceneState.Loading + viewModelScope.launch(Dispatchers.IO) { + val wallet = wallet() ?: return@launch + val options = runGateway { paymentService.getPaymentOptions(link, wallet.addresses()) } ?: return@launch + when (options) { + is GemPaymentOptions.Outcome -> state.value = PaymentSceneState.Outcome(options.v1.status.toUIModel()) + is GemPaymentOptions.Quotes -> { + payment.value = ActivePayment(link.provider, options.v1, wallet) + val quotes = options.v1 + 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(PaymentError.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(PaymentError.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 fun GemPaymentQuotes.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() }, + selected = quotes.firstOrNull()?.id, + expiresAt = expiresAt?.times(1000), + expired = false, + ) + + private fun watchExpiry(quotes: GemPaymentQuotes) { + val expiresAt = quotes.expiresAt ?: return + expiryJob = viewModelScope.launch(Dispatchers.IO) { + delay((expiresAt * 1000 - System.currentTimeMillis()).coerceAtLeast(0)) + val current = state.value + if (current is PaymentSceneState.Quotes) { + state.value = current.copy(expired = true) + } + } + } + + private suspend fun select(quote: GemPaymentQuote?) { + if (quote == null) { + state.value = PaymentSceneState.Error(PaymentError.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: GemPaymentQuote) { + val current = payment.value ?: return + val prepared = runGateway { + paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet.addresses()) + } ?: return + payment.value = current.prepared(prepared.quote, prepared.actions) + 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(current.provider.toPrimitives(), current.quotes, 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 + } + state.value = 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) + } + } + + private fun signMessageState( + action: PaymentAction.SignMessage, + current: ActivePayment, + ): PaymentSceneState { + val chain = action.message.chain.toChain() ?: return PaymentSceneState.Error(PaymentError.NoAccount) + val signer = runCatching { MessageSigner(action.message) }.getOrNull() + val preview = signer?.let { runCatching { it.payloadPreview(emptyList()) }.getOrNull() } + return PaymentSceneState.SignMessage( + merchant = current.quotes.merchant.toUIModel(), + chain = chain, + walletName = current.wallet.name, + plainMessage = signer?.let { runCatching { it.plainPreview() }.getOrNull() }.orEmpty(), + primaryPayloadFields = preview?.primary?.map { it.toPrimitives() }.orEmpty() + .withExplorerLinks(chain, null), + secondaryPayloadFields = preview?.secondary?.map { it.toPrimitives() }.orEmpty() + .withExplorerLinks(chain, null), + ) + } + + private suspend fun approvalState( + action: PaymentAction.ApproveToken, + current: ActivePayment, + ): PaymentSceneState { + val account = current.wallet.account(action.chain) ?: return failure(PaymentError.NoAccount, "approval: no ${action.chain} account") + val assetId = current.quote?.amount?.assetId?.toAssetId() + ?: return failure(PaymentError.UnknownAsset, "approval: bad quote asset ${current.quote?.amount?.assetId}") + val asset = asset(assetId) ?: return failure(PaymentError.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, + ) + ) + } + + private fun confirmState( + chain: String, + transaction: SignableTransaction, + isSendable: Boolean, + current: ActivePayment, + ): PaymentSceneState { + val account = current.wallet.account(chain) ?: return PaymentSceneState.Error(PaymentError.NoAccount) + return PaymentSceneState.Confirm( + transaction.toConfirmParams( + requestId = current.quote?.paymentId.orEmpty(), + account = account, + app = SigningRequestApp( + name = current.quotes.merchant.name, + description = current.quotes.merchant.name, + url = "", + icon = current.quotes.merchant.iconUrl.orEmpty(), + ), + isSendable = isSendable, + inputType = if (isSendable) { + ConfirmParams.TransferParams.InputType.EncodeTransaction + } else { + ConfirmParams.TransferParams.InputType.Signature + }, + ) + ) + } + + private fun failure(error: PaymentError, reason: String): PaymentSceneState { + Log.e(TAG, reason) + return PaymentSceneState.Error(error) + } + + private suspend fun asset(assetId: AssetId): Asset? = getAssetInfo(assetId).firstOrNull()?.asset + ?: sessionRepository.session().firstOrNull()?.currency + ?.also { searchTokensCase.search(assetId, it) } + ?.let { getAssetInfo(assetId).firstOrNull()?.asset } + + private suspend fun wallet(): Wallet? { + val wallet = sessionRepository.session().firstOrNull()?.wallet + if (wallet == null) { + state.value = PaymentSceneState.Error(PaymentError.NoWallet) + return null + } + if (wallet.type == WalletType.View) { + state.value = PaymentSceneState.Error(PaymentError.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(PaymentError.Gateway(err as? PaymentException)) + } + .getOrNull() + + private fun Wallet.addresses(): List = + accounts.map { GemChainAddress(chain = it.chain.string, address = it.address) } + + private fun Wallet.account(chain: String): Account? = + accounts.firstOrNull { it.chain.string == chain } + + 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..968fd1ffc9 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt @@ -0,0 +1,70 @@ +package com.gemwallet.android.features.payment.viewmodels + +import android.util.Log +import com.gemwallet.android.cases.transactions.CreateTransaction +import com.gemwallet.android.ext.toAssetId +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.PaymentMerchant +import com.wallet.core.primitives.PaymentProviderName +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 uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import java.math.BigInteger +import javax.inject.Inject + +class RecordPayment @Inject constructor( + private val createTransaction: CreateTransaction, +) { + suspend operator fun invoke( + provider: PaymentProviderName, + quotes: GemPaymentQuotes, + quote: GemPaymentQuote, + wallet: Wallet, + ) { + val assetId = quote.amount.assetId.toAssetId() + if (assetId == null) { + Log.e(TAG, "Record payment: bad asset ${quote.amount.assetId}") + return + } + 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 = TransactionPaymentMetadata( + paymentId = quote.paymentId, + merchant = PaymentMerchant(quotes.merchant.name, quotes.merchant.iconUrl), + provider = provider, + ).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..d63dc2269a --- /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 uniffi.gemstone.GemPaymentMerchant + +data class PaymentMerchantUIModel( + val name: String, + val iconUrl: String?, +) + +fun GemPaymentMerchant.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..dd5e843e93 --- /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 uniffi.gemstone.GemPaymentStatus + +enum class PaymentOutcomeUIModel { + Success, + Pending, + Cancelled, + Expired, + Failed, +} + +fun GemPaymentStatus.toUIModel() = when (this) { + GemPaymentStatus.SUCCEEDED -> PaymentOutcomeUIModel.Success + GemPaymentStatus.PROCESSING -> PaymentOutcomeUIModel.Pending + GemPaymentStatus.CANCELLED -> PaymentOutcomeUIModel.Cancelled + GemPaymentStatus.EXPIRED -> PaymentOutcomeUIModel.Expired + GemPaymentStatus.FAILED, + GemPaymentStatus.REQUIRES_ACTION -> 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..ad5227e2b9 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt @@ -0,0 +1,35 @@ +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.toAssetId +import com.gemwallet.android.model.Crypto +import com.gemwallet.android.model.ValueFormatter +import uniffi.gemstone.GemPaymentPrice +import uniffi.gemstone.GemPaymentQuote + +private val amountFormatter = ValueFormatter(ValueFormatter.Style.Short) +private val priceFormatter = ValueFormatter(ValueFormatter.Style.Full) + +data class PaymentQuoteUIModel( + val id: String, + val symbol: String, + val amount: String, + val iconUrl: String?, + val supportIconUrl: String?, +) { + val amountText: String get() = "$amount $symbol" +} + +fun GemPaymentQuote.toUIModel(): PaymentQuoteUIModel { + val assetId = amount.assetId.toAssetId() + return PaymentQuoteUIModel( + id = id, + symbol = amount.symbol, + amount = amountFormatter.string(Crypto(amount.value).value(amount.decimals)), + iconUrl = assetId?.getIconUrl(), + supportIconUrl = assetId?.getSupportIconUrl(), + ) +} + +fun GemPaymentPrice.toPriceText(): String = priceFormatter.string(Crypto(value).value(decimals), currency = 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..8db2932c17 --- /dev/null +++ b/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt @@ -0,0 +1,84 @@ +package com.gemwallet.android.features.payment.viewmodels + +import com.gemwallet.android.testkit.mockWallet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import uniffi.gemstone.GemApprovalData +import uniffi.gemstone.GemPaymentAmount +import uniffi.gemstone.GemPaymentMerchant +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.PaymentAction +import uniffi.gemstone.SignDigestType +import uniffi.gemstone.SignMessage + +class ActivePaymentTest { + + @Test + fun `signs before it spends`() { + val payment = payment(listOf(approve(), signMessage())) + + assertEquals(listOf(1, 0), payment.order) + } + + @Test + fun `results keep the gateway's positions while signing first`() { + var payment = payment(listOf(approve(), signMessage())) + + payment = payment.completing("signature") + payment = payment.completing("approval-hash") + + 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 = GemPaymentProviderName.WALLET_CONNECT_PAY, + quotes = quotes(), + wallet = mockWallet(), + ).prepared(quote(), actions) + + private fun quotes() = GemPaymentQuotes( + merchant = GemPaymentMerchant(name = "Gem Wallet Test Merchant", iconUrl = null), + price = null, + expiresAt = null, + quotes = listOf(quote()), + ) + + private fun quote() = GemPaymentQuote( + id = "opt_1", + paymentId = "pay_1", + amount = GemPaymentAmount( + assetId = "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/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..794e8f71ca --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt @@ -0,0 +1,37 @@ +package com.gemwallet.android.ext + +import com.wallet.core.primitives.Payment +import com.wallet.core.primitives.PaymentLink +import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.PaymentRequest +import uniffi.gemstone.GemPayment +import uniffi.gemstone.GemPaymentLink +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentRequest + +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 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 +} 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..c3b2605152 --- /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 uniffi.gemstone.SignableTransaction +import uniffi.gemstone.TransferDataOutputType +import java.math.BigInteger + +data class SigningRequestApp( + val name: String, + val description: String, + val url: String, + val icon: String, +) + +fun SignableTransaction.toConfirmParams( + requestId: String, + account: Account, + app: SigningRequestApp, + isSendable: Boolean, + inputType: ConfirmParams.TransferParams.InputType?, +): ConfirmParams.TransferParams.Generic { + val asset = account.chain.asset() + return when (this) { + is SignableTransaction.Ethereum -> generic( + requestId = requestId, + asset = asset, + account = account, + app = app, + memo = data.data, + gasLimit = data.gasLimit, + inputType = inputType, + destination = DestinationAddress(data.to), + amount = data.value?.hexToBigInteger() ?: BigInteger.ZERO, + isSendable = isSendable, + transactionType = transactionType.toPrimitives(), + ) + is SignableTransaction.Solana -> encoded(requestId, asset, account, app, data.transaction, outputType, isSendable) + is SignableTransaction.Sui -> encoded(requestId, asset, account, app, data.transaction, outputType, isSendable) + is SignableTransaction.Ton -> encoded(requestId, asset, account, app, data, outputType, isSendable) + is SignableTransaction.Tron -> encoded(requestId, asset, account, app, data, outputType, isSendable) + } +} + +private fun encoded( + requestId: String, + asset: com.wallet.core.primitives.Asset, + account: Account, + app: SigningRequestApp, + payload: String, + outputType: TransferDataOutputType, + isSendable: Boolean, +) = generic( + requestId = requestId, + asset = asset, + account = account, + app = app, + 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, +) + +private fun generic( + requestId: String, + asset: com.wallet.core.primitives.Asset, + account: Account, + app: SigningRequestApp, + memo: String?, + gasLimit: String?, + inputType: ConfirmParams.TransferParams.InputType?, + destination: DestinationAddress, + amount: BigInteger, + isSendable: Boolean, + transactionType: com.wallet.core.primitives.TransactionType = com.wallet.core.primitives.TransactionType.SmartContractCall, +) = ConfirmParams.TransferParams.Generic( + requestId = requestId, + asset = asset, + from = account, + memo = memo, + name = app.name, + description = app.description, + url = app.url, + icon = app.icon, + gasLimit = gasLimit, + inputType = inputType, + destination = destination, + amount = amount, + isSendable = isSendable, + decodedTransactionType = transactionType, +) 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/WalletConnector.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt index 3abe85a4c1..2066774859 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 @@ -1,17 +1,9 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.Account -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata -import uniffi.gemstone.GemWalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata 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, @@ -19,8 +11,8 @@ fun Account.toGem() = uniffi.gemstone.Account( extendedPublicKey = extendedPublicKey, ) -val WalletConnectionSessionAppMetadata.shortName: String - get() = walletConnectAppShortName(toGem()) +val WalletConnectAppMetadata.shortName: String + 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..059ebb53db 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 @@ -23,7 +23,7 @@ import com.wallet.core.primitives.NFTAsset import com.wallet.core.primitives.PerpetualType import com.wallet.core.primitives.Resource 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 +33,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 @@ -195,7 +195,7 @@ sealed class ConfirmParams() { val type = requireNotNull(inputType) { "inputType is required for Generic transactions" } return Generic( asset = asset.toGem(), - metadata = GemWalletConnectionSessionAppMetadata( + appMetadata = GemTransactionAppMetadata( name = name, description = description, url = url, diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt deleted file mode 100644 index de541d0a84..0000000000 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.wallet.core.primitives - -typealias ApprovalData = com.wallet.core.primitives.swap.ApprovalData diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.kt new file mode 100644 index 0000000000..6dc574f7dc --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.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 ApprovalData ( + val token: String, + val spender: String, + val value: String, + val isUnlimited: 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..8dda616939 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Signing.kt @@ -0,0 +1,24 @@ +/** + * 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 +) + 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/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt index 2def50e3a9..ed29f52630 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName @Serializable -data class WalletConnectionSessionAppMetadata ( +data class WalletConnectAppMetadata ( val name: String, val description: String, val url: String, @@ -16,10 +16,10 @@ data class WalletConnectionSessionAppMetadata ( ) @Serializable -data class WalletConnectionSessionProposal ( +data class WalletConnectSessionProposal ( val defaultWallet: Wallet, val wallets: List, - val metadata: WalletConnectionSessionAppMetadata + val metadata: WalletConnectAppMetadata ) @Serializable @@ -37,7 +37,7 @@ enum class WalletConnectionVerificationStatus(val string: String) { @Serializable data class WCPairingProposal ( val pairingId: String, - val proposal: WalletConnectionSessionProposal, + val proposal: WalletConnectSessionProposal, val verificationStatus: WalletConnectionVerificationStatus ) @@ -52,19 +52,19 @@ enum class WalletConnectionState(val string: String) { } @Serializable -data class WalletConnectionSession ( +data class WalletConnectSession ( val id: String, val sessionId: String, val state: WalletConnectionState, val chains: List, val createdAt: SerializedDate, val expireAt: SerializedDate, - val metadata: WalletConnectionSessionAppMetadata + val metadata: WalletConnectAppMetadata ) @Serializable data class WalletConnection ( - val session: WalletConnectionSession, + val session: WalletConnectSession, val wallet: Wallet ) diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt index 7b43f82806..faf8a64184 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt @@ -7,14 +7,6 @@ package com.wallet.core.primitives.swap import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName -@Serializable -data class ApprovalData ( - val token: String, - val spender: String, - val value: String, - val isUnlimited: Boolean -) - @Serializable data class SwapProviderData ( val provider: SwapProvider, diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt new file mode 100644 index 0000000000..24c10ce3e7 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt @@ -0,0 +1,3 @@ +package com.wallet.core.primitives.swap + +typealias ApprovalData = com.wallet.core.primitives.ApprovalData 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/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 41d8bdce76..376e1ce69c 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,23 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payment" +version = "2.114.5" +dependencies = [ + "async-trait", + "chrono", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "gem_wallet_connect", + "primitives", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/core/Cargo.toml b/core/Cargo.toml index ed23e13fac..cdd991d937 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..b91b48bd70 100644 --- a/core/crates/gem_evm/src/signer/chain_signer.rs +++ b/core/crates/gem_evm/src/signer/chain_signer.rs @@ -172,7 +172,7 @@ fn sign_contract_call( call_data: Vec, gas_limit: u64, value: U256, - approval: Option<&primitives::swap::ApprovalData>, + approval: Option<&primitives::ApprovalData>, private_key: &[u8], ) -> Result, SignerError> { let params = TransactionParams::from_input(input)?; @@ -200,8 +200,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; use primitives::{ - Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, WalletConnectionSessionAppMetadata, - contract_call_data::ContractCallData, nft::NFTAsset, swap::*, + ApprovalData, Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionAppMetadata, TransactionInputType, TransactionLoadMetadata, + TransferDataExtra, contract_call_data::ContractCallData, nft::NFTAsset, swap::*, }; #[test] @@ -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/provider/preload.rs b/core/crates/gem_tron/src/provider/preload.rs index ff786e899a..f098f97539 100644 --- a/core/crates/gem_tron/src/provider/preload.rs +++ b/core/crates/gem_tron/src/provider/preload.rs @@ -8,9 +8,9 @@ use num_bigint::BigInt; use gem_client::Client; use number_formatter::BigNumberFormatter; use primitives::{ - Asset, AssetSubtype, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, - TransactionPreloadInput, TransferDataOutputAction, TronStakeData, decode_hex, - swap::{ApprovalData, SwapData, SwapQuoteData, SwapQuoteDataType}, + ApprovalData, Asset, AssetSubtype, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, + TransactionLoadMetadata, TransactionPreloadInput, TransferDataOutputAction, TronStakeData, decode_hex, + swap::{SwapData, SwapQuoteData, SwapQuoteDataType}, }; use crate::{ diff --git a/core/crates/gem_tron/src/signer/chain_signer.rs b/core/crates/gem_tron/src/signer/chain_signer.rs index 02e653309c..657f575012 100644 --- a/core/crates/gem_tron/src/signer/chain_signer.rs +++ b/core/crates/gem_tron/src/signer/chain_signer.rs @@ -45,10 +45,10 @@ 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, - swap::{ApprovalData, SwapData, SwapQuote, SwapQuoteData}, + ApprovalData, 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::{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_tron/src/signer/transaction.rs b/core/crates/gem_tron/src/signer/transaction.rs index 0d7490263d..5bf03f6c0b 100644 --- a/core/crates/gem_tron/src/signer/transaction.rs +++ b/core/crates/gem_tron/src/signer/transaction.rs @@ -1,7 +1,7 @@ use gem_hash::sha2::sha256; use primitives::{ - SignerError, SignerInput, StakeType, TransactionLoadMetadata, TronStakeData, decode_hex, - swap::{ApprovalData, SwapQuoteData, SwapQuoteDataType}, + ApprovalData, SignerError, SignerInput, StakeType, TransactionLoadMetadata, TronStakeData, decode_hex, + swap::{SwapQuoteData, SwapQuoteDataType}, }; use signer::{SignatureScheme, Signer}; 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/sign_type.rs b/core/crates/gem_wallet_connect/src/sign_type.rs deleted file mode 100644 index 1e599c6ea2..0000000000 --- a/core/crates/gem_wallet_connect/src/sign_type.rs +++ /dev/null @@ -1,19 +0,0 @@ -use primitives::Chain; - -#[derive(Debug, Clone, PartialEq)] -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, -} 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..8fd5e0aba5 --- /dev/null +++ b/core/crates/payment/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "payment" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +primitives = { path = "../primitives" } +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..35ad1af949 --- /dev/null +++ b/core/crates/payment/src/action.rs @@ -0,0 +1,68 @@ +use primitives::{ApprovalData, 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) + } +} + +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(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::TransferDataOutputType; + + fn send(chain: Chain) -> PaymentAction { + PaymentAction::SendTransaction { + chain, + transaction: SignableTransaction::Ton { + data: String::new(), + output_type: TransferDataOutputType::EncodedTransaction, + }, + } + } + + #[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..884c53b6f4 --- /dev/null +++ b/core/crates/payment/src/lib.rs @@ -0,0 +1,13 @@ +mod action; +mod config; +mod error; +mod provider; +mod service; +mod wallet_connect_pay; + +pub use action::{PaymentAction, PreparedPayment}; +pub use config::PaymentConfig; +pub use error::PaymentError; +pub use provider::PaymentProvider; +pub use service::PaymentService; +pub use wallet_connect_pay::WalletConnectPayAuth; diff --git a/core/crates/payment/src/provider.rs b/core/crates/payment/src/provider.rs new file mode 100644 index 0000000000..81abc4b2d5 --- /dev/null +++ b/core/crates/payment/src/provider.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use primitives::{ChainAddress, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::action::PreparedPayment; +use crate::error::PaymentError; + +#[async_trait] +pub trait PaymentProvider: Send + Sync { + fn name(&self) -> PaymentProviderName; + + async fn get_options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result; + async fn get_prepared_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result; + async fn confirm(&self, quote: &PaymentQuote, action_results: Vec) -> Result; + async fn get_status(&self, payment_id: &str) -> Result; + async fn cancel(&self, payment_id: &str) -> Result<(), PaymentError>; +} diff --git a/core/crates/payment/src/service.rs b/core/crates/payment/src/service.rs new file mode 100644 index 0000000000..50f62a564b --- /dev/null +++ b/core/crates/payment/src/service.rs @@ -0,0 +1,61 @@ +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::provider::PaymentProvider; +use crate::wallet_connect_pay::{WALLET_CONNECT_PAY_API_URL, WalletConnectPayService}; + +pub struct PaymentService { + providers: Vec>, +} + +impl PaymentService { + pub fn new(provider: Arc, config: PaymentConfig) -> Self { + Self { + providers: vec![Box::new(WalletConnectPayService::new( + RpcClient::new(WALLET_CONNECT_PAY_API_URL.to_string(), provider), + config.wallet_connect_pay, + ))], + } + } + + pub async fn get_options(&self, link: &PaymentLink, addresses: &[ChainAddress]) -> Result { + self.provider(link.provider)?.get_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)?.get_prepared_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(quote, action_results).await + } + + pub async fn get_status(&self, provider: PaymentProviderName, payment_id: &str) -> Result { + self.provider(provider)?.get_status(payment_id).await + } + + pub async fn cancel(&self, provider: PaymentProviderName, payment_id: &str) -> Result<(), PaymentError> { + self.provider(provider)?.cancel(payment_id).await + } + + fn provider(&self, name: PaymentProviderName) -> Result<&dyn PaymentProvider, PaymentError> { + self.providers + .iter() + .find(|provider| provider.name() == name) + .map(|provider| provider.as_ref()) + .ok_or(PaymentError::NotSupported) + } +} 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..b2dd988439 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs @@ -0,0 +1,212 @@ +use gem_evm::call_decoder::decode_call; +use gem_wallet_connect::{WalletConnectAction, WalletConnectRequestHandler, decode_sign_message}; +use primitives::payment_decoder::wallet_connect_pay::WALLET_CONNECT_PAY_HOST; +use primitives::{ApprovalData, SignableTransaction, SignableTransactionType, TransferDataOutputType}; + +use crate::PaymentAction; +use crate::wallet_connect_pay::error::WalletConnectPayError; +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::normalize(&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(WalletConnectPayError::InvalidRequest)?; + if let WalletConnectAction::Unsupported { method } = action { + return Err(WalletConnectPayError::Unsupported(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(WalletConnectPayError::InvalidRequest)?, + }), + WalletConnectAction::SendTransaction { chain, transaction_type, data } => { + let transaction = WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(WalletConnectPayError::InvalidRequest)?; + Ok(match map_approval(&transaction) { + Some(approval) => PaymentAction::ApproveToken { chain, approval }, + None => PaymentAction::SendTransaction { chain, transaction }, + }) + } + WalletConnectAction::SignAllTransactions { .. } => Err(WalletConnectPayError::Unsupported("signAllTransactions".to_string())), + WalletConnectAction::ChainOperation { .. } => Err(WalletConnectPayError::Unsupported("chainOperation".to_string())), + WalletConnectAction::GetAccounts { .. } => Err(WalletConnectPayError::Unsupported("getAccounts".to_string())), + WalletConnectAction::Unsupported { method } => Err(WalletConnectPayError::Unsupported(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()); + Some(ApprovalData::new(data.to.clone(), param(APPROVE_SPENDER)?, param(APPROVE_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, + } +} + +#[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(WalletConnectPayError::InvalidRequest(message)) if message.contains("mismatch") + )); + assert!(matches!( + map_wallet_rpc(mismatched_account, &actions[1]), + Err(WalletConnectPayError::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(WalletConnectPayError::Unsupported(method)) if method == "eth_sign" + )); + + assert!(matches!( + map_wallet_rpc("invalid-account", &actions[0]), + Err(WalletConnectPayError::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..b874322893 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/client.rs @@ -0,0 +1,235 @@ +use gem_client::{Client, build_path_with_query}; +use std::collections::HashMap; + +use crate::wallet_connect_pay::error::WalletConnectPayError; +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, WalletConnectPayError> { + 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 cancel(&self, payment_id: &str) -> Result<(), WalletConnectPayError> { + let path = Self::path(payment_id, "/cancel", &[])?; + let _: serde_json::Value = self.client.post_with(&path, &serde_json::Value::Null, self.headers()).await?; + Ok(()) + } + + 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(WalletConnectPayError::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| WalletConnectPayError::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(WalletConnectPayError::PaymentExpired)); + + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 404, body: vec![] }), + WalletConnectPayError::PaymentNotFound + ); + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 409, body: vec![] }), + WalletConnectPayError::QuoteExpired + ); + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 429, body: vec![] }), + WalletConnectPayError::RateLimited + ); + assert!(matches!( + WalletConnectPayError::from(ClientError::Http { + status: 400, + body: b"bad".to_vec() + }), + WalletConnectPayError::InvalidRequest(_) + )); + assert!(matches!( + WalletConnectPayError::from(ClientError::Http { status: 500, body: vec![] }), + WalletConnectPayError::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..7d2496bf69 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/error.rs @@ -0,0 +1,128 @@ +use gem_client::ClientError; +use std::fmt; + +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"; + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectPayError { + PaymentNotFound, + PaymentExpired, + NoPaymentOptions, + UnsupportedAccounts, + QuoteExpired, + RateLimited, + SanctionedUser, + InvalidRequest(String), + Unsupported(String), + Network(String), +} + +impl fmt::Display for WalletConnectPayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PaymentNotFound => write!(f, "Payment not found"), + Self::PaymentExpired => write!(f, "Payment expired"), + Self::NoPaymentOptions => write!(f, "No payment options available"), + Self::UnsupportedAccounts => write!(f, "Wallet has no account the payment accepts"), + Self::QuoteExpired => write!(f, "Payment quote expired"), + Self::RateLimited => write!(f, "Rate limited"), + Self::SanctionedUser => write!(f, "Payment not allowed"), + Self::InvalidRequest(msg) => write!(f, "Invalid payment request: {msg}"), + Self::Unsupported(msg) => write!(f, "Unsupported payment action: {msg}"), + Self::Network(msg) => write!(f, "Network error: {msg}"), + } + } +} + +impl std::error::Error for WalletConnectPayError {} + +impl From for WalletConnectPayError { + 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 WalletConnectPayError { + 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::SanctionedUser, + _ 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!(WalletConnectPayError::from(stale), WalletConnectPayError::QuoteExpired); + + let invalid = ClientError::Http { + status: 400, + body: br#"{"message":"Invalid URL"}"#.to_vec(), + }; + assert!(matches!(WalletConnectPayError::from(invalid), WalletConnectPayError::InvalidRequest(_))); + + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 410, body: vec![] }), + WalletConnectPayError::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!(WalletConnectPayError::from(sanctioned), WalletConnectPayError::SanctionedUser); + + let not_found = ClientError::Http { + status: 400, + body: br#"{"code":"payment_not_found","message":"No such payment"}"#.to_vec(), + }; + assert_eq!(WalletConnectPayError::from(not_found), WalletConnectPayError::PaymentNotFound); + + let expired = ClientError::Http { + status: 500, + body: br#"{"code":"payment_expired"}"#.to_vec(), + }; + assert_eq!(WalletConnectPayError::from(expired), WalletConnectPayError::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..c157bf7573 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/mod.rs @@ -0,0 +1,18 @@ +mod account; +mod action_mapper; +mod client; +mod error; +mod model; +mod params; +mod payment_mapper; +mod provider; +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..f4b5d0afb9 --- /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::wallet_connect_pay::error::WalletConnectPayError; + +#[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 = WalletConnectPayError; + + fn try_from(action: WalletConnectPayAction) -> Result { + match action { + WalletConnectPayAction::WalletRpc(wallet_rpc) => Ok(wallet_rpc), + WalletConnectPayAction::Build(_) => Err(WalletConnectPayError::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..2d5c0b91c1 --- /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::wallet_connect_pay::error::WalletConnectPayError; + +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 normalize(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, WalletConnectPayError> { + 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, WalletConnectPayError> { + value.as_object().ok_or_else(|| invalid(format!("Expected {name} object"))) +} + +fn invalid(message: impl Display) -> WalletConnectPayError { + WalletConnectPayError::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_normalize() { + let transaction = Value::String("base64".to_string()); + let solana_params = Value::Array(vec![transaction.clone()]); + assert_eq!(normalize("solana_signTransaction", &solana_params).unwrap(), transaction); + assert_eq!(normalize("eth_sendTransaction", &solana_params).unwrap(), solana_params); + + let params = normalize(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 = normalize(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!(normalize(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| normalize(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..42116c226e --- /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::wallet_connect_pay::account::account_chain; +use crate::wallet_connect_pay::error::WalletConnectPayError; +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(WalletConnectPayError::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, WalletConnectPayError> { + DateTime::from_timestamp(payment.expires_at, 0).ok_or_else(|| WalletConnectPayError::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(WalletConnectPayError::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/provider.rs b/core/crates/payment/src/wallet_connect_pay/provider.rs new file mode 100644 index 0000000000..a22f8ecc1f --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/provider.rs @@ -0,0 +1,51 @@ +use crate::{PaymentError, PaymentProvider, PreparedPayment}; +use async_trait::async_trait; +use gem_client::Client; +use primitives::{ChainAddress, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::wallet_connect_pay::error::WalletConnectPayError; +use crate::wallet_connect_pay::service::WalletConnectPayService; + +#[async_trait] +impl PaymentProvider for WalletConnectPayService { + fn name(&self) -> PaymentProviderName { + PaymentProviderName::WalletConnectPay + } + + async fn get_options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result { + Ok(self.payment_options(payment_id, addresses).await?) + } + + async fn get_prepared_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result { + Ok(self.prepare_payment(quotes, quote, addresses).await?) + } + + async fn confirm(&self, quote: &PaymentQuote, results: Vec) -> Result { + Ok(self.confirm_payment(quote, results).await?) + } + + async fn get_status(&self, payment_id: &str) -> Result { + Ok(self.get_payment_status(payment_id).await?) + } + + async fn cancel(&self, payment_id: &str) -> Result<(), PaymentError> { + Ok(self.cancel_payment(payment_id).await?) + } +} + +impl From for PaymentError { + fn from(error: WalletConnectPayError) -> Self { + match error { + WalletConnectPayError::PaymentNotFound => Self::PaymentNotFound, + WalletConnectPayError::PaymentExpired => Self::PaymentExpired, + WalletConnectPayError::QuoteExpired => Self::QuoteExpired, + WalletConnectPayError::NoPaymentOptions => Self::NoPaymentOptions, + WalletConnectPayError::UnsupportedAccounts => Self::UnsupportedAccounts, + WalletConnectPayError::SanctionedUser => Self::Rejected, + WalletConnectPayError::RateLimited => Self::RateLimited, + WalletConnectPayError::Unsupported(_) => Self::NotSupported, + WalletConnectPayError::InvalidRequest(message) => Self::InvalidRequest(message), + WalletConnectPayError::Network(message) => Self::Network(message), + } + } +} 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..695af07538 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/service.rs @@ -0,0 +1,339 @@ +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::wallet_connect_pay::client::{WalletConnectPayAuth, WalletConnectPayClient}; +use crate::wallet_connect_pay::error::WalletConnectPayError; +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}; + +#[derive(Debug)] +pub struct WalletConnectPayService { + client: WalletConnectPayClient, +} + +impl WalletConnectPayService { + pub fn new(client: C, auth: WalletConnectPayAuth) -> Self { + Self { + client: WalletConnectPayClient::new(client, auth), + } + } + + pub(super) 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.payment_actions(quote).await { + Ok(actions) => Ok(PreparedPayment { + quotes: quotes.clone(), + quote: quote.clone(), + actions, + }), + Err(WalletConnectPayError::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.payment_options(&expired.payment_id, addresses).await? { + PaymentOptions::Quotes(quotes) => quotes, + PaymentOptions::Outcome(_) => return Err(WalletConnectPayError::PaymentExpired), + }; + let quote = Self::get_quote("es, &expired.amount.asset_id)?; + let actions = self.payment_actions("e).await?; + Ok(PreparedPayment { quotes, quote, actions }) + } + + pub(super) 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(super) async fn payment_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(WalletConnectPayError::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(WalletConnectPayError::PaymentExpired), + } + if quoted.expires_at <= Utc::now() { + return Err(WalletConnectPayError::PaymentExpired); + } + + let quotes = Self::payment_quotes(payment_id, quoted.options); + if quotes.is_empty() { + return Err(WalletConnectPayError::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) + .or(quotes.quotes.first()) + .cloned() + .ok_or(WalletConnectPayError::NoPaymentOptions) + } + + async fn payment_actions(&self, quote: &PaymentQuote) -> Result, WalletConnectPayError> { + let option = Self::option(quote)?; + let actions = self.wallet_rpc_actions("e.payment_id, &option).await?; + if actions.is_empty() { + return Err(WalletConnectPayError::InvalidRequest("Payment option has no executable actions".to_string())); + } + + actions.iter().map(|action| map_wallet_rpc(&option.account, action)).collect() + } + + pub(super) async fn cancel_payment(&self, payment_id: &str) -> Result<(), WalletConnectPayError> { + self.client.cancel(payment_id).await + } + + pub(super) 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 payment_quotes(payment_id: &str, options: Vec) -> Vec { + let (open, collecting): (Vec, Vec) = options.into_iter().partition(|option| option.collect_data_url.is_none()); + open.into_iter() + .chain(collecting) + .map(|option| 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, WalletConnectPayError> { + 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| WalletConnectPayError::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}; + 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.payment_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.payment_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); + } + + 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_get_payment_options() { + let service = service_with_response(far_future); + let prepared = service.payment_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_get_payment_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.payment_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_get_payment_options_settled() { + let service = service_with_response(|response| { + far_future(response); + response["info"]["status"] = serde_json::json!("succeeded"); + }); + + let options = service.payment_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.payment_options("pay_123", &addresses()).await.unwrap(); + assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Processing)); + } + + #[tokio::test] + async fn test_get_payment_options_rejects_unpayable() { + let failed = service_with_response(|response| { + far_future(response); + response["info"]["status"] = serde_json::json!("failed"); + }); + assert_eq!(failed.payment_options("pay_123", &addresses()).await, Err(WalletConnectPayError::PaymentExpired)); + + let expired = service_with_response(|response| { + response["info"]["expiresAt"] = serde_json::json!(1); + }); + assert_eq!(expired.payment_options("pay_123", &addresses()).await, Err(WalletConnectPayError::PaymentExpired)); + + let no_options = service_with_response(|response| { + far_future(response); + response["options"] = serde_json::json!([]); + }); + assert_eq!(no_options.payment_options("pay_123", &addresses()).await, Err(WalletConnectPayError::NoPaymentOptions)); + + let unsupported_addresses = vec![ChainAddress::new(Chain::Bitcoin, "bc1".to_string())]; + assert_eq!( + service_with_response(far_future).payment_options("pay_123", &unsupported_addresses).await, + Err(WalletConnectPayError::UnsupportedAccounts) + ); + } + + #[test] + fn test_payment_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::::payment_quotes("pay_123", vec![option("opt_form", Some("https://form")), option("opt_plain", None)]); + + 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")); + } +} 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..c6be02d709 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/validator.rs @@ -0,0 +1,102 @@ +use gem_wallet_connect::WalletConnectAction; +use primitives::{ChainType, SignDigestType, ValueAccess, WCEthereumTransaction, WalletConnectCAIP2}; +use serde_json::Value; + +use crate::wallet_connect_pay::error::WalletConnectPayError; + +pub fn validate_signer(account: &str, params: &Value, action: &WalletConnectAction) -> Result<(), WalletConnectPayError> { + let chain_address = + WalletConnectCAIP2::parse_account(account.to_string()).ok_or_else(|| WalletConnectPayError::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| WalletConnectPayError::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(WalletConnectPayError::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<(), WalletConnectPayError> { + if actual.to_lowercase() == expected.to_lowercase() { + return Ok(()); + } + Err(WalletConnectPayError::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/approval_data.rs b/core/crates/primitives/src/approval_data.rs new file mode 100644 index 0000000000..9137312bbc --- /dev/null +++ b/core/crates/primitives/src/approval_data.rs @@ -0,0 +1,50 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +const UNLIMITED_APPROVE_BIT_WIDTHS: [u32; 2] = [160, 256]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub is_unlimited: bool, +} + +impl ApprovalData { + pub fn new(token: String, spender: String, value: String) -> Self { + Self { + is_unlimited: Self::is_unlimited_value(&value), + token, + spender, + value, + } + } + + fn is_unlimited_value(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::*; + + #[test] + fn test_approval_data_reads_an_unlimited_value() { + let unlimited = |value: &str| ApprovalData::new("0xtoken".to_string(), "0xspender".to_string(), value.to_string()).is_unlimited; + + assert!(unlimited("115792089237316195423570985008687907853269984665640564039457584007913129639935")); + assert!(unlimited("1461501637330902918203684832716283019655932542975")); + + assert!(!unlimited("1000000")); + assert!(!unlimited("0")); + assert!(!unlimited("not a number")); + } +} diff --git a/core/crates/primitives/src/contract_call_data.rs b/core/crates/primitives/src/contract_call_data.rs index 8da3af6b3b..4ebef09019 100644 --- a/core/crates/primitives/src/contract_call_data.rs +++ b/core/crates/primitives/src/contract_call_data.rs @@ -1,4 +1,4 @@ -use crate::swap::ApprovalData; +use crate::ApprovalData; use serde::{Deserialize, Serialize}; use typeshare::typeshare; diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index 0fa726bcfa..88cc088056 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; @@ -199,8 +210,8 @@ pub mod wallet_import; pub use self::wallet_import::WalletImport; pub mod wallet_connector; pub use self::wallet_connector::{ - WCPairingProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, WalletConnectionSession, WalletConnectionSessionAppMetadata, - WalletConnectionSessionProposal, WalletConnectionState, WalletConnectionVerificationStatus, + WCPairingProposal, WalletConnectAppMetadata, WalletConnectSession, WalletConnectSessionProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, + WalletConnectionState, WalletConnectionVerificationStatus, }; pub mod nft; pub use self::nft::{NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollection, NFTCollectionId, NFTData, NFTImages, NFTResource, NFTType, ReportNft}; @@ -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; @@ -249,6 +260,8 @@ pub mod priority; pub use self::priority::{PrioritizedProvider, sort_by_priority_then_amount}; pub mod swap_provider; pub use self::swap_provider::SwapProvider; +pub mod approval_data; +pub use self::approval_data::ApprovalData; pub mod swap; pub mod websocket; pub use self::websocket::{WebSocketPriceAction, WebSocketPriceActionType, WebSocketPricePayload}; diff --git a/core/crates/primitives/src/payment.rs b/core/crates/primitives/src/payment.rs new file mode 100644 index 0000000000..5015a272db --- /dev/null +++ b/core/crates/primitives/src/payment.rs @@ -0,0 +1,133 @@ +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, +} + +#[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..4dc63a1f1b --- /dev/null +++ b/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs @@ -0,0 +1,118 @@ +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"; +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) +} + +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..47d058fd47 --- /dev/null +++ b/core/crates/primitives/src/signing.rs @@ -0,0 +1,110 @@ +use crate::{Chain, TransactionType, TransferDataOutputType, UInt64, WCEthereumTransaction}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq)] +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/swap/approval.rs b/core/crates/primitives/src/swap/approval.rs index 60b04b4c30..3779dc85f5 100644 --- a/core/crates/primitives/src/swap/approval.rs +++ b/core/crates/primitives/src/swap/approval.rs @@ -1,17 +1,7 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use crate::{SwapProvider, TransactionState}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[typeshare(swift = "Equatable, Hashable, Sendable")] -#[serde(rename_all = "camelCase")] -pub struct ApprovalData { - pub token: String, - pub spender: String, - pub value: String, - pub is_unlimited: bool, -} +use crate::{ApprovalData, SwapProvider, TransactionState}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[typeshare(swift = "Equatable, Hashable, Sendable")] diff --git a/core/crates/primitives/src/testkit/mod.rs b/core/crates/primitives/src/testkit/mod.rs index 2d2617adcc..bf81d3ab32 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; @@ -24,6 +25,6 @@ pub mod transaction_mock; pub mod transaction_preload_input_mock; pub mod transaction_state_request_mock; pub mod transfer_data_extra_mock; +pub mod wallet_connect_app_metadata_mock; pub mod wallet_connect_mock; -pub mod wallet_connection_session_mock; pub mod zcash_mock; diff --git a/core/crates/primitives/src/testkit/swap_mock.rs b/core/crates/primitives/src/testkit/swap_mock.rs index 906aef4d10..25b7f06522 100644 --- a/core/crates/primitives/src/testkit/swap_mock.rs +++ b/core/crates/primitives/src/testkit/swap_mock.rs @@ -1,8 +1,8 @@ use super::signer_mock::TEST_EVM_RECIPIENT; use crate::{ - SwapProvider, + ApprovalData, SwapProvider, asset_constants::ETHEREUM_USDT_TOKEN_ID, - swap::{ApprovalData, Slippage, SlippageMode, SwapData, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteDataType}, + swap::{Slippage, SlippageMode, SwapData, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteDataType}, }; impl Slippage { 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/testkit/wallet_connection_session_mock.rs b/core/crates/primitives/src/testkit/wallet_connect_app_metadata_mock.rs similarity index 65% rename from core/crates/primitives/src/testkit/wallet_connection_session_mock.rs rename to core/crates/primitives/src/testkit/wallet_connect_app_metadata_mock.rs index 8a5c641742..86eaf15d14 100644 --- a/core/crates/primitives/src/testkit/wallet_connection_session_mock.rs +++ b/core/crates/primitives/src/testkit/wallet_connect_app_metadata_mock.rs @@ -1,8 +1,8 @@ -use crate::WalletConnectionSessionAppMetadata; +use crate::WalletConnectAppMetadata; -impl WalletConnectionSessionAppMetadata { +impl WalletConnectAppMetadata { pub fn mock() -> Self { - WalletConnectionSessionAppMetadata { + WalletConnectAppMetadata { name: "Test Dapp".to_string(), description: "Test Dapp".to_string(), url: "https://example.com".to_string(), 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..ef5c7458ad 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::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/transfer_amount.rs b/core/crates/primitives/src/transfer_amount.rs index 3ef5b22731..9a7fca58a1 100644 --- a/core/crates/primitives/src/transfer_amount.rs +++ b/core/crates/primitives/src/transfer_amount.rs @@ -152,7 +152,7 @@ impl TransferAmountInput { #[cfg(test)] mod tests { use super::*; - use crate::{AccountDataType, Delegation, DelegationValidator, PerpetualConfirmData, PerpetualDirection, Resource, nft::NFTAsset, swap::ApprovalData}; + use crate::{AccountDataType, ApprovalData, Delegation, DelegationValidator, PerpetualConfirmData, PerpetualDirection, Resource, nft::NFTAsset}; const SOLANA_MINIMUM_ACCOUNT_BALANCE: u64 = 890_880; const FEE: u64 = 5_000; 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..398b10ba59 100644 --- a/core/crates/primitives/src/wallet_connector.rs +++ b/core/crates/primitives/src/wallet_connector.rs @@ -6,7 +6,7 @@ use typeshare::typeshare; #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] pub struct WalletConnection { - pub session: WalletConnectionSession, + pub session: WalletConnectSession, pub wallet: Wallet, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -83,20 +83,20 @@ pub enum WalletConnectionEvents { #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] -pub struct WalletConnectionSession { +pub struct WalletConnectSession { pub id: String, pub session_id: String, pub state: WalletConnectionState, pub chains: Vec, pub created_at: DateTime, pub expire_at: DateTime, - pub metadata: WalletConnectionSessionAppMetadata, + pub metadata: WalletConnectAppMetadata, } #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] -pub struct WalletConnectionSessionAppMetadata { +pub struct WalletConnectAppMetadata { pub name: String, pub description: String, pub url: String, @@ -106,9 +106,15 @@ pub struct WalletConnectionSessionAppMetadata { const SHORT_NAME_SEPARATORS: [char; 3] = ['-', ':', '|']; const SHORT_NAME_MAX_LENGTH: usize = 80; -impl WalletConnectionSessionAppMetadata { +impl WalletConnectAppMetadata { pub fn short_name(&self) -> String { - let name = self.name.trim(); + 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(); @@ -124,10 +130,10 @@ impl WalletConnectionSessionAppMetadata { #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] -pub struct WalletConnectionSessionProposal { +pub struct WalletConnectSessionProposal { pub default_wallet: Wallet, pub wallets: Vec, - pub metadata: WalletConnectionSessionAppMetadata, + pub metadata: WalletConnectAppMetadata, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -145,6 +151,6 @@ pub enum WalletConnectionVerificationStatus { #[serde(rename_all = "camelCase")] pub struct WCPairingProposal { pub pairing_id: String, - pub proposal: WalletConnectionSessionProposal, + pub proposal: WalletConnectSessionProposal, pub verification_status: WalletConnectionVerificationStatus, } diff --git a/core/crates/swapper/src/across/provider.rs b/core/crates/swapper/src/across/provider.rs index 2523c5df6d..646f8a93c0 100644 --- a/core/crates/swapper/src/across/provider.rs +++ b/core/crates/swapper/src/across/provider.rs @@ -35,7 +35,7 @@ use gem_evm::{ weth::WETH9, }; use num_bigint::{BigInt, Sign}; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, EVMChain}; use serde_serializers::biguint_from_hex_str; use std::{fmt::Debug, str::FromStr, sync::Arc}; diff --git a/core/crates/swapper/src/approval/evm.rs b/core/crates/swapper/src/approval/evm.rs index 127f7dbf00..45a1c72e16 100644 --- a/core/crates/swapper/src/approval/evm.rs +++ b/core/crates/swapper/src/approval/evm.rs @@ -17,7 +17,7 @@ use gem_evm::{ jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, permit2::IAllowanceTransfer, }; -use primitives::{Chain, swap::ApprovalData}; +use primitives::{ApprovalData, Chain}; use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, diff --git a/core/crates/swapper/src/approval/mod.rs b/core/crates/swapper/src/approval/mod.rs index 289ba954c1..101a8060aa 100644 --- a/core/crates/swapper/src/approval/mod.rs +++ b/core/crates/swapper/src/approval/mod.rs @@ -2,7 +2,7 @@ pub mod evm; pub mod tron; pub use evm::*; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; pub use tron::*; pub const DEFAULT_EVM_SWAP_GAS_LIMIT: u64 = 750_000; diff --git a/core/crates/swapper/src/approval/tron.rs b/core/crates/swapper/src/approval/tron.rs index 0133851371..601de18a1d 100644 --- a/core/crates/swapper/src/approval/tron.rs +++ b/core/crates/swapper/src/approval/tron.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use alloy_primitives::U256; use gem_tron::address::TronAddress; use num_bigint::BigUint; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use serde_serializers::biguint_from_hex_str; use crate::{SwapperError, alien::RpcProvider, client_factory::create_tron_client, models::ApprovalType}; diff --git a/core/crates/swapper/src/mayan/tx_builder/evm.rs b/core/crates/swapper/src/mayan/tx_builder/evm.rs index 8dd60d7324..2acd277790 100644 --- a/core/crates/swapper/src/mayan/tx_builder/evm.rs +++ b/core/crates/swapper/src/mayan/tx_builder/evm.rs @@ -6,7 +6,7 @@ use crate::{ use alloy_primitives::{Address, Bytes, U256}; use alloy_sol_types::{SolCall, sol}; use futures::try_join; -use primitives::{AssetId, ChainType, decode_hex, hex, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, ChainType, decode_hex, hex}; use std::{future::Future, str::FromStr, sync::Arc}; sol! { diff --git a/core/crates/swapper/src/models.rs b/core/crates/swapper/src/models.rs index f981011007..cb6a70ce52 100644 --- a/core/crates/swapper/src/models.rs +++ b/core/crates/swapper/src/models.rs @@ -1,9 +1,10 @@ use super::permit2_data::Permit2Data; use crate::{SwapperProvider, SwapperQuoteAsset, SwapperSlippage, config::DEFAULT_SLIPPAGE_BPS}; +pub use primitives::ApprovalData; pub use primitives::swap::SwapResult; use primitives::{ AssetId, Chain, - swap::{ApprovalData, SlippageMode, SwapProviderMode}, + swap::{SlippageMode, SwapProviderMode}, }; use serde::Serialize; use std::fmt::Debug; diff --git a/core/crates/swapper/src/okx/quote_data.rs b/core/crates/swapper/src/okx/quote_data.rs index 8bf847c9e2..26b3b152c2 100644 --- a/core/crates/swapper/src/okx/quote_data.rs +++ b/core/crates/swapper/src/okx/quote_data.rs @@ -14,8 +14,8 @@ use gem_encoding::encode_base64; use gem_evm::provider::preload_mapper::calculate_gas_limit_with_increase; use num_bigint::BigInt; use primitives::{ - Chain, ChainType, - swap::{ApprovalData, QuoteAsset, SwapQuoteData}, + ApprovalData, Chain, ChainType, + swap::{QuoteAsset, SwapQuoteData}, }; use std::{str::FromStr, sync::Arc}; diff --git a/core/crates/swapper/src/relay/mapper.rs b/core/crates/swapper/src/relay/mapper.rs index c8e54ff424..2c97c91dfb 100644 --- a/core/crates/swapper/src/relay/mapper.rs +++ b/core/crates/swapper/src/relay/mapper.rs @@ -1,4 +1,4 @@ -use primitives::{TransactionSwapMetadata, swap::ApprovalData}; +use primitives::{ApprovalData, TransactionSwapMetadata}; use super::{ asset::map_currency_to_asset_id, diff --git a/core/crates/swapper/src/relay/provider.rs b/core/crates/swapper/src/relay/provider.rs index 497ae2bbc2..f6a100eefb 100644 --- a/core/crates/swapper/src/relay/provider.rs +++ b/core/crates/swapper/src/relay/provider.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use alloy_primitives::U256; use async_trait::async_trait; use gem_client::Client; -use primitives::{AssetId, Chain, ChainType, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, ChainType, EVMChain}; use super::{ asset::{SUPPORTED_CHAINS, asset_to_currency}, diff --git a/core/crates/swapper/src/thorchain/provider.rs b/core/crates/swapper/src/thorchain/provider.rs index 227d0f4841..9ae713c69b 100644 --- a/core/crates/swapper/src/thorchain/provider.rs +++ b/core/crates/swapper/src/thorchain/provider.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, fmt::Debug, sync::Arc, time::Duration}; use alloy_primitives::U256; use async_trait::async_trait; use gem_client::Client; -use primitives::{AssetId, Chain, ChainType, MINUTE, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, ChainType, MINUTE}; use num_bigint::BigInt; diff --git a/core/crates/swapper/src/thorchain/quote_data_mapper.rs b/core/crates/swapper/src/thorchain/quote_data_mapper.rs index 301309919c..ba0a1bcb6f 100644 --- a/core/crates/swapper/src/thorchain/quote_data_mapper.rs +++ b/core/crates/swapper/src/thorchain/quote_data_mapper.rs @@ -7,7 +7,7 @@ use alloy_primitives::{Address, U256, hex::encode_prefixed as HexEncode}; use alloy_sol_types::SolCall; use gem_evm::thorchain::contracts::RouterInterface; use num_bigint::BigInt; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use super::{DEFAULT_DEPOSIT_GAS_LIMIT, asset::THORChainAsset, model::RouteData}; use crate::{SwapperQuoteData, approval::get_swap_gas_limit_with_approval}; @@ -51,7 +51,7 @@ pub fn map_quote_data( mod tests { use super::*; use crate::thorchain::{THORChainNetwork, chain::ChainName}; - use primitives::{Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID, swap::ApprovalData}; + use primitives::{ApprovalData, Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID}; fn asset(chain: Chain, token_id: Option) -> THORChainAsset { THORChainAsset { diff --git a/core/crates/swapper/src/uniswap/v3/provider.rs b/core/crates/swapper/src/uniswap/v3/provider.rs index f33b5660f8..15ad8fd398 100644 --- a/core/crates/swapper/src/uniswap/v3/provider.rs +++ b/core/crates/swapper/src/uniswap/v3/provider.rs @@ -21,7 +21,7 @@ use gem_evm::{ uniswap::{command::encode_commands, path::get_base_pair}, }; use gem_jsonrpc::client::JsonRpcClient; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, EVMChain}; use std::{fmt, str::FromStr, sync::Arc}; use super::{DEFAULT_SWAP_GAS_LIMIT, UniversalRouterProvider, commands::build_commands, path::build_paths_with_routes}; diff --git a/core/crates/swapper/src/uniswap/v4/provider.rs b/core/crates/swapper/src/uniswap/v4/provider.rs index c2dfdd8308..e77882dfaf 100644 --- a/core/crates/swapper/src/uniswap/v4/provider.rs +++ b/core/crates/swapper/src/uniswap/v4/provider.rs @@ -27,7 +27,7 @@ use gem_evm::{ }; use gem_hash::keccak::keccak256; use gem_jsonrpc::client::JsonRpcClient; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, EVMChain}; use super::{ DEFAULT_SWAP_GAS_LIMIT, diff --git a/core/crates/yielder/src/yo/client.rs b/core/crates/yielder/src/yo/client.rs index ea413ce45e..555a58deb7 100644 --- a/core/crates/yielder/src/yo/client.rs +++ b/core/crates/yielder/src/yo/client.rs @@ -6,7 +6,7 @@ use gem_evm::jsonrpc::TransactionObject; use gem_evm::multicall3::{create_call3, decode_call3_return}; use gem_evm::rpc::EthereumClient; use gem_jsonrpc::alien::RpcClient; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use super::assets::YoAsset; use super::contract::IYoGateway; diff --git a/core/crates/yielder/src/yo/mapper.rs b/core/crates/yielder/src/yo/mapper.rs index d822bb358a..9cfad5cabb 100644 --- a/core/crates/yielder/src/yo/mapper.rs +++ b/core/crates/yielder/src/yo/mapper.rs @@ -2,7 +2,7 @@ use alloy_primitives::U256; use gem_evm::jsonrpc::TransactionObject; use gem_evm::u256::u256_to_biguint; use num_bigint::BigUint; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationState, DelegationValidator, StakeProviderType, YieldProvider}; use super::assets::YoAsset; 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/approval.rs b/core/gemstone/src/models/approval.rs new file mode 100644 index 0000000000..3d9b4123a5 --- /dev/null +++ b/core/gemstone/src/models/approval.rs @@ -0,0 +1,11 @@ +pub use primitives::ApprovalData; + +pub type GemApprovalData = ApprovalData; + +#[uniffi::remote(Record)] +pub struct GemApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub is_unlimited: bool, +} diff --git a/core/gemstone/src/models/mod.rs b/core/gemstone/src/models/mod.rs index ab60e3c5db..c183778508 100644 --- a/core/gemstone/src/models/mod.rs +++ b/core/gemstone/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod approval; pub mod asset; pub mod balance; pub mod chain_address; @@ -15,6 +16,7 @@ pub mod swap; pub mod token; pub mod transaction; +pub use approval::*; pub use asset::*; pub use balance::*; pub use chain_address::*; diff --git a/core/gemstone/src/models/swap.rs b/core/gemstone/src/models/swap.rs index a9247f269f..5a3933570c 100644 --- a/core/gemstone/src/models/swap.rs +++ b/core/gemstone/src/models/swap.rs @@ -1,9 +1,9 @@ use crate::config::swap_config::get_swap_config; +use crate::models::approval::GemApprovalData; use primitives::swap::SwapQuoteDataType; -pub use primitives::swap::{ApprovalData, SwapData, SwapPriceImpact, SwapPriceImpactType, SwapProviderData, SwapQuote, SwapQuoteData}; +pub use primitives::swap::{SwapData, SwapPriceImpact, SwapPriceImpactType, SwapProviderData, SwapQuote, SwapQuoteData}; pub use swapper::SwapperProvider; -pub type GemApprovalData = ApprovalData; pub type GemSwapData = SwapData; pub type GemSwapPriceImpact = SwapPriceImpact; pub type GemSwapPriceImpactType = SwapPriceImpactType; @@ -12,14 +12,6 @@ pub type GemSwapQuote = SwapQuote; pub type GemSwapQuoteData = SwapQuoteData; pub type GemSwapQuoteDataType = SwapQuoteDataType; -#[uniffi::remote(Record)] -pub struct GemApprovalData { - pub token: String, - pub spender: String, - pub value: String, - pub is_unlimited: bool, -} - #[uniffi::remote(Enum)] pub enum GemSwapQuoteDataType { Contract, diff --git a/core/gemstone/src/models/transaction.rs b/core/gemstone/src/models/transaction.rs index 9960862bfe..83f0395292 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 approval::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, WalletConnectAppMetadata, perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData}, }; use std::collections::HashMap; -use swap::{GemApprovalData, GemSwapData}; +use swap::GemSwapData; use swapper::SwapperProvider; pub type GemPerpetualDirection = PerpetualDirection; @@ -202,16 +203,26 @@ pub enum GemEarnType { Withdraw(GemDelegation), } -pub type GemWalletConnectionSessionAppMetadata = WalletConnectionSessionAppMetadata; +pub type GemWalletConnectAppMetadata = WalletConnectAppMetadata; #[uniffi::remote(Record)] -pub struct GemWalletConnectionSessionAppMetadata { +pub struct GemWalletConnectAppMetadata { pub name: String, pub description: String, pub url: String, 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..d061a52559 100644 --- a/core/gemstone/src/payment/mod.rs +++ b/core/gemstone/src/payment/mod.rs @@ -1,43 +1,97 @@ -use crate::GemstoneError; -use primitives::PaymentURLDecoder; - -#[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, +pub mod error; +pub mod remote_types; + +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::approval::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, } -/// 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()), - }) +#[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 }, } -#[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 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(uniffi::Object)] +pub struct GemPaymentService { + service: PaymentService, +} + +#[uniffi::export] +impl GemPaymentService { + #[uniffi::constructor] + pub fn new(provider: Arc, app_id: String, client_id: String) -> Self { + Self { + service: PaymentService::new( + Arc::new(AlienProviderWrapper::new(provider)), + PaymentConfig::new(WalletConnectPayAuth::new(app_id, client_id)), + ), + } + } + + 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 { + 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 cancel_payment(&self, provider: PaymentProviderName, payment_id: String) -> Result<(), PaymentError> { + self.service.cancel(provider, &payment_id).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..49dd45768c --- /dev/null +++ b/core/gemstone/src/payment/remote_types.rs @@ -0,0 +1,152 @@ +use chrono::{DateTime, Utc}; + +use crate::GemstoneError; +use primitives::payment_decoder::wallet_connect_pay::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_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 1ea5e5bf93..ae61ec9c6a 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(); @@ -265,18 +265,14 @@ mod tests { assert_eq!(sign_one(token.clone()), vec![signer.sign_token_transfer(token, key.clone()).unwrap()]); // TokenApprove must route to sign_token_approval, not sign_token_transfer (the resolved iOS divergence). - let approve: GemSignerInput = SignerInput::mock_evm(TransactionInputType::TokenApprove(Asset::mock(), primitives::swap::ApprovalData::mock()), "0", 65000).into(); + let approve: GemSignerInput = SignerInput::mock_evm(TransactionInputType::TokenApprove(Asset::mock(), primitives::ApprovalData::mock()), "0", 65000).into(); assert_eq!(sign_one(approve.clone()), vec![signer.sign_token_approval(approve, key.clone()).unwrap()]); let nft: GemSignerInput = SignerInput::mock_evm(TransactionInputType::TransferNft(Asset::mock(), NFTAsset::mock()), "0", 100000).into(); 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..fc313ee9ca 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)] @@ -421,8 +433,8 @@ mod tests { #[test] fn short_name_strips_separators() { - use primitives::WalletConnectionSessionAppMetadata; - let meta = |name: &str| WalletConnectionSessionAppMetadata { + use primitives::WalletConnectAppMetadata; + let meta = |name: &str| WalletConnectAppMetadata { name: name.to_string(), description: String::new(), url: String::new(), 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/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift new file mode 100644 index 0000000000..86f0aef0fc --- /dev/null +++ b/ios/Features/Payments/Package.swift @@ -0,0 +1,70 @@ +// 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: "Blockchain", path: "../../Packages/Blockchain"), + .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: "GemstonePrimitives", path: "../../Packages/GemstonePrimitives"), + .package(name: "Keystore", path: "../../Packages/Keystore"), + .package(name: "Signer", path: "../../Packages/Signer"), + .package(name: "EventPresenterService", path: "../EventPresenterService"), + .package(name: "Formatters", path: "../../Packages/Formatters"), + ], + targets: [ + .target( + name: "Payments", + dependencies: [ + "Primitives", + "Components", + "Localization", + "Style", + "PrimitivesComponents", + "GemstonePrimitives", + "Formatters", + "Keystore", + "Signer", + .product(name: "TransferService", package: "FeatureServices"), + "EventPresenterService", + "Blockchain", + .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "ChainService", package: "ChainServices"), + .product(name: "ScanService", package: "ChainServices"), + .product(name: "AssetsService", package: "FeatureServices"), + .product(name: "BalanceService", package: "FeatureServices"), + .product(name: "PaymentService", package: "ChainServices"), + .product(name: "TransactionStateService", package: "FeatureServices"), + ], + path: "Sources/Payments", + ), + .testTarget( + name: "PaymentsTests", + dependencies: [ + "Payments", + .product(name: "PrimitivesTestKit", package: "Primitives"), + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), + .product(name: "PaymentServiceTestKit", package: "ChainServices"), + .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..4099afcb3c --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -0,0 +1,52 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Foundation +import Localization +import PaymentService +import Primitives +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: SigningRequestCallback + private let onComplete: () -> Void + + public init( + callback: SigningRequestCallback, + 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) + } + + private func onMessage(_ payload: [String: Any]) { + switch payload[Self.messageTypeKey] as? String { + case Self.completeMessageType: + finish(.success(.empty)) + case Self.errorMessageType: + finish(.failure(AnyError(payload[Self.messageErrorKey] as? String ?? Localized.Errors.transferError))) + default: + break + } + } + + private func finish(_ result: Result) { + callback.delegate(result) + onComplete() + } +} diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift new file mode 100644 index 0000000000..a39423a631 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift @@ -0,0 +1,49 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Style +import SwiftUI +import Primitives + +public struct PaymentNavigationStack: View { + private let type: PaymentSheetType + private let presenter: SheetPresenter + + public init( + type: PaymentSheetType, + presenter: SheetPresenter, + ) { + self.type = type + self.presenter = presenter + } + + public var body: some View { + NavigationStack { + Group { + switch type { + case let .quotes(data): + PaymentQuotesScene( + model: PaymentQuotesSceneViewModel( + request: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: { presenter.complete(type: type) }, + ) + case let .dataCollection(data): + PaymentDataCollectionScene( + callback: data, + onComplete: { presenter.complete(type: type) }, + ) + } + } + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark) { + presenter.cancelSheet(type: type) + } + } + } + } + } +} 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..30768866d0 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift @@ -0,0 +1,83 @@ +// 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) + } + + 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..3ab5de6584 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -0,0 +1,151 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import PaymentService +import Primitives +import SigningRequestService + +public struct PaymentActionExecutor: Sendable { + private let interactor: any SigningRequestInteractable + private let approvalExecutor: any PaymentApprovalExecutable + private let simulator: any SigningSimulatable + private let assetsProvider: any PaymentAssetsProvidable + + public init( + interactor: any SigningRequestInteractable, + simulator: any SigningSimulatable, + approvalExecutor: any PaymentApprovalExecutable, + assetsProvider: any PaymentAssetsProvidable, + ) { + self.interactor = interactor + self.approvalExecutor = approvalExecutor + self.simulator = simulator + self.assetsProvider = assetsProvider + } + + @MainActor + public func perform( + actions: [PaymentAction], + paymentId: String, + appMetadata: TransactionAppMetadata, + payment: PaymentData, + wallet: Wallet, + onSubmitted: @MainActor () -> Void = {}, + ) async throws -> PaymentActionResults { + let networkFee = try await getApprovalsFee(actions: actions, payment: payment, wallet: wallet) + + var signatures: [(index: Int, action: PaymentAction)] = [] + var spends: [(index: Int, action: PaymentAction)] = [] + for (index, action) in actions.enumerated() { + switch action { + case .signMessage, .signTransaction: + signatures.append((index, action)) + case .approveToken, .sendTransaction: + spends.append((index, action)) + } + } + + var results = [String](repeating: "", count: actions.count) + var transactionHash: String? + var submitted = false + for (index, action) in signatures + spends { + let value = try await perform( + action: action, + id: "\(paymentId).\(index)", + appMetadata: appMetadata, + payment: payment, + networkFee: networkFee, + wallet: wallet, + ) + results[index] = value + + switch action { + case .sendTransaction: + transactionHash = value + case .approveToken: + if !submitted { + submitted = true + onSubmitted() + } + try await approvalExecutor.waitForApproval(hash: value, assetId: payment.quote.amount.assetId, wallet: wallet) + case .signMessage, .signTransaction: + break + } + } + if !submitted { + onSubmitted() + } + return PaymentActionResults(results: results, transactionHash: transactionHash) + } +} + +public struct PaymentActionResults: Sendable { + public let results: [String] + public let transactionHash: String? +} + +// MARK: - Private + +extension PaymentActionExecutor { + @MainActor + private func perform(action: PaymentAction, id: String, appMetadata: TransactionAppMetadata, payment: PaymentData, networkFee: AssetValuePrice?, 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, + simulation: simulator.simulateSignMessage(message: message, sessionDomain: appMetadata.url ?? .empty), + payment: payment, + networkFee: networkFee, + ) + return try await interactor.signMessage(payload: payload) + case let .signTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + chain: chain, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .sign, + payment: payment, + ) + return try await interactor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + case let .approveToken(_, approval): + return try await approvalExecutor.approve(assetId: payment.quote.amount.assetId, approval: approval, wallet: wallet) + case let .sendTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + chain: chain, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .send, + payment: payment, + ) + return try await interactor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + } + } +} + +// MARK: - Private + +extension PaymentActionExecutor { + private func getApprovalsFee(actions: [PaymentAction], payment: PaymentData, wallet: Wallet) async throws -> AssetValuePrice { + let approvals = actions.compactMap { action -> (chain: Chain, approval: ApprovalData)? in + guard case let .approveToken(chain, approval) = action else { + return .none + } + return (chain, approval) + } + let chain = approvals.first?.chain ?? payment.quote.amount.assetId.chain + var value = BigInt.zero + for approval in approvals { + value += try await approvalExecutor.getApprovalFee(assetId: payment.quote.amount.assetId, approval: approval.approval, wallet: wallet) + } + return AssetValuePrice( + asset: chain.asset, + value: value, + price: assetsProvider.assetsData(walletId: wallet.id, assetIds: [chain.assetId]).first?.price, + ) + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift new file mode 100644 index 0000000000..b6f2a55c2d --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift @@ -0,0 +1,110 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import AssetsService +import BigInt +import ChainService +import BalanceService +import Foundation +import Keystore +import PaymentService +import Primitives +import ScanService +import Signer +import TransactionStateService +import TransferService +import Blockchain + +public struct PaymentApprovalExecutor: PaymentApprovalExecutable { + private static let confirmationTimeout: Duration = .seconds(120) + + private let keystore: any Keystore + private let assetsService: AssetsService + private let chainServiceFactory: any ChainServiceFactorable + private let scanService: ScanService + private let assetsEnabler: any AssetsEnabler + private let transactionStateScheduler: TransactionStateScheduler + + public init( + keystore: any Keystore, + assetsService: AssetsService, + chainServiceFactory: any ChainServiceFactorable, + scanService: ScanService, + assetsEnabler: any AssetsEnabler, + transactionStateScheduler: TransactionStateScheduler, + ) { + self.keystore = keystore + self.assetsService = assetsService + self.chainServiceFactory = chainServiceFactory + self.scanService = scanService + self.assetsEnabler = assetsEnabler + self.transactionStateScheduler = transactionStateScheduler + } + + public func getApprovalFee(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> BigInt { + try await load(assetId: assetId, approval: approval, wallet: wallet).transaction.transactionData.fee.fee + } + + public func approve(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> String { + let loaded = try await load(assetId: assetId, approval: approval, wallet: wallet) + return try await broadcast(data: loaded.data, transaction: loaded.transaction, wallet: wallet, chainService: loaded.chainService) + } + + public func waitForApproval(hash: String, assetId: AssetId, wallet: Wallet) async throws { + let chain = assetId.chain + try await TransactionConfirmationWaiter(chainService: chainServiceFactory.service(for: chain)) + .wait(hash: hash, chain: chain, senderAddress: try wallet.account(for: chain).address) + } +} + +private struct LoadedApproval { + let data: TransferData + let transaction: TransferTransactionData + let chainService: any ChainServiceable +} + +// MARK: - Private + +extension PaymentApprovalExecutor { + private func load(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> LoadedApproval { + let asset = try await assetsService.getOrFetchAsset(for: assetId) + let chainService = chainServiceFactory.service(for: assetId.chain) + let account = try wallet.account(for: assetId.chain) + let data = TransferData( + type: .tokenApprove(asset, approval), + recipientData: RecipientData(recipient: Recipient(name: .none, address: approval.spender, memo: .none), amount: .none), + amount: .exact(.zero), + ) + let available = try await chainService.coinBalance(for: account.address).balance.available + let transaction = try await TransferTransactionProvider(chainService: chainService, scanService: scanService) + .loadTransferTransactionData(wallet: wallet, data: data, selection: .preset(.normal), available: available) + + return LoadedApproval(data: data, transaction: transaction, chainService: chainService) + } + + private func broadcast( + data: TransferData, + transaction: TransferTransactionData, + wallet: Wallet, + chainService: any ChainServiceable, + ) async throws -> String { + let input = TransferConfirmationInput( + data: data, + wallet: wallet, + transactionData: transaction.transactionData, + amount: TransferAmount(value: .zero, networkFee: transaction.transactionData.fee.fee, useMaxAmount: false), + simulation: .none, + delegate: .none, + ) + let hashes = try await TransferExecutor( + signer: TransactionSigner(keystore: keystore), + chainService: chainService, + assetsEnabler: assetsEnabler, + transactionStateScheduler: transactionStateScheduler, + ).execute(input: input) + + guard let hash = hashes.first else { + throw PaymentLinkError.approvalNotBroadcast + } + return hash + } +} 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..da1e0c9515 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -0,0 +1,135 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import TransactionStateService + +public final class PaymentManager: Sendable { + private let service: any PaymentServiceable + private let executor: PaymentActionExecutor + private let presenter: any PaymentSheetPresentable + private let assetsProvider: any PaymentAssetsProvidable + private let transactionStateScheduler: TransactionStateScheduler + private let queue = PaymentQueue() + + public init( + service: any PaymentServiceable, + executor: PaymentActionExecutor, + 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 { + try await queue.enqueue(paymentId: link.id) { [self] in + try await perform(link: link, wallet: wallet) + }.value + } +} + +// MARK: - Private + +extension PaymentManager { + @MainActor + private func perform(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 SigningRequestError.userCancelled { + return PaymentOutcome(status: .cancelled, transactionId: .none) + } + } + + @MainActor + 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 = 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 + } + + @MainActor + 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 isRelayed = payment.actions.allSatisfy { action in + switch action { + case .signMessage, .signTransaction, .approveToken: true + case .sendTransaction: false + } + } + let results = try await executor.perform( + 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 isRelayed else { + return + } + save(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) + } + } + + @MainActor + 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)) + } + + @MainActor + private func save(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/Services/PaymentQueue.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentQueue.swift new file mode 100644 index 0000000000..39afebe1e2 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentQueue.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +actor PaymentQueue { + private var last: Task? + private var payments: [String: Task] = [:] + + func enqueue(paymentId: String, work: @Sendable @escaping () async throws -> PaymentOutcome) -> Task { + if let payment = payments[paymentId] { + return payment + } + let previous = last + let payment = Task { + await previous?.value + return try await work() + } + payments[paymentId] = payment + last = Task { [weak self] in + _ = try? await payment.value + await self?.finish(paymentId: paymentId) + } + return payment + } + + private func finish(paymentId: String) { + payments[paymentId] = .none + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift new file mode 100644 index 0000000000..cab7cda652 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives + +public protocol PaymentSheetPresentable: Sendable { + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String +} + +extension SheetPresenter: PaymentSheetPresentable where Sheet == PaymentSheetType { + public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + try await present(payload: request, sheet: { .quotes($0) }) + } + + public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + try await present(payload: request, sheet: { .dataCollection($0) }) + } +} 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..42af742ed4 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization + +public enum PaymentLinkError: Error, Equatable { + case noQuotes + case quoteUnavailable + case invalidDataCollectionUrl + case approvalNotBroadcast +} + +extension PaymentLinkError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noQuotes, .quoteUnavailable, .invalidDataCollectionUrl: Localized.Errors.notSupported + case .approvalNotBroadcast: Localized.Errors.errorOccurred + } + } +} 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..9fe0e11ab0 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives + +public enum PaymentSheetType: Sendable, Identifiable { + case quotes(SigningRequestCallback) + case dataCollection(SigningRequestCallback) + + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) + } + + private var callback: any SigningRequestRejectable { + switch self { + case let .quotes(callback): callback + case let .dataCollection(callback): callback + } + } +} + +// MARK: - SigningRequestRejectable + +extension PaymentSheetType: SigningRequestRejectable {} 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/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift new file mode 100644 index 0000000000..e28d0fa1f2 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -0,0 +1,190 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import PaymentService +import Primitives +import SigningRequestService +import PrimitivesTestKit +import PaymentServiceTestKit +import SigningRequestServiceTestKit +import Testing +@testable import Payments + +@MainActor +struct PaymentActionExecutorTests { + @Test + func sendTransactionReturnsHash() async throws { + let interactor = SigningRequestInteractableMock() + interactor.transactionHash = "transaction-hash" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + 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 executor = PaymentApprovalExecutableMock(hash: "approval-hash") + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock()).perform( + 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: .mock(), + wallet: .mock(), + ) + + #expect(results.results == ["approval-hash", "permit-signature"]) + #expect(results.transactionHash == nil) + #expect(executor.approvals.count == 1) + } + + @Test + func performsEveryActionInOrder() async throws { + let interactor = SigningRequestInteractableMock() + interactor.signature = "permit-signature" + interactor.transactionHash = "approval-hash" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + 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: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [PaymentAction.mockSignMessage(data: Data("pay".utf8))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: payment, + wallet: .mock(), + ) + + #expect(interactor.signMessagePayloads.first?.payment == payment) + } + + @Test + func nothingIsSignedWhenAnApprovalCannotBeAfforded() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock(validationError: AnyError("insufficient balance")) + + await #expect(throws: (any Error).self) { + try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [ + PaymentAction.approveToken(chain: .ethereum, approval: .mock()), + PaymentAction.mockSignMessage(data: Data("{}".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + } + + #expect(interactor.signMessagePayloads.isEmpty) + } + @Test + func paymentIsRecordedAfterTheApprovalIsBroadcastAndBeforeItIsMined() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock(hash: "approval-hash") + var approvalsWhenRecorded: Int? + var confirmationsWhenRecorded: Int? + + _ = try await PaymentActionExecutor( + interactor: interactor, + simulator: SigningSimulatableMock(), + approvalExecutor: executor, + assetsProvider: PaymentAssetsProvidableMock(), + ).perform( + actions: [ + .approveToken(chain: .ethereum, approval: .mock()), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + onSubmitted: { + approvalsWhenRecorded = executor.approvals.count + confirmationsWhenRecorded = executor.confirmedHashes.count + }, + ) + + #expect(approvalsWhenRecorded == 1) + #expect(confirmationsWhenRecorded == 0) + #expect(executor.confirmedHashes == ["approval-hash"]) + } + + @Test + func signMessageShowsWhatTheApprovalWillCost() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock(fee: BigInt(7295000000000)) + let price = Price.mock(price: 2000) + + _ = try await PaymentActionExecutor( + interactor: interactor, + simulator: SigningSimulatableMock(), + approvalExecutor: executor, + assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock(asset: .mock(id: AssetId(chain: .ethereum)), price: price)]), + ).perform( + actions: [ + .approveToken(chain: .ethereum, approval: .mock()), + .mockSignMessage(data: Data("{}".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + let fee = try #require(interactor.signMessagePayloads.first?.networkFee) + #expect(fee.value == BigInt(7295000000000)) + #expect(fee.asset == Chain.ethereum.asset) + #expect(fee.price == price) + } + + @Test + func signMessageIsSimulatedBeforeItIsShown() async throws { + let interactor = SigningRequestInteractableMock() + let warning = SimulationWarning(severity: .critical, warning: .suspiciousSpender, message: "suspicious spender") + let simulator = SigningSimulatableMock( + result: SimulationResult(warnings: [warning], balanceChanges: [], payload: [], header: .none), + ) + + _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [PaymentAction.mockSignMessage(data: Data("{}".utf8))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(interactor.signMessagePayloads.first?.simulation.warnings == [warning]) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift new file mode 100644 index 0000000000..1e850ce9f1 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -0,0 +1,173 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import SigningRequestService +import PrimitivesTestKit +import Store +import StoreTestKit +import PaymentServiceTestKit +import SigningRequestServiceTestKit +import Testing +import TransactionStateServiceTestKit +@testable import Payments + +@MainActor +struct PaymentManagerTests { + private let interactor = SigningRequestInteractableMock() + private let presenter = PaymentSheetPresentableMock() + + private func makeManager( + service: PaymentServiceableMock, + transactionStore: TransactionStore = .mock(), + ) -> PaymentManager { + PaymentManager( + service: service, + executor: PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()), + 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(interactor.signMessagePayloads.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).pay(link: .mock(), wallet: .mock()) + + #expect(interactor.signMessagePayloads.first?.id == "pay_1.0") + #expect(interactor.signMessagePayloads.first?.appMetadata.name == "Coffee Shop") + #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).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .processing) + } + + @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 = SigningRequestError.userCancelled + 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.cancelledPaymentIds.isEmpty) + #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(interactor.signMessagePayloads.count == 1) + #expect(interactor.signMessagePayloads.first?.payment?.quote == 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/PaymentQueueTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentQueueTests.swift new file mode 100644 index 0000000000..933e435712 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentQueueTests.swift @@ -0,0 +1,74 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import PrimitivesTestKit +import Testing +@testable import Payments + +struct PaymentQueueTests { + @Test + func joinsThePaymentAlreadyInFlight() async throws { + let queue = PaymentQueue() + let gate = Gate() + + let first = await queue.enqueue(paymentId: "pay_1") { + await gate.wait() + return .mock(status: .succeeded) + } + let second = await queue.enqueue(paymentId: "pay_1") { + Issue.record("Expected the payment in flight to be reused") + return .mock(status: .failed) + } + + await gate.open() + #expect(try await first.value.status == .succeeded) + #expect(try await second.value.status == .succeeded) + } + + @Test + func runsPaymentsOneAfterAnother() async throws { + let queue = PaymentQueue() + let gate = Gate() + let order = Order() + + let first = await queue.enqueue(paymentId: "pay_1") { + await gate.wait() + await order.append("first") + return .mock() + } + let second = await queue.enqueue(paymentId: "pay_2") { + await order.append("second") + return .mock() + } + + #expect(await order.values.isEmpty) + await gate.open() + _ = try await first.value + _ = try await second.value + #expect(await order.values == ["first", "second"]) + } +} + +private actor Gate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation = $0 } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} + +private actor Order { + private(set) var values: [String] = [] + + func append(_ value: String) { + values.append(value) + } +} 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..868366c7c9 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift @@ -0,0 +1,32 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +@testable import Payments +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 + } +} 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 b56d92a0f2..f034bac407 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,16 +62,17 @@ let package = Package( "Validators", "Stake", - "WalletConnector", "InfoSheet", "Swap", "Perpetuals", "EventPresenterService", .product(name: "ChainService", package: "ChainServices"), + .product(name: "PaymentService", package: "ChainServices"), .product(name: "WalletService", package: "FeatureServices"), .product(name: "NodeService", package: "ChainServices"), .product(name: "TransactionStateService", package: "FeatureServices"), + .product(name: "TransferService", package: "FeatureServices"), .product(name: "ScanService", package: "ChainServices"), .product(name: "BalanceService", package: "FeatureServices"), .product(name: "AssetsService", package: "FeatureServices"), @@ -91,6 +91,7 @@ let package = Package( name: "TransferTestKit", dependencies: [ "Transfer", + .product(name: "TransferServiceTestKit", package: "FeatureServices"), "Primitives", .product(name: "PrimitivesTestKit", package: "Primitives"), ], @@ -101,10 +102,12 @@ let package = Package( dependencies: [ "Transfer", "TransferTestKit", + .product(name: "TransferServiceTestKit", package: "FeatureServices"), "GemstonePrimitives", .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "BlockchainTestKit", package: "Blockchain"), .product(name: "ScanServiceTestKit", package: "ChainServices"), + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), .product(name: "SwapServiceTestKit", package: "FeatureServices"), .product(name: "KeystoreTestKit", package: "Keystore"), .product(name: "WalletServiceTestKit", package: "FeatureServices"), 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..5124367742 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.paymentExpiry.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/Transfer/Sources/Services/ConfirmService.swift b/ios/Features/Transfer/Sources/Services/ConfirmService.swift index a83f987829..dfef7be166 100644 --- a/ios/Features/Transfer/Sources/Services/ConfirmService.swift +++ b/ios/Features/Transfer/Sources/Services/ConfirmService.swift @@ -8,6 +8,7 @@ import EventPresenterService import ExplorerService import Keystore import Primitives +import TransferService public struct ConfirmService: Sendable { private let metadataProvider: any TransferMetadataProvidable diff --git a/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift b/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift index 323b4ce106..49aef640f9 100644 --- a/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift +++ b/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift @@ -11,6 +11,7 @@ import Foundation import Keystore import PriceService import Primitives +import TransferService import ScanService import Signer import TransactionStateService diff --git a/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift b/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift index 53e1de694d..e5f97f51ba 100644 --- a/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift +++ b/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift @@ -2,6 +2,7 @@ import Foundation import Primitives +import TransferService import PrimitivesComponents import Validators diff --git a/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift b/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift index 47936ef83c..f602ae0465 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift @@ -5,6 +5,7 @@ import Foundation import GemstonePrimitives import InfoSheet import Primitives +import TransferService import PrimitivesComponents import Validators diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift index 51d51a2ccd..7800fb463e 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift @@ -3,6 +3,7 @@ import Blockchain import Foundation import Primitives +import TransferService import Validators enum ConfirmTransferError { 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/Transfer/Sources/Types/ScanRecipientResult.swift b/ios/Features/Transfer/Sources/Types/ScanRecipientResult.swift deleted file mode 100644 index d533da4f67..0000000000 --- a/ios/Features/Transfer/Sources/Types/ScanRecipientResult.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -struct PaymentScanResult { - let address: String - let amount: String? - let 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..862e240047 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 paymentExpiry: PaymentExpiry + 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 + paymentExpiry = PaymentExpiry(payment: request.data.type.payment) 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 }) + paymentExpiry.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 ca59389674..51c0f1cc44 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/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/ConfirmTransferInput+TestKit.swift b/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift index e2ed01d540..fdfb3de014 100644 --- a/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift +++ b/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift @@ -3,6 +3,7 @@ import BigInt import Foundation import Primitives +import TransferServiceTestKit import PrimitivesComponents import Transfer import Validators diff --git a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift index 422621ae4d..9e435bd3f7 100644 --- a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift @@ -92,6 +92,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() @@ -99,10 +110,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) @@ -120,9 +130,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/Services/ConfirmTransferInputProviderTests.swift b/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift index 7b4089669f..b8198f9bbc 100644 --- a/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift +++ b/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift @@ -5,6 +5,8 @@ import Foundation import PrimitivesTestKit import Testing @testable import Transfer +import TransferService +import TransferServiceTestKit import TransferTestKit import Validators 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..e90d9ece9d 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift @@ -25,6 +25,7 @@ import Primitives import PrimitivesComponents import PrimitivesTestKit import ScanServiceTestKit +import SigningRequestServiceTestKit import Store import Testing import TransactionStateServiceTestKit @@ -71,7 +72,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 +88,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 +191,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 +200,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 +327,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 +607,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.paymentExpiry.start() + + #expect(model.isButtonDisabled) + } } private extension ConfirmTransferSceneViewModel { 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..ead575d5f8 100644 --- a/ios/Features/WalletConnector/Package.swift +++ b/ios/Features/WalletConnector/Package.swift @@ -32,6 +32,8 @@ let package = Package( name: "WalletConnector", dependencies: [ "Primitives", + .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "PaymentService", package: "ChainServices"), .product(name: "WalletConnectorService", package: "ChainServices"), .product(name: "ExplorerService", package: "ChainServices"), "Components", @@ -43,6 +45,7 @@ let package = Package( "QRScanner", .product(name: "AddressNameService", package: "FeatureServices"), .product(name: "WalletSessionService", package: "FeatureServices"), + .product(name: "TransactionStateService", package: "FeatureServices"), .product(name: "ConnectionsService", package: "FeatureServices"), "Keystore", "Gemstone", @@ -59,7 +62,10 @@ let package = Package( .product(name: "WalletSessionServiceTestKit", package: "FeatureServices"), .product(name: "ConnectionsServiceTestKit", package: "FeatureServices"), .product(name: "AddressNameServiceTestKit", package: "FeatureServices"), + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), + .product(name: "PaymentServiceTestKit", package: "ChainServices"), .product(name: "WalletConnectorServiceTestKit", package: "ChainServices"), + .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), .product(name: "KeystoreTestKit", package: "Keystore"), "WalletConnector", "Gemstone", diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift index 9f25ad6d86..e157c6abbb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift @@ -2,14 +2,10 @@ import Foundation import Primitives -import WalletConnectorService +import SigningRequestService 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/ConnectionProposalNavigationStack.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift new file mode 100644 index 0000000000..74a253b4a9 --- /dev/null +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift @@ -0,0 +1,44 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Style +import SwiftUI +import Primitives + +public struct ConnectionProposalNavigationStack: View { + private let type: WalletConnectorSheetType + private let presenter: SheetPresenter + + public init( + type: WalletConnectorSheetType, + presenter: SheetPresenter, + ) { + self.type = type + self.presenter = presenter + } + + public var body: some View { + NavigationStack { + Group { + switch type { + case let .connectionProposal(data): + ConnectionProposalScene( + model: ConnectionProposalViewModel( + confirmTransferDelegate: data.delegate, + pairingProposal: data.payload, + ), + onComplete: { presenter.complete(type: type) }, + ) + } + } + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark) { + presenter.cancelSheet(type: type) + } + } + } + } + } +} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift index 6909a54eff..7a677fde7e 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 @@ -76,6 +75,6 @@ public struct ConnectionsScene: View { ) .navigationTitle(model.title) .taskOnce { model.fetch() } - .onChange(of: model.walletConnectorPresenter?.isPresentingSheet?.id, model.hideConnectionBar) + .onChange(of: model.walletConnectorPresenter?.sheets.isPresentingSheet?.id, model.hideConnectionBar) } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift index 19fee17e51..82d0a1c992 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/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,28 @@ public struct SignMessageScene: View { ) } + if let expiresAt = model.expiresAt { + Section { + ListItemExpiryView( + title: model.expiresTitle, + expiresAt: expiresAt, + ) + } + } + + if let fee = model.networkFeeText { + Section { + ListItemView(title: model.networkFeeTitle, subtitle: fee) + } + } + if model.hasWarnings { Section { SimulationWarningsContent(warnings: model.simulationWarnings) } } - if model.hasPayload { + if model.showsPayload { Section { SimulationPayloadFieldsContent( fields: model.primaryPayloadFields, @@ -63,6 +85,7 @@ public struct SignMessageScene: View { .contentMargins(.top, .scene.top, for: .scrollContent) .listSectionSpacing(.compact) .taskOnce { model.fetch() } + .task { await model.paymentExpiry.start() } .safeAreaButton { StateButton( text: model.buttonTitle, diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index 5357d6213f..c0252b974d 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 SigningRequestService 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? SigningRequestError, case .userCancelled = 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.sheets.present(payload: payload, sheet: { .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..0134b901fb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,33 +1,16 @@ // Copyright (c). Gem Wallet. All rights reserved. -import SwiftUI +import Foundation +import Primitives @Observable public final class WalletConnectorPresenter: 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 - } - - @MainActor - public func cancelSheet(type: WalletConnectorSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } - - type.reject(ConnectionsError.userCancelled) - isPresentingSheet = nil - } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index f54a37769d..d8a87f53eb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift @@ -1,13 +1,12 @@ // 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 SigningRequestService import Store import WalletConnectorService import WalletConnectSign @@ -16,15 +15,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,16 +91,17 @@ 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, simulation: simulation, ) - return try await walletConnectorInteractor.signMessage(payload: payload) + return try await signingInteractor.signMessage(payload: payload) } - public func updateSessions(sessions: [WalletConnectionSession]) throws { + public func updateSessions(sessions: [WalletConnectSession]) throws { if sessions.isEmpty { _ = try? connectionsStore.deleteAll() } else { @@ -119,33 +122,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,114 +130,52 @@ 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( + case .solana, .sui, .ton, .tron: + let transferData = try SigningTransferDataFactory.transferData( chain: chain, - metadata: session.session.metadata, + 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( + chain: chain, + 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( + let transferData = SigningTransferDataFactory.encodedTransferData( chain: chain, - metadata: session.session.metadata, + 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, ), ) } - private func validate(chain: Chain, session: WalletConnectionSession) throws { + private func validate(chain: Chain, session: WalletConnectSession) throws { if !session.chains.contains(chain) { throw WalletConnectorServiceError.unresolvedChainId(chain.rawValue) } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift deleted file mode 100644 index bda4b397dd..0000000000 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Localization - -enum ConnectionsError: LocalizedError { - case userCancelled - - var errorDescription: String? { - switch self { - case .userCancelled: Localized.Errors.Connections.userCancelled - } - } -} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift deleted file mode 100644 index 9b5fbd276d..0000000000 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public final class TransferDataCallback: Sendable, Identifiable { - public typealias ConfirmTransferDelegate = @Sendable (Result) -> Void - - public let payload: T - public let delegate: ConfirmTransferDelegate - - public init( - payload: T, - delegate: @escaping ConfirmTransferDelegate, - ) { - self.payload = payload - self.delegate = delegate - } - - public var id: any Hashable { - payload.id - } -} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 1132555b94..040964eade 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -1,29 +1,27 @@ // Copyright (c). Gem Wallet. All rights reserved. +import Foundation import Primitives import WalletConnectorService public enum WalletConnectorSheetType: Sendable, Identifiable { - case transferData(TransferDataCallback) - case signMessage(TransferDataCallback) - case connectionProposal(TransferDataCallback) + case connectionProposal(SigningRequestCallback) - 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 SigningRequestRejectable { 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 } } } + +// MARK: - SigningRequestRejectable + +extension WalletConnectorSheetType: SigningRequestRejectable {} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift index c27e33be0d..e34820019a 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 @@ -127,7 +127,7 @@ public struct ConnectionProposalViewModel { ) } - private var payload: WalletConnectionSessionProposal { + private var payload: WalletConnectSessionProposal { pairingProposal.proposal } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift index dbd28ab7ae..9e632c77c2 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift @@ -4,11 +4,15 @@ import AddressNameService import Components import ExplorerService import Foundation +import Formatters +import BigInt import class Gemstone.MessageSigner -import GemstonePrimitives import Keystore import Localization +import Preferences +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import Style import WalletConnectorService @@ -16,28 +20,34 @@ 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 paymentExpiry: PaymentExpiry 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 + paymentExpiry = PaymentExpiry(payment: payload.payment) let signer = MessageSigner(message: payload.message) self.signer = signer let plainMessage = signer.plainPreview() @@ -56,12 +66,46 @@ 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.payment?.expiresAt + } + + public var expiresTitle: String { + Localized.Transfer.paymentExpiresIn + } + + public var networkFeeTitle: String { + Localized.Transfer.networkFee + } + + public var networkFeeText: String? { + guard let networkFee = payload.networkFee else { + return .none + } + let display = NumericViewModel( + data: networkFee, + style: AmountDisplayStyle(formatter: .auto, currencyCode: Preferences.standard.currency), + ) + return display.fiat?.text ?? display.amount.text + } + + 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 +116,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 +149,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 +193,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 }) + paymentExpiry.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } public var buttonType: ButtonType { 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/ConnectionProposalViewModelTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift index 22a38799cf..e3f01441e5 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift @@ -8,7 +8,7 @@ import Testing struct ConnectionProposalViewModelTests { @Test func appTextKeepsNameAndDomain() { - let metadata = WalletConnectionSessionAppMetadata.mock( + let metadata = WalletConnectAppMetadata.mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", ) diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift index a72975f853..5fbde1d5d3 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift @@ -4,9 +4,13 @@ import AddressNameServiceTestKit import Foundation import struct Gemstone.SignMessage import KeystoreTestKit +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import PrimitivesTestKit +import PaymentServiceTestKit +import SigningRequestServiceTestKit import Testing @testable import WalletConnector import WalletConnectorService @@ -17,9 +21,9 @@ struct SignMessageSceneViewModelTests { @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 +39,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 +62,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 +83,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 +98,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 +119,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 +138,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))), + payment: .mock(expiresAt: Date(timeIntervalSinceNow: 0.2)), + ) + + let viewModel = SignMessageSceneViewModel( + keystore: KeystoreMock(), + addressNameService: .mock(), + payload: payload, + confirmTransferDelegate: { _ in }, + ) + + #expect(!viewModel.isButtonDisabled) + + await viewModel.paymentExpiry.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 +184,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 +209,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 +242,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 +263,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 +337,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 +369,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 +416,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/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift new file mode 100644 index 0000000000..141a954407 --- /dev/null +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift @@ -0,0 +1,43 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import SigningRequestService +@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..5a2be0ff4d 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift @@ -13,6 +13,7 @@ import WalletConnectorService import WalletConnectSign import WalletSessionService import WalletSessionServiceTestKit +import SigningRequestService struct WalletConnectorSignerTests { @Test @@ -217,6 +218,7 @@ extension WalletConnectorSigner { connectionsStore: connectionsStore, walletSessionService: walletSessionService, walletConnectorInteractor: WalletConnectorManager(presenter: WalletConnectorPresenter()), + signingInteractor: SheetPresenter(), ) } diff --git a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift index e9927b1f52..9632c36921 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 { private let balanceService: BalanceService private let bannerService: BannerService private let walletService: WalletService + private let payService: any PaymentLinkPayable private let balanceCalculator = BalanceCalculator() let observablePreferences: ObservablePreferences @@ -42,6 +43,7 @@ public final class WalletSceneViewModel: Sendable { 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 { balanceService: BalanceService, bannerService: BannerService, walletService: WalletService, + payService: any PaymentLinkPayable, nftService: NFTService, observablePreferences: ObservablePreferences, wallet: Wallet, @@ -62,8 +65,9 @@ public final class WalletSceneViewModel: Sendable { self.balanceService = balanceService self.bannerService = bannerService self.walletService = walletService + self.payService = payService self.observablePreferences = observablePreferences - self.collectionsModel = CollectionsViewModel( + collectionsModel = CollectionsViewModel( nftService: nftService, walletService: walletService, wallet: wallet, @@ -74,7 +78,7 @@ public final class WalletSceneViewModel: Sendable { 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 { 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 bbdc4323de..8630131767 100644 --- a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift +++ b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift @@ -18,6 +18,7 @@ public extension WalletSceneViewModel { balanceService: .mock(), bannerService: .mock(), walletService: .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 2951733d2d..d8ef6ecf1d 100644 --- a/ios/Gem.xcodeproj/project.pbxproj +++ b/ios/Gem.xcodeproj/project.pbxproj @@ -17,7 +17,10 @@ 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 */; }; + 0A11FA0000000000000000E1 /* SigningRequestService in Frameworks */ = {isa = PBXBuildFile; productRef = 0A11FA0000000000000000E2 /* SigningRequestService */; }; 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 */; }; @@ -34,7 +37,7 @@ 8361BB6D2D400E4F008D89CF /* AssetsService in Frameworks */ = {isa = PBXBuildFile; productRef = 8361BB6C2D400E4F008D89CF /* AssetsService */; }; 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 */; }; + 837A1E6D2D0B096600733AC1 /* SigningRequestNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6C2D0B096600733AC1 /* SigningRequestNavigationStack.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 */; }; @@ -214,7 +217,7 @@ 8361BB5C2D3FDBF1008D89CF /* Transactions */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Transactions; sourceTree = ""; }; 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 = ""; }; + 837A1E6C2D0B096600733AC1 /* SigningRequestNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SigningRequestNavigationStack.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 +229,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 +328,10 @@ buildActionMask = 2147483647; files = ( 833809D12D273E450055D91F /* WalletConnector in Frameworks */, + 0A11FA0000000000000000B1 /* Payments in Frameworks */, 833809D02D273E450055D91F /* WalletConnectorService in Frameworks */, + 0A11FA0000000000000000A1 /* PaymentService in Frameworks */, + 0A11FA0000000000000000E1 /* SigningRequestService in Frameworks */, C3D1C4E22A2A9485006E8EEA /* Signer in Frameworks */, D85AD2A62CD7017E0010DEF8 /* NativeProviderService in Frameworks */, 8361BB6D2D400E4F008D89CF /* AssetsService in Frameworks */, @@ -478,7 +485,7 @@ 8361BB6F2D402C63008D89CF /* WalletConnector */ = { isa = PBXGroup; children = ( - 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */, + 837A1E6C2D0B096600733AC1 /* SigningRequestNavigationStack.swift */, ); path = WalletConnector; sourceTree = ""; @@ -712,6 +719,7 @@ 833931A32D2D54060063BB6A /* PriceAlerts */, D9F015752F10654000000001 /* InAppNotifications */, 83FE37B22D2715BF0048D54C /* WalletConnector */, + 0A11FA0000000000000000B3 /* Payments */, D8D83C822CECF90A0083AA53 /* Swap */, 8393897A2CCA8F0000735088 /* InfoSheet */, E1B4C7082E85B00000EARN01 /* Stake */, @@ -855,7 +863,10 @@ 833931A72D2D663C0063BB6A /* PriceService */, B6STREAM02F5000000000002 /* StreamService */, 83385DDD2D27147900D76803 /* WalletConnectorService */, + 0A11FA0000000000000000A2 /* PaymentService */, + 0A11FA0000000000000000E2 /* SigningRequestService */, 83FE37B32D273CC80048D54C /* WalletConnector */, + 0A11FA0000000000000000B2 /* Payments */, 83EB5E2F2D2ECF00006A7CFB /* Onboarding */, 8352C8D42D393E05000DEDD9 /* ExplorerService */, 07A9FE3C2D3565CD00ACDB94 /* NFTService */, @@ -1179,7 +1190,7 @@ FADEC0DE000000000000B002 /* AddContactNavigationView.swift in Sources */, B67ED5482DDB5DCD009F74E6 /* NavigationPresenter.swift in Sources */, C366790A2A0B7E5800F1D74D /* Environment.swift in Sources */, - 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */, + 837A1E6D2D0B096600733AC1 /* SigningRequestNavigationStack.swift in Sources */, D8D5C1D12E6CAC16007628A1 /* Errors.swift in Sources */, D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */, D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */, @@ -2000,6 +2011,18 @@ isa = XCSwiftPackageProductDependency; productName = WalletConnector; }; + 0A11FA0000000000000000A2 /* PaymentService */ = { + isa = XCSwiftPackageProductDependency; + productName = PaymentService; + }; + 0A11FA0000000000000000E2 /* SigningRequestService */ = { + isa = XCSwiftPackageProductDependency; + productName = SigningRequestService; + }; + 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 2d3b1abd48..730c870d1a 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -29,6 +29,8 @@ struct GemApp: App { model: RootSceneViewModel( observablePreferences: resolver.storages.observablePreferences, walletConnectorPresenter: resolver.services.walletConnectorManager.presenter, + paymentSheetPresenter: resolver.services.paymentSheetPresenter, + signingRequestPresenter: resolver.services.signingRequestPresenter, onstartService: resolver.services.onstartService, onstartWalletService: resolver.services.onstartWalletService, transactionStateScheduler: resolver.services.transactionStateScheduler, @@ -37,6 +39,7 @@ struct GemApp: App { navigationHandler: resolver.services.navigationHandler, lockWindowManager: LockWindowManager(lockModel: LockSceneViewModel()), walletService: resolver.services.walletService, + 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 ddd7d535f4..e6c1166fda 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/Wallet/WalletNavigationStack.swift b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift index 598bf34fcd..f05e4170ee 100644 --- a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift +++ b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift @@ -12,6 +12,7 @@ import PriceAlerts import PriceService import Primitives import PrimitivesComponents +import QRScanner import StakeService import Store import SwiftUI @@ -87,6 +88,13 @@ struct WalletNavigationStack: View { ) .liquidGlass() } + if model.showScanner { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: model.onSelectScanner) { + model.scannerImage + } + } + } ToolbarItem(placement: .navigationBarTrailing) { Button(action: model.onToggleSearch) { model.searchImage @@ -94,6 +102,9 @@ struct WalletNavigationStack: View { } } } + .sheet(isPresented: $model.isPresentingScanner) { + ScanQRCodeNavigationStack(action: model.onHandleScan) + } .navigationDestination(for: Scenes.Asset.self) { AssetNavigationView( model: AssetSceneViewModel( diff --git a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift b/ios/Gem/Navigation/WalletConnector/SigningRequestNavigationStack.swift similarity index 73% rename from ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift rename to ios/Gem/Navigation/WalletConnector/SigningRequestNavigationStack.swift index 82f8fb9edb..75d8fd016c 100644 --- a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift +++ b/ios/Gem/Navigation/WalletConnector/SigningRequestNavigationStack.swift @@ -3,21 +3,22 @@ import ExplorerService import Primitives import Signer +import SigningRequestService import Style import SwiftUI import TransactionStateService import Transfer import WalletConnector -struct WalletConnectorNavigationStack: View { +struct SigningRequestNavigationStack: View { @Environment(\.viewModelFactory) private var viewModelFactory - private let type: WalletConnectorSheetType - private let presenter: WalletConnectorPresenter + private let type: SigningRequestSheetType + private let presenter: SheetPresenter init( - type: WalletConnectorSheetType, - presenter: WalletConnectorPresenter, + type: SigningRequestSheetType, + presenter: SheetPresenter, ) { self.type = type self.presenter = presenter @@ -45,14 +46,6 @@ struct WalletConnectorNavigationStack: View { ), onComplete: { presenter.complete(type: type) }, ) - case let .connectionProposal(data): - ConnectionProposalScene( - model: ConnectionProposalViewModel( - confirmTransferDelegate: data.delegate, - pairingProposal: data.payload, - ), - onComplete: { presenter.complete(type: type) }, - ) } } .interactiveDismissDisabled(true) diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index 310f3f2c56..44428d53c6 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -4,10 +4,12 @@ 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,10 +36,22 @@ struct RootScene: View { await model.handleOpenUrl(url) } } - .sheet(item: $model.isPresentingConnectorSheet) { type in - WalletConnectorNavigationStack( + .sheet(item: $model.isPresentingConnectorSheet, onDismiss: model.walletConnectorPresenter.sheets.onSheetDismiss) { type in + ConnectionProposalNavigationStack( type: type, - presenter: model.walletConnectorPresenter, + presenter: model.walletConnectorPresenter.sheets, + ) + } + .sheet(item: $model.isPresentingSigningRequestSheet, onDismiss: model.signingRequestPresenter.onSheetDismiss) { type in + SigningRequestNavigationStack( + type: type, + presenter: model.signingRequestPresenter, + ) + } + .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.onSheetDismiss) { type in + PaymentNavigationStack( + type: type, + presenter: model.paymentSheetPresenter, ) } .sheet(isPresented: $model.isPresentingCreateWalletSheet) { diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index b0c6cb6b7d..c4f5700419 100644 --- a/ios/Gem/Services/AppResolver+Services.swift +++ b/ios/Gem/Services/AppResolver+Services.swift @@ -17,6 +17,7 @@ import EventPresenterService import ExplorerService import FiatService import Foundation +import Payments import NameService import NFTService import NodeService @@ -27,6 +28,7 @@ import PriceService import RewardsService import ServiceStatusService import ScanService +import SigningRequestService import StakeService import StreamService import SupportChatService @@ -36,6 +38,7 @@ import TransactionStateService import WalletConnector import WalletService import WalletSessionService +import Primitives extension AppResolver { struct Services { @@ -77,6 +80,9 @@ extension AppResolver { let onstartAsyncService: OnstartAsyncService let onstartWalletService: OnstartWalletService let walletConnectorManager: WalletConnectorManager + let paymentLinkManager: PaymentLinkManager + let paymentSheetPresenter: SheetPresenter + let signingRequestPresenter: SheetPresenter let perpetualService: PerpetualService let hyperliquidObserverService: any PerpetualObservable let nameService: NameService @@ -132,6 +138,9 @@ extension AppResolver { onstartAsyncService: OnstartAsyncService, onstartWalletService: OnstartWalletService, walletConnectorManager: WalletConnectorManager, + paymentLinkManager: PaymentLinkManager, + paymentSheetPresenter: SheetPresenter, + signingRequestPresenter: SheetPresenter, perpetualService: PerpetualService, hyperliquidObserverService: any PerpetualObservable, nameService: NameService, @@ -187,6 +196,9 @@ extension AppResolver { self.onstartAsyncService = onstartAsyncService self.onstartWalletService = onstartWalletService self.walletConnectorManager = walletConnectorManager + self.paymentLinkManager = paymentLinkManager + self.paymentSheetPresenter = paymentSheetPresenter + self.signingRequestPresenter = signingRequestPresenter 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 aaa825fa81..b82eb37904 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -22,6 +22,7 @@ import FiatService import Foundation import GemAPI import GemAPIDevice +import func Gemstone.paymentWalletConnectUrl import GemstonePrimitives import Keystore import NameService @@ -33,7 +34,10 @@ import PerpetualService import Preferences import PriceAlertService import PriceService +import PaymentService +import Payments import Primitives +import SigningRequestService import RewardsService import ScanService import ServiceStatusService @@ -47,6 +51,7 @@ import TransactionsService import TransactionStateService import Transfer import WalletConnector +import WalletConnectorService import WalletService import WalletSessionService import WebSocketClient @@ -148,6 +153,11 @@ struct ServicesFactory { assetsService: assetsService, addressStore: storeManager.addressStore, ) + let paymentService = PaymentService( + provider: nativeProvider, + appId: Constants.WalletConnect.projectId, + clientId: (try? securePreferences.getDeviceId()) ?? .empty, + ) let transactionStateScheduler = Self.makeTransactionService( transactionStore: storeManager.transactionStore, gatewayService: gatewayService, @@ -155,6 +165,7 @@ struct ServicesFactory { earnService: earnService, nftService: nftService, balanceService: balanceService, + paymentStatusService: paymentService, ) let preferences = storages.observablePreferences.preferences @@ -229,10 +240,12 @@ struct ServicesFactory { let presenter = WalletConnectorPresenter() let walletConnectorManager = WalletConnectorManager(presenter: presenter) + let signingRequestPresenter = SheetPresenter() let connectionsService = Self.makeConnectionsService( connectionsStore: storeManager.connectionsStore, walletSessionService: walletSessionService, interactor: walletConnectorManager, + signingInteractor: signingRequestPresenter, nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider, ) @@ -294,7 +307,6 @@ struct ServicesFactory { let activityService = ActivityService(store: storeManager.recentActivityStore) let authService = AuthService(apiService: apiService, keystore: storages.keystore) let rewardsService = RewardsService(apiService: apiService, authService: authService) - let eventPresenterService = EventPresenterService() let walletSearchService = WalletSearchService( assetsService: assetsService, searchStore: storeManager.searchStore, @@ -325,6 +337,33 @@ struct ServicesFactory { hyperliquidObserverService: hyperliquidObserverService, ) + let paymentAssetsProvider = PaymentAssetsProvider(assetStore: storeManager.assetStore) + let paymentSheetPresenter = SheetPresenter() + + let eventPresenterService = EventPresenterService() + let paymentLinkManager = PaymentLinkManager( + paymentManager: PaymentManager( + service: paymentService, + executor: PaymentActionExecutor( + interactor: signingRequestPresenter, + simulator: SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), + approvalExecutor: PaymentApprovalExecutor( + keystore: storages.keystore, + assetsService: assetsService, + chainServiceFactory: chainServiceFactory, + scanService: scanService, + assetsEnabler: assetsEnabler, + transactionStateScheduler: transactionStateScheduler, + ), + assetsProvider: paymentAssetsProvider, + ), + presenter: paymentSheetPresenter, + assetsProvider: paymentAssetsProvider, + transactionStateScheduler: transactionStateScheduler, + ), + eventPresenterService: eventPresenterService, + ) + let viewModelFactory = ViewModelFactory( keystore: storages.keystore, chainServiceFactory: chainServiceFactory, @@ -389,6 +428,9 @@ struct ServicesFactory { onstartAsyncService: onstartAsyncService, onstartWalletService: onstartWalletService, walletConnectorManager: walletConnectorManager, + paymentLinkManager: paymentLinkManager, + paymentSheetPresenter: paymentSheetPresenter, + signingRequestPresenter: signingRequestPresenter, perpetualService: perpetualService, hyperliquidObserverService: hyperliquidObserverService, nameService: nameService, @@ -531,6 +573,7 @@ extension ServicesFactory { earnService: EarnService, nftService: NFTService, balanceService: BalanceService, + paymentStatusService: any PaymentStatusServiceable, ) -> TransactionStateScheduler { let postProcessingService = TransactionPostProcessingService( transactionStore: transactionStore, @@ -543,6 +586,7 @@ extension ServicesFactory { transactionStore: transactionStore, gatewayService: gatewayService, postProcessingService: postProcessingService, + paymentStatusService: paymentStatusService, ) return TransactionStateScheduler( transactionStore: transactionStore, @@ -581,6 +625,7 @@ extension ServicesFactory { connectionsStore: ConnectionsStore, walletSessionService: WalletSessionService, interactor: any WalletConnectorInteractable, + signingInteractor: any SigningRequestInteractable, nodeProvider: any NodeURLFetchable, requestInterceptor: any RequestInterceptable, ) -> ConnectionsService { @@ -590,6 +635,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 a05f12bb34..a662058f95 100644 --- a/ios/Gem/Services/ViewModelFactory.swift +++ b/ios/Gem/Services/ViewModelFactory.swift @@ -17,7 +17,9 @@ import PerpetualService import Preferences import PriceAlertService import PriceService +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import ScanService import Stake @@ -152,7 +154,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 +297,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 efc3cd4806..e5f4d113ef 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? { @@ -35,6 +36,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 f7ed73002e..c5e609091d 100644 --- a/ios/Gem/ViewModels/RootSceneViewModel.swift +++ b/ios/Gem/ViewModels/RootSceneViewModel.swift @@ -12,8 +12,10 @@ import Localization import LockManager import NameService import Onboarding +import Payments import Preferences import Primitives +import SigningRequestService import SwiftUI import TransactionsService import TransactionStateService @@ -37,9 +39,12 @@ final class RootSceneViewModel { let observablePreferences: ObservablePreferences let walletSetupService: WalletSetupService let walletService: WalletService + let payService: any PaymentLinkPayable let nameService: NameService let avatarService: AvatarService let walletConnectorPresenter: WalletConnectorPresenter + let paymentSheetPresenter: SheetPresenter + let signingRequestPresenter: SheetPresenter let lockManager: any LockWindowManageable var currentWallet: Wallet? { walletService.currentWallet @@ -58,8 +63,18 @@ final class RootSceneViewModel { } var isPresentingConnectorSheet: WalletConnectorSheetType? { - get { walletConnectorPresenter.isPresentingSheet } - set { walletConnectorPresenter.isPresentingSheet = newValue } + get { walletConnectorPresenter.sheets.isPresentingSheet } + set { walletConnectorPresenter.sheets.isPresentingSheet = newValue } + } + + var isPresentingSigningRequestSheet: SigningRequestSheetType? { + get { signingRequestPresenter.isPresentingSheet } + set { signingRequestPresenter.isPresentingSheet = newValue } + } + + var isPresentingPaymentSheet: PaymentSheetType? { + get { paymentSheetPresenter.isPresentingSheet } + set { paymentSheetPresenter.isPresentingSheet = newValue } } var isPresentingConnectorBar: Bool { @@ -77,6 +92,8 @@ final class RootSceneViewModel { init( observablePreferences: ObservablePreferences, walletConnectorPresenter: WalletConnectorPresenter, + paymentSheetPresenter: SheetPresenter, + signingRequestPresenter: SheetPresenter, onstartService: OnstartService, onstartWalletService: OnstartWalletService, transactionStateScheduler: TransactionStateScheduler, @@ -85,6 +102,7 @@ final class RootSceneViewModel { navigationHandler: NavigationHandler, lockWindowManager: any LockWindowManageable, walletService: WalletService, + payService: any PaymentLinkPayable, walletSetupService: WalletSetupService, nameService: NameService, releaseAlertService: ReleaseAlertService, @@ -95,6 +113,8 @@ final class RootSceneViewModel { ) { self.observablePreferences = observablePreferences self.walletConnectorPresenter = walletConnectorPresenter + self.paymentSheetPresenter = paymentSheetPresenter + self.signingRequestPresenter = signingRequestPresenter self.onstartService = onstartService self.onstartWalletService = onstartWalletService self.transactionStateScheduler = transactionStateScheduler @@ -103,6 +123,7 @@ final class RootSceneViewModel { self.navigationHandler = navigationHandler lockManager = lockWindowManager self.walletService = walletService + self.payService = payService self.walletSetupService = walletSetupService self.nameService = nameService self.releaseAlertService = releaseAlertService @@ -155,6 +176,8 @@ extension RootSceneViewModel { switch action { case let .walletConnect(walletConnectAction): try await handleWalletConnect(walletConnectAction) + case let .payment(link): + await handlePayment(link) case .deeplink: await navigationHandler.handle(action) } @@ -227,6 +250,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 6263cdb66b..d27aad3f78 100644 --- a/ios/Gem/Views/MainTabView.swift +++ b/ios/Gem/Views/MainTabView.swift @@ -18,6 +18,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(\.walletService) private var walletService @@ -46,6 +47,7 @@ struct MainTabView: View { balanceService: balanceService, bannerService: bannerService, walletService: walletService, + 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..3321110e30 100644 --- a/ios/GemTests/unit_frameworks.xctestplan +++ b/ios/GemTests/unit_frameworks.xctestplan @@ -175,6 +175,20 @@ "name" : "TransferTests" } }, + { + "target" : { + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "TransferServiceTests", + "name" : "TransferServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Features\/Payments", + "identifier" : "PaymentsTests", + "name" : "PaymentsTests" + } + }, { "target" : { "containerPath" : "container:Packages\/GemAPI", @@ -339,6 +353,13 @@ "name" : "WalletConnectorServiceTests" } }, + { + "target" : { + "containerPath" : "container:Packages\/ChainServices", + "identifier" : "SigningRequestServiceTests", + "name" : "SigningRequestServiceTests" + } + }, { "target" : { "containerPath" : "container:Packages\/ChainServices", 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..11332095d1 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -15,6 +15,10 @@ let package = Package( .library(name: "StakeServiceTestKit", targets: ["StakeServiceTestKit"]), .library(name: "NodeService", targets: ["NodeService"]), .library(name: "NodeServiceTestKit", targets: ["NodeServiceTestKit"]), + .library(name: "SigningRequestService", targets: ["SigningRequestService"]), + .library(name: "SigningRequestServiceTestKit", targets: ["SigningRequestServiceTestKit"]), + .library(name: "PaymentService", targets: ["PaymentService"]), + .library(name: "PaymentServiceTestKit", targets: ["PaymentServiceTestKit"]), .library(name: "WalletConnectorService", targets: ["WalletConnectorService"]), .library(name: "WalletConnectorServiceTestKit", targets: ["WalletConnectorServiceTestKit"]), .library(name: "ScanService", targets: ["ScanService"]), @@ -102,10 +106,61 @@ let package = Package( ], path: "NodeService/Tests", ), + .target( + name: "SigningRequestService", + dependencies: [ + "Primitives", + "Gemstone", + "GemstonePrimitives", + "NativeProviderService", + ], + path: "SigningRequestService", + exclude: ["TestKit", "Tests"], + ), + .testTarget( + name: "SigningRequestServiceTests", + dependencies: [ + "SigningRequestService", + "SigningRequestServiceTestKit", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "SigningRequestService/Tests", + ), + .target( + name: "SigningRequestServiceTestKit", + dependencies: [ + "SigningRequestService", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "SigningRequestService/TestKit", + ), + .target( + name: "PaymentService", + dependencies: [ + "SigningRequestService", + "Primitives", + "Store", + "Gemstone", + "GemstonePrimitives", + "NativeProviderService", + ], + path: "PaymentService", + exclude: ["TestKit"], + ), + .target( + name: "PaymentServiceTestKit", + dependencies: [ + "PaymentService", + "SigningRequestServiceTestKit", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "PaymentService/TestKit", + ), .target( name: "WalletConnectorService", dependencies: [ "Primitives", + "SigningRequestService", "Gemstone", "GemstonePrimitives", "NativeProviderService", @@ -120,6 +175,7 @@ let package = Package( name: "WalletConnectorServiceTestKit", dependencies: [ "WalletConnectorService", + "SigningRequestService", .product(name: "PrimitivesTestKit", package: "Primitives"), ], path: "WalletConnectorService/TestKit", diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift b/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift new file mode 100644 index 0000000000..bbc8107787 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift @@ -0,0 +1,22 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import enum Gemstone.PaymentAction +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/ChainServices/PaymentService/PaymentAction.swift b/ios/Packages/ChainServices/PaymentService/PaymentAction.swift new file mode 100644 index 0000000000..85f599029e --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAction.swift @@ -0,0 +1,13 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import Primitives +import SigningRequestService + +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/ChainServices/PaymentService/PaymentApprovalExecutable.swift b/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift new file mode 100644 index 0000000000..175314763a --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import Primitives + +public protocol PaymentApprovalExecutable: Sendable { + func getApprovalFee(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> BigInt + func approve(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> String + func waitForApproval(hash: String, assetId: AssetId, wallet: Wallet) async throws +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift new file mode 100644 index 0000000000..05a2e32c4f --- /dev/null +++ b/ios/Packages/ChainServices/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]) -> [AssetData] +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift new file mode 100644 index 0000000000..b4707c4499 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift @@ -0,0 +1,22 @@ +// 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]) -> [AssetData] { + do { + return try assetStore.getAssetsData(walletId: walletId, filters: [.chainsOrAssets([], assetIds.map(\.identifier))]) + } catch { + debugLog("PaymentAssetsProvider assets data error: \(error)") + return [] + } + } +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift b/ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift new file mode 100644 index 0000000000..558fc88085 --- /dev/null +++ b/ios/Packages/ChainServices/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/ChainServices/PaymentService/PaymentQuotesRequest.swift b/ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift new file mode 100644 index 0000000000..486bf9b343 --- /dev/null +++ b/ios/Packages/ChainServices/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/ChainServices/PaymentService/PaymentService.swift b/ios/Packages/ChainServices/PaymentService/PaymentService.swift new file mode 100644 index 0000000000..7b6050aa26 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentService.swift @@ -0,0 +1,63 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import NativeProviderService +import Primitives + +public protocol PaymentStatusServiceable: Sendable { + func hasStatus(provider: PaymentProviderName) -> Bool + func getPaymentStatus(provider: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome +} + +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 + func cancelPayment(provider: PaymentProviderName, paymentId: String) async throws +} + +public final class PaymentService: PaymentServiceable { + private let service: Gemstone.GemPaymentService + + public init(provider: NativeProvider, appId: String, clientId: String) { + service = Gemstone.GemPaymentService(provider: provider, 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() }, + ) + } + + 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 cancelPayment(provider: PaymentProviderName, paymentId: String) async throws { + try await service.cancelPayment(provider: provider.map(), paymentId: paymentId) + } + + public func hasStatus(provider: PaymentProviderName) -> Bool { + switch provider { + case .walletConnectPay: true + case .solanaPay: false + } + } + + 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/ChainServices/PaymentService/PreparedPayment.swift b/ios/Packages/ChainServices/PaymentService/PreparedPayment.swift new file mode 100644 index 0000000000..c0f9bdbdc3 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PreparedPayment.swift @@ -0,0 +1,16 @@ +// 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 init(quotes: PaymentQuotes, quote: PaymentQuote, actions: [PaymentAction]) { + self.quotes = quotes + self.quote = quote + self.actions = actions + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift new file mode 100644 index 0000000000..2c002a1e1c --- /dev/null +++ b/ios/Packages/ChainServices/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/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift new file mode 100644 index 0000000000..abc3f7e8fc --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift @@ -0,0 +1,36 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import PaymentService +import Primitives + +public final class PaymentApprovalExecutableMock: PaymentApprovalExecutable, @unchecked Sendable { + public private(set) var approvals: [ApprovalData] = [] + public private(set) var confirmedHashes: [String] = [] + private let hash: String + private let fee: BigInt + private let validationError: (any Error)? + + public init(hash: String = "0xapproval", fee: BigInt = .zero, validationError: (any Error)? = .none) { + self.hash = hash + self.fee = fee + self.validationError = validationError + } + + public func getApprovalFee(assetId _: AssetId, approval _: ApprovalData, wallet _: Wallet) async throws -> BigInt { + if let validationError { + throw validationError + } + return fee + } + + public func approve(assetId _: AssetId, approval: ApprovalData, wallet _: Wallet) async throws -> String { + approvals.append(approval) + return hash + } + + public func waitForApproval(hash: String, assetId _: AssetId, wallet _: Wallet) async throws { + confirmedHashes.append(hash) + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift new file mode 100644 index 0000000000..81ae6f2231 --- /dev/null +++ b/ios/Packages/ChainServices/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]) -> [AssetData] { + assetsData.filter { assetIds.contains($0.asset.id) } + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift new file mode 100644 index 0000000000..72a32fab63 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift @@ -0,0 +1,64 @@ +// 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 confirmOutcome: PaymentOutcome + private let confirmError: (any Error)? + private let statusOutcome: PaymentOutcome + + public private(set) var cancelledPaymentIds: [String] = [] + public private(set) var confirmedResults: [[String]] = [] + public private(set) var requestedQuotes: [PaymentQuote] = [] + + public init( + options: [PaymentOptions], + actions: [PaymentAction] = [], + confirmOutcome: PaymentOutcome = .mock(), + confirmError: (any Error)? = .none, + statusOutcome: PaymentOutcome = .mock(), + ) { + self.options = options + self.actions = actions + 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) + } + + public func confirmPayment(provider _: PaymentProviderName, quote _: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome { + confirmedResults.append(actionResults) + if let confirmError { + throw confirmError + } + return confirmOutcome + } + + public func cancelPayment(provider _: PaymentProviderName, paymentId: String) async throws { + cancelledPaymentIds.append(paymentId) + } + + public nonisolated func hasStatus(provider _: PaymentProviderName) -> Bool { + true + } + + public func getPaymentStatus(provider _: PaymentProviderName, paymentId _: String) async throws -> PaymentOutcome { + statusOutcome + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift new file mode 100644 index 0000000000..4a81bc482f --- /dev/null +++ b/ios/Packages/ChainServices/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/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/WalletConnectorService/SignMessagePayload.swift b/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift similarity index 51% rename from ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift rename to ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift index 56493bb979..11b7235ac3 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift @@ -4,30 +4,33 @@ 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 networkFee: AssetValuePrice? public init( + id: String, chain: Chain, - session: WalletConnectionSession, + appMetadata: TransactionAppMetadata, wallet: Wallet, message: SignMessage, simulation: SimulationResult, + payment: PaymentData? = .none, + networkFee: AssetValuePrice? = .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.networkFee = networkFee } } diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift similarity index 69% rename from ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift rename to ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift index be9773e158..5c27ba5f9f 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.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() -> 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/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift similarity index 72% rename from ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift rename to ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift index b6fa0e5424..7a7bccfbaa 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift @@ -3,8 +3,8 @@ 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/ChainServices/SigningRequestService/SigningRequestInteractable.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestInteractable.swift new file mode 100644 index 0000000000..9bc3b26f9e --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestInteractable.swift @@ -0,0 +1,10 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +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/ChainServices/SigningRequestService/SigningRequestPresenter.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift new file mode 100644 index 0000000000..339cd27f5d --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift @@ -0,0 +1,18 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +extension SheetPresenter: SigningRequestInteractable where Sheet == SigningRequestSheetType { + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await present(payload: payload, sheet: { .signMessage($0) }) + } + + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData, sheet: { .transferData($0) }) + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData, sheet: { .transferData($0) }) + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift new file mode 100644 index 0000000000..7d253b8e65 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift @@ -0,0 +1,28 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public enum SigningRequestSheetType: Sendable, Identifiable { + case transferData(SigningRequestCallback) + case signMessage(SigningRequestCallback) + + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) + } + + private var callback: any SigningRequestRejectable { + switch self { + case let .transferData(callback): callback + case let .signMessage(callback): callback + } + } +} + +// MARK: - SigningRequestRejectable + +extension SigningRequestSheetType: SigningRequestRejectable {} diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift b/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift new file mode 100644 index 0000000000..befabe34f4 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift @@ -0,0 +1,43 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.Chain +import enum Gemstone.SignDigestType +import struct Gemstone.SignMessage +import class Gemstone.WalletConnectSimulationClient +import enum Gemstone.SignableTransactionType +import NativeProviderService +import Primitives +import GemstonePrimitives + +public protocol SigningSimulatable: 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 SigningSimulatable { + 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 SigningSimulator: SigningSimulatable, 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/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift b/ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift similarity index 88% rename from ios/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift rename to ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift index 2e93991bb0..77f1df068c 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift @@ -3,7 +3,7 @@ import Foundation import Primitives -public struct WCTransferData: Identifiable, Sendable { +public struct SigningTransferData: Identifiable, Sendable { public let transferData: TransferData public let wallet: Wallet public let simulation: SimulationResult diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift b/ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift new file mode 100644 index 0000000000..f84cd56e33 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift @@ -0,0 +1,116 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import Primitives + +public enum SigningTransferDataFactory { + public static func transferData( + chain: Chain, + appMetadata: TransactionAppMetadata, + transaction: SignableTransaction, + outputAction: TransferDataOutputAction, + payment: PaymentData? = .none, + ) throws -> TransferData { + switch transaction { + case let .ethereum(transaction, transactionType): + try ethereumTransferData(chain: chain, 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(chain: chain, appMetadata: appMetadata, transaction: transaction, outputType: outputType, outputAction: outputAction, payment: payment) + } + } + + public static func ethereumTransferData( + chain: Chain, + 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: chain.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( + chain: Chain, + appMetadata: TransactionAppMetadata, + transaction: String, + outputType: TransferDataOutputType, + outputAction: TransferDataOutputAction, + payment: PaymentData? = .none, + ) -> TransferData { + TransferData( + type: Self.type( + asset: chain.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/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift b/ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift new file mode 100644 index 0000000000..a621ad0bf4 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift @@ -0,0 +1,44 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import SigningRequestService +import Primitives +import PrimitivesTestKit + +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, + ) -> SignMessagePayload { + SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message, + simulation: simulation, + payment: payment, + ) + } +} + +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/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift new file mode 100644 index 0000000000..b096cc44a6 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift @@ -0,0 +1,33 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import SigningRequestService + +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 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 { + transactionHash + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift new file mode 100644 index 0000000000..6737fea789 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import enum Gemstone.SignDigestType +import struct Gemstone.Chain +import enum Gemstone.SignableTransactionType +import SigningRequestService +import Primitives + +public struct SigningSimulatableMock: SigningSimulatable { + 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/SigningRequestService/Tests/SheetPresenterTests.swift b/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift new file mode 100644 index 0000000000..c900c35fcf --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift @@ -0,0 +1,112 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import PrimitivesTestKit +@testable import SigningRequestService +import SigningRequestServiceTestKit +import Testing + +struct SheetPresenterTests { + @Test + @MainActor + func completeDismissesThePresentedSheet() { + let presenter = SheetPresenter() + let type = Self.sheet(id: "request") + + presenter.isPresentingSheet = type + presenter.complete(type: type) + + #expect(presenter.isPresentingSheet == nil) + } + + @Test + @MainActor + func presentReturnsTheAnswerOnlyAfterTheSheetReportsItClosed() async throws { + let presenter = SheetPresenter() + let answer = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "request"), sheet: { .signMessage($0) }) + } + try await Self.wait { presenter.isPresentingSheet != nil } + + guard case let .signMessage(callback) = presenter.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + callback.delegate(.success("signature")) + try await Self.wait { presenter.isPresentingSheet == nil } + presenter.onSheetDismiss() + + #expect(try await answer.value == "signature") + } + + @Test + @MainActor + func presentQueuesBehindTheSheetThatIsStillClosing() async throws { + let presenter = SheetPresenter() + let first = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "first"), sheet: { .signMessage($0) }) + } + try await Self.wait { presenter.isPresentingSheet?.id == "first" } + + guard case let .signMessage(callback) = presenter.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + let second = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "second"), sheet: { .signMessage($0) }) + } + callback.delegate(.success("signature")) + try await Self.wait { presenter.isPresentingSheet == nil } + + #expect(presenter.isPresentingSheet == nil) + + presenter.onSheetDismiss() + try await Self.wait { presenter.isPresentingSheet?.id == "second" } + + #expect(try await first.value == "signature") + + guard let sheet = presenter.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 = SheetPresenter() + let answer = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "request"), sheet: { .signMessage($0) }) + } + try await Self.wait { presenter.isPresentingSheet != nil } + + guard let sheet = presenter.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + presenter.cancelSheet(type: sheet) + presenter.onSheetDismiss() + + await #expect(throws: SigningRequestError.userCancelled) { + try await answer.value + } + } + + private static func sheet(id: String) -> SigningRequestSheetType { + .signMessage(SigningRequestCallback(payload: .mock(id: id), delegate: { _ in })) + } + + 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") + } +} diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift b/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift index 801d594421..786fd0316b 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift @@ -5,8 +5,8 @@ import Primitives import ReownWalletKit extension AppMetadata { - var metadata: WalletConnectionSessionAppMetadata { - WalletConnectionSessionAppMetadata( + var metadata: WalletConnectAppMetadata { + WalletConnectAppMetadata( name: name, description: description, url: url, diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift b/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift index 3e3548b01f..038784d382 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift @@ -5,12 +5,12 @@ import Primitives import ReownWalletKit extension Session { - var asSession: Primitives.WalletConnectionSession { + var asSession: Primitives.WalletConnectSession { let sessionChains = namespaces.values .flatMap(\.accounts) .compactMap(\.chain) - return WalletConnectionSession( + return WalletConnectSession( id: topic, sessionId: topic, state: .active, 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..56267485f8 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift @@ -3,6 +3,7 @@ import Foundation import struct Gemstone.SignMessage import Primitives +import SigningRequestService import WalletConnectorService import WalletConnectSign @@ -12,7 +13,7 @@ public struct WalletConnectorSignableMock: WalletConnectorSignable { public init() {} public func addConnection(connection _: WalletConnection) throws {} - public func updateSessions(sessions _: [WalletConnectionSession]) throws {} + public func updateSessions(sessions _: [WalletConnectSession]) throws {} public func sessionReject(error _: any Error) async {} public func sessionReject(id _: String, error _: any Error) async throws {} public func getCurrentWallet() throws -> Wallet { @@ -51,11 +52,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..df197ab504 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift @@ -9,11 +9,12 @@ 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 enum Gemstone.SignableTransaction +import enum Gemstone.SignableTransactionType import GemstonePrimitives import NativeProviderService import Primitives +import SigningRequestService @preconcurrency import ReownWalletKit @preconcurrency import WalletConnectPairing @@ -22,7 +23,7 @@ public final class WalletConnectorService { private let signer: WalletConnectorSignable private let messageTracker = MessageTracker() private let walletConnect = WalletConnect() - private let simulationClient: WalletConnectSimulationClient + private let simulator: SigningSimulator public init( signer: WalletConnectorSignable, @@ -30,7 +31,7 @@ public final class WalletConnectorService { requestInterceptor: any RequestInterceptable = EmptyRequestInterceptor(), ) { self.signer = signer - simulationClient = WalletConnectSimulationClient(provider: NativeProvider(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor)) + simulator = SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor) } } @@ -98,19 +99,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 { @@ -320,7 +317,7 @@ extension WalletConnectorService { throw WalletConnectorServiceError.invalidOrigin } - let payload = WalletConnectionSessionProposal( + let payload = WalletConnectSessionProposal( defaultWallet: preselectedWallet, wallets: wallets, metadata: metadata, diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift index 8b8d35a1bb..deb04c9651 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift @@ -3,13 +3,14 @@ import Foundation import struct Gemstone.SignMessage import Primitives +import SigningRequestService import WalletConnectSign public protocol WalletConnectorSignable: Sendable { var allChains: [Primitives.Chain] { get } func addConnection(connection: WalletConnection) throws - func updateSessions(sessions: [WalletConnectionSession]) throws + func updateSessions(sessions: [WalletConnectSession]) throws func sessionReject(error: any Error) async func sessionReject(id: String, error: any Error) async throws func getCurrentWallet() throws -> Primitives.Wallet @@ -21,7 +22,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/ConnectionsService/ConnectionsService.swift b/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift index f8ff85e516..b446131065 100644 --- a/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift +++ b/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift @@ -63,7 +63,7 @@ public extension ConnectionsService { try await connector.pair(uri: uri) } - func disconnect(session: WalletConnectionSession) async throws { + func disconnect(session: WalletConnectSession) async throws { try await disconnect(sessionId: session.sessionId) } diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index dd0fc31177..da7308ffcc 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -25,6 +25,8 @@ let package = Package( .library(name: "PriceAlertService", targets: ["PriceAlertService"]), .library(name: "PriceAlertServiceTestKit", targets: ["PriceAlertServiceTestKit"]), .library(name: "TransactionStateService", targets: ["TransactionStateService"]), + .library(name: "TransferService", targets: ["TransferService"]), + .library(name: "TransferServiceTestKit", targets: ["TransferServiceTestKit"]), .library(name: "TransactionStateServiceTestKit", targets: ["TransactionStateServiceTestKit"]), .library(name: "TransactionsService", targets: ["TransactionsService"]), .library(name: "TransactionsServiceTestKit", targets: ["TransactionsServiceTestKit"]), @@ -73,6 +75,7 @@ let package = Package( .package(name: "GemstonePrimitives", path: "../GemstonePrimitives"), .package(name: "Gemstone", path: "../Gemstone"), .package(name: "Signer", path: "../Signer"), + .package(name: "Validators", path: "../Validators"), .package(name: "Keystore", path: "../Keystore"), .package(name: "Formatters", path: "../Formatters"), .package(name: "SwiftHTTPClient", path: "../SwiftHTTPClient"), @@ -265,9 +268,31 @@ let package = Package( ], path: "PriceAlertService/TestKit", ), + .target( + name: "TransferService", + dependencies: [ + .product(name: "PaymentService", package: "ChainServices"), + .product(name: "ScanService", package: "ChainServices"), + .product(name: "Validators", package: "Validators"), + "Primitives", + "Blockchain", + "Signer", + "BalanceService", + "TransactionStateService", + "GemstonePrimitives", + ], + path: "TransferService", + exclude: ["TestKit", "Tests"], + ), + .target( + name: "TransferServiceTestKit", + dependencies: ["TransferService"], + path: "TransferService/TestKit", + ), .target( name: "TransactionStateService", dependencies: [ + .product(name: "PaymentService", package: "ChainServices"), "Primitives", "Store", "Blockchain", @@ -283,6 +308,8 @@ let package = Package( .target( name: "TransactionStateServiceTestKit", dependencies: [ + .product(name: "PaymentServiceTestKit", package: "ChainServices"), + .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "StoreTestKit", package: "Store"), .product(name: "StakeServiceTestKit", package: "ChainServices"), .product(name: "PreferencesTestKit", package: "Preferences"), @@ -722,9 +749,24 @@ let package = Package( ], path: "AssetsService/Tests", ), + .testTarget( + name: "TransferServiceTests", + dependencies: [ + "TransferService", + "TransferServiceTestKit", + .product(name: "PrimitivesTestKit", package: "Primitives"), + .product(name: "BlockchainTestKit", package: "Blockchain"), + .product(name: "ChainServiceTestKit", package: "ChainServices"), + .product(name: "SignerTestKit", package: "Signer"), + "TransactionStateServiceTestKit", + "GemstonePrimitives", + ], + path: "TransferService/Tests", + ), .testTarget( name: "TransactionStateServiceTests", dependencies: [ + .product(name: "PaymentServiceTestKit", package: "ChainServices"), "TransactionStateService", "TransactionStateServiceTestKit", "BalanceServiceTestKit", 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/Features/Transfer/Sources/Services/FeeRateService.swift b/ios/Packages/FeatureServices/TransferService/FeeRateService.swift similarity index 66% rename from ios/Features/Transfer/Sources/Services/FeeRateService.swift rename to ios/Packages/FeatureServices/TransferService/FeeRateService.swift index 2317b86fe4..ba4bc88652 100644 --- a/ios/Features/Transfer/Sources/Services/FeeRateService.swift +++ b/ios/Packages/FeatureServices/TransferService/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/Errors/ScanTransactionError.swift b/ios/Packages/FeatureServices/TransferService/ScanTransactionError.swift similarity index 82% rename from ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift rename to ios/Packages/FeatureServices/TransferService/ScanTransactionError.swift index 0164b65982..eba55b2009 100644 --- a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift +++ b/ios/Packages/FeatureServices/TransferService/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/Validators/ScanTransactionValidator.swift b/ios/Packages/FeatureServices/TransferService/ScanTransactionValidator.swift similarity index 86% rename from ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift rename to ios/Packages/FeatureServices/TransferService/ScanTransactionValidator.swift index 55268f9441..62ff5d0f2c 100644 --- a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift +++ b/ios/Packages/FeatureServices/TransferService/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/TestKit/TransactionData+TestKit.swift b/ios/Packages/FeatureServices/TransferService/TestKit/TransactionData+TestKit.swift similarity index 100% rename from ios/Features/Transfer/TestKit/TransactionData+TestKit.swift rename to ios/Packages/FeatureServices/TransferService/TestKit/TransactionData+TestKit.swift diff --git a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift b/ios/Packages/FeatureServices/TransferService/TestKit/TransferExecutor+TestKit.swift similarity index 59% rename from ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift rename to ios/Packages/FeatureServices/TransferService/TestKit/TransferExecutor+TestKit.swift index 011e2d913b..dd3492fc65 100644 --- a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift +++ b/ios/Packages/FeatureServices/TransferService/TestKit/TransferExecutor+TestKit.swift @@ -1,18 +1,22 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Transfer +import TransferService 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/TestKit/TransferTransactionProvider+TestKit.swift b/ios/Packages/FeatureServices/TransferService/TestKit/TransferTransactionProvider+TestKit.swift similarity index 96% rename from ios/Features/Transfer/TestKit/TransferTransactionProvider+TestKit.swift rename to ios/Packages/FeatureServices/TransferService/TestKit/TransferTransactionProvider+TestKit.swift index cf82b4183a..20c3ec7362 100644 --- a/ios/Features/Transfer/TestKit/TransferTransactionProvider+TestKit.swift +++ b/ios/Packages/FeatureServices/TransferService/TestKit/TransferTransactionProvider+TestKit.swift @@ -3,7 +3,7 @@ import BigInt import Foundation import Primitives -import Transfer +import TransferService public struct TransferTransactionProviderMock: TransferTransactionProvidable { public var result: Result diff --git a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift b/ios/Packages/FeatureServices/TransferService/Tests/ScanTransactionValidatorTests.swift similarity index 97% rename from ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift rename to ios/Packages/FeatureServices/TransferService/Tests/ScanTransactionValidatorTests.swift index cedcefec04..eff551261b 100644 --- a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift +++ b/ios/Packages/FeatureServices/TransferService/Tests/ScanTransactionValidatorTests.swift @@ -4,8 +4,8 @@ import Foundation import Primitives import PrimitivesTestKit import Testing -@testable import Transfer -import TransferTestKit +import TransferServiceTestKit +@testable import TransferService struct ScanTransactionValidatorTests { @Test diff --git a/ios/Features/Transfer/Tests/Services/TransferExecutorTests.swift b/ios/Packages/FeatureServices/TransferService/Tests/TransferExecutorTests.swift similarity index 99% rename from ios/Features/Transfer/Tests/Services/TransferExecutorTests.swift rename to ios/Packages/FeatureServices/TransferService/Tests/TransferExecutorTests.swift index 43e04f51d3..cc44615194 100644 --- a/ios/Features/Transfer/Tests/Services/TransferExecutorTests.swift +++ b/ios/Packages/FeatureServices/TransferService/Tests/TransferExecutorTests.swift @@ -11,7 +11,8 @@ import StoreTestKit import Testing import TransactionStateService import TransactionStateServiceTestKit -@testable import Transfer +import TransferServiceTestKit +@testable import TransferService struct TransferExecutorTests { @Test diff --git a/ios/Features/Transfer/Tests/Services/TransferTransactionProviderTests.swift b/ios/Packages/FeatureServices/TransferService/Tests/TransferTransactionProviderTests.swift similarity index 95% rename from ios/Features/Transfer/Tests/Services/TransferTransactionProviderTests.swift rename to ios/Packages/FeatureServices/TransferService/Tests/TransferTransactionProviderTests.swift index 80912e7205..a1bc69baeb 100644 --- a/ios/Features/Transfer/Tests/Services/TransferTransactionProviderTests.swift +++ b/ios/Packages/FeatureServices/TransferService/Tests/TransferTransactionProviderTests.swift @@ -3,7 +3,8 @@ import Blockchain import Primitives import Testing -@testable import Transfer +import TransferServiceTestKit +@testable import TransferService struct TransferTransactionProviderTests { @Test diff --git a/ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift b/ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift new file mode 100644 index 0000000000..0f5e7aa88f --- /dev/null +++ b/ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift @@ -0,0 +1,54 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Blockchain +import Foundation +import GemstonePrimitives +import Localization +import Primitives + +public enum TransactionConfirmationError: Error, Equatable { + case reverted + case timedOut +} + +extension TransactionConfirmationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .reverted: Localized.Transaction.Status.failed + case .timedOut: Localized.Errors.errorOccurred + } + } +} + +public struct TransactionConfirmationWaiter: Sendable { + private let chainService: any ChainServiceable + private let timeout: Duration + + public init( + chainService: any ChainServiceable, + timeout: Duration = .seconds(120), + ) { + self.chainService = chainService + self.timeout = timeout + } + + public func wait(hash: String, chain: Chain, senderAddress: String) async throws { + let configuration = chain.transactionStateConfig + let request = TransactionStateRequest(id: hash, senderAddress: senderAddress, createdAt: Date(), blockNumber: 0) + let deadline = ContinuousClock.now.advanced(by: timeout) + var intervalMs = configuration.initialIntervalMs + + while ContinuousClock.now < deadline { + switch try await chainService.transactionState(for: request).state { + case .confirmed: + return + case .failed, .reverted: + throw TransactionConfirmationError.reverted + case .pending, .inTransit: + try await Task.sleep(for: .milliseconds(Int(intervalMs))) + intervalMs = configuration.nextInterval(after: intervalMs) + } + } + throw TransactionConfirmationError.timedOut + } +} diff --git a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift b/ios/Packages/FeatureServices/TransferService/TransactionFactory.swift similarity index 97% rename from ios/Features/Transfer/Sources/Services/TransactionFactory.swift rename to ios/Packages/FeatureServices/TransferService/TransactionFactory.swift index 70cfd3c74c..d83b0d9dc0 100644 --- a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift +++ b/ios/Packages/FeatureServices/TransferService/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/Types/TransferConfirmationInput.swift b/ios/Packages/FeatureServices/TransferService/TransferConfirmationInput.swift similarity index 82% rename from ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift rename to ios/Packages/FeatureServices/TransferService/TransferConfirmationInput.swift index 5568a0edf3..a69251024b 100644 --- a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift +++ b/ios/Packages/FeatureServices/TransferService/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/Services/TransferExecutor.swift b/ios/Packages/FeatureServices/TransferService/TransferExecutor.swift similarity index 91% rename from ios/Features/Transfer/Sources/Services/TransferExecutor.swift rename to ios/Packages/FeatureServices/TransferService/TransferExecutor.swift index 1f9b822708..560944b7e3 100644 --- a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift +++ b/ios/Packages/FeatureServices/TransferService/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/TransferTransactionData.swift b/ios/Packages/FeatureServices/TransferService/TransferTransactionData.swift similarity index 100% rename from ios/Features/Transfer/Sources/Types/TransferTransactionData.swift rename to ios/Packages/FeatureServices/TransferService/TransferTransactionData.swift diff --git a/ios/Features/Transfer/Sources/Services/TransferTransactionProvider.swift b/ios/Packages/FeatureServices/TransferService/TransferTransactionProvider.swift similarity index 100% rename from ios/Features/Transfer/Sources/Services/TransferTransactionProvider.swift rename to ios/Packages/FeatureServices/TransferService/TransferTransactionProvider.swift 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/GemWalletConnectAppMetadata+GemstonePrimitives.swift similarity index 51% rename from ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift rename to ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectAppMetadata+GemstonePrimitives.swift index 0e9e2aa71b..fc750ccbb5 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectAppMetadata+GemstonePrimitives.swift @@ -4,9 +4,9 @@ import Foundation import Gemstone import Primitives -public extension GemWalletConnectionSessionAppMetadata { - func map() -> WalletConnectionSessionAppMetadata { - WalletConnectionSessionAppMetadata( +public extension GemWalletConnectAppMetadata { + func map() -> WalletConnectAppMetadata { + WalletConnectAppMetadata( name: name, description: description, url: url, @@ -15,9 +15,9 @@ public extension GemWalletConnectionSessionAppMetadata { } } -public extension WalletConnectionSessionAppMetadata { - func map() -> GemWalletConnectionSessionAppMetadata { - GemWalletConnectionSessionAppMetadata( +public extension WalletConnectAppMetadata { + func map() -> GemWalletConnectAppMetadata { + GemWalletConnectAppMetadata( name: name, description: description, url: url, @@ -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/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/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..b23f50104d --- /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 WalletConnectAppMetadata { + 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/Extensions/WalletConnectionSessionProposal+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift index 84d21a1c43..c8f9a95e43 100644 --- a/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift @@ -2,7 +2,7 @@ import Foundation -extension WalletConnectionSessionProposal: Identifiable { +extension WalletConnectSessionProposal: Identifiable { public var id: String { metadata.url } diff --git a/ios/Packages/Primitives/Sources/Generated/ApprovalData.swift b/ios/Packages/Primitives/Sources/Generated/ApprovalData.swift new file mode 100644 index 0000000000..5944b7d2fe --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/ApprovalData.swift @@ -0,0 +1,19 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct ApprovalData: Codable, Equatable, Hashable, Sendable { + public let token: String + public let spender: String + public let value: String + public let isUnlimited: Bool + + public init(token: String, spender: String, value: String, isUnlimited: Bool) { + self.token = token + self.spender = spender + self.value = value + self.isUnlimited = isUnlimited + } +} 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..748558a4eb --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/Signing.swift @@ -0,0 +1,33 @@ +/* + 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 + } +} diff --git a/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift b/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift index 42ce2c1120..915f39f6d1 100644 --- a/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift +++ b/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift @@ -4,20 +4,6 @@ import Foundation -public struct ApprovalData: Codable, Equatable, Hashable, Sendable { - public let token: String - public let spender: String - public let value: String - public let isUnlimited: Bool - - public init(token: String, spender: String, value: String, isUnlimited: Bool) { - self.token = token - self.spender = spender - self.value = value - self.isUnlimited = isUnlimited - } -} - public struct SwapProviderData: Codable, Equatable, Hashable, Sendable { public let provider: SwapProvider public let name: String 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/Generated/WalletConnector.swift b/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift index dc5a721efc..0abc01036a 100644 --- a/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift +++ b/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift @@ -4,7 +4,7 @@ import Foundation -public struct WalletConnectionSessionAppMetadata: Codable, Equatable, Hashable, Sendable { +public struct WalletConnectAppMetadata: Codable, Equatable, Hashable, Sendable { public let name: String public let description: String public let url: String @@ -18,12 +18,12 @@ public struct WalletConnectionSessionAppMetadata: Codable, Equatable, Hashable, } } -public struct WalletConnectionSessionProposal: Codable, Equatable, Hashable, Sendable { +public struct WalletConnectSessionProposal: Codable, Equatable, Hashable, Sendable { public let defaultWallet: Wallet public let wallets: [Wallet] - public let metadata: WalletConnectionSessionAppMetadata + public let metadata: WalletConnectAppMetadata - public init(defaultWallet: Wallet, wallets: [Wallet], metadata: WalletConnectionSessionAppMetadata) { + public init(defaultWallet: Wallet, wallets: [Wallet], metadata: WalletConnectAppMetadata) { self.defaultWallet = defaultWallet self.wallets = wallets self.metadata = metadata @@ -39,10 +39,10 @@ public enum WalletConnectionVerificationStatus: String, Codable, Hashable, Senda public struct WCPairingProposal: Codable, Sendable { public let pairingId: String - public let proposal: WalletConnectionSessionProposal + public let proposal: WalletConnectSessionProposal public let verificationStatus: WalletConnectionVerificationStatus - public init(pairingId: String, proposal: WalletConnectionSessionProposal, verificationStatus: WalletConnectionVerificationStatus) { + public init(pairingId: String, proposal: WalletConnectSessionProposal, verificationStatus: WalletConnectionVerificationStatus) { self.pairingId = pairingId self.proposal = proposal self.verificationStatus = verificationStatus @@ -55,16 +55,16 @@ public enum WalletConnectionState: String, Codable, Hashable, Sendable { case expired } -public struct WalletConnectionSession: Codable, Equatable, Hashable, Sendable { +public struct WalletConnectSession: Codable, Equatable, Hashable, Sendable { public let id: String public let sessionId: String public let state: WalletConnectionState public let chains: [Chain] public let createdAt: Date public let expireAt: Date - public let metadata: WalletConnectionSessionAppMetadata + public let metadata: WalletConnectAppMetadata - public init(id: String, sessionId: String, state: WalletConnectionState, chains: [Chain], createdAt: Date, expireAt: Date, metadata: WalletConnectionSessionAppMetadata) { + public init(id: String, sessionId: String, state: WalletConnectionState, chains: [Chain], createdAt: Date, expireAt: Date, metadata: WalletConnectAppMetadata) { self.id = id self.sessionId = sessionId self.state = state @@ -76,10 +76,10 @@ public struct WalletConnectionSession: Codable, Equatable, Hashable, Sendable { } public struct WalletConnection: Codable, Equatable, Hashable, Sendable { - public let session: WalletConnectionSession + public let session: WalletConnectSession public let wallet: Wallet - public init(session: WalletConnectionSession, wallet: Wallet) { + public init(session: WalletConnectSession, wallet: Wallet) { self.session = session self.wallet = wallet } 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/SheetPresenter.swift b/ios/Packages/Primitives/Sources/SheetPresenter.swift new file mode 100644 index 0000000000..d423919d86 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SheetPresenter.swift @@ -0,0 +1,97 @@ +// 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() {} + + public func present( + payload: Payload, + sheet: @Sendable @escaping (SigningRequestCallback) -> Sheet, + ) async throws -> String where Payload.ID == String { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) + let callback = SigningRequestCallback(payload: payload) { + continuation.yield(with: $0) + continuation.finish() + } + await show(sheet: sheet(callback)) + + do { + for try await value in stream { + await dismissPresentedSheet() + return value + } + } catch { + await dismissPresentedSheet() + throw error + } + await dismissPresentedSheet() + throw SigningRequestError.userCancelled + } + + @MainActor + public func complete(type: Sheet) { + guard isPresentingSheet?.id == type.id else { + return + } + dismiss() + } + + @MainActor + public func cancelSheet(type: Sheet) { + guard isPresentingSheet?.id == type.id else { + return + } + type.reject(SigningRequestError.userCancelled) + dismiss() + } + + @MainActor + public func onSheetDismiss() { + isDismissingSheet = false + let waiting = dismissals + dismissals = [] + waiting.forEach { $0.resume() } + } +} + +// MARK: - Private + +extension SheetPresenter { + @MainActor + private func show(sheet: Sheet) async { + await waitForDismiss() + isPresentingSheet = sheet + } + + @MainActor + private func dismissPresentedSheet() async { + dismiss() + await waitForDismiss() + } + + @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/Primitives/Sources/SigningRequestCallback.swift b/ios/Packages/Primitives/Sources/SigningRequestCallback.swift new file mode 100644 index 0000000000..4cac675b19 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SigningRequestCallback.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public protocol SigningRequestRejectable: Sendable { + var id: String { get } + func reject(_ error: any Error) +} + +public final class SigningRequestCallback: SigningRequestRejectable, 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/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/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/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/Primitives/TestKit/WCPairingProposal+TestKit.swift b/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift index 385234721c..e3760f1d35 100644 --- a/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift @@ -5,7 +5,7 @@ import Primitives public extension WCPairingProposal { static func mock( pairingId: String = "pairing-id", - proposal: WalletConnectionSessionProposal = .mock(), + proposal: WalletConnectSessionProposal = .mock(), verificationStatus: WalletConnectionVerificationStatus = .verified, ) -> WCPairingProposal { WCPairingProposal( diff --git a/ios/Packages/Primitives/TestKit/WalletConnectionSession+TestKit.swift b/ios/Packages/Primitives/TestKit/WalletConnectSession+TestKit.swift similarity index 72% rename from ios/Packages/Primitives/TestKit/WalletConnectionSession+TestKit.swift rename to ios/Packages/Primitives/TestKit/WalletConnectSession+TestKit.swift index 09de672e6e..6e616791e4 100644 --- a/ios/Packages/Primitives/TestKit/WalletConnectionSession+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WalletConnectSession+TestKit.swift @@ -3,7 +3,7 @@ import Foundation import Primitives -public extension WalletConnectionSession { +public extension WalletConnectSession { static func mock( id: String = .empty, sessionId: String = .empty, @@ -11,9 +11,9 @@ public extension WalletConnectionSession { chains: [Chain] = [.ethereum], createdAt: Date = .now, expireAt: Date = .distantFuture, - metadata: WalletConnectionSessionAppMetadata = .mock(), - ) -> WalletConnectionSession { - WalletConnectionSession( + metadata: WalletConnectAppMetadata = .mock(), + ) -> WalletConnectSession { + WalletConnectSession( id: id, sessionId: sessionId, state: state, @@ -25,14 +25,14 @@ public extension WalletConnectionSession { } } -public extension WalletConnectionSessionAppMetadata { +public extension WalletConnectAppMetadata { static func mock( name: String = "", description: String = "", url: String = "", icon: String = "", - ) -> WalletConnectionSessionAppMetadata { - WalletConnectionSessionAppMetadata( + ) -> WalletConnectAppMetadata { + WalletConnectAppMetadata( name: name, description: description, url: url, diff --git a/ios/Packages/Primitives/TestKit/WalletConnectionSessionProposal+TestKit.swift b/ios/Packages/Primitives/TestKit/WalletConnectSessionProposal+TestKit.swift similarity index 59% rename from ios/Packages/Primitives/TestKit/WalletConnectionSessionProposal+TestKit.swift rename to ios/Packages/Primitives/TestKit/WalletConnectSessionProposal+TestKit.swift index fe1b3d591a..20520d3d3d 100644 --- a/ios/Packages/Primitives/TestKit/WalletConnectionSessionProposal+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WalletConnectSessionProposal+TestKit.swift @@ -2,13 +2,13 @@ import Primitives -public extension WalletConnectionSessionProposal { +public extension WalletConnectSessionProposal { static func mock( defaultWallet: Wallet = .mock(), wallets: [Wallet] = [.mock()], - metadata: WalletConnectionSessionAppMetadata = .mock(), - ) -> WalletConnectionSessionProposal { - WalletConnectionSessionProposal( + metadata: WalletConnectAppMetadata = .mock(), + ) -> WalletConnectSessionProposal { + WalletConnectSessionProposal( defaultWallet: defaultWallet, wallets: wallets, metadata: metadata, diff --git a/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift b/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift index 7c63046baf..866375f500 100644 --- a/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift @@ -4,7 +4,7 @@ import Primitives public extension WalletConnection { static func mock( - session: WalletConnectionSession = .mock(), + session: WalletConnectSession = .mock(), wallet: Wallet = .mock(), ) -> WalletConnection { WalletConnection( 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/Features/WalletConnector/Sources/WalletConnector/Types/AppPreviewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift similarity index 53% rename from ios/Features/WalletConnector/Sources/WalletConnector/Types/AppPreviewModel.swift rename to ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift index 9e0dae095d..81510a9cc2 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/AppPreviewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift @@ -1,10 +1,15 @@ // Copyright (c). Gem Wallet. All rights reserved. import Components -import PrimitivesComponents 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/PaymentExpiry.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentExpiry.swift new file mode 100644 index 0000000000..59bdf354c5 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentExpiry.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +@Observable +@MainActor +public final class PaymentExpiry { + public private(set) var isExpired: Bool = false + + private let expiresAt: Date? + + public init(payment: PaymentData?) { + expiresAt = payment?.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/TransactionViewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift index 6ae1191e83..7a96ffc783 100644 --- a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift @@ -178,6 +178,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 +353,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/Store/Sources/Models/WalletConnectionRecord.swift b/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift index 98478ee161..5dc8bede63 100644 --- a/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift +++ b/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift @@ -87,15 +87,15 @@ extension WalletConnection { } extension WalletConnectionRecord { - var session: WalletConnectionSession { - WalletConnectionSession( + var session: WalletConnectSession { + WalletConnectSession( id: sessionId, sessionId: sessionId, state: state, chains: chains ?? [], createdAt: createdAt, expireAt: expireAt, - metadata: WalletConnectionSessionAppMetadata( + metadata: WalletConnectAppMetadata( name: appName, description: appDescription, url: appLink, @@ -104,7 +104,7 @@ extension WalletConnectionRecord { ) } - func update(with session: WalletConnectionSession) -> WalletConnectionRecord { + func update(with session: WalletConnectSession) -> WalletConnectionRecord { WalletConnectionRecord( id: session.sessionId, sessionId: session.sessionId, diff --git a/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift b/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift index 37f61e5a3b..6d4443171b 100644 --- a/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift +++ b/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift @@ -35,7 +35,7 @@ public struct ConnectionsStore: Sendable { } } - public func getSessions() throws -> [WalletConnectionSession] { + public func getSessions() throws -> [WalletConnectSession] { try db.read { db in try WalletConnectionRecord .fetchAll(db) @@ -43,7 +43,7 @@ public struct ConnectionsStore: Sendable { } } - public func updateConnectionSession(_ session: WalletConnectionSession) throws { + public func updateConnectionSession(_ session: WalletConnectSession) throws { let connection = try getConnection(id: session.id).update(with: session) try db.write { db in try connection.upsert(db) 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 = 將支付