From 29f5520f5aa09c793edaec97b3047f479d613fd8 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:12:24 +0300 Subject: [PATCH 01/53] core: add the shared payment models A payment link resolves to a request or a hosted link, so the decoder stops blanking the recipient on a send. Adds the WalletConnect Pay URL shape, the app metadata a signing payload carries when there is no session, the payment metadata a transaction record keeps, and a neutral home for the signing payload types the gateway and a dApp request both decode into. --- .../core/primitives/generated/Payment.kt | 112 ++++++++++ .../core/primitives/generated/Signing.kt | 24 ++ .../generated/TransactionAppMetadata.kt | 17 ++ .../generated/TransactionMetadataTypes.kt | 7 + .../crates/gem_evm/src/signer/chain_signer.rs | 4 +- core/crates/primitives/src/lib.rs | 21 +- core/crates/primitives/src/payment.rs | 133 +++++++++++ .../primitives/src/payment_decoder/decoder.rs | 179 ++++++--------- .../primitives/src/payment_decoder/mod.rs | 3 +- .../src/payment_decoder/wallet_connect_pay.rs | 118 ++++++++++ core/crates/primitives/src/signing.rs | 110 +++++++++ core/crates/primitives/src/testkit/mod.rs | 1 + .../testkit/transaction_app_metadata_mock.rs | 12 + .../testkit/transaction_load_input_mock.rs | 4 +- .../src/transaction_app_metadata.rs | 20 ++ .../primitives/src/transaction_input_type.rs | 7 +- .../src/transaction_metadata_types.rs | 11 +- .../src/transaction_preload_input.rs | 2 +- core/crates/primitives/src/url_action.rs | 20 +- .../src/wallet_connect_namespace.rs | 43 +++- .../crates/primitives/src/wallet_connector.rs | 22 +- .../Sources/Generated/Payment.swift | 211 ++++++++++++++++++ .../Sources/Generated/Signing.swift | 33 +++ .../Generated/TransactionAppMetadata.swift | 19 ++ .../Generated/TransactionMetadataTypes.swift | 12 + 25 files changed, 1005 insertions(+), 140 deletions(-) create mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Payment.kt create mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Signing.kt create mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionAppMetadata.kt create mode 100644 core/crates/primitives/src/payment.rs create mode 100644 core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs create mode 100644 core/crates/primitives/src/signing.rs create mode 100644 core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs create mode 100644 core/crates/primitives/src/transaction_app_metadata.rs create mode 100644 ios/Packages/Primitives/Sources/Generated/Payment.swift create mode 100644 ios/Packages/Primitives/Sources/Generated/Signing.swift create mode 100644 ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift 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/core/crates/gem_evm/src/signer/chain_signer.rs b/core/crates/gem_evm/src/signer/chain_signer.rs index 24f2c86c01..6030ef363a 100644 --- a/core/crates/gem_evm/src/signer/chain_signer.rs +++ b/core/crates/gem_evm/src/signer/chain_signer.rs @@ -200,7 +200,7 @@ mod tests { use super::*; use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; use primitives::{ - Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, WalletConnectionSessionAppMetadata, + Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionAppMetadata, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, contract_call_data::ContractCallData, nft::NFTAsset, swap::*, }; @@ -338,7 +338,7 @@ mod tests { let signer = EvmChainSigner; let extra = TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]); let input = SignerInput::mock_evm( - TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), WalletConnectionSessionAppMetadata::mock(), extra), + TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), TransactionAppMetadata::mock(), extra), "0", 100000, ); diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index 0fa726bcfa..ae16acd6ee 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, WalletConnectionSessionAppMetadata, WalletConnectionSession, WalletConnectionSessionProposal, 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; 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/testkit/mod.rs b/core/crates/primitives/src/testkit/mod.rs index 2d2617adcc..5382872c03 100644 --- a/core/crates/primitives/src/testkit/mod.rs +++ b/core/crates/primitives/src/testkit/mod.rs @@ -17,6 +17,7 @@ pub mod signer_mock; pub mod simulation_mock; pub mod subscription_mock; pub mod swap_mock; +pub mod transaction_app_metadata_mock; pub mod transaction_fee_mock; pub mod transaction_load_input_mock; pub mod transaction_load_metadata_mock; diff --git a/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs b/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs new file mode 100644 index 0000000000..db8dfa5af6 --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs @@ -0,0 +1,12 @@ +use crate::TransactionAppMetadata; + +impl TransactionAppMetadata { + pub fn mock() -> Self { + TransactionAppMetadata { + name: "Test Dapp".to_string(), + description: None, + url: Some("https://example.com".to_string()), + icon: Some("https://example.com/icon.png".to_string()), + } + } +} diff --git a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs index 30aea4aa28..ecc00f2854 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, + TransactionAppMetadata, TransferDataOutputType, }; use num_bigint::BigInt; use std::collections::HashMap; @@ -208,7 +208,7 @@ impl TransactionLoadInput { TransactionLoadInput { input_type: TransactionInputType::Generic( Asset::from_chain(chain), - WalletConnectionSessionAppMetadata::mock(), + TransactionAppMetadata::mock(), TransferDataExtra { data: Some(data.as_bytes().to_vec()), output_type, diff --git a/core/crates/primitives/src/transaction_app_metadata.rs b/core/crates/primitives/src/transaction_app_metadata.rs new file mode 100644 index 0000000000..4fe7cac46e --- /dev/null +++ b/core/crates/primitives/src/transaction_app_metadata.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +use crate::wallet_connector::short_name; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionAppMetadata { + pub name: String, + pub description: Option, + pub url: Option, + pub icon: Option, +} + +impl TransactionAppMetadata { + pub fn short_name(&self) -> String { + short_name(&self.name) + } +} diff --git a/core/crates/primitives/src/transaction_input_type.rs b/core/crates/primitives/src/transaction_input_type.rs index 544efb20c2..3de41b0ded 100644 --- a/core/crates/primitives/src/transaction_input_type.rs +++ b/core/crates/primitives/src/transaction_input_type.rs @@ -1,10 +1,11 @@ +use crate::swap::ApprovalData; use crate::contract_call_data::ContractCallData; use crate::earn_type::EarnType; use crate::stake_type::StakeType; -use crate::swap::{ApprovalData, SwapData, SwapQuoteDataType}; +use crate::swap::{SwapData, SwapQuoteDataType}; use crate::transaction_fee::TransactionFee; use crate::transaction_load_metadata::TransactionLoadMetadata; -use crate::{Asset, GasPriceType, PerpetualType, SignerError, TransactionType, TransferDataExtra, WalletConnectionSessionAppMetadata, nft::NFTAsset, perpetual::AccountDataType}; +use crate::{Asset, GasPriceType, PerpetualType, SignerError, TransactionAppMetadata, TransactionType, TransferDataExtra, nft::NFTAsset, perpetual::AccountDataType}; use num_bigint::BigInt; use num_traits::ToPrimitive; use serde::{Deserialize, Serialize}; @@ -22,7 +23,7 @@ pub enum TransactionInputType { Swap(Asset, Asset, SwapData), Stake(Asset, StakeType), TokenApprove(Asset, ApprovalData), - Generic(Asset, WalletConnectionSessionAppMetadata, TransferDataExtra), + Generic(Asset, TransactionAppMetadata, TransferDataExtra), TransferNft(Asset, NFTAsset), Account(Asset, AccountDataType), Perpetual(Asset, PerpetualType), diff --git a/core/crates/primitives/src/transaction_metadata_types.rs b/core/crates/primitives/src/transaction_metadata_types.rs index 39b88a17fa..99ef858da8 100644 --- a/core/crates/primitives/src/transaction_metadata_types.rs +++ b/core/crates/primitives/src/transaction_metadata_types.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use crate::{AssetId, NFTAssetId, PerpetualDirection, PerpetualProvider, stake_type::Resource}; +use crate::{AssetId, NFTAssetId, PaymentMerchant, PaymentProviderName, PerpetualDirection, PerpetualProvider, stake_type::Resource}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[typeshare(swift = "Sendable")] @@ -26,6 +26,15 @@ pub struct TransactionSwapMetadata { pub provider: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct TransactionPaymentMetadata { + pub payment_id: String, + pub merchant: PaymentMerchant, + pub provider: PaymentProviderName, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Sendable")] #[serde(rename_all = "camelCase")] diff --git a/core/crates/primitives/src/transaction_preload_input.rs b/core/crates/primitives/src/transaction_preload_input.rs index 9471a23c1b..d570f6b2a2 100644 --- a/core/crates/primitives/src/transaction_preload_input.rs +++ b/core/crates/primitives/src/transaction_preload_input.rs @@ -19,7 +19,7 @@ impl TransactionPreloadInput { pub fn get_website(&self) -> Option { match &self.input_type { - TransactionInputType::Generic(_, app_metadata, _) => Some(app_metadata.url.clone()), + TransactionInputType::Generic(_, app, _) => app.url.clone(), _ => None, } } diff --git a/core/crates/primitives/src/url_action.rs b/core/crates/primitives/src/url_action.rs index e85baf413d..becf112123 100644 --- a/core/crates/primitives/src/url_action.rs +++ b/core/crates/primitives/src/url_action.rs @@ -1,13 +1,17 @@ -use crate::{Deeplink, WalletConnectLink}; +use crate::{Deeplink, Payment, PaymentLink, PaymentURLDecoder, WalletConnectLink}; #[derive(Debug, Clone, PartialEq)] pub enum UrlAction { Deeplink { deeplink: Deeplink }, + Payment { link: PaymentLink }, WalletConnect { link: WalletConnectLink }, } impl UrlAction { pub fn from_url(url: &str) -> Option { + if let Ok(Payment::Link(link)) = PaymentURLDecoder::decode(url) { + return Some(Self::Payment { link }); + } if let Some(link) = WalletConnectLink::from_url(url) { return Some(Self::WalletConnect { link }); } @@ -18,7 +22,7 @@ impl UrlAction { #[cfg(test)] mod tests { use super::*; - use crate::{AssetId, Chain}; + use crate::{AssetId, Chain, PaymentProviderName}; #[test] fn test_from_url() { @@ -44,6 +48,18 @@ mod tests { }, }) ); + assert_eq!( + UrlAction::from_url("https://pay.walletconnect.com/?pid=pay_123"), + Some(UrlAction::Payment { + link: PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()), + }) + ); + assert_eq!( + UrlAction::from_url("wc:abc@2?pay=https%3A%2F%2Fpay.walletconnect.com%2F%3Fpid%3Dpay_123"), + Some(UrlAction::Payment { + link: PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()), + }) + ); assert_eq!(UrlAction::from_url("https://example.com/tokens/bitcoin"), None); assert_eq!(UrlAction::from_url("not a url"), None); } diff --git a/core/crates/primitives/src/wallet_connect_namespace.rs b/core/crates/primitives/src/wallet_connect_namespace.rs index 44a7e33dd3..f739ac3378 100644 --- a/core/crates/primitives/src/wallet_connect_namespace.rs +++ b/core/crates/primitives/src/wallet_connect_namespace.rs @@ -1,4 +1,4 @@ -use crate::{Chain, ChainAddress, ChainType}; +use crate::{AssetId, Chain, ChainAddress, ChainType}; use serde::Serialize; use std::str::FromStr; use strum::{AsRefStr, EnumString}; @@ -170,3 +170,44 @@ mod tests { assert_eq!(WalletConnectCAIP2::parse_account("eip155:99999:0x1".to_string()), None); } } + +const SLIP44_NAMESPACE: &str = "slip44"; + +pub struct WalletConnectCAIP19; + +impl WalletConnectCAIP19 { + pub fn get_asset_id(asset: &str) -> Option { + let (chain_id, asset) = match asset.split_once('/') { + Some((chain_id, asset)) => (chain_id, Some(asset)), + None => (asset, None), + }; + let chain = WalletConnectCAIP2::get_chain_from_id(Some(chain_id.to_string())).ok()?; + let Some(asset) = asset else { + return Some(AssetId::from(chain, None)); + }; + match asset.split_once(':')? { + (SLIP44_NAMESPACE, _) => Some(AssetId::from(chain, None)), + (_, token_id) => Some(AssetId::from_token(chain, token_id)), + } + } +} + +#[cfg(test)] +mod caip19_tests { + use super::*; + + #[test] + fn test_get_asset_id() { + assert_eq!(WalletConnectCAIP19::get_asset_id("eip155:1/slip44:60"), Some(AssetId::from(Chain::Ethereum, None))); + assert_eq!(WalletConnectCAIP19::get_asset_id("eip155:1"), Some(AssetId::from(Chain::Ethereum, None))); + assert_eq!( + WalletConnectCAIP19::get_asset_id("eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + Some(AssetId::from_token(Chain::Base, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")) + ); + assert_eq!( + WalletConnectCAIP19::get_asset_id("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + Some(AssetId::from_token(Chain::Solana, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")) + ); + assert_eq!(WalletConnectCAIP19::get_asset_id("bitcoin:000000000019d6689c085ae165831e93"), None); + } +} diff --git a/core/crates/primitives/src/wallet_connector.rs b/core/crates/primitives/src/wallet_connector.rs index 2b28f0d243..0a0c263814 100644 --- a/core/crates/primitives/src/wallet_connector.rs +++ b/core/crates/primitives/src/wallet_connector.rs @@ -108,17 +108,21 @@ const SHORT_NAME_MAX_LENGTH: usize = 80; impl WalletConnectionSessionAppMetadata { pub fn short_name(&self) -> String { - let name = self.name.trim(); - for sep in SHORT_NAME_SEPARATORS { - if let Some(idx) = name.find(sep) { - return name[..idx].trim().to_string(); - } - } - if name.len() > SHORT_NAME_MAX_LENGTH { - return name[..SHORT_NAME_MAX_LENGTH].to_string(); + short_name(&self.name) + } +} + +pub fn short_name(name: &str) -> String { + let name = name.trim(); + for sep in SHORT_NAME_SEPARATORS { + if let Some(idx) = name.find(sep) { + return name[..idx].trim().to_string(); } - name.to_string() } + if name.len() > SHORT_NAME_MAX_LENGTH { + return name[..SHORT_NAME_MAX_LENGTH].to_string(); + } + name.to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/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/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 From a731e2c6c9cf4b5d0f997b72874a7e154832dfb8 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:12:40 +0300 Subject: [PATCH 02/53] core: split WalletConnect request handling behind a feature The payment gateway hands over a raw method and params rather than a session request, so parsing an action becomes its own entry point and the crate gains request and session features. A payment depends on request only, reusing the same decoder a dApp request uses without pulling session code in. --- .../gem_tron/src/signer/chain_signer.rs | 13 +-- core/crates/gem_wallet_connect/Cargo.toml | 9 +- core/crates/gem_wallet_connect/src/actions.rs | 96 +------------------ core/crates/gem_wallet_connect/src/decode.rs | 2 +- core/crates/gem_wallet_connect/src/lib.rs | 21 +++- .../src/request_handler/ethereum.rs | 15 +-- .../src/request_handler/mod.rs | 31 +++--- .../src/request_handler/solana.rs | 19 ++-- .../src/request_handler/sui.rs | 19 ++-- .../src/request_handler/ton.rs | 11 ++- .../src/request_handler/tron.rs | 19 ++-- .../gem_wallet_connect/src/validator.rs | 15 ++- 12 files changed, 103 insertions(+), 167 deletions(-) diff --git a/core/crates/gem_tron/src/signer/chain_signer.rs b/core/crates/gem_tron/src/signer/chain_signer.rs index 02e653309c..668c857d50 100644 --- a/core/crates/gem_tron/src/signer/chain_signer.rs +++ b/core/crates/gem_tron/src/signer/chain_signer.rs @@ -45,9 +45,9 @@ mod tests { use gem_hash::sha2::sha256; use num_bigint::BigInt; use primitives::{ - Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, SwapProvider, TransactionFee, - TransactionInputType, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, - WalletConnectionSessionAppMetadata, decode_hex, + Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, SwapProvider, TransactionAppMetadata, + TransactionFee, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, + decode_hex, swap::{ApprovalData, SwapData, SwapQuote, SwapQuoteData}, }; use serde_json::{Value, json}; @@ -655,12 +655,7 @@ mod tests { SignerInput::mock_tron( TransactionInputType::Generic( Asset::from_chain(Chain::Tron), - WalletConnectionSessionAppMetadata { - name: "Test".to_string(), - description: "Test".to_string(), - url: "https://example.com".to_string(), - icon: "https://example.com/icon.png".to_string(), - }, + TransactionAppMetadata::mock(), TransferDataExtra { data: Some(payload), output_type, diff --git a/core/crates/gem_wallet_connect/Cargo.toml b/core/crates/gem_wallet_connect/Cargo.toml index a46d872743..9289abc476 100644 --- a/core/crates/gem_wallet_connect/Cargo.toml +++ b/core/crates/gem_wallet_connect/Cargo.toml @@ -3,15 +3,20 @@ name = "gem_wallet_connect" edition = { workspace = true } version = { workspace = true } +[features] +default = [] +request = ["dep:url"] +session = ["request", "dep:base64"] + [dependencies] primitives = { path = "../primitives" } gem_evm = { path = "../gem_evm" } gem_ton = { path = "../gem_ton", features = ["signer"] } -base64 = { workspace = true } +base64 = { workspace = true, optional = true } hex = { workspace = true } serde_json = { workspace = true } -url = { workspace = true } +url = { workspace = true, optional = true } [dev-dependencies] primitives = { path = "../primitives", features = ["testkit"] } diff --git a/core/crates/gem_wallet_connect/src/actions.rs b/core/crates/gem_wallet_connect/src/actions.rs index c06b49043c..ec8d564804 100644 --- a/core/crates/gem_wallet_connect/src/actions.rs +++ b/core/crates/gem_wallet_connect/src/actions.rs @@ -1,5 +1,4 @@ -use crate::sign_type::SignDigestType; -use primitives::{Chain, TransactionType, TransferDataOutputType, WCEthereumTransaction}; +use primitives::{Chain, SignDigestType, SignableTransactionType}; #[derive(Debug, Clone, PartialEq)] pub enum WalletConnectAction { @@ -10,17 +9,17 @@ pub enum WalletConnectAction { }, SignTransaction { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, data: String, }, SignAllTransactions { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, transactions: Vec, }, SendTransaction { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, data: String, }, ChainOperation { @@ -34,24 +33,6 @@ pub enum WalletConnectAction { }, } -#[derive(Debug, Clone, PartialEq)] -pub enum WalletConnectTransactionType { - Ethereum, - Solana { output_type: TransferDataOutputType }, - Sui { output_type: TransferDataOutputType }, - Ton { output_type: TransferDataOutputType }, - Tron { output_type: TransferDataOutputType }, -} - -impl WalletConnectTransactionType { - pub fn get_output_type(&self) -> Option { - match self { - Self::Ethereum => None, - Self::Solana { output_type } | Self::Sui { output_type } | Self::Ton { output_type } | Self::Tron { output_type } => Some(output_type.clone()), - } - } -} - #[derive(Debug, Clone, PartialEq)] pub enum WalletConnectChainOperation { AddChain, @@ -59,77 +40,8 @@ pub enum WalletConnectChainOperation { GetChainId, } -#[derive(Debug, Clone)] -pub struct WCEthereumTransactionData { - pub chain_id: Option, - pub from: String, - pub to: String, - pub value: Option, - pub gas: Option, - pub gas_limit: Option, - pub gas_price: Option, - pub max_fee_per_gas: Option, - pub max_priority_fee_per_gas: Option, - pub nonce: Option, - pub data: Option, -} - -#[derive(Debug, Clone)] -pub struct WCSolanaTransactionData { - pub transaction: String, -} - -#[derive(Debug, Clone)] -pub struct WCSuiTransactionData { - pub transaction: String, - pub wallet_address: String, -} - -#[derive(Debug, Clone)] -#[allow(clippy::large_enum_variant)] -pub enum WalletConnectTransaction { - Ethereum { - data: WCEthereumTransactionData, - transaction_type: TransactionType, - }, - Solana { - data: WCSolanaTransactionData, - output_type: TransferDataOutputType, - }, - Sui { - data: WCSuiTransactionData, - output_type: TransferDataOutputType, - }, - Ton { - data: String, - output_type: TransferDataOutputType, - }, - Tron { - data: String, - output_type: TransferDataOutputType, - }, -} - #[derive(Debug, Clone, PartialEq)] pub enum WalletConnectResponseType { String { value: String }, Object { json: String }, } - -impl From for WCEthereumTransactionData { - fn from(tx: WCEthereumTransaction) -> Self { - Self { - chain_id: tx.chain_id, - from: tx.from, - to: tx.to, - value: tx.value, - gas: tx.gas, - gas_limit: tx.gas_limit, - gas_price: tx.gas_price, - max_fee_per_gas: tx.max_fee_per_gas, - max_priority_fee_per_gas: tx.max_priority_fee_per_gas, - nonce: tx.nonce, - data: tx.data, - } - } -} diff --git a/core/crates/gem_wallet_connect/src/decode.rs b/core/crates/gem_wallet_connect/src/decode.rs index 91d2d74ca2..f266fc5aa2 100644 --- a/core/crates/gem_wallet_connect/src/decode.rs +++ b/core/crates/gem_wallet_connect/src/decode.rs @@ -2,7 +2,7 @@ use gem_evm::siwe::SiweMessage; use hex::FromHex; use primitives::Chain; -use crate::sign_type::{SignDigestType, SignMessage}; +use primitives::{SignDigestType, SignMessage}; pub fn decode_sign_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { let mut utf8_value = None; diff --git a/core/crates/gem_wallet_connect/src/lib.rs b/core/crates/gem_wallet_connect/src/lib.rs index c854ad5a0b..2277fb4852 100644 --- a/core/crates/gem_wallet_connect/src/lib.rs +++ b/core/crates/gem_wallet_connect/src/lib.rs @@ -1,21 +1,36 @@ -pub mod accounts; +#[cfg(feature = "request")] pub mod actions; +#[cfg(feature = "request")] pub mod decode; +#[cfg(feature = "request")] pub mod request_handler; + +#[cfg(feature = "session")] +pub mod accounts; +#[cfg(feature = "session")] pub mod response_handler; +#[cfg(feature = "session")] pub mod session; -pub mod sign_type; +#[cfg(feature = "session")] pub mod validator; +#[cfg(feature = "session")] pub mod verifier; #[cfg(test)] mod testkit; +#[cfg(feature = "request")] pub use actions::*; +#[cfg(feature = "request")] pub use decode::decode_sign_message; +#[cfg(feature = "request")] pub use request_handler::WalletConnectRequestHandler; + +#[cfg(feature = "session")] pub use response_handler::WalletConnectResponseHandler; +#[cfg(feature = "session")] pub use session::config_session_properties; -pub use sign_type::SignDigestType; +#[cfg(feature = "session")] pub use validator::{SignMessageValidation, validate_send_transaction, validate_sign_message}; +#[cfg(feature = "session")] pub use verifier::WalletConnectVerifier; diff --git a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs index 876fc70bde..dd28bac820 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs @@ -1,6 +1,7 @@ -use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, ValueAccess, WCEthereumTransaction, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; pub struct EthereumRequestHandler; @@ -49,7 +50,7 @@ impl EthereumRequestHandler { Ok(WalletConnectAction::SignTransaction { chain, - transaction_type: WalletConnectTransactionType::Ethereum, + transaction_type: SignableTransactionType::Ethereum, data, }) } @@ -59,15 +60,15 @@ impl EthereumRequestHandler { Ok(WalletConnectAction::SendTransaction { chain, - transaction_type: WalletConnectTransactionType::Ethereum, + transaction_type: SignableTransactionType::Ethereum, data, }) } - pub fn decode_send_transaction(data: String) -> Result { + pub fn decode_send_transaction(data: String) -> Result { let transaction: WCEthereumTransaction = serde_json::from_str(&data).map_err(|e| e.to_string())?; let transaction_type = gem_evm::transaction::decode_transaction_type(transaction.data.as_deref()); - Ok(WalletConnectTransaction::Ethereum { + Ok(SignableTransaction::Ethereum { data: transaction.into(), transaction_type, }) @@ -199,7 +200,7 @@ mod tests { WalletConnectAction::SendTransaction { chain, transaction_type, data } => { let transaction: Value = serde_json::from_str(&data).unwrap(); assert_eq!(chain, Chain::Ethereum); - assert_eq!(transaction_type, WalletConnectTransactionType::Ethereum); + assert_eq!(transaction_type, SignableTransactionType::Ethereum); assert_eq!(transaction["from"], "0xsender"); assert_eq!(transaction["chainId"], "0x1"); } diff --git a/core/crates/gem_wallet_connect/src/request_handler/mod.rs b/core/crates/gem_wallet_connect/src/request_handler/mod.rs index 522adc410f..36e1988f11 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/mod.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/mod.rs @@ -4,9 +4,10 @@ mod sui; mod ton; mod tron; -use crate::actions::{WalletConnectAction, WalletConnectChainOperation, WalletConnectTransaction, WalletConnectTransactionType}; +use crate::actions::{WalletConnectAction, WalletConnectChainOperation}; use ethereum::EthereumRequestHandler; use primitives::{Chain, ChainType, ValueAccess, WalletConnectCAIP2, WalletConnectRequest, WalletConnectionMethods, hex}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; use solana::SolanaRequestHandler; use sui::SuiRequestHandler; @@ -20,6 +21,10 @@ impl WalletConnectRequestHandler { let WalletConnectRequest { method, params, chain_id, domain, .. } = request; + Self::parse_action(method, params, chain_id, domain) + } + + pub fn parse_action(method: String, params: String, chain_id: Option, domain: String) -> Result { let method_name = method; let method = match serde_json::from_value::(serde_json::Value::String(method_name.clone())) { Ok(m) => m, @@ -73,13 +78,13 @@ impl WalletConnectRequestHandler { } } - pub fn decode_send_transaction(transaction_type: WalletConnectTransactionType, data: String) -> Result { + pub fn decode_send_transaction(transaction_type: SignableTransactionType, data: String) -> Result { match transaction_type { - WalletConnectTransactionType::Ethereum => EthereumRequestHandler::decode_send_transaction(data), - WalletConnectTransactionType::Solana { output_type } => SolanaRequestHandler::decode_send_transaction(data, output_type), - WalletConnectTransactionType::Sui { output_type } => SuiRequestHandler::decode_send_transaction(data, output_type), - WalletConnectTransactionType::Ton { output_type } => TonRequestHandler::decode_send_transaction(data, output_type), - WalletConnectTransactionType::Tron { output_type } => TronRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Ethereum => EthereumRequestHandler::decode_send_transaction(data), + SignableTransactionType::Solana { output_type } => SolanaRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Sui { output_type } => SuiRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Ton { output_type } => TonRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Tron { output_type } => TronRequestHandler::decode_send_transaction(data, output_type), } } @@ -104,8 +109,8 @@ impl WalletConnectRequestHandler { #[cfg(test)] mod tests { use super::*; - use crate::sign_type::SignDigestType; use gem_evm::testkit::eip712_mock::mock_eip712_json; + use primitives::SignDigestType; use primitives::TransferDataOutputType; #[test] @@ -256,7 +261,7 @@ mod tests { assert_eq!(transactions.len(), 1); let decoded = WalletConnectRequestHandler::decode_send_transaction(transaction_type.clone(), transactions[0].clone()).unwrap(); match decoded { - WalletConnectTransaction::Solana { data, output_type } => { + SignableTransaction::Solana { data, output_type } => { assert!(data.transaction.starts_with("AQAAAAAAAAA")); assert_eq!(output_type, TransferDataOutputType::EncodedTransaction); } @@ -271,13 +276,13 @@ mod tests { fn test_decode_ethereum_transaction_accepts_numeric_and_hex_string_chain_id() { for (chain_id, expected) in [(r#"4663"#, 4663), (r#""0x1237""#, 4663)] { let decoded = WalletConnectRequestHandler::decode_send_transaction( - WalletConnectTransactionType::Ethereum, + SignableTransactionType::Ethereum, format!(r#"{{"chainId":{chain_id},"from":"0xsender","to":"0xrouter","data":"0x1234","value":"0x0"}}"#), ) .unwrap(); match decoded { - WalletConnectTransaction::Ethereum { data, transaction_type } => { + SignableTransaction::Ethereum { data, transaction_type } => { assert_eq!(data.chain_id, Some(expected)); assert_eq!(data.data, Some("0x1234".to_string())); assert_eq!(transaction_type, primitives::TransactionType::SmartContractCall); @@ -297,13 +302,13 @@ mod tests { ]; for (data, expected) in cases { let decoded = WalletConnectRequestHandler::decode_send_transaction( - WalletConnectTransactionType::Ethereum, + SignableTransactionType::Ethereum, format!(r#"{{"from":"0xsender","to":"0xrouter","data":{data},"value":"0x0"}}"#), ) .unwrap(); match decoded { - WalletConnectTransaction::Ethereum { transaction_type, .. } => assert_eq!(transaction_type, expected), + SignableTransaction::Ethereum { transaction_type, .. } => assert_eq!(transaction_type, expected), _ => panic!("Expected Ethereum transaction"), } } diff --git a/core/crates/gem_wallet_connect/src/request_handler/solana.rs b/core/crates/gem_wallet_connect/src/request_handler/solana.rs index 8cbe0b0385..bd56f7690c 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/solana.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/solana.rs @@ -1,6 +1,7 @@ -use crate::actions::{WCSolanaTransactionData, WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType, SolanaTransactionData}; use serde_json::Value; pub struct SolanaRequestHandler; @@ -31,7 +32,7 @@ impl SolanaRequestHandler { Ok(WalletConnectAction::SignTransaction { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::Signature, }, data: params.to_string(), @@ -43,22 +44,22 @@ impl SolanaRequestHandler { Ok(WalletConnectAction::SendTransaction { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { let json: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; let transaction = json .get("transaction") .and_then(|value| value.as_str()) .ok_or_else(|| "Missing transaction field".to_string())? .to_string(); - Ok(WalletConnectTransaction::Solana { - data: WCSolanaTransactionData { transaction }, + Ok(SignableTransaction::Solana { + data: SolanaTransactionData { transaction }, output_type, }) } @@ -79,7 +80,7 @@ impl SolanaRequestHandler { Ok(WalletConnectAction::SignAllTransactions { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::EncodedTransaction, }, transactions, @@ -112,7 +113,7 @@ mod tests { SolanaRequestHandler::parse_sign_transaction(Chain::Solana, params).unwrap(), WalletConnectAction::SignTransaction { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::Signature, }, data: expected_data, diff --git a/core/crates/gem_wallet_connect/src/request_handler/sui.rs b/core/crates/gem_wallet_connect/src/request_handler/sui.rs index eacba2f239..9c54f2362a 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/sui.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/sui.rs @@ -1,6 +1,7 @@ -use crate::actions::{WCSuiTransactionData, WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType, SuiTransactionData}; use serde_json::Value; pub struct SuiRequestHandler; @@ -31,7 +32,7 @@ impl SuiRequestHandler { Ok(WalletConnectAction::SignTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::Signature, }, data: params.to_string(), @@ -43,14 +44,14 @@ impl SuiRequestHandler { Ok(WalletConnectAction::SendTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { let json: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; let transaction = json .get("transaction") @@ -63,8 +64,8 @@ impl SuiRequestHandler { .and_then(|value| value.as_str()) .unwrap_or_default() .to_string(); - Ok(WalletConnectTransaction::Sui { - data: WCSuiTransactionData { transaction, wallet_address }, + Ok(SignableTransaction::Sui { + data: SuiTransactionData { transaction, wallet_address }, output_type, }) } @@ -95,7 +96,7 @@ mod tests { SuiRequestHandler::parse_sign_transaction(Chain::Sui, params).unwrap(), WalletConnectAction::SignTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::Signature, }, data: expected_data, @@ -111,7 +112,7 @@ mod tests { SuiRequestHandler::parse_send_transaction(Chain::Sui, params).unwrap(), WalletConnectAction::SendTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, diff --git a/core/crates/gem_wallet_connect/src/request_handler/ton.rs b/core/crates/gem_wallet_connect/src/request_handler/ton.rs index 00cb034a7a..17c1725205 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/ton.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/ton.rs @@ -1,7 +1,8 @@ -use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; use gem_ton::signer::TonSignMessageData; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; pub struct TonRequestHandler; @@ -36,15 +37,15 @@ impl TonRequestHandler { params.get_value("messages")?; Ok(WalletConnectAction::SendTransaction { chain: Chain::Ton, - transaction_type: WalletConnectTransactionType::Ton { + transaction_type: SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { - Ok(WalletConnectTransaction::Ton { data, output_type }) + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + Ok(SignableTransaction::Ton { data, output_type }) } } diff --git a/core/crates/gem_wallet_connect/src/request_handler/tron.rs b/core/crates/gem_wallet_connect/src/request_handler/tron.rs index 6254fa283c..988c2bbc9f 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/tron.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/tron.rs @@ -1,6 +1,7 @@ -use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; pub struct TronRequestHandler; @@ -30,7 +31,7 @@ impl TronRequestHandler { Ok(WalletConnectAction::SignTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), @@ -42,15 +43,15 @@ impl TronRequestHandler { Ok(WalletConnectAction::SendTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { - Ok(WalletConnectTransaction::Tron { data, output_type }) + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + Ok(SignableTransaction::Tron { data, output_type }) } } @@ -79,7 +80,7 @@ mod tests { TronRequestHandler::parse_sign_transaction(Chain::Tron, params).unwrap(), WalletConnectAction::SignTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, @@ -95,7 +96,7 @@ mod tests { TronRequestHandler::parse_send_transaction(Chain::Tron, params).unwrap(), WalletConnectAction::SendTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, @@ -118,7 +119,7 @@ mod tests { action, WalletConnectAction::SendTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, diff --git a/core/crates/gem_wallet_connect/src/validator.rs b/core/crates/gem_wallet_connect/src/validator.rs index f49c8ae956..8daa21a036 100644 --- a/core/crates/gem_wallet_connect/src/validator.rs +++ b/core/crates/gem_wallet_connect/src/validator.rs @@ -1,10 +1,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use crate::actions::WalletConnectTransactionType; -use crate::sign_type::SignDigestType; use gem_evm::domain::host_only; use gem_evm::siwe::SiweMessage; use primitives::Chain; +use primitives::{SignDigestType, SignableTransactionType}; pub struct SignMessageValidation<'a> { pub chain: Chain, @@ -65,8 +64,8 @@ fn validate_session_domain(message: &SiweMessage, session_domain: &str) -> Resul Ok(()) } -pub fn validate_send_transaction(transaction_type: &WalletConnectTransactionType, data: &str) -> Result<(), String> { - let WalletConnectTransactionType::Ton { .. } = transaction_type else { +pub fn validate_send_transaction(transaction_type: &SignableTransactionType, data: &str) -> Result<(), String> { + let SignableTransactionType::Ton { .. } = transaction_type else { return Ok(()); }; @@ -135,7 +134,7 @@ mod tests { #[test] fn test_validate_ton_send_transaction_expired() { - let ton_type = WalletConnectTransactionType::Ton { + let ton_type = SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }; assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 1234567890, "messages": []}"#).is_err()); @@ -143,7 +142,7 @@ mod tests { #[test] fn test_validate_ton_send_transaction_valid() { - let ton_type = WalletConnectTransactionType::Ton { + let ton_type = SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }; assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 9999999999, "messages": []}"#).is_ok()); @@ -151,12 +150,12 @@ mod tests { #[test] fn test_validate_ethereum_send_transaction_always_ok() { - assert!(validate_send_transaction(&WalletConnectTransactionType::Ethereum, "{}").is_ok()); + assert!(validate_send_transaction(&SignableTransactionType::Ethereum, "{}").is_ok()); } #[test] fn test_validate_ton_send_transaction_no_expiry() { - let ton_type = WalletConnectTransactionType::Ton { + let ton_type = SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }; assert!(validate_send_transaction(&ton_type, r#"{"messages": []}"#).is_ok()); From f15700ef47f06bff7b226d37fdaac3e51bc1b78f Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:12:50 +0300 Subject: [PATCH 03/53] core: add the payment crate with the WalletConnect Pay provider The gateway owns the decision: which quote is payable, whether the payment is still open, and which actions the wallet has to sign or send. PaymentService dispatches on the link's provider with an exhaustive match, so adding a rail is a compile error until it is handled rather than a runtime failure. --- core/Cargo.lock | 19 + core/Cargo.toml | 1 + core/crates/payment/Cargo.toml | 22 ++ core/crates/payment/src/action.rs | 112 ++++++ core/crates/payment/src/config.rs | 12 + core/crates/payment/src/error.rs | 33 ++ core/crates/payment/src/lib.rs | 11 + core/crates/payment/src/service.rs | 64 ++++ .../payment/src/wallet_connect_pay/account.rs | 46 +++ .../src/wallet_connect_pay/action_mapper.rs | 229 ++++++++++++ .../payment/src/wallet_connect_pay/client.rs | 223 ++++++++++++ .../payment/src/wallet_connect_pay/error.rs | 92 +++++ .../payment/src/wallet_connect_pay/mod.rs | 17 + .../payment/src/wallet_connect_pay/model.rs | 151 ++++++++ .../payment/src/wallet_connect_pay/params.rs | 154 ++++++++ .../src/wallet_connect_pay/payment_mapper.rs | 160 +++++++++ .../payment/src/wallet_connect_pay/quote.rs | 21 ++ .../payment/src/wallet_connect_pay/service.rs | 339 ++++++++++++++++++ .../payment/src/wallet_connect_pay/testkit.rs | 16 + .../src/wallet_connect_pay/validator.rs | 100 ++++++ .../testdata/fetch_response_permit2.json | 30 ++ .../testdata/fetch_response_solana.json | 16 + ...fetch_response_transfer_authorization.json | 15 + .../payment/testdata/option_collect_data.json | 32 ++ .../payment/testdata/options_response.json | 43 +++ .../testdata/status_response_succeeded.json | 16 + 26 files changed, 1974 insertions(+) create mode 100644 core/crates/payment/Cargo.toml create mode 100644 core/crates/payment/src/action.rs create mode 100644 core/crates/payment/src/config.rs create mode 100644 core/crates/payment/src/error.rs create mode 100644 core/crates/payment/src/lib.rs create mode 100644 core/crates/payment/src/service.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/account.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/action_mapper.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/client.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/error.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/mod.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/model.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/params.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/payment_mapper.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/quote.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/service.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/testkit.rs create mode 100644 core/crates/payment/src/wallet_connect_pay/validator.rs create mode 100644 core/crates/payment/testdata/fetch_response_permit2.json create mode 100644 core/crates/payment/testdata/fetch_response_solana.json create mode 100644 core/crates/payment/testdata/fetch_response_transfer_authorization.json create mode 100644 core/crates/payment/testdata/option_collect_data.json create mode 100644 core/crates/payment/testdata/options_response.json create mode 100644 core/crates/payment/testdata/status_response_succeeded.json diff --git a/core/Cargo.lock b/core/Cargo.lock index f1b4ad88ae..571b1de44b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4121,6 +4121,7 @@ dependencies = [ "hex", "num-bigint 0.5.1", "number_formatter", + "payment", "primitives", "serde_json", "signer", @@ -5985,6 +5986,24 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payment" +version = "2.114.6" +dependencies = [ + "async-trait", + "chrono", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "gem_wallet_connect", + "num-bigint 0.5.1", + "primitives", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/core/Cargo.toml b/core/Cargo.toml index 3cf330d403..841b8de99d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/lists", "crates/settings", "crates/settings_chain", + "crates/payment", "crates/pricer", "crates/chain_primitives", "crates/chain_traits", diff --git a/core/crates/payment/Cargo.toml b/core/crates/payment/Cargo.toml new file mode 100644 index 0000000000..11126d8d9e --- /dev/null +++ b/core/crates/payment/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "payment" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +primitives = { path = "../primitives" } +num-bigint = { workspace = true } +gem_client = { path = "../gem_client" } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"] } +gem_evm = { path = "../gem_evm" } +gem_wallet_connect = { path = "../gem_wallet_connect", features = ["request"] } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +gem_client = { path = "../gem_client", features = ["testkit"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/payment/src/action.rs b/core/crates/payment/src/action.rs new file mode 100644 index 0000000000..0cd44e0093 --- /dev/null +++ b/core/crates/payment/src/action.rs @@ -0,0 +1,112 @@ +use primitives::swap::ApprovalData; +use primitives::{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::{AssetId, PaymentAmount, PaymentMerchant, TransferDataOutputType}; + + fn send(chain: Chain) -> PaymentAction { + PaymentAction::SendTransaction { + chain, + transaction: SignableTransaction::Ton { + data: String::new(), + output_type: TransferDataOutputType::EncodedTransaction, + }, + } + } + + fn sign(chain: Chain) -> PaymentAction { + PaymentAction::SignTransaction { + chain, + transaction: SignableTransaction::Ton { + data: String::new(), + output_type: TransferDataOutputType::Signature, + }, + } + } + + #[test] + fn test_actions_keep_the_order_the_gateway_sent() { + let actions = vec![send(Chain::Ethereum), sign(Chain::Ethereum)]; + let prepared = PreparedPayment { + quotes: PaymentQuotes { + merchant: PaymentMerchant { + name: "Merchant".to_string(), + icon_url: None, + }, + price: None, + expires_at: None, + quotes: vec![], + }, + quote: PaymentQuote { + id: "option_1".to_string(), + payment_id: "pay_1".to_string(), + amount: PaymentAmount { + asset_id: AssetId::from_chain(Chain::Ethereum), + value: "1".to_string(), + symbol: "ETH".to_string(), + decimals: 18, + }, + expires_at: None, + collect_data_url: None, + provider_data: "{}".to_string(), + }, + actions, + }; + + assert!(matches!(prepared.actions[0], PaymentAction::SendTransaction { .. })); + assert!(matches!(prepared.actions[1], PaymentAction::SignTransaction { .. })); + } + + #[test] + fn test_validate_actions() { + let addresses = vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string())]; + + assert!(validate_actions(&[send(Chain::Ethereum)], &addresses).is_ok()); + assert!(validate_actions(&[send(Chain::Ethereum), send(Chain::Solana)], &addresses).is_err()); + assert!(validate_actions(&[], &addresses).is_err()); + } +} diff --git a/core/crates/payment/src/config.rs b/core/crates/payment/src/config.rs new file mode 100644 index 0000000000..860cced875 --- /dev/null +++ b/core/crates/payment/src/config.rs @@ -0,0 +1,12 @@ +use crate::wallet_connect_pay::WalletConnectPayAuth; + +#[derive(Debug, Clone)] +pub struct PaymentConfig { + pub wallet_connect_pay: WalletConnectPayAuth, +} + +impl PaymentConfig { + pub fn new(wallet_connect_pay: WalletConnectPayAuth) -> Self { + Self { wallet_connect_pay } + } +} diff --git a/core/crates/payment/src/error.rs b/core/crates/payment/src/error.rs new file mode 100644 index 0000000000..42655597b4 --- /dev/null +++ b/core/crates/payment/src/error.rs @@ -0,0 +1,33 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum PaymentError { + NotSupported, + PaymentNotFound, + PaymentExpired, + QuoteExpired, + NoPaymentOptions, + UnsupportedAccounts, + Rejected, + RateLimited, + InvalidRequest(String), + Network(String), +} + +impl fmt::Display for PaymentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSupported => write!(f, "Payment is not supported"), + Self::PaymentNotFound => write!(f, "Payment not found"), + Self::PaymentExpired => write!(f, "Payment expired"), + Self::QuoteExpired => write!(f, "Quote expired"), + Self::NoPaymentOptions => write!(f, "No payment options"), + Self::UnsupportedAccounts => write!(f, "No supported accounts"), + Self::Rejected => write!(f, "Payment rejected"), + Self::RateLimited => write!(f, "Too many requests"), + Self::InvalidRequest(message) | Self::Network(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for PaymentError {} diff --git a/core/crates/payment/src/lib.rs b/core/crates/payment/src/lib.rs new file mode 100644 index 0000000000..0b0b332c86 --- /dev/null +++ b/core/crates/payment/src/lib.rs @@ -0,0 +1,11 @@ +mod action; +mod config; +mod error; +mod service; +mod wallet_connect_pay; + +pub use action::{PaymentAction, PreparedPayment}; +pub use config::PaymentConfig; +pub use error::PaymentError; +pub use service::PaymentService; +pub use wallet_connect_pay::WalletConnectPayAuth; diff --git a/core/crates/payment/src/service.rs b/core/crates/payment/src/service.rs new file mode 100644 index 0000000000..daa126a21f --- /dev/null +++ b/core/crates/payment/src/service.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use gem_jsonrpc::alien::{RpcClient, RpcProvider}; +use primitives::{ChainAddress, PaymentLink, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::action::PreparedPayment; +use crate::config::PaymentConfig; +use crate::error::PaymentError; +use crate::wallet_connect_pay::{WALLET_CONNECT_PAY_API_URL, WalletConnectPayService}; + +pub struct PaymentService { + wallet_connect_pay: WalletConnectPayService, +} + +impl PaymentService { + pub fn new(provider: Arc, config: PaymentConfig) -> Self { + Self { + wallet_connect_pay: WalletConnectPayService::new(RpcClient::new(WALLET_CONNECT_PAY_API_URL.to_string(), provider), config.wallet_connect_pay), + } + } + + pub async fn get_options(&self, link: &PaymentLink, addresses: &[ChainAddress]) -> Result { + match link.provider { + PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.payment_options(&link.id, addresses).await, + PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), + } + } + + pub async fn get_prepared_payment( + &self, + provider: PaymentProviderName, + quotes: &PaymentQuotes, + quote: &PaymentQuote, + addresses: &[ChainAddress], + ) -> Result { + let payment = match provider { + PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.prepare_payment(quotes, quote, addresses).await?, + PaymentProviderName::SolanaPay => return Err(PaymentError::NotSupported), + }; + payment.validate(addresses)?; + Ok(payment) + } + + pub async fn confirm(&self, provider: PaymentProviderName, quote: &PaymentQuote, action_results: Vec) -> Result { + match provider { + PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.confirm_payment(quote, action_results).await, + PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), + } + } + + pub async fn get_status(&self, provider: PaymentProviderName, payment_id: &str) -> Result { + match provider { + PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.get_payment_status(payment_id).await, + PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), + } + } + + pub async fn cancel(&self, provider: PaymentProviderName, payment_id: &str) -> Result<(), PaymentError> { + match provider { + PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.cancel_payment(payment_id).await, + PaymentProviderName::SolanaPay => Err(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..19db1b0906 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs @@ -0,0 +1,229 @@ +use gem_evm::call_decoder::decode_call; +use gem_wallet_connect::{WalletConnectAction, WalletConnectRequestHandler, decode_sign_message}; +use num_bigint::BigUint; +use primitives::payment_decoder::wallet_connect_pay::WALLET_CONNECT_PAY_HOST; +use primitives::swap::ApprovalData; +use primitives::{SignableTransaction, SignableTransactionType, TransferDataOutputType}; + +use crate::PaymentAction; +use crate::error::PaymentError; +use crate::wallet_connect_pay::model::WalletRpcAction; +use crate::wallet_connect_pay::{params, validator}; + +const APPROVE_CALL: &str = "approve"; +const APPROVE_SPENDER: &str = "spender"; +const APPROVE_VALUE: &str = "value"; + +pub fn map_wallet_rpc(account: &str, wallet_rpc: &WalletRpcAction) -> Result { + let params = params::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(PaymentError::InvalidRequest)?; + if let WalletConnectAction::Unsupported { method } = action { + return Err(PaymentError::InvalidRequest(method)); + } + validator::validate_signer(account, ¶ms, &action)?; + map_action(with_encoded_solana_output(action)) +} + +fn map_action(action: WalletConnectAction) -> Result { + match action { + WalletConnectAction::SignMessage { chain, sign_type, data } => Ok(PaymentAction::SignMessage { + message: decode_sign_message(chain, sign_type, data), + }), + WalletConnectAction::SignTransaction { chain, transaction_type, data } => Ok(PaymentAction::SignTransaction { + chain, + transaction: WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(PaymentError::InvalidRequest)?, + }), + WalletConnectAction::SendTransaction { chain, transaction_type, data } => { + let transaction = WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(PaymentError::InvalidRequest)?; + Ok(match map_approval(&transaction) { + Some(approval) => PaymentAction::ApproveToken { chain, approval }, + None => PaymentAction::SendTransaction { chain, transaction }, + }) + } + WalletConnectAction::SignAllTransactions { .. } => Err(PaymentError::InvalidRequest("signAllTransactions".to_string())), + WalletConnectAction::ChainOperation { .. } => Err(PaymentError::InvalidRequest("chainOperation".to_string())), + WalletConnectAction::GetAccounts { .. } => Err(PaymentError::InvalidRequest("getAccounts".to_string())), + WalletConnectAction::Unsupported { method } => Err(PaymentError::InvalidRequest(method)), + } +} + +fn map_approval(transaction: &SignableTransaction) -> Option { + let SignableTransaction::Ethereum { data, .. } = transaction else { + return None; + }; + let call = decode_call(data.data.as_ref()?, None).ok()?; + if call.function != APPROVE_CALL { + return None; + } + let param = |name: &str| call.params.iter().find(|param| param.name == name).map(|param| param.value.clone()); + let value = param(APPROVE_VALUE)?; + Some(ApprovalData { + is_unlimited: is_unlimited_approval(&value), + token: data.to.clone(), + spender: param(APPROVE_SPENDER)?, + value, + }) +} + +fn with_encoded_solana_output(action: WalletConnectAction) -> WalletConnectAction { + match action { + WalletConnectAction::SignTransaction { + chain, + transaction_type: SignableTransactionType::Solana { .. }, + data, + } => WalletConnectAction::SignTransaction { + chain, + transaction_type: SignableTransactionType::Solana { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data, + }, + action => action, + } +} + +const UNLIMITED_APPROVE_BIT_WIDTHS: [u32; 2] = [160, 256]; + +fn is_unlimited_approval(value: &str) -> bool { + let Ok(value) = value.parse::() else { + return false; + }; + UNLIMITED_APPROVE_BIT_WIDTHS.iter().any(|bits| value == (BigUint::from(1u8) << bits) - BigUint::from(1u8)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_connect_pay::model::{FetchActionsResponse, WalletConnectPayAction}; + use crate::wallet_connect_pay::testkit::{TEST_ACCOUNT_ETHEREUM, TEST_ACCOUNT_POLYGON, TEST_ACCOUNT_SOLANA}; + use primitives::Chain; + use primitives::{SignDigestType, SignMessage}; + use serde_json::Value; + + fn get_actions(json: &str) -> Vec { + let response: FetchActionsResponse = serde_json::from_str(json).unwrap(); + response + .actions + .into_iter() + .map(|action| match action { + WalletConnectPayAction::WalletRpc(wallet_rpc) => wallet_rpc, + WalletConnectPayAction::Build(_) => panic!("Expected walletRpc action"), + }) + .collect() + } + + fn sign_message_text(account: &str, action: &WalletRpcAction) -> String { + let PaymentAction::SignMessage { message } = map_wallet_rpc(account, action).unwrap() else { + panic!("Expected a SignMessage action"); + }; + String::from_utf8(message.data).unwrap() + } + + #[test] + fn test_map_wallet_rpc_action() { + let actions = get_actions(include_str!("../../testdata/fetch_response_transfer_authorization.json")); + assert!(matches!( + map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &actions[0]).unwrap(), + PaymentAction::SignMessage { + message: SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip712, + .. + } + } + )); + + let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json")); + match map_wallet_rpc(TEST_ACCOUNT_POLYGON, &actions[0]).unwrap() { + PaymentAction::ApproveToken { chain, approval } => { + assert_eq!(chain, Chain::Polygon); + assert_eq!(approval.token, "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"); + assert_eq!(approval.spender, "0x000000000022D473030F116dDEE9F6B43aC78BA3"); + assert!(approval.is_unlimited); + } + action => panic!("Expected an approval, got {action:?}"), + } + assert!(matches!( + map_wallet_rpc(TEST_ACCOUNT_POLYGON, &actions[1]).unwrap(), + PaymentAction::SignMessage { + message: SignMessage { + chain: Chain::Polygon, + sign_type: SignDigestType::Eip712, + .. + } + } + )); + + let actions = get_actions(include_str!("../../testdata/fetch_response_solana.json")); + match map_wallet_rpc(TEST_ACCOUNT_SOLANA, &actions[0]).unwrap() { + PaymentAction::SignTransaction { chain, transaction } => { + assert_eq!(chain, Chain::Solana); + let SignableTransaction::Solana { data, output_type } = transaction else { + panic!("Expected a Solana transaction"); + }; + assert_eq!(output_type, TransferDataOutputType::EncodedTransaction); + assert!(!data.transaction.is_empty()); + } + action => panic!("Expected Solana SignTransaction, got {action:?}"), + } + } + + #[test] + fn test_map_wallet_rpc_action_normalizes_typed_data() { + let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json")); + let typed_data: Value = serde_json::from_str(&sign_message_text(TEST_ACCOUNT_POLYGON, &actions[1])).unwrap(); + assert_eq!( + typed_data["types"]["EIP712Domain"], + serde_json::json!([ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ]) + ); + + let actions = get_actions(include_str!("../../testdata/fetch_response_transfer_authorization.json")); + assert_eq!(Value::String(sign_message_text(TEST_ACCOUNT_ETHEREUM, &actions[0])), actions[0].params[1]); + } + + #[test] + fn test_map_wallet_rpc_action_rejects() { + let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json")); + + let mismatched_account = "eip155:137:0x9999999999999999999999999999999999999999"; + assert!(matches!( + map_wallet_rpc(mismatched_account, &actions[0]), + Err(PaymentError::InvalidRequest(message)) if message.contains("mismatch") + )); + assert!(matches!( + map_wallet_rpc(mismatched_account, &actions[1]), + Err(PaymentError::InvalidRequest(message)) if message.contains("mismatch") + )); + + let wrong_chain = WalletRpcAction { + chain_id: "eip155:1".to_string(), + ..actions[1].clone() + }; + assert!(map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &wrong_chain).is_err()); + + let unsupported = WalletRpcAction { + chain_id: "eip155:1".to_string(), + method: "eth_sign".to_string(), + params: serde_json::json!([]), + }; + assert!(matches!( + map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &unsupported), + Err(PaymentError::InvalidRequest(method)) if method == "eth_sign" + )); + + assert!(matches!( + map_wallet_rpc("invalid-account", &actions[0]), + Err(PaymentError::InvalidRequest(message)) if message.contains("Invalid option account") + )); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/client.rs b/core/crates/payment/src/wallet_connect_pay/client.rs new file mode 100644 index 0000000000..2c7408ccce --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/client.rs @@ -0,0 +1,223 @@ +use gem_client::{Client, build_path_with_query}; +use std::collections::HashMap; + +use crate::error::PaymentError; +use crate::wallet_connect_pay::model::{ + ConfirmPaymentRequest, FetchActionsRequest, FetchActionsResponse, PaymentOptionsRequest, PaymentOptionsResponse, PaymentStatusResponse, WalletConnectPayAction, + WalletConnectPayActionResult, +}; +use primitives::payment_decoder::wallet_connect_pay::is_payment_id; + +pub const WALLET_CONNECT_PAY_API_URL: &str = "https://api.pay.walletconnect.com"; +const WALLET_CONNECT_PAY_VERSION: &str = "2026-02-18"; + +const HEADER_WALLET_CONNECT_PAY_VERSION: &str = "WCP-Version"; +const HEADER_APP_ID: &str = "App-Id"; +const HEADER_CLIENT_ID: &str = "Client-Id"; +const HEADER_SDK_NAME: &str = "Sdk-Name"; +const HEADER_SDK_VERSION: &str = "Sdk-Version"; + +const SDK_NAME: &str = "gem-wallet"; +const SDK_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const QUERY_INCLUDE_PAYMENT_INFO: &str = "includePaymentInfo"; +const QUERY_MAX_POLL_MS: &str = "maxPollMs"; +const MAX_POLL_MS: i64 = 60_000; + +#[derive(Debug, Clone)] +pub struct WalletConnectPayAuth { + pub app_id: String, + pub client_id: String, +} + +impl WalletConnectPayAuth { + pub fn new(app_id: String, client_id: String) -> Self { + Self { app_id, client_id } + } +} + +#[derive(Debug)] +pub struct WalletConnectPayClient { + client: C, + auth: WalletConnectPayAuth, +} + +impl WalletConnectPayClient { + pub fn new(client: C, auth: WalletConnectPayAuth) -> Self { + Self { client, auth } + } + + pub async fn get_options(&self, payment_id: &str, accounts: Vec) -> Result { + let path = Self::path(payment_id, "/options", &[(QUERY_INCLUDE_PAYMENT_INFO, "true".to_string())])?; + let request = PaymentOptionsRequest { accounts }; + Ok(self.client.post_with(&path, &request, self.headers()).await?) + } + + pub async fn get_actions(&self, payment_id: &str, option_id: &str, data: String) -> Result, PaymentError> { + let path = Self::path(payment_id, "/fetch", &[])?; + let request = FetchActionsRequest { + option_id: option_id.to_string(), + data, + }; + let response: FetchActionsResponse = self.client.post_with(&path, &request, self.headers()).await?; + Ok(response.actions) + } + + pub async fn confirm(&self, payment_id: &str, option_id: &str, action_results: Vec) -> Result { + let path = Self::path(payment_id, "/confirm", &[(QUERY_MAX_POLL_MS, MAX_POLL_MS.to_string())])?; + let request = ConfirmPaymentRequest { + option_id: option_id.to_string(), + results: action_results.into_iter().map(WalletConnectPayActionResult::wallet_rpc).collect(), + }; + Ok(self.client.post_with(&path, &request, self.headers()).await?) + } + + pub async fn cancel(&self, payment_id: &str) -> Result<(), PaymentError> { + 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(PaymentError::InvalidRequest(format!("Invalid payment id: {payment_id}"))); + } + let path = format!("/v1/gateway/payment/{payment_id}{suffix}"); + if query.is_empty() { + return Ok(path); + } + build_path_with_query(&path, &query).map_err(|error| PaymentError::InvalidRequest(error.to_string())) + } + + fn headers(&self) -> HashMap { + [ + (HEADER_WALLET_CONNECT_PAY_VERSION.to_string(), WALLET_CONNECT_PAY_VERSION.to_string()), + (HEADER_SDK_NAME.to_string(), SDK_NAME.to_string()), + (HEADER_SDK_VERSION.to_string(), SDK_VERSION.to_string()), + (HEADER_APP_ID.to_string(), self.auth.app_id.clone()), + (HEADER_CLIENT_ID.to_string(), self.auth.client_id.clone()), + ] + .into_iter() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_connect_pay::model::WalletRpcAction; + use crate::wallet_connect_pay::testkit::{TEST_APP_ID, TEST_CLIENT_ID}; + use gem_client::{ClientError, testkit::MockClient}; + use primitives::PaymentStatus; + + fn client(mock: MockClient) -> WalletConnectPayClient { + WalletConnectPayClient::new(mock, WalletConnectPayAuth::mock()) + } + + #[tokio::test] + async fn test_get_options() { + let mock = MockClient::new().with_post_with_headers(|path, body, headers| { + assert_eq!(headers.get(HEADER_WALLET_CONNECT_PAY_VERSION).unwrap(), WALLET_CONNECT_PAY_VERSION); + assert_eq!(headers.get(HEADER_SDK_NAME).unwrap(), SDK_NAME); + assert_eq!(headers.get(HEADER_APP_ID).unwrap(), TEST_APP_ID); + assert_eq!(headers.get(HEADER_CLIENT_ID).unwrap(), TEST_CLIENT_ID); + assert!(path.ends_with("/v1/gateway/payment/pay_123/options?includePaymentInfo=true")); + assert_eq!(serde_json::from_slice::(body).unwrap()["accounts"], serde_json::json!(["eip155:1:0x1"])); + Ok(include_str!("../../testdata/options_response.json").as_bytes().to_vec()) + }); + + let response = client(mock).get_options("pay_123", vec!["eip155:1:0x1".to_string()]).await.unwrap(); + let info = response.info.unwrap(); + assert_eq!(info.status, PaymentStatus::RequiresAction); + assert_eq!(info.merchant.name, "Gem Wallet Test Merchant"); + assert_eq!(info.amount.value, "5000"); + assert_eq!(info.amount.display.decimals, 2); + + let option = response.options.unwrap().remove(0); + assert_eq!(option.id, "opt_1"); + assert_eq!(option.account, "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD"); + assert_eq!(option.amount.display.asset_symbol, "USDC"); + assert!(option.actions.is_empty()); + assert!(option.collect_data.is_none()); + } + + #[tokio::test] + async fn test_fetch_actions() { + let mock = MockClient::new().with_post(|path, body| { + assert!(path.ends_with("/v1/gateway/payment/pay_123/fetch")); + assert_eq!( + serde_json::from_slice::(body).unwrap(), + serde_json::json!({"optionId": "opt_1", "data": ""}) + ); + Ok(include_str!("../../testdata/fetch_response_transfer_authorization.json").as_bytes().to_vec()) + }); + + let actions = client(mock).get_actions("pay_123", "opt_1", String::new()).await.unwrap(); + assert!(matches!(actions.as_slice(), [WalletConnectPayAction::WalletRpc(WalletRpcAction { method, .. })] if method == "eth_signTypedData_v4")); + } + + #[tokio::test] + async fn test_confirm() { + let mock = MockClient::new().with_post(|path, body| { + assert!(path.ends_with("/v1/gateway/payment/pay_123/confirm?maxPollMs=60000")); + assert_eq!( + serde_json::from_slice::(body).unwrap(), + serde_json::json!({ + "optionId": "opt_1", + "results": [{ "type": "walletRpc", "data": ["0xsignature"] }] + }) + ); + Ok(br#"{"status":"processing","isFinal":false,"pollInMs":1000}"#.to_vec()) + }); + + let response = client(mock).confirm("pay_123", "opt_1", vec!["0xsignature".to_string()]).await.unwrap(); + assert_eq!(response.status, PaymentStatus::Processing); + assert!(!response.is_final); + assert_eq!(response.poll_in_ms, Some(1000)); + } + + #[tokio::test] + async fn test_get_status() { + let mock = MockClient::new().with_get(|path| { + assert!(path.ends_with("/v1/gateway/payment/pay_123/status?maxPollMs=60000")); + Ok(include_str!("../../testdata/status_response_succeeded.json").as_bytes().to_vec()) + }); + + let response = client(mock).get_status("pay_123").await.unwrap(); + assert_eq!(response.status, PaymentStatus::Succeeded); + assert!(response.is_final); + assert_eq!(response.info.unwrap().tx_id, "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4"); + } + + #[test] + fn test_path_rejects_unsafe_payment_id() { + assert!(WalletConnectPayClient::::path("pay_123", "/status", &[]).is_ok()); + assert!(WalletConnectPayClient::::path("pay_123/cancel", "/status", &[]).is_err()); + assert!(WalletConnectPayClient::::path("pay_123?x=1", "", &[]).is_err()); + assert!(WalletConnectPayClient::::path("pay 123", "", &[]).is_err()); + assert!(WalletConnectPayClient::::path("", "/status", &[]).is_err()); + } + + #[tokio::test] + async fn test_error_mapping() { + let mock = MockClient::new().with_get(|_| Err(ClientError::Http { status: 410, body: vec![] })); + assert_eq!(client(mock).get_status("pay_123").await, Err(PaymentError::PaymentExpired)); + + assert_eq!(PaymentError::from(ClientError::Http { status: 404, body: vec![] }), PaymentError::PaymentNotFound); + assert_eq!(PaymentError::from(ClientError::Http { status: 409, body: vec![] }), PaymentError::QuoteExpired); + assert_eq!(PaymentError::from(ClientError::Http { status: 429, body: vec![] }), PaymentError::RateLimited); + assert!(matches!( + PaymentError::from(ClientError::Http { + status: 400, + body: b"bad".to_vec() + }), + PaymentError::InvalidRequest(_) + )); + assert!(matches!(PaymentError::from(ClientError::Http { status: 500, body: vec![] }), PaymentError::Network(_))); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/error.rs b/core/crates/payment/src/wallet_connect_pay/error.rs new file mode 100644 index 0000000000..5cf70bec0f --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/error.rs @@ -0,0 +1,92 @@ +use crate::error::PaymentError; +use gem_client::ClientError; + +const CODE_PAYMENT_NOT_FOUND: &str = "payment_not_found"; +const CODE_PAYMENT_EXPIRED: &str = "payment_expired"; +const CODE_QUOTE_EXPIRED: &str = "quote_expired"; +const CODE_RATE_LIMITED: &str = "rate_limited"; +const CODE_SANCTIONED_USER: &str = "sanctioned_user"; +const STALE_OPTION_MESSAGE: &str = "Option not found"; + +impl From for PaymentError { + fn from(error: ClientError) -> Self { + match error { + ClientError::Http { status, body } => Self::from_response(status, &body), + ClientError::Network(msg) | ClientError::Serialization(msg) => Self::Network(msg), + ClientError::Timeout => Self::Network("Timeout".to_string()), + } + } +} + +impl PaymentError { + fn from_response(status: u16, body: &[u8]) -> Self { + let body = String::from_utf8_lossy(&body[..body.len().min(512)]).to_string(); + match Self::error_code(&body).as_deref() { + Some(CODE_PAYMENT_NOT_FOUND) => Self::PaymentNotFound, + Some(CODE_PAYMENT_EXPIRED) => Self::PaymentExpired, + Some(CODE_QUOTE_EXPIRED) => Self::QuoteExpired, + Some(CODE_RATE_LIMITED) => Self::RateLimited, + Some(CODE_SANCTIONED_USER) => Self::Rejected, + _ if body.contains(STALE_OPTION_MESSAGE) => Self::QuoteExpired, + _ => Self::from_status(status, body), + } + } + + fn from_status(status: u16, body: String) -> Self { + match status { + 404 => Self::PaymentNotFound, + 409 => Self::QuoteExpired, + 410 => Self::PaymentExpired, + 429 => Self::RateLimited, + 400 | 422 => Self::InvalidRequest(format!("{status}: {body}")), + _ => Self::Network(format!("{status}: {body}")), + } + } + + fn error_code(body: &str) -> Option { + serde_json::from_str::(body).ok()?.get("code")?.as_str().map(str::to_string) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_from_client_error() { + let stale = ClientError::Http { + status: 400, + body: br#"{"code":"params_validation","message":"Validation error: Option not found"}"#.to_vec(), + }; + assert_eq!(PaymentError::from(stale), PaymentError::QuoteExpired); + + let invalid = ClientError::Http { + status: 400, + body: br#"{"message":"Invalid URL"}"#.to_vec(), + }; + assert!(matches!(PaymentError::from(invalid), PaymentError::InvalidRequest(_))); + + assert_eq!(PaymentError::from(ClientError::Http { status: 410, body: vec![] }), PaymentError::PaymentExpired); + } + + #[test] + fn test_error_reads_the_gateway_code_over_the_status() { + let sanctioned = ClientError::Http { + status: 400, + body: br#"{"code":"sanctioned_user","message":"User is sanctioned"}"#.to_vec(), + }; + assert_eq!(PaymentError::from(sanctioned), PaymentError::Rejected); + + let not_found = ClientError::Http { + status: 400, + body: br#"{"code":"payment_not_found","message":"No such payment"}"#.to_vec(), + }; + assert_eq!(PaymentError::from(not_found), PaymentError::PaymentNotFound); + + let expired = ClientError::Http { + status: 500, + body: br#"{"code":"payment_expired"}"#.to_vec(), + }; + assert_eq!(PaymentError::from(expired), PaymentError::PaymentExpired); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/mod.rs b/core/crates/payment/src/wallet_connect_pay/mod.rs new file mode 100644 index 0000000000..4964a28a15 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/mod.rs @@ -0,0 +1,17 @@ +mod account; +mod action_mapper; +mod client; +mod error; +mod model; +mod params; +mod payment_mapper; +mod quote; +mod service; +mod validator; + +#[cfg(test)] +mod testkit; + +pub(crate) use client::WALLET_CONNECT_PAY_API_URL; +pub use client::WalletConnectPayAuth; +pub(crate) use service::WalletConnectPayService; diff --git a/core/crates/payment/src/wallet_connect_pay/model.rs b/core/crates/payment/src/wallet_connect_pay/model.rs new file mode 100644 index 0000000000..d1e8856806 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/model.rs @@ -0,0 +1,151 @@ +use primitives::{PaymentMerchant, PaymentStatus}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::PaymentError; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAmount { + pub unit: String, + pub value: String, + pub display: PaymentAmountDisplay, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAmountDisplay { + pub asset_symbol: String, + pub asset_name: String, + pub decimals: i32, + #[serde(default)] + pub icon_url: Option, + #[serde(default)] + pub network_name: Option, + #[serde(default)] + pub network_icon_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentInfo { + pub status: PaymentStatus, + pub amount: PaymentAmount, + pub expires_at: i64, + pub merchant: PaymentMerchant, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOption { + pub id: String, + pub account: String, + pub amount: PaymentAmount, + #[serde(default)] + pub expires_at: Option, + #[serde(default)] + pub actions: Vec, + #[serde(default)] + pub collect_data: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentCollectData { + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +pub enum WalletConnectPayAction { + WalletRpc(WalletRpcAction), + Build(BuildAction), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalletRpcAction { + pub chain_id: String, + pub method: String, + pub params: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuildAction { + pub data: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +pub enum WalletConnectPayActionResult { + WalletRpc(Vec), +} + +impl WalletConnectPayActionResult { + pub fn wallet_rpc(result: String) -> Self { + Self::WalletRpc(vec![Value::String(result)]) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOptionsRequest { + pub accounts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOptionsResponse { + #[serde(default)] + pub info: Option, + #[serde(default)] + pub options: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchActionsRequest { + pub option_id: String, + pub data: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FetchActionsResponse { + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmPaymentRequest { + pub option_id: String, + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentStatusResponse { + pub status: PaymentStatus, + pub is_final: bool, + #[serde(default)] + pub poll_in_ms: Option, + #[serde(default)] + pub info: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentResultInfo { + pub tx_id: String, + #[serde(default)] + pub option_amount: Option, +} + +impl TryFrom for WalletRpcAction { + type Error = PaymentError; + + fn try_from(action: WalletConnectPayAction) -> Result { + match action { + WalletConnectPayAction::WalletRpc(wallet_rpc) => Ok(wallet_rpc), + WalletConnectPayAction::Build(_) => Err(PaymentError::InvalidRequest("Payment action is not a wallet RPC call".to_string())), + } + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/params.rs b/core/crates/payment/src/wallet_connect_pay/params.rs new file mode 100644 index 0000000000..d2b8dbd249 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/params.rs @@ -0,0 +1,154 @@ +use gem_evm::eip712::{EIP712Type, eip712_domain_types}; +use primitives::ValueAccess; +use serde_json::{Map, Value}; +use std::fmt::Display; + +use crate::error::PaymentError; + +const SOLANA_METHOD_PREFIX: &str = "solana_"; +const METHOD_ETH_SIGN_TYPED_DATA_V4: &str = "eth_signTypedData_v4"; +const TYPE_EIP712_DOMAIN: &str = "EIP712Domain"; + +pub fn 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, PaymentError> { + let domain = object(domain, "EIP712 domain")?; + let fields = eip712_domain_types(); + + for (name, value) in domain { + if !fields.iter().any(|field| field.name == *name) { + return Err(invalid(format!("Unsupported EIP712 domain field: {name}"))); + } + if value.is_null() { + return Err(invalid(format!("Missing EIP712 domain field value: {name}"))); + } + } + + Ok(fields.into_iter().filter(|field| domain.contains_key(&field.name)).collect()) +} + +fn object<'a>(value: &'a Value, name: &str) -> Result<&'a Map, PaymentError> { + value.as_object().ok_or_else(|| invalid(format!("Expected {name} object"))) +} + +fn invalid(message: impl Display) -> PaymentError { + PaymentError::InvalidRequest(message.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signer_params(typed_data: Value) -> Value { + Value::Array(vec![Value::String("0x1".to_string()), typed_data]) + } + + fn domain_schema_of(params: &Value) -> Value { + let typed_data = match ¶ms[1] { + Value::String(json) => serde_json::from_str(json).unwrap(), + value => value.clone(), + }; + typed_data["types"][TYPE_EIP712_DOMAIN].clone() + } + + fn permit2_typed_data() -> Value { + serde_json::json!({ + "domain": {"name": "Permit2", "chainId": 1, "verifyingContract": "0x00"}, + "types": {"PermitSingle": []}, + "primaryType": "PermitSingle", + "message": {}, + }) + } + + #[test] + fn test_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..2b0d33e12d --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs @@ -0,0 +1,160 @@ +use chrono::{DateTime, Utc}; +use primitives::{AssetId, PaymentAmount, PaymentOutcome, PaymentPrice, WalletConnectCAIP19}; +use url::Url; + +use crate::error::PaymentError; +use crate::wallet_connect_pay::account::account_chain; +use crate::wallet_connect_pay::model::{PaymentInfo, PaymentOption, PaymentOptionsResponse, PaymentStatusResponse}; +use crate::wallet_connect_pay::quote::{QuotedOption, QuotedPayment}; + +const CAIP19_PREFIX: &str = "caip19"; +const COLLECT_DATA_HOST: &str = "walletconnect.com"; + +pub fn map_quoted_payment(response: PaymentOptionsResponse) -> Result { + let payment = response.info.ok_or(PaymentError::PaymentNotFound)?; + Ok(QuotedPayment { + status: payment.status, + expires_at: map_expiry(&payment)?, + price: PaymentPrice { + symbol: payment.amount.display.asset_symbol.clone(), + value: payment.amount.value.clone(), + decimals: payment.amount.display.decimals, + }, + merchant: payment.merchant, + options: response.options.unwrap_or_default().into_iter().filter_map(map_option).collect(), + }) +} + +pub fn map_payment_outcome(response: PaymentStatusResponse) -> PaymentOutcome { + PaymentOutcome { + status: response.status, + transaction_id: response.info.map(|info| info.tx_id), + } +} + +fn map_expiry(payment: &PaymentInfo) -> Result, PaymentError> { + DateTime::from_timestamp(payment.expires_at, 0).ok_or_else(|| PaymentError::InvalidRequest("Invalid payment expiry".to_string())) +} + +fn map_option(option: PaymentOption) -> Option { + let provider_data = serde_json::to_string(&option).ok()?; + let collect_data_url = match &option.collect_data { + Some(collect_data) => Some(collect_data_url(&collect_data.url)?), + None => None, + }; + Some(QuotedOption { + id: option.id.clone(), + expires_at: option.expires_at.and_then(|expires_at| DateTime::from_timestamp(expires_at, 0)), + chain: account_chain(&option.account)?, + provider_data, + amount: PaymentAmount { + asset_id: asset_id(&option.amount.unit)?, + value: option.amount.value, + symbol: option.amount.display.asset_symbol, + decimals: option.amount.display.decimals, + }, + collect_data_url, + }) +} + +fn collect_data_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" { + return None; + } + let host = parsed.host_str()?.to_lowercase(); + if host == COLLECT_DATA_HOST || host.ends_with(&format!(".{COLLECT_DATA_HOST}")) { + Some(url.to_string()) + } else { + None + } +} + +fn asset_id(unit: &str) -> Option { + match unit.split_once('/')? { + (CAIP19_PREFIX, asset) => WalletConnectCAIP19::get_asset_id(asset), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, PaymentStatus}; + + fn options_response() -> PaymentOptionsResponse { + serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap() + } + + #[test] + fn test_map_quoted_payment() { + let quoted = map_quoted_payment(options_response()).unwrap(); + + assert_eq!(quoted.status, PaymentStatus::RequiresAction); + assert_eq!(quoted.merchant.name, "Gem Wallet Test Merchant"); + assert_eq!(quoted.expires_at.timestamp(), 1785175272); + + let option = "ed.options[0]; + assert_eq!(option.id, "opt_1"); + assert_eq!(option.chain, Chain::Ethereum); + assert_eq!(option.collect_data_url, None); + } + + #[test] + fn test_asset_id_reads_the_chain_coin_and_its_tokens() { + assert_eq!(asset_id("caip19/eip155:1/slip44:60"), Some(AssetId::from(Chain::Ethereum, None))); + assert_eq!( + asset_id("caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + Some(AssetId::from_token(Chain::Base, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")) + ); + assert_eq!(asset_id("caip19/eip155:1").unwrap(), AssetId::from(Chain::Ethereum, None)); + assert_eq!(asset_id("caip19/eip155:99999/erc20:0x1"), None); + assert_eq!(asset_id("eip155:1/erc20:0x1"), None); + assert_eq!(asset_id("iso4217/USD"), None); + } + + #[test] + fn test_map_option_with_collect_data() { + let option: PaymentOption = serde_json::from_str(include_str!("../../testdata/option_collect_data.json")).unwrap(); + let mapped = map_option(option.clone()).unwrap(); + + assert_eq!(mapped.collect_data_url.unwrap(), "https://data-collection.walletconnect.com/ic/pay_123"); + + for url in [ + "https://evil.com/ic/pay_123", + "https://evil-walletconnect.com/ic/pay_123", + "http://walletconnect.com/ic/pay_123", + "not a url", + ] { + let mut off_domain = option.clone(); + off_domain.collect_data.as_mut().unwrap().url = url.to_string(); + + assert!(map_option(off_domain).is_none(), "{url} must not reach the collection web view"); + } + } + + #[test] + fn test_map_payment_options_drops_unpayable_option() { + let mut response = options_response(); + let options = response.options.as_mut().unwrap(); + options[0].account = "cosmos:cosmoshub-4:cosmos1".to_string(); + + assert!(map_quoted_payment(response).unwrap().options.is_empty()); + } + + #[test] + fn test_map_payment_options_without_info() { + let response = PaymentOptionsResponse { info: None, options: None }; + + assert_eq!(map_quoted_payment(response), Err(PaymentError::PaymentNotFound)); + } + + #[test] + fn test_map_payment_outcome() { + let response: PaymentStatusResponse = serde_json::from_str(include_str!("../../testdata/status_response_succeeded.json")).unwrap(); + let outcome = map_payment_outcome(response); + + assert_eq!(outcome.status, PaymentStatus::Succeeded); + assert_eq!(outcome.transaction_id.unwrap(), "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4"); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/quote.rs b/core/crates/payment/src/wallet_connect_pay/quote.rs new file mode 100644 index 0000000000..be0debd41f --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/quote.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use primitives::{Chain, PaymentAmount, PaymentMerchant, PaymentPrice, PaymentStatus}; + +#[derive(Debug, Clone, PartialEq)] +pub struct QuotedPayment { + pub status: PaymentStatus, + pub expires_at: DateTime, + pub merchant: PaymentMerchant, + pub price: PaymentPrice, + pub options: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct QuotedOption { + pub id: String, + pub expires_at: Option>, + pub chain: Chain, + pub amount: PaymentAmount, + pub collect_data_url: Option, + pub provider_data: String, +} diff --git a/core/crates/payment/src/wallet_connect_pay/service.rs b/core/crates/payment/src/wallet_connect_pay/service.rs new file mode 100644 index 0000000000..875fd322ca --- /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::error::PaymentError; +use crate::wallet_connect_pay::client::{WalletConnectPayAuth, WalletConnectPayClient}; +use crate::wallet_connect_pay::model::{PaymentOption, WalletConnectPayAction, WalletRpcAction}; +use crate::wallet_connect_pay::payment_mapper; +use crate::wallet_connect_pay::quote::QuotedOption; +use crate::{PaymentAction, PreparedPayment}; + +#[derive(Debug)] +pub struct WalletConnectPayService { + client: WalletConnectPayClient, +} + +impl WalletConnectPayService { + pub fn new(client: C, auth: WalletConnectPayAuth) -> Self { + Self { + client: WalletConnectPayClient::new(client, auth), + } + } + + pub(crate) async fn prepare_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result { + if Self::is_expired(quote) { + return self.get_requoted_actions(quote, addresses).await; + } + match self.payment_actions(quote).await { + Ok(actions) => Ok(PreparedPayment { + quotes: quotes.clone(), + quote: quote.clone(), + actions, + }), + Err(PaymentError::QuoteExpired) => self.get_requoted_actions(quote, addresses).await, + Err(error) => Err(error), + } + } + + async fn get_requoted_actions(&self, expired: &PaymentQuote, addresses: &[ChainAddress]) -> Result { + let quotes = match self.payment_options(&expired.payment_id, addresses).await? { + PaymentOptions::Quotes(quotes) => quotes, + PaymentOptions::Outcome(_) => return Err(PaymentError::PaymentExpired), + }; + let quote = Self::get_quote("es, &expired.amount.asset_id)?; + let actions = self.payment_actions("e).await?; + Ok(PreparedPayment { quotes, quote, actions }) + } + + pub(crate) async fn confirm_payment(&self, quote: &PaymentQuote, action_results: Vec) -> Result { + let response = self.client.confirm("e.payment_id, "e.id, action_results).await?; + Ok(payment_mapper::map_payment_outcome(response)) + } + + pub(crate) async fn 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(PaymentError::UnsupportedAccounts); + } + let response = self.client.get_options(payment_id, identifiers).await?; + let quoted = payment_mapper::map_quoted_payment(response)?; + + match quoted.status { + PaymentStatus::RequiresAction => {} + PaymentStatus::Succeeded | PaymentStatus::Processing => { + return Ok(PaymentOptions::Outcome(PaymentOutcome { + status: quoted.status, + transaction_id: None, + })); + } + PaymentStatus::Failed | PaymentStatus::Expired | PaymentStatus::Cancelled => return Err(PaymentError::PaymentExpired), + } + if quoted.expires_at <= Utc::now() { + return Err(PaymentError::PaymentExpired); + } + + let quotes = Self::payment_quotes(payment_id, quoted.options); + if quotes.is_empty() { + return Err(PaymentError::NoPaymentOptions); + } + + Ok(PaymentOptions::Quotes(PaymentQuotes { + merchant: quoted.merchant, + price: Some(quoted.price), + expires_at: Some(quoted.expires_at), + quotes, + })) + } + + fn is_expired(quote: &PaymentQuote) -> bool { + quote.expires_at.is_some_and(|expires_at| expires_at <= Utc::now()) + } + + fn get_quote(quotes: &PaymentQuotes, asset_id: &AssetId) -> Result { + quotes + .quotes + .iter() + .find(|quote| "e.amount.asset_id == asset_id) + .or(quotes.quotes.first()) + .cloned() + .ok_or(PaymentError::NoPaymentOptions) + } + + async fn payment_actions(&self, quote: &PaymentQuote) -> Result, PaymentError> { + let option = Self::option(quote)?; + let actions = self.wallet_rpc_actions("e.payment_id, &option).await?; + if actions.is_empty() { + return Err(PaymentError::InvalidRequest("Payment option has no executable actions".to_string())); + } + + actions.iter().map(|action| map_wallet_rpc(&option.account, action)).collect() + } + + pub(crate) async fn cancel_payment(&self, payment_id: &str) -> Result<(), PaymentError> { + self.client.cancel(payment_id).await + } + + pub(crate) async fn get_payment_status(&self, payment_id: &str) -> Result { + let response = self.client.get_status(payment_id).await?; + Ok(payment_mapper::map_payment_outcome(response)) + } + + fn 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, PaymentError> { + let actions = match option.actions.first() { + None => self.client.get_actions(payment_id, &option.id, String::new()).await?, + Some(WalletConnectPayAction::Build(build)) => self.client.get_actions(payment_id, &option.id, build.data.clone()).await?, + Some(WalletConnectPayAction::WalletRpc(_)) => option.actions.clone(), + }; + + actions.into_iter().map(WalletRpcAction::try_from).collect() + } + + fn option(quote: &PaymentQuote) -> Result { + serde_json::from_str("e.provider_data).map_err(|error| PaymentError::InvalidRequest(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use chrono::Duration; + use gem_client::ClientError; + use gem_client::testkit::MockClient; + use primitives::{AssetId, Chain, PaymentAmount}; + 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(PaymentError::PaymentExpired)); + + let expired = service_with_response(|response| { + response["info"]["expiresAt"] = serde_json::json!(1); + }); + assert_eq!(expired.payment_options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired)); + + let no_options = service_with_response(|response| { + far_future(response); + response["options"] = serde_json::json!([]); + }); + assert_eq!(no_options.payment_options("pay_123", &addresses()).await, Err(PaymentError::NoPaymentOptions)); + + let unsupported_addresses = vec![ChainAddress::new(Chain::Bitcoin, "bc1".to_string())]; + assert_eq!( + service_with_response(far_future).payment_options("pay_123", &unsupported_addresses).await, + Err(PaymentError::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..7b748e4ac5 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/validator.rs @@ -0,0 +1,100 @@ +use gem_wallet_connect::WalletConnectAction; +use primitives::{ChainType, SignDigestType, ValueAccess, WCEthereumTransaction, WalletConnectCAIP2}; +use serde_json::Value; + +use crate::error::PaymentError; + +pub fn validate_signer(account: &str, params: &Value, action: &WalletConnectAction) -> Result<(), PaymentError> { + let chain_address = WalletConnectCAIP2::parse_account(account.to_string()).ok_or_else(|| PaymentError::InvalidRequest(format!("Invalid option account: {account}")))?; + if chain_address.chain.chain_type() != ChainType::Ethereum { + return Ok(()); + } + + match action { + WalletConnectAction::SendTransaction { data, .. } | WalletConnectAction::SignTransaction { data, .. } => { + let transaction: WCEthereumTransaction = serde_json::from_str(data).map_err(|error| PaymentError::InvalidRequest(format!("Invalid transaction payload: {error}")))?; + validate_address_match(&chain_address.address, &transaction.from) + } + WalletConnectAction::SignMessage { + sign_type: SignDigestType::Eip712, + .. + } => { + let signer = params.at(0).and_then(Value::string).map_err(PaymentError::InvalidRequest)?; + validate_address_match(&chain_address.address, signer) + } + WalletConnectAction::SignMessage { + sign_type: + SignDigestType::Eip191 | SignDigestType::Base58 | SignDigestType::SuiPersonal | SignDigestType::Siwe | SignDigestType::TonPersonal | SignDigestType::TronPersonal, + .. + } + | WalletConnectAction::SignAllTransactions { .. } + | WalletConnectAction::ChainOperation { .. } + | WalletConnectAction::GetAccounts { .. } + | WalletConnectAction::Unsupported { .. } => Ok(()), + } +} + +fn validate_address_match(expected: &str, actual: &str) -> Result<(), PaymentError> { + if actual.to_lowercase() == expected.to_lowercase() { + return Ok(()); + } + Err(PaymentError::InvalidRequest(format!("Signer address mismatch: expected {expected}, got {actual}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_connect_pay::testkit::{TEST_ACCOUNT_ETHEREUM, TEST_ACCOUNT_SOLANA}; + use primitives::SignableTransactionType; + use primitives::{Chain, TransferDataOutputType}; + + fn send_transaction(from: &str) -> WalletConnectAction { + WalletConnectAction::SendTransaction { + chain: Chain::Ethereum, + transaction_type: SignableTransactionType::Ethereum, + data: serde_json::json!({"from": from, "to": "0x00"}).to_string(), + } + } + + fn sign_message(sign_type: SignDigestType) -> WalletConnectAction { + WalletConnectAction::SignMessage { + chain: Chain::Ethereum, + sign_type, + data: "{}".to_string(), + } + } + + #[test] + fn test_validate_signer_transaction() { + let address = "0x1085c5f70F7F7591D97da281A64688385455c2bD"; + + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction(address)).is_ok()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction(&address.to_lowercase())).is_ok()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction("0xdead")).is_err()); + } + + #[test] + fn test_validate_signer_typed_data() { + let signer = serde_json::json!(["0x1085c5f70f7f7591d97da281a64688385455c2bd", {}]); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &signer, &sign_message(SignDigestType::Eip712)).is_ok()); + + let other_signer = serde_json::json!(["0xdead", {}]); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &other_signer, &sign_message(SignDigestType::Eip712)).is_err()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &sign_message(SignDigestType::Eip712)).is_err()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &other_signer, &sign_message(SignDigestType::Eip191)).is_ok()); + } + + #[test] + fn test_validate_signer_skips_non_ethereum() { + let action = WalletConnectAction::SignTransaction { + chain: Chain::Solana, + transaction_type: SignableTransactionType::Solana { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: "base64".to_string(), + }; + + assert!(validate_signer(TEST_ACCOUNT_SOLANA, &Value::Null, &action).is_ok()); + assert!(validate_signer("not-an-account", &Value::Null, &action).is_err()); + } +} diff --git a/core/crates/payment/testdata/fetch_response_permit2.json b/core/crates/payment/testdata/fetch_response_permit2.json new file mode 100644 index 0000000000..65a20e92ec --- /dev/null +++ b/core/crates/payment/testdata/fetch_response_permit2.json @@ -0,0 +1,30 @@ +{ + "actions": [ + { + "type": "walletRpc", + "data": { + "chain_id": "eip155:137", + "method": "eth_sendTransaction", + "params": [ + { + "from": "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "to": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "value": "0x0", + "data": "0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + } + }, + { + "type": "walletRpc", + "data": { + "chain_id": "eip155:137", + "method": "eth_signTypedData_v4", + "params": [ + "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "{\"domain\":{\"name\":\"Permit2\",\"chainId\":\"0x89\",\"verifyingContract\":\"0x000000000022d473030f116ddee9f6b43ac78ba3\"},\"types\":{\"PermitTransferFrom\":[{\"name\":\"permitted\",\"type\":\"TokenPermissions\"},{\"name\":\"spender\",\"type\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\"}],\"TokenPermissions\":[{\"name\":\"token\",\"type\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\"}]},\"primaryType\":\"PermitTransferFrom\",\"message\":{\"permitted\":{\"token\":\"0xc2132d05d31c914a87c6611c10748aeb04b58e8f\",\"amount\":\"1000000\"},\"spender\":\"0x0000000000a84d1a9b0063a910315c7ffa9cd248\",\"nonce\":\"0x08fda2bbc9b3fcd08a09d38f61b4c56a80a3a5a7e0a050e3a5f9c012b3c60a11\",\"deadline\":\"1785175272\"}}" + ] + } + } + ] +} diff --git a/core/crates/payment/testdata/fetch_response_solana.json b/core/crates/payment/testdata/fetch_response_solana.json new file mode 100644 index 0000000000..c6920791bb --- /dev/null +++ b/core/crates/payment/testdata/fetch_response_solana.json @@ -0,0 +1,16 @@ +{ + "actions": [ + { + "type": "walletRpc", + "data": { + "chain_id": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "method": "solana_signTransaction", + "params": [ + { + "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ] + } + } + ] +} diff --git a/core/crates/payment/testdata/fetch_response_transfer_authorization.json b/core/crates/payment/testdata/fetch_response_transfer_authorization.json new file mode 100644 index 0000000000..4e13de757c --- /dev/null +++ b/core/crates/payment/testdata/fetch_response_transfer_authorization.json @@ -0,0 +1,15 @@ +{ + "actions": [ + { + "type": "walletRpc", + "data": { + "chain_id": "eip155:1", + "method": "eth_signTypedData_v4", + "params": [ + "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "{\"domain\":{\"name\":\"USD Coin\",\"version\":\"2\",\"chainId\":\"0x1\",\"verifyingContract\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\"},\"types\":{\"EIP712Domain\":[{\"type\":\"string\",\"name\":\"name\"},{\"type\":\"string\",\"name\":\"version\"},{\"type\":\"uint256\",\"name\":\"chainId\"},{\"type\":\"address\",\"name\":\"verifyingContract\"}],\"ReceiveWithAuthorization\":[{\"type\":\"address\",\"name\":\"from\"},{\"type\":\"address\",\"name\":\"to\"},{\"type\":\"uint256\",\"name\":\"value\"},{\"type\":\"uint256\",\"name\":\"validAfter\"},{\"type\":\"uint256\",\"name\":\"validBefore\"},{\"type\":\"bytes32\",\"name\":\"nonce\"}]},\"primaryType\":\"ReceiveWithAuthorization\",\"message\":{\"from\":\"0x1085c5f70F7F7591D97da281A64688385455c2bD\",\"nonce\":\"0x1111111111111111111111111111111111111111111111111111111111111111\",\"to\":\"0x2222222222222222222222222222222222222222\",\"validAfter\":\"0x0\",\"validBefore\":\"0x699cb700\",\"value\":\"0x2faf080\"}}" + ] + } + } + ] +} diff --git a/core/crates/payment/testdata/option_collect_data.json b/core/crates/payment/testdata/option_collect_data.json new file mode 100644 index 0000000000..4073ee0bb1 --- /dev/null +++ b/core/crates/payment/testdata/option_collect_data.json @@ -0,0 +1,32 @@ +{ + "id": "opt_ic", + "account": "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD", + "amount": { + "unit": "caip19/eip155:1/slip44:60", + "value": "1064973576179385", + "display": { "assetSymbol": "ETH", "assetName": "Ethereum", "decimals": 18 } + }, + "etaS": 5, + "collectData": { + "url": "https://data-collection.walletconnect.com/ic/pay_123", + "fields": [], + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "fullName": { "type": "string", "title": "Full Name", "minLength": 1, "description": "User's full legal name" }, + "dob": { "type": "string", "format": "date", "title": "Date of Birth", "description": "Date of birth in YYYY-MM-DD format" }, + "pobAddress": { "type": "string", "title": "Place of Birth Address", "maxLength": 200 }, + "pobCountry": { "type": "string", "pattern": "^[A-Z]{2}$", "title": "Place of Birth Country" }, + "porAddress": { "type": "string", "title": "Place of Residence Address", "maxLength": 200 }, + "porCountry": { "type": "string", "pattern": "^[A-Z]{2}$", "title": "Place of Residence Country" }, + "tosConfirmed": { "type": "boolean", "const": true, "title": "Terms of Service Confirmation" } + }, + "required": ["fullName", "dob", "tosConfirmed"], + "anyOf": [ + { "required": ["pobCountry", "pobAddress"] }, + { "required": ["porCountry", "porAddress"] } + ] + } + } +} diff --git a/core/crates/payment/testdata/options_response.json b/core/crates/payment/testdata/options_response.json new file mode 100644 index 0000000000..7916a68335 --- /dev/null +++ b/core/crates/payment/testdata/options_response.json @@ -0,0 +1,43 @@ +{ + "paymentId": "pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4", + "info": { + "status": "requires_action", + "amount": { + "unit": "iso4217/USD", + "value": "5000", + "display": { + "assetSymbol": "USD", + "assetName": "US Dollar", + "decimals": 2 + } + }, + "expiresAt": 1785175272, + "merchant": { + "name": "Gem Wallet Test Merchant", + "iconUrl": "https://imagedelivery.net/example/md" + }, + "buyer": null + }, + "options": [ + { + "id": "opt_1", + "account": "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD", + "amount": { + "unit": "caip19/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "value": "50000000", + "display": { + "assetSymbol": "USDC", + "assetName": "USD Coin", + "decimals": 6, + "networkName": "Ethereum", + "iconUrl": "https://assets.walletconnect.com/usdc.png", + "networkIconUrl": "https://assets.walletconnect.com/ethereum.png" + } + }, + "etaS": 5, + "collectData": null + } + ], + "collectData": null, + "resultInfo": null +} diff --git a/core/crates/payment/testdata/status_response_succeeded.json b/core/crates/payment/testdata/status_response_succeeded.json new file mode 100644 index 0000000000..c6e812a61b --- /dev/null +++ b/core/crates/payment/testdata/status_response_succeeded.json @@ -0,0 +1,16 @@ +{ + "status": "succeeded", + "isFinal": true, + "info": { + "txId": "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4", + "optionAmount": { + "unit": "caip19/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "value": "50000000", + "display": { + "assetSymbol": "USDC", + "assetName": "USD Coin", + "decimals": 6 + } + } + } +} From 4cd599a8f043a1c4faf75f37bf9b4f73318a50c3 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:13:05 +0300 Subject: [PATCH 04/53] core: expose payments through gemstone GemPaymentService carries the gateway flow across the FFI with a typed PaymentError, so a decision core makes reaches the apps as a case they can localize instead of an opaque message. Reading a short app name now takes a name rather than a whole metadata record. --- core/gemstone/Cargo.toml | 3 +- core/gemstone/src/models/transaction.rs | 27 +++- core/gemstone/src/payment/error.rs | 15 ++ core/gemstone/src/payment/mod.rs | 145 ++++++++++++----- core/gemstone/src/payment/remote_types.rs | 152 ++++++++++++++++++ core/gemstone/src/signer/chain.rs | 14 +- core/gemstone/src/url_action.rs | 4 +- core/gemstone/src/wallet_connect/mod.rs | 124 +++++++------- .../gemstone/src/wallet_connect/simulation.rs | 32 ++-- .../src/wallet_connect/simulation_client.rs | 40 +++-- 10 files changed, 404 insertions(+), 152 deletions(-) create mode 100644 core/gemstone/src/payment/error.rs create mode 100644 core/gemstone/src/payment/remote_types.rs diff --git a/core/gemstone/Cargo.toml b/core/gemstone/Cargo.toml index a941a533b1..e76ee4fed5 100644 --- a/core/gemstone/Cargo.toml +++ b/core/gemstone/Cargo.toml @@ -38,9 +38,10 @@ gem_cardano = { path = "../crates/gem_cardano", features = ["rpc", "signer"] } gem_algorand = { path = "../crates/gem_algorand", features = ["rpc", "signer"] } gem_stellar = { path = "../crates/gem_stellar", features = ["rpc", "signer"] } gem_xrp = { path = "../crates/gem_xrp", features = ["rpc", "signer"] } +payment = { path = "../crates/payment" } gem_near = { path = "../crates/gem_near", features = ["rpc", "signer"] } gem_polkadot = { path = "../crates/gem_polkadot", features = ["rpc", "signer"] } -gem_wallet_connect = { path = "../crates/gem_wallet_connect" } +gem_wallet_connect = { path = "../crates/gem_wallet_connect", features = ["session"] } gem_keystore = { path = "../crates/gem_keystore", features = ["v3"] } gem_derivation = { path = "../crates/gem_derivation" } gem_crypto = { path = "../crates/gem_crypto", features = ["random"] } diff --git a/core/gemstone/src/models/transaction.rs b/core/gemstone/src/models/transaction.rs index 9960862bfe..4fef1644e4 100644 --- a/core/gemstone/src/models/transaction.rs +++ b/core/gemstone/src/models/transaction.rs @@ -1,17 +1,18 @@ use crate::address::checksum_address; use crate::models::*; +use swap::GemApprovalData; use chrono::{DateTime, Utc}; use num_bigint::BigInt; use primitives::contract_call_data::ContractCallData; use primitives::{ AccountDataType, Asset, Chain, EarnType, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualProvider, - PerpetualType, Resource, SignerInput, StakeType, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, - TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType, TransactionUpdate, TransferDataExtra, - TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata, + PerpetualType, Resource, SignerInput, StakeType, TransactionAppMetadata, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, + TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType, + TransactionUpdate, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata, perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData}, }; use std::collections::HashMap; -use swap::{GemApprovalData, GemSwapData}; +use swap::GemSwapData; use swapper::SwapperProvider; pub type GemPerpetualDirection = PerpetualDirection; @@ -212,6 +213,16 @@ pub struct GemWalletConnectionSessionAppMetadata { pub icon: String, } +pub type GemTransactionAppMetadata = TransactionAppMetadata; + +#[uniffi::remote(Record)] +pub struct GemTransactionAppMetadata { + pub name: String, + pub description: Option, + pub url: Option, + pub icon: Option, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct GemTransferDataExtra { pub to: String, @@ -310,7 +321,7 @@ pub enum GemTransactionInputType { }, Generic { asset: GemAsset, - metadata: GemWalletConnectionSessionAppMetadata, + app_metadata: GemTransactionAppMetadata, extra: GemTransferDataExtra, }, TransferNft { @@ -573,9 +584,9 @@ impl From for GemTransactionInputType { stake_type: stake_type.into(), }, TransactionInputType::TokenApprove(asset, approval_data) => GemTransactionInputType::TokenApprove { asset, approval_data }, - TransactionInputType::Generic(asset, metadata, extra) => GemTransactionInputType::Generic { + TransactionInputType::Generic(asset, app_metadata, extra) => GemTransactionInputType::Generic { asset, - metadata, + app_metadata, extra: extra.into(), }, TransactionInputType::TransferNft(asset, nft_asset) => GemTransactionInputType::TransferNft { asset, nft_asset }, @@ -727,7 +738,7 @@ impl From for TransactionInputType { is_unlimited: approval_data.is_unlimited, }, ), - GemTransactionInputType::Generic { asset, metadata, extra } => TransactionInputType::Generic(asset, metadata, extra.into()), + GemTransactionInputType::Generic { asset, app_metadata, extra } => TransactionInputType::Generic(asset, app_metadata, extra.into()), GemTransactionInputType::TransferNft { asset, nft_asset } => TransactionInputType::TransferNft(asset, nft_asset), GemTransactionInputType::Account { asset, account_type } => TransactionInputType::Account(asset, account_type), GemTransactionInputType::Perpetual { asset, perpetual_type } => TransactionInputType::Perpetual(asset, perpetual_type), diff --git a/core/gemstone/src/payment/error.rs b/core/gemstone/src/payment/error.rs new file mode 100644 index 0000000000..61d1549542 --- /dev/null +++ b/core/gemstone/src/payment/error.rs @@ -0,0 +1,15 @@ +pub type PaymentError = payment::PaymentError; + +#[uniffi::remote(Enum)] +pub enum PaymentError { + NotSupported, + PaymentNotFound, + PaymentExpired, + QuoteExpired, + NoPaymentOptions, + UnsupportedAccounts, + Rejected, + RateLimited, + InvalidRequest(String), + Network(String), +} diff --git a/core/gemstone/src/payment/mod.rs b/core/gemstone/src/payment/mod.rs index feb4124d31..00c06599b9 100644 --- a/core/gemstone/src/payment/mod.rs +++ b/core/gemstone/src/payment/mod.rs @@ -1,43 +1,114 @@ -use crate::GemstoneError; -use primitives::PaymentURLDecoder; +pub mod error; +pub mod remote_types; -#[derive(Debug, Clone, PartialEq, uniffi::Record)] -pub struct PaymentWrapper { - pub address: String, - pub amount: Option, - pub memo: Option, - pub asset_id: Option, - pub payment_link: Option, +use std::sync::Arc; + +use payment::PaymentConfig; +use payment::WalletConnectPayAuth; +use payment::{PaymentAction as CorePaymentAction, PaymentService}; +use primitives::{Chain, ChainAddress, PaymentLink, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::alien::{AlienProvider, AlienProviderWrapper}; +use crate::message::sign_type::SignMessage; +use crate::models::swap::GemApprovalData; +use crate::payment::error::PaymentError; +use crate::wallet_connect::SignableTransaction; + +#[derive(Debug, uniffi::Record)] +pub struct GemPreparedPayment { + pub quotes: PaymentQuotes, + pub quote: PaymentQuote, + pub actions: Vec, +} + +#[derive(Debug, uniffi::Enum)] +#[allow(clippy::large_enum_variant)] +pub enum PaymentAction { + SignMessage { message: SignMessage }, + SignTransaction { chain: Chain, transaction: SignableTransaction }, + SendTransaction { chain: Chain, transaction: SignableTransaction }, + ApproveToken { chain: Chain, approval: GemApprovalData }, +} + +impl From for PaymentAction { + fn from(action: CorePaymentAction) -> Self { + match action { + CorePaymentAction::SignMessage { message } => Self::SignMessage { message: message.into() }, + CorePaymentAction::SignTransaction { chain, transaction } => Self::SignTransaction { + chain, + transaction: transaction.into(), + }, + CorePaymentAction::SendTransaction { chain, transaction } => Self::SendTransaction { + chain, + transaction: transaction.into(), + }, + CorePaymentAction::ApproveToken { chain, approval } => Self::ApproveToken { chain, approval }, + } + } +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemWalletConnectPayAuth { + pub app_id: String, + pub client_id: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct GemPaymentConfig { + pub wallet_connect_pay: GemWalletConnectPayAuth, +} + +impl From for PaymentConfig { + fn from(config: GemPaymentConfig) -> Self { + PaymentConfig::new(WalletConnectPayAuth::new( + config.wallet_connect_pay.app_id, + config.wallet_connect_pay.client_id, + )) + } +} + +#[derive(uniffi::Object)] +pub struct GemPaymentService { + service: PaymentService, } -/// Exports functions #[uniffi::export] -pub fn payment_decode_url(string: &str) -> Result { - let payment = PaymentURLDecoder::decode(string)?; - Ok(PaymentWrapper { - address: payment.address, - amount: payment.amount, - memo: payment.memo, - asset_id: payment.asset_id.map(|c| c.to_string()), - payment_link: payment.link.map(|c| c.to_string()), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_address() { - assert_eq!( - payment_decode_url("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(), - PaymentWrapper { - address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(), - amount: Some("0.42301".to_string()), - memo: None, - asset_id: Some("solana".to_string()), - payment_link: None, - } - ); +impl GemPaymentService { + #[uniffi::constructor] + pub fn new(provider: Arc, config: GemPaymentConfig) -> Self { + Self { + service: PaymentService::new(Arc::new(AlienProviderWrapper::new(provider)), config.into()), + } + } + + pub async fn get_payment_options(&self, link: PaymentLink, addresses: Vec) -> Result { + self.service.get_options(&link, &addresses).await + } + + pub async fn get_prepared_payment( + &self, + provider: PaymentProviderName, + quotes: PaymentQuotes, + quote: PaymentQuote, + addresses: Vec, + ) -> Result { + let payment = self.service.get_prepared_payment(provider, "es, "e, &addresses).await?; + Ok(GemPreparedPayment { + 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 c325b67260..71ddb6b691 100644 --- a/core/gemstone/src/signer/chain.rs +++ b/core/gemstone/src/signer/chain.rs @@ -208,8 +208,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::{TEST_EVM_RECIPIENT, TEST_PRIVATE_KEY}; use primitives::{ - DelegationValidator, StakeType, SwapProvider, TransactionFee, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputType, - WalletConnectionSessionAppMetadata, contract_call_data::ContractCallData, nft::NFTAsset, + DelegationValidator, StakeType, SwapProvider, TransactionAppMetadata, TransactionFee, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, + TransferDataOutputType, contract_call_data::ContractCallData, nft::NFTAsset, }; #[test] @@ -226,13 +226,13 @@ mod tests { fn test_sign_input_ton_wallet_connect() { let request = include_str!("../../../crates/gem_ton/testdata/wallet_connect_dedust_send_message.json"); let transaction = gem_wallet_connect::WalletConnectRequestHandler::decode_send_transaction( - gem_wallet_connect::WalletConnectTransactionType::Ton { + primitives::SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }, request.to_string(), ) .unwrap(); - let gem_wallet_connect::WalletConnectTransaction::Ton { data, .. } = transaction else { + let primitives::SignableTransaction::Ton { data, .. } = transaction else { panic!("expected TON transaction"); }; let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").unwrap(); @@ -272,11 +272,7 @@ mod tests { assert_eq!(sign_one(nft.clone()), vec![signer.sign_nft_transfer(nft, key.clone()).unwrap()]); let generic: GemSignerInput = SignerInput::mock_evm( - TransactionInputType::Generic( - Asset::mock(), - WalletConnectionSessionAppMetadata::mock(), - TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]), - ), + TransactionInputType::Generic(Asset::mock(), TransactionAppMetadata::mock(), TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd])), "0", 100000, ) diff --git a/core/gemstone/src/url_action.rs b/core/gemstone/src/url_action.rs index 8a6d187d1d..d0e449d587 100644 --- a/core/gemstone/src/url_action.rs +++ b/core/gemstone/src/url_action.rs @@ -1,8 +1,9 @@ -use primitives::{Deeplink, UrlAction, WalletConnectLink}; +use primitives::{Deeplink, PaymentLink, UrlAction, WalletConnectLink}; #[uniffi::remote(Enum)] pub enum UrlAction { Deeplink { deeplink: Deeplink }, + Payment { link: PaymentLink }, WalletConnect { link: WalletConnectLink }, } @@ -19,6 +20,7 @@ mod tests { fn test_url_action() { assert!(matches!(url_action("https://gemwallet.com/tokens/bitcoin"), Some(UrlAction::Deeplink { .. }))); assert!(matches!(url_action("gem://wc?sessionTopic=abc"), Some(UrlAction::WalletConnect { .. }))); + assert!(matches!(url_action("https://pay.walletconnect.com/?pid=pay_123"), Some(UrlAction::Payment { .. }))); assert_eq!(url_action("https://example.com"), None); } } diff --git a/core/gemstone/src/wallet_connect/mod.rs b/core/gemstone/src/wallet_connect/mod.rs index 31717480e9..df865d14c4 100644 --- a/core/gemstone/src/wallet_connect/mod.rs +++ b/core/gemstone/src/wallet_connect/mod.rs @@ -1,12 +1,14 @@ use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectAction as WcWalletConnectAction, - WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler, - WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectTransaction as WcWalletConnectTransaction, - WalletConnectTransactionType as WcWalletConnectTransactionType, WalletConnectVerifier, config_session_properties, + WalletConnectAction as WcWalletConnectAction, WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler, + WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectVerifier, config_session_properties, }; use primitives::{ Account, Chain, ChainAddress, TransactionType, TransferDataOutputType, WCEthereumTransaction, WalletConnectCAIP2, WalletConnectLink, WalletConnectRequest, - WalletConnectionVerificationStatus, + WalletConnectionVerificationStatus, wallet_connector::short_name, +}; +use primitives::{ + EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignMessage as CoreSignMessage, SignableTransaction as CoreSignableTransaction, + SignableTransactionType as CoreSignableTransactionType, }; use std::collections::HashMap; use std::str::FromStr; @@ -37,7 +39,7 @@ pub enum WalletConnectLink { } #[derive(Debug, Clone, uniffi::Record)] -pub struct WCEthereumTransactionData { +pub struct EthereumTransactionData { pub chain_id: Option, pub from: String, pub to: String, @@ -60,12 +62,12 @@ pub struct Account { } #[derive(Debug, Clone, uniffi::Record)] -pub struct WCSolanaTransactionData { +pub struct SolanaTransactionData { pub transaction: String, } #[derive(Debug, Clone, uniffi::Record)] -pub struct WCSuiTransactionData { +pub struct SuiTransactionData { pub transaction: String, pub wallet_address: String, } @@ -79,17 +81,17 @@ pub enum WalletConnectAction { }, SignTransaction { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, data: String, }, SignAllTransactions { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, transactions: Vec, }, SendTransaction { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, data: String, }, ChainOperation { @@ -104,7 +106,7 @@ pub enum WalletConnectAction { } #[derive(Debug, Clone, PartialEq, uniffi::Enum)] -pub enum WalletConnectTransactionType { +pub enum SignableTransactionType { Ethereum, Solana { output_type: TransferDataOutputType }, Sui { output_type: TransferDataOutputType }, @@ -121,17 +123,17 @@ pub enum WalletConnectChainOperation { #[derive(Debug, Clone, uniffi::Enum)] #[allow(clippy::large_enum_variant)] -pub enum WalletConnectTransaction { +pub enum SignableTransaction { Ethereum { - data: WCEthereumTransactionData, + data: EthereumTransactionData, transaction_type: TransactionType, }, Solana { - data: WCSolanaTransactionData, + data: SolanaTransactionData, output_type: TransferDataOutputType, }, Sui { - data: WCSuiTransactionData, + data: SuiTransactionData, output_type: TransferDataOutputType, }, Ton { @@ -152,7 +154,7 @@ pub enum WalletConnectResponseType { // From conversions: primitives -> UniFFI -impl From for WCEthereumTransactionData { +impl From for EthereumTransactionData { fn from(transaction: WCEthereumTransaction) -> Self { Self { chain_id: transaction.chain_id, @@ -172,21 +174,31 @@ impl From for WCEthereumTransactionData { // From conversions: gem_wallet_connect -> UniFFI -impl From for SignDigestType { - fn from(t: WcSignDigestType) -> Self { +impl From for SignMessage { + fn from(message: CoreSignMessage) -> Self { + Self { + chain: message.chain, + sign_type: message.sign_type.into(), + data: message.data, + } + } +} + +impl From for SignDigestType { + fn from(t: CoreSignDigestType) -> Self { match t { - WcSignDigestType::Eip191 => Self::Eip191, - WcSignDigestType::Eip712 => Self::Eip712, - WcSignDigestType::Base58 => Self::Base58, - WcSignDigestType::SuiPersonal => Self::SuiPersonal, - WcSignDigestType::Siwe => Self::Siwe, - WcSignDigestType::TonPersonal => Self::TonPersonal, - WcSignDigestType::TronPersonal => Self::TronPersonal, + CoreSignDigestType::Eip191 => Self::Eip191, + CoreSignDigestType::Eip712 => Self::Eip712, + CoreSignDigestType::Base58 => Self::Base58, + CoreSignDigestType::SuiPersonal => Self::SuiPersonal, + CoreSignDigestType::Siwe => Self::Siwe, + CoreSignDigestType::TonPersonal => Self::TonPersonal, + CoreSignDigestType::TronPersonal => Self::TronPersonal, } } } -impl From for WcSignDigestType { +impl From for CoreSignDigestType { fn from(t: SignDigestType) -> Self { match t { SignDigestType::Eip191 => Self::Eip191, @@ -200,26 +212,26 @@ impl From for WcSignDigestType { } } -impl From for WalletConnectTransactionType { - fn from(t: WcWalletConnectTransactionType) -> Self { +impl From for SignableTransactionType { + fn from(t: CoreSignableTransactionType) -> Self { match t { - WcWalletConnectTransactionType::Ethereum => Self::Ethereum, - WcWalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, - WcWalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, - WcWalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, - WcWalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + CoreSignableTransactionType::Ethereum => Self::Ethereum, + CoreSignableTransactionType::Solana { output_type } => Self::Solana { output_type }, + CoreSignableTransactionType::Sui { output_type } => Self::Sui { output_type }, + CoreSignableTransactionType::Ton { output_type } => Self::Ton { output_type }, + CoreSignableTransactionType::Tron { output_type } => Self::Tron { output_type }, } } } -impl From for WcWalletConnectTransactionType { - fn from(t: WalletConnectTransactionType) -> Self { +impl From for CoreSignableTransactionType { + fn from(t: SignableTransactionType) -> Self { match t { - WalletConnectTransactionType::Ethereum => Self::Ethereum, - WalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, - WalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, - WalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, - WalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + SignableTransactionType::Ethereum => Self::Ethereum, + SignableTransactionType::Solana { output_type } => Self::Solana { output_type }, + SignableTransactionType::Sui { output_type } => Self::Sui { output_type }, + SignableTransactionType::Ton { output_type } => Self::Ton { output_type }, + SignableTransactionType::Tron { output_type } => Self::Tron { output_type }, } } } @@ -268,8 +280,8 @@ impl From for WalletConnectAction { } } -impl From for WCEthereumTransactionData { - fn from(d: WcEthereumTransactionData) -> Self { +impl From for EthereumTransactionData { + fn from(d: CoreEthereumTransactionData) -> Self { Self { chain_id: d.chain_id, from: d.from, @@ -286,26 +298,26 @@ impl From for WCEthereumTransactionData { } } -impl From for WalletConnectTransaction { - fn from(t: WcWalletConnectTransaction) -> Self { +impl From for SignableTransaction { + fn from(t: CoreSignableTransaction) -> Self { match t { - WcWalletConnectTransaction::Ethereum { data, transaction_type } => Self::Ethereum { + CoreSignableTransaction::Ethereum { data, transaction_type } => Self::Ethereum { data: data.into(), transaction_type, }, - WcWalletConnectTransaction::Solana { data, output_type } => Self::Solana { - data: WCSolanaTransactionData { transaction: data.transaction }, + CoreSignableTransaction::Solana { data, output_type } => Self::Solana { + data: SolanaTransactionData { transaction: data.transaction }, output_type, }, - WcWalletConnectTransaction::Sui { data, output_type } => Self::Sui { - data: WCSuiTransactionData { + CoreSignableTransaction::Sui { data, output_type } => Self::Sui { + data: SuiTransactionData { transaction: data.transaction, wallet_address: data.wallet_address, }, output_type, }, - WcWalletConnectTransaction::Ton { data, output_type } => Self::Ton { data, output_type }, - WcWalletConnectTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, + CoreSignableTransaction::Ton { data, output_type } => Self::Ton { data, output_type }, + CoreSignableTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, } } } @@ -403,16 +415,16 @@ impl WalletConnect { config_session_properties(properties, &chains, &accounts) } - pub fn decode_send_transaction(&self, transaction_type: WalletConnectTransactionType, data: String) -> Result { - let wc_type: WcWalletConnectTransactionType = transaction_type.into(); + pub fn decode_send_transaction(&self, transaction_type: SignableTransactionType, data: String) -> Result { + let wc_type: CoreSignableTransactionType = transaction_type.into(); let wc_result = WalletConnectRequestHandler::decode_send_transaction(wc_type, data).map_err(|e| GemstoneError::AnyError { msg: e })?; Ok(wc_result.into()) } } #[uniffi::export] -pub fn wallet_connect_app_short_name(metadata: primitives::WalletConnectionSessionAppMetadata) -> String { - metadata.short_name() +pub fn wallet_connect_app_short_name(name: String) -> String { + short_name(&name) } #[cfg(test)] diff --git a/core/gemstone/src/wallet_connect/simulation.rs b/core/gemstone/src/wallet_connect/simulation.rs index 86e7939db2..cab6292b92 100644 --- a/core/gemstone/src/wallet_connect/simulation.rs +++ b/core/gemstone/src/wallet_connect/simulation.rs @@ -1,28 +1,22 @@ -use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, SignMessageValidation, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectRequestHandler, - WalletConnectTransaction as WcWalletConnectTransaction, WalletConnectTransactionType as WcWalletConnectTransactionType, decode_sign_message, validate_send_transaction, - validate_sign_message, -}; +use gem_wallet_connect::{SignMessageValidation, WalletConnectRequestHandler, decode_sign_message, validate_send_transaction, validate_sign_message}; use primitives::{Chain, SimulationWarning, hex}; +use primitives::{ + EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignableTransaction as CoreSignableTransaction, + SignableTransactionType as CoreSignableTransactionType, +}; use crate::message::sign_type::{SignDigestType, SignMessage}; pub fn decode_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { - let sign_type: WcSignDigestType = sign_type.into(); - let result = decode_sign_message(chain, sign_type, data); - - SignMessage { - chain: result.chain, - sign_type: result.sign_type.into(), - data: result.data, - } + let sign_type: CoreSignDigestType = sign_type.into(); + decode_sign_message(chain, sign_type, data).into() } pub(super) fn parse_eip712_message(data: &str) -> Option { serde_json::from_str(data).ok().and_then(|value| gem_evm::eip712::parse_eip712_json(&value).ok()) } -pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignDigestType, data: &str, session_domain: &str) -> Vec { +pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &CoreSignDigestType, data: &str, session_domain: &str) -> Vec { let input = SignMessageValidation { chain, sign_type, @@ -33,7 +27,7 @@ pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignD validate_sign_message(&input).err().into_iter().map(SimulationWarning::validation_error).collect() } -pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletConnectTransactionType, data: &str) -> Vec { +pub(super) fn send_transaction_validation_warnings(transaction_type: &CoreSignableTransactionType, data: &str) -> Vec { validate_send_transaction(transaction_type, data) .err() .into_iter() @@ -41,15 +35,15 @@ pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletCo .collect() } -pub(super) fn decode_ethereum_transaction(data: &str) -> Result { - let transaction = WalletConnectRequestHandler::decode_send_transaction(WcWalletConnectTransactionType::Ethereum, data.to_string())?; +pub(super) fn decode_ethereum_transaction(data: &str) -> Result { + let transaction = WalletConnectRequestHandler::decode_send_transaction(CoreSignableTransactionType::Ethereum, data.to_string())?; match transaction { - WcWalletConnectTransaction::Ethereum { data, .. } => Ok(data), + CoreSignableTransaction::Ethereum { data, .. } => Ok(data), _ => Err("Invalid Ethereum transaction".to_string()), } } -pub(super) fn decode_ethereum_calldata(transaction: &WcEthereumTransactionData) -> Vec { +pub(super) fn decode_ethereum_calldata(transaction: &CoreEthereumTransactionData) -> Vec { transaction.data.as_deref().and_then(|calldata| hex::decode_hex(calldata).ok()).unwrap_or_default() } diff --git a/core/gemstone/src/wallet_connect/simulation_client.rs b/core/gemstone/src/wallet_connect/simulation_client.rs index e5a9bf3c11..f730e65f6b 100644 --- a/core/gemstone/src/wallet_connect/simulation_client.rs +++ b/core/gemstone/src/wallet_connect/simulation_client.rs @@ -9,10 +9,8 @@ use gem_solana::rpc::{SolanaClient, SolanaProvider}; use gem_sui::rpc::{SuiClient, SuiProvider}; use gem_ton::rpc::client::TonClient; use gem_tron::rpc::{TronProvider, client::TronClient}; -use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectTransactionType as WcWalletConnectTransactionType, -}; use primitives::{Chain, EVMChain, SimulationInput, SimulationResult}; +use primitives::{EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignableTransactionType as CoreSignableTransactionType}; use crate::{ GemstoneError, @@ -21,7 +19,7 @@ use crate::{ network::JsonRpcClient, }; -use super::{WalletConnectTransactionType, simulation}; +use super::{SignableTransactionType, simulation}; #[derive(uniffi::Object)] pub struct WalletConnectSimulationClient { @@ -38,11 +36,11 @@ impl WalletConnectSimulationClient { } pub async fn simulate_sign_message(&self, chain: Chain, sign_type: SignDigestType, data: String, session_domain: String) -> Result { - let sign_type: WcSignDigestType = sign_type.into(); + let sign_type: CoreSignDigestType = sign_type.into(); let validation_warnings = simulation::sign_message_validation_warnings(chain, &sign_type, &data, &session_domain); let simulation = match sign_type { - WcSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { + CoreSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { Some(message) => self.simulate_eip712_message(chain, &message).await?, None => SimulationResult::default(), }, @@ -52,15 +50,15 @@ impl WalletConnectSimulationClient { Ok(simulation.prepend_warnings(validation_warnings)) } - pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: WalletConnectTransactionType, data: String) -> Result { - let transaction_type: WcWalletConnectTransactionType = transaction_type.into(); + pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: SignableTransactionType, data: String) -> Result { + let transaction_type: CoreSignableTransactionType = transaction_type.into(); let validation_warnings = simulation::send_transaction_validation_warnings(&transaction_type, &data); let simulation = match &transaction_type { - WcWalletConnectTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await, - WcWalletConnectTransactionType::Solana { .. } | WcWalletConnectTransactionType::Sui { .. } => self.simulate_encoded_transaction(&transaction_type, &data).await, - WcWalletConnectTransactionType::Ton { .. } => self.simulate_chain_transaction(Chain::Ton, SimulationInput::new(&data)).await, - WcWalletConnectTransactionType::Tron { .. } => self.simulate_chain_transaction(Chain::Tron, SimulationInput::new(&data)).await, + CoreSignableTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await, + CoreSignableTransactionType::Solana { .. } | CoreSignableTransactionType::Sui { .. } => self.simulate_encoded_transaction(&transaction_type, &data).await, + CoreSignableTransactionType::Ton { .. } => self.simulate_chain_transaction(Chain::Ton, SimulationInput::new(&data)).await, + CoreSignableTransactionType::Tron { .. } => self.simulate_chain_transaction(Chain::Tron, SimulationInput::new(&data)).await, } .unwrap_or_default(); @@ -99,7 +97,7 @@ impl WalletConnectSimulationClient { chain: Chain, calldata: &[u8], provider: &EthereumProvider, - transaction: &WcEthereumTransactionData, + transaction: &CoreEthereumTransactionData, ) -> (Result, Result) { let calldata_task = async { if calldata.is_empty() { @@ -117,17 +115,17 @@ impl WalletConnectSimulationClient { async fn simulate_ethereum_balance_changes( &self, provider: &EthereumProvider, - transaction: &WcEthereumTransactionData, + transaction: &CoreEthereumTransactionData, ) -> Result { let encoded_transaction = serde_json::to_string(&map_transaction_object(transaction)).map_err(|error| error.to_string())?; Ok(provider.simulate_transaction(SimulationInput::new(encoded_transaction)).await?) } - async fn simulate_encoded_transaction(&self, transaction_type: &WcWalletConnectTransactionType, data: &str) -> Result { + async fn simulate_encoded_transaction(&self, transaction_type: &CoreSignableTransactionType, data: &str) -> Result { let chain = match transaction_type { - WcWalletConnectTransactionType::Solana { .. } => Chain::Solana, - WcWalletConnectTransactionType::Sui { .. } => Chain::Sui, + CoreSignableTransactionType::Solana { .. } => Chain::Solana, + CoreSignableTransactionType::Sui { .. } => Chain::Sui, _ => return Err("Chain does not use encoded transaction simulation".into()), }; let input: SimulationInput = serde_json::from_str(data).map_err(|error| error.to_string())?; @@ -162,7 +160,7 @@ impl WalletConnectSimulationClient { } /// Keeps the gas limit so out-of-gas failures surface, but omits fee prices - they make the trace charge gas and leak fee accounting into the signer's balance diff. -fn map_transaction_object(transaction: &WcEthereumTransactionData) -> TransactionObject { +fn map_transaction_object(transaction: &CoreEthereumTransactionData) -> TransactionObject { TransactionObject { from: Some(transaction.from.clone()), to: transaction.to.clone(), @@ -180,8 +178,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER}; - fn mock_wc_transaction() -> WcEthereumTransactionData { - WcEthereumTransactionData { + fn mock_wc_transaction() -> CoreEthereumTransactionData { + CoreEthereumTransactionData { chain_id: None, from: TEST_EVM_SENDER.to_string(), to: TEST_EVM_RECIPIENT.to_string(), @@ -205,7 +203,7 @@ mod tests { #[test] fn test_map_transaction_object_passes_gas_limit_and_omits_fee_prices() { - let transaction = WcEthereumTransactionData { + let transaction = CoreEthereumTransactionData { gas_limit: Some("0x5208".to_string()), gas_price: Some("0x9502f900".to_string()), max_fee_per_gas: Some("0x59682f10".to_string()), From 00998b36a613a962064166c24d8d03216ea3f883 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:15:27 +0300 Subject: [PATCH 05/53] Localize the payment link strings Six keys covering the payment sheet, merchant, expiry and the two refusal errors. Generation requires every language to carry the same key set, so the translations land with the English source. --- android/ui/src/main/res/values-ar/strings.xml | 6 ++++++ android/ui/src/main/res/values-bn/strings.xml | 6 ++++++ android/ui/src/main/res/values-cs/strings.xml | 6 ++++++ android/ui/src/main/res/values-da/strings.xml | 6 ++++++ android/ui/src/main/res/values-de/strings.xml | 6 ++++++ android/ui/src/main/res/values-es/strings.xml | 6 ++++++ android/ui/src/main/res/values-fa/strings.xml | 6 ++++++ android/ui/src/main/res/values-fil/strings.xml | 6 ++++++ android/ui/src/main/res/values-fr/strings.xml | 6 ++++++ android/ui/src/main/res/values-ha/strings.xml | 6 ++++++ android/ui/src/main/res/values-hi/strings.xml | 6 ++++++ android/ui/src/main/res/values-id/strings.xml | 6 ++++++ android/ui/src/main/res/values-it/strings.xml | 6 ++++++ android/ui/src/main/res/values-iw/strings.xml | 6 ++++++ android/ui/src/main/res/values-ja/strings.xml | 6 ++++++ android/ui/src/main/res/values-ko/strings.xml | 6 ++++++ android/ui/src/main/res/values-ms/strings.xml | 6 ++++++ android/ui/src/main/res/values-nl/strings.xml | 6 ++++++ android/ui/src/main/res/values-pl/strings.xml | 6 ++++++ android/ui/src/main/res/values-pt-rBR/strings.xml | 6 ++++++ android/ui/src/main/res/values-ro/strings.xml | 6 ++++++ android/ui/src/main/res/values-ru/strings.xml | 6 ++++++ android/ui/src/main/res/values-sw/strings.xml | 6 ++++++ android/ui/src/main/res/values-th/strings.xml | 6 ++++++ android/ui/src/main/res/values-tr/strings.xml | 6 ++++++ android/ui/src/main/res/values-uk/strings.xml | 6 ++++++ android/ui/src/main/res/values-ur/strings.xml | 6 ++++++ android/ui/src/main/res/values-vi/strings.xml | 6 ++++++ android/ui/src/main/res/values-zh-rCN/strings.xml | 6 ++++++ android/ui/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ android/ui/src/main/res/values/strings.xml | 6 ++++++ ios/Packages/Localization/Sources/Localized.swift | 12 ++++++++++++ .../Resources/ar.lproj/Localizable.strings | 6 ++++++ .../Resources/bn.lproj/Localizable.strings | 6 ++++++ .../Resources/cs.lproj/Localizable.strings | 6 ++++++ .../Resources/da.lproj/Localizable.strings | 6 ++++++ .../Resources/de.lproj/Localizable.strings | 6 ++++++ .../Resources/en.lproj/Localizable.strings | 6 ++++++ .../Resources/es.lproj/Localizable.strings | 6 ++++++ .../Resources/fa.lproj/Localizable.strings | 6 ++++++ .../Resources/fil.lproj/Localizable.strings | 6 ++++++ .../Resources/fr.lproj/Localizable.strings | 6 ++++++ .../Resources/ha.lproj/Localizable.strings | 6 ++++++ .../Resources/he.lproj/Localizable.strings | 6 ++++++ .../Resources/hi.lproj/Localizable.strings | 6 ++++++ .../Resources/id.lproj/Localizable.strings | 6 ++++++ .../Resources/it.lproj/Localizable.strings | 6 ++++++ .../Resources/ja.lproj/Localizable.strings | 6 ++++++ .../Resources/ko.lproj/Localizable.strings | 6 ++++++ .../Resources/ms.lproj/Localizable.strings | 6 ++++++ .../Resources/nl.lproj/Localizable.strings | 6 ++++++ .../Resources/pl.lproj/Localizable.strings | 6 ++++++ .../Resources/pt-BR.lproj/Localizable.strings | 6 ++++++ .../Resources/ro.lproj/Localizable.strings | 6 ++++++ .../Resources/ru.lproj/Localizable.strings | 6 ++++++ .../Resources/sw.lproj/Localizable.strings | 6 ++++++ .../Resources/th.lproj/Localizable.strings | 6 ++++++ .../Resources/tr.lproj/Localizable.strings | 6 ++++++ .../Resources/uk.lproj/Localizable.strings | 6 ++++++ .../Resources/ur.lproj/Localizable.strings | 6 ++++++ .../Resources/vi.lproj/Localizable.strings | 6 ++++++ .../Resources/zh-Hans.lproj/Localizable.strings | 6 ++++++ .../Resources/zh-Hant.lproj/Localizable.strings | 6 ++++++ localization/app/ar.ftl | 9 +++++++++ localization/app/bn.ftl | 9 +++++++++ localization/app/cs.ftl | 9 +++++++++ localization/app/da.ftl | 9 +++++++++ localization/app/de.ftl | 9 +++++++++ localization/app/en.ftl | 15 +++++++++++++++ localization/app/es.ftl | 9 +++++++++ localization/app/fa.ftl | 9 +++++++++ localization/app/fil.ftl | 9 +++++++++ localization/app/fr.ftl | 9 +++++++++ localization/app/ha.ftl | 9 +++++++++ localization/app/he.ftl | 9 +++++++++ localization/app/hi.ftl | 9 +++++++++ localization/app/id.ftl | 9 +++++++++ localization/app/it.ftl | 9 +++++++++ localization/app/ja.ftl | 9 +++++++++ localization/app/ko.ftl | 9 +++++++++ localization/app/ms.ftl | 9 +++++++++ localization/app/nl.ftl | 9 +++++++++ localization/app/pl.ftl | 9 +++++++++ localization/app/pt-BR.ftl | 9 +++++++++ localization/app/ro.ftl | 9 +++++++++ localization/app/ru.ftl | 9 +++++++++ localization/app/sw.ftl | 9 +++++++++ localization/app/th.ftl | 9 +++++++++ localization/app/tr.ftl | 9 +++++++++ localization/app/uk.ftl | 9 +++++++++ localization/app/ur.ftl | 9 +++++++++ localization/app/vi.ftl | 9 +++++++++ localization/app/zh-Hans.ftl | 9 +++++++++ localization/app/zh-Hant.ftl | 9 +++++++++ 94 files changed, 669 insertions(+) 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/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/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 = 將支付 From 08992a0cf237c1661756e9eb36e02ccbbd8cf6c6 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:15:52 +0300 Subject: [PATCH 06/53] iOS: map the payment models and shared rows Mappers keep Gemstone out of feature code, so a scanned link arrives as a Primitives value. Adds the expiry row and the web view the compliance step needs, plus the TestKit fixtures the payment suites build on. --- .../Protocols/SelectableListAdoptable.swift | 4 +- .../SearchableSelectableListView.swift | 1 + .../ListViews/SelectableListView.swift | 1 + .../Sources/Lists/ListItemExpiryView.swift | 45 +++++++ .../Components/Sources/SelectableSheet.swift | 2 +- .../Components/Sources/WebView/WebView.swift | 98 +++++++++++++++ .../ChainAddress+GemstonePrimitives.swift | 11 ++ .../GemPayment+GemstonePrimitives.swift | 55 ++++++++ ...GemPaymentOptions+GemstonePrimitives.swift | 117 ++++++++++++++++++ ...nsactionInputType+GemstonePrimitives.swift | 10 +- ...essionAppMetadata+GemstonePrimitives.swift | 2 +- ...actionAppMetadata+GemstonePrimitives.swift | 27 ++++ .../TransferDataType+GemstonePrimitives.swift | 3 +- .../UrlAction+GemstonePrimitives.swift | 1 + .../Sources/PaymentURLDecoder.swift | 6 +- .../Sources/URLParser.swift | 10 +- .../PaymentURLDecoderTests.swift | 37 +++--- ios/Packages/Primitives/Sources/Action.swift | 1 + .../Extensions/Account+Primitives.swift | 6 + .../Sources/Extensions/Date+Primitives.swift | 8 ++ .../SimulationResult+Primitives.swift | 12 ++ .../Extensions/Transaction+Primitives.swift | 11 ++ .../TransactionAppMetadata+Primitives.swift | 17 +++ .../Extensions/Wallet+Primitives.swift | 4 + .../Primitives/Sources/PaymentData.swift | 27 ++++ .../Protocols/PaymentLinkPayable.swift | 7 ++ .../Sources/SigningRequestError.swift | 7 ++ .../Sources/TransactionExtendedMetadata.swift | 4 + .../Primitives/Sources/TransferDataType.swift | 25 +++- .../Primitives/Sources/URLAction.swift | 1 + .../TestKit/Payment+PrimitivesTestKit.swift | 92 ++++++++++++++ .../TestKit/PaymentLinkPayableMock.swift | 16 +++ .../Transaction+PrimitivesTestKit.swift | 3 +- ...sactionAppMetadata+PrimitivesTestKit.swift | 15 +++ .../Types/TransactionHeaderBuilder.swift | 3 +- .../Sources/ViewModels/AppPreviewModel.swift | 15 +++ .../Sources/ViewModels/ExpiryCountdown.swift | 23 ++++ .../Sources/ViewModels/PaymentQuoteItem.swift | 92 ++++++++++++++ .../ViewModels/TransactionViewModel.swift | 10 ++ .../TransactionViewModelTests.swift | 30 +++++ 40 files changed, 816 insertions(+), 43 deletions(-) create mode 100644 ios/Packages/Components/Sources/Lists/ListItemExpiryView.swift create mode 100644 ios/Packages/Components/Sources/WebView/WebView.swift create mode 100644 ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift create mode 100644 ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift create mode 100644 ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift create mode 100644 ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift create mode 100644 ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift create mode 100644 ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift create mode 100644 ios/Packages/Primitives/Sources/PaymentData.swift create mode 100644 ios/Packages/Primitives/Sources/Protocols/PaymentLinkPayable.swift create mode 100644 ios/Packages/Primitives/Sources/SigningRequestError.swift create mode 100644 ios/Packages/Primitives/TestKit/Payment+PrimitivesTestKit.swift create mode 100644 ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift create mode 100644 ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift create mode 100644 ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift create mode 100644 ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift create mode 100644 ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift create mode 100644 ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift 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/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift new file mode 100644 index 0000000000..7d631deaf8 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension Primitives.ChainAddress { + func map() -> Gemstone.ChainAddress { + Gemstone.ChainAddress(chain: chain.rawValue, address: address) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift new file mode 100644 index 0000000000..85dd8c1f3e --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemPayment { + func map() throws -> Payment { + switch self { + case let .request(request): try .request(request.map()) + case let .link(link): .link(link.map()) + } + } +} + +public extension GemPaymentRequest { + func map() throws -> PaymentRequest { + try PaymentRequest( + address: address, + amount: amount, + memo: memo, + assetId: assetId.map { try AssetId(id: $0) }, + ) + } +} + +public extension GemPaymentLink { + func map() -> PaymentLink { + PaymentLink(provider: provider.map(), id: id) + } +} + +public extension PaymentLink { + func map() -> GemPaymentLink { + GemPaymentLink(provider: provider.map(), id: id) + } +} + +public extension PaymentProviderName { + func map() -> GemPaymentProviderName { + switch self { + case .solanaPay: .solanaPay + case .walletConnectPay: .walletConnectPay + } + } +} + +public extension GemPaymentProviderName { + func map() -> PaymentProviderName { + switch self { + case .solanaPay: .solanaPay + case .walletConnectPay: .walletConnectPay + } + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift new file mode 100644 index 0000000000..74bb55626b --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift @@ -0,0 +1,117 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemPaymentOptions { + func map() throws -> PaymentOptions { + switch self { + case let .quotes(quotes): try .quotes(quotes.map()) + case let .outcome(outcome): .outcome(outcome.map()) + } + } +} + +public extension PaymentQuotes { + func map() -> GemPaymentQuotes { + GemPaymentQuotes( + merchant: merchant.map(), + price: price?.map(), + expiresAt: expiresAt.map { Int64($0.timeIntervalSince1970) }, + quotes: quotes.map { $0.map() }, + ) + } +} + +public extension PaymentMerchant { + func map() -> GemPaymentMerchant { + GemPaymentMerchant(name: name, iconUrl: iconUrl) + } +} + +public extension PaymentPrice { + func map() -> GemPaymentPrice { + GemPaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} + +public extension GemPaymentQuotes { + func map() throws -> PaymentQuotes { + try PaymentQuotes( + merchant: merchant.map(), + price: price?.map(), + expiresAt: expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + quotes: quotes.map { try $0.map() }, + ) + } +} + +public extension GemPaymentQuote { + func map() throws -> PaymentQuote { + try PaymentQuote( + id: id, + paymentId: paymentId, + amount: amount.map(), + expiresAt: expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + collectDataUrl: collectDataUrl, + providerData: providerData, + ) + } +} + +public extension PaymentQuote { + func map() -> GemPaymentQuote { + GemPaymentQuote( + id: id, + paymentId: paymentId, + amount: amount.map(), + expiresAt: expiresAt.map { Int64($0.timeIntervalSince1970) }, + collectDataUrl: collectDataUrl, + providerData: providerData, + ) + } +} + +public extension PaymentAmount { + func map() -> GemPaymentAmount { + GemPaymentAmount(assetId: assetId.identifier, value: value, symbol: symbol, decimals: decimals) + } +} + +public extension GemPaymentAmount { + func map() throws -> PaymentAmount { + try PaymentAmount(assetId: AssetId(id: assetId), value: value, symbol: symbol, decimals: decimals) + } +} + +public extension GemPaymentMerchant { + func map() -> PaymentMerchant { + PaymentMerchant(name: name, iconUrl: iconUrl) + } +} + +public extension GemPaymentOutcome { + func map() -> PaymentOutcome { + PaymentOutcome(status: status.map(), transactionId: transactionId) + } +} + +public extension GemPaymentStatus { + func map() -> PaymentStatus { + switch self { + case .requiresAction: .requiresAction + case .processing: .processing + case .succeeded: .succeeded + case .failed: .failed + case .expired: .expired + case .cancelled: .cancelled + } + } +} + +public extension GemPaymentPrice { + func map() -> PaymentPrice { + PaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift index c5ee1a0be0..d4990bcf4b 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift @@ -38,8 +38,8 @@ public extension GemTransactionInputType { try TransferDataType.stake(asset.map(), type.map()) case let .tokenApprove(asset, approvalData): try TransferDataType.tokenApprove(asset.map(), approvalData.map()) - case let .generic(asset, metadata, extra): - try TransferDataType.generic(asset: asset.map(), metadata: metadata.map(), extra: extra.map()) + case let .generic(asset, appMetadata, extra): + try TransferDataType.generic(asset: asset.map(), appMetadata: appMetadata.map(), extra: extra.map()) case let .account(asset, accountType): try TransferDataType.account(asset.map(), accountType.map()) case let .perpetual(asset: asset, perpetualType: perpetualType): @@ -67,8 +67,10 @@ public extension TransferDataType { return .stake(asset: asset.map(), stakeType: stakeType.map()) case let .tokenApprove(asset, approvalData): return .tokenApprove(asset: asset.map(), approvalData: approvalData.map()) - case let .generic(asset, metadata, extra): - return .generic(asset: asset.map(), metadata: metadata.map(), extra: extra.map()) + case let .generic(asset, appMetadata, extra): + return .generic(asset: asset.map(), appMetadata: appMetadata.map(), extra: extra.map()) + case let .payment(asset, payment, extra): + return .generic(asset: asset.map(), appMetadata: TransactionAppMetadata(merchant: payment.merchant).map(), extra: extra.map()) case let .withdrawal(asset): if asset.chain == .hyperCore { return .withdrawal(asset: asset.map()) diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift index 0e9e2aa71b..d7efb8bb1f 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift @@ -26,6 +26,6 @@ public extension WalletConnectionSessionAppMetadata { } var shortName: String { - Gemstone.walletConnectAppShortName(metadata: map()) + Gemstone.walletConnectAppShortName(name: name) } } diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/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/Primitives/Sources/Action.swift b/ios/Packages/Primitives/Sources/Action.swift index 16750cb856..1a157137b4 100644 --- a/ios/Packages/Primitives/Sources/Action.swift +++ b/ios/Packages/Primitives/Sources/Action.swift @@ -4,6 +4,7 @@ import Foundation public typealias VoidAction = (() -> Void)? public typealias StringAction = ((String) -> Void)? +public typealias StringResultAction = @Sendable (Result) -> Void public typealias AssetAction = ((Asset) -> Void)? public typealias AssetIdAction = ((AssetId) -> Void)? public typealias AssetBoolAction = ((Asset, Bool) -> Void)? diff --git a/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift index e9edade9e1..d4819d0f21 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift @@ -7,3 +7,9 @@ extension Account: Identifiable { "\(chain)\(address)" } } + +public extension Account { + var chainAddress: ChainAddress { + ChainAddress(chain: chain, address: address) + } +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift index 0d5f80b0fe..7854cedb28 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift @@ -25,4 +25,12 @@ public extension Date { var millisecondsSince1970: Int64 { Int64(timeIntervalSince1970 * 1000) } + + func sleepUntil() async { + let remaining = timeIntervalSinceNow + guard remaining > 0 else { + return + } + try? await Task.sleep(for: .seconds(remaining)) + } } diff --git a/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift new file mode 100644 index 0000000000..a9927b3e1d --- /dev/null +++ b/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public extension SimulationResult { + static let empty = SimulationResult( + warnings: [], + balanceChanges: [], + payload: [], + header: .none, + ) +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift index 82cefb55db..c3325945aa 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift @@ -51,6 +51,17 @@ public extension Transaction { var swapProvider: String? { metadata?.decode(TransactionSwapMetadata.self)?.provider } + + var paymentMetadata: TransactionPaymentMetadata? { + metadata?.decode(TransactionPaymentMetadata.self) + } + + var isAwaitingPaymentHash: Bool { + guard let paymentMetadata else { + return false + } + return id.hash == paymentMetadata.paymentId + } } extension Transaction: Identifiable {} diff --git a/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift new file mode 100644 index 0000000000..d5dfb04575 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public extension WalletConnectionSessionAppMetadata { + var transactionAppMetadata: TransactionAppMetadata { + TransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } +} + +public extension TransactionAppMetadata { + var iconURL: URL? { + guard let icon, let iconURL = icon.asURL else { return .none } + guard iconURL.host() == nil else { return iconURL } + return url.flatMap { ($0 + icon).asURL } + } +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift index ace48a61e1..cddc68370a 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift @@ -17,6 +17,10 @@ public extension Wallet { type == .multicoin } + var chainAddresses: [ChainAddress] { + accounts.map(\.chainAddress) + } + var addressChains: [AddressChains] { Dictionary(grouping: accounts, by: \.address) .map { AddressChains(address: $0.key, chains: Set($0.value.map(\.chain)).sorted()) } diff --git a/ios/Packages/Primitives/Sources/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/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/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift b/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift index f36c36d97e..afa4204eae 100644 --- a/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift +++ b/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift @@ -56,7 +56,8 @@ public enum TransactionHeaderTypeBuilder { .deposit, .withdrawal, .stake, - .generic: + .generic, + .payment: return .amount(showFiat: true) case .tokenApprove: return .assetImage diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift new file mode 100644 index 0000000000..81510a9cc2 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components + +public struct AppPreviewModel: AssetPreviewable { + public let assetImage: AssetImage + public let name: String + public let subtitleSymbol: String? + + public init(assetImage: AssetImage, name: String, subtitleSymbol: String?) { + self.assetImage = assetImage + self.name = name + self.subtitleSymbol = subtitleSymbol + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift new file mode 100644 index 0000000000..16899c1903 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/ExpiryCountdown.swift @@ -0,0 +1,23 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +@Observable +@MainActor +public final class ExpiryCountdown { + public private(set) var isExpired: Bool = false + + private let expiresAt: Date? + + public init(expiresAt: Date?) { + self.expiresAt = expiresAt + } + + public func start() async { + guard let expiresAt else { + return + } + await expiresAt.sleepUntil() + isExpired = true + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift new file mode 100644 index 0000000000..5a90c3389b --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift @@ -0,0 +1,92 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Components +import Formatters +import Foundation +import Primitives +import Style +import SwiftUI + +public struct PaymentQuoteItem: Identifiable, Sendable { + public let quote: PaymentQuote + + private let assetData: AssetData? + private let formatter: ValueFormatter + + public init( + quote: PaymentQuote, + assetData: AssetData? = .none, + formatter: ValueFormatter, + ) { + self.quote = quote + self.assetData = assetData + self.formatter = formatter + } + + public var id: String { + quote.id + } + + public var amountText: String { + guard let value = BigInt(quote.amount.value) else { + return quote.amount.symbol + } + return formatter.string(value, decimals: quote.amount.decimals.asInt, currency: quote.amount.symbol) + } +} + +// MARK: - ListAssetItemViewable + +extension PaymentQuoteItem: ListAssetItemViewable { + public var name: String { + assetData?.asset.name ?? quote.amount.symbol + } + + public var symbol: String? { + .none + } + + public var assetImage: AssetImage { + AssetIdViewModel(assetId: quote.amount.assetId).assetImage + } + + public var subtitleView: ListAssetItemSubtitleView { + .type(TextValue(text: quote.amount.assetId.chain.networkName, style: .calloutSecondary)) + } + + public var rightView: ListAssetItemRightView { + .balance( + balance: TextValue(text: amountText, style: TextStyle(font: .callout, color: Colors.black, fontWeight: .semibold)), + totalFiat: TextValue(text: balanceText, style: TextStyle(font: .footnote, color: Colors.gray)), + ) + } + + public var action: ((ListAssetItemAction) -> Void)? { + get { .none } + set {} + } +} + +// MARK: - Hashable + +extension PaymentQuoteItem: Hashable { + public static func == (lhs: PaymentQuoteItem, rhs: PaymentQuoteItem) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Private + +extension PaymentQuoteItem { + private var balanceText: String { + guard let assetData else { + return .empty + } + return formatter.string(assetData.balance.available, decimals: assetData.asset.decimals.asInt, currency: assetData.asset.symbol) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift index 6ae1191e83..f633be1d8b 100644 --- a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift @@ -106,6 +106,9 @@ public struct TransactionViewModel: Sendable { return Localized.Transfer.title } case .smartContractCall: + if paymentMerchantName != nil { + return Localized.Transfer.paymentTitle + } return Localized.Transfer.SmartContract.title case .swap: return Localized.Wallet.swap @@ -178,6 +181,9 @@ public struct TransactionViewModel: Sendable { let chain = assetId.chain switch transaction.transaction.type { case .transfer, .transferNFT, .tokenApproval, .smartContractCall: + if let merchant = paymentMerchantName { + return String(format: "%@ %@", Localized.Transfer.to, merchant) + } switch transaction.transaction.direction { case .incoming: return participantTitle(prefix: Localized.Transfer.from, address: transaction.transaction.from, chain: chain) @@ -350,6 +356,10 @@ public struct TransactionViewModel: Sendable { return String(format: "%@ %@", prefix, value) } + private var paymentMerchantName: String? { + transaction.transaction.paymentMetadata?.merchant.name + } + private func getResourceTitle() -> String? { guard let resourceType = transaction.transaction.metadata?.decode(TransactionResourceTypeMetadata.self)?.resourceType else { return nil } return ResourceViewModel(resource: resourceType).title diff --git a/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift b/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift new file mode 100644 index 0000000000..759ef0b1cd --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/TransactionViewModelTests.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization +import Primitives +@testable import PrimitivesComponents +import PrimitivesTestKit +import Testing + +struct TransactionViewModelTests { + @Test + func smartContractCallTitle() { + let payment = TransactionPaymentMetadata( + paymentId: "pay_1", + merchant: PaymentMerchant(name: "Gem Wallet Test Merchant", iconUrl: .none), + provider: .walletConnectPay, + ) + + #expect(model(metadata: AnyCodableValue.encode(payment)).titleTextValue.text == Localized.Transfer.paymentTitle) + #expect(model(metadata: .none).titleTextValue.text == Localized.Transfer.SmartContract.title) + } + + private func model(metadata: AnyCodableValue?) -> TransactionViewModel { + TransactionViewModel( + explorerService: MockExplorerLink(), + transaction: .mock(transaction: .mock(type: .smartContractCall, metadata: metadata)), + currency: "USD", + ) + } +} From e72bf90e10f0cfe6cbc20ad26ea15612cb96f3aa Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:05 +0300 Subject: [PATCH 07/53] iOS: add the signing request and payment services A payment has a merchant, not a session, so the transfer-data factory and the sign payload move below WalletConnector where a caller without a session can reach them. PaymentService adapts the gemstone flow and reports payment state back to the transaction scheduler. --- ios/Packages/ChainServices/Package.swift | 56 ++++++++ .../PaymentAction+Mapping.swift | 22 +++ .../PaymentService/PaymentAction.swift | 13 ++ .../PaymentApprovalExecutable.swift | 8 ++ .../PaymentAssetsProvidable.swift | 8 ++ .../PaymentAssetsProvider.swift | 22 +++ .../PaymentDataCollectionRequest.swift | 13 ++ .../PaymentService/PaymentQuotesRequest.swift | 22 +++ .../PaymentService/PaymentService.swift | 66 +++++++++ .../PaymentService/PreparedPayment.swift | 16 +++ .../TestKit/PaymentAction+TestKit.swift | 12 ++ .../PaymentApprovalExecutableMock.swift | 15 ++ .../TestKit/PaymentAssetsProvidableMock.swift | 17 +++ .../TestKit/PaymentServiceableMock.swift | 64 +++++++++ .../PaymentStatusServiceableMock.swift | 30 ++++ .../ScanService/ScanService.swift | 2 +- .../SheetPresenter.swift | 98 ++++++++++++++ .../SignMessagePayload.swift | 23 ++-- .../SignableTransaction+Mapping.swift} | 15 +- .../SignableTransaction.swift} | 4 +- .../SigningRequestCallback.swift | 30 ++++ .../SigningRequestInteractable.swift | 10 ++ .../SigningSimulator.swift | 43 ++++++ .../SigningTransferData.swift | 20 +++ .../SigningTransferDataFactory.swift | 116 ++++++++++++++++ .../TestKit/SignMessagePayload+TestKit.swift | 46 +++++++ .../SigningRequestInteractableMock.swift | 35 +++++ .../TestKit/SigningSimulatableMock.swift | 24 ++++ .../Tests/SheetPresenterTests.swift | 128 ++++++++++++++++++ .../TestKit/SignMessagePayload+TestKit.swift | 25 ---- .../TestKit/WalletConnectorSignableMock.swift | 5 +- .../WalletConnectorService.swift | 19 ++- .../WalletConnectorSignable.swift | 5 +- 33 files changed, 970 insertions(+), 62 deletions(-) create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentAction.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentService.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PreparedPayment.swift create mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift create mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift create mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift create mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift create mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift rename ios/Packages/ChainServices/{WalletConnectorService => SigningRequestService}/SignMessagePayload.swift (53%) rename ios/Packages/ChainServices/{WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift => SigningRequestService/SignableTransaction+Mapping.swift} (69%) rename ios/Packages/ChainServices/{WalletConnectorService/WalletConnectorTransaction.swift => SigningRequestService/SignableTransaction.swift} (72%) create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestInteractable.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift delete mode 100644 ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift 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..f502ef6232 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentApprovalExecutable: Sendable { + 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..4ce303d086 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentService.swift @@ -0,0 +1,66 @@ +// 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, + config: GemPaymentConfig(walletConnectPay: GemWalletConnectPayAuth(appId: appId, clientId: clientId)), + ) + } + + public func getPaymentOptions(link: PaymentLink, wallet: Wallet) async throws -> PaymentOptions { + try await service.getPaymentOptions(link: link.map(), addresses: wallet.chainAddresses.map { $0.map() }).map() + } + + public func getPreparedPayment(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PreparedPayment { + let payment = try await service.getPreparedPayment( + provider: provider.map(), + quotes: quotes.map(), + quote: quote.map(), + addresses: wallet.chainAddresses.map { $0.map() }, + ) + return try PreparedPayment( + quotes: payment.quotes.map(), + quote: payment.quote.map(), + actions: payment.actions.map { try $0.map() }, + ) + } + + 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..0e8a465a94 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives + +public final class PaymentApprovalExecutableMock: PaymentApprovalExecutable, @unchecked Sendable { + public private(set) var confirmedHashes: [String] = [] + + public init() {} + + 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/SigningRequestService/SheetPresenter.swift b/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift new file mode 100644 index 0000000000..c31a5402c6 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift @@ -0,0 +1,98 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +@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/ChainServices/WalletConnectorService/SignMessagePayload.swift b/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift similarity index 53% rename from ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift rename to ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift index 56493bb979..db8ba9b57b 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 expiresAt: Date? public init( + id: String, chain: Chain, - session: WalletConnectionSession, + appMetadata: TransactionAppMetadata, wallet: Wallet, message: SignMessage, simulation: SimulationResult, + payment: PaymentData? = .none, + expiresAt: Date? = .none, ) { + self.id = id self.chain = chain + self.appMetadata = appMetadata self.wallet = wallet - self.session = session self.message = message self.simulation = simulation - } -} - -extension SignMessagePayload: Identifiable { - public var id: String { - session.id + self.payment = payment + self.expiresAt = expiresAt } } diff --git a/ios/Packages/ChainServices/WalletConnectorService/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/SigningRequestCallback.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift new file mode 100644 index 0000000000..6d85359efa --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +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/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/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/Packages/ChainServices/SigningRequestService/SigningTransferData.swift b/ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift new file mode 100644 index 0000000000..77f1df068c --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct SigningTransferData: Identifiable, Sendable { + public let transferData: TransferData + public let wallet: Wallet + public let simulation: SimulationResult + + public init(transferData: TransferData, wallet: Wallet, simulation: SimulationResult) { + self.transferData = transferData + self.wallet = wallet + self.simulation = simulation + } + + public var id: String { + wallet.id.id + } +} diff --git a/ios/Packages/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..c29484f343 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift @@ -0,0 +1,46 @@ +// 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, + expiresAt: Date? = .none, + ) -> SignMessagePayload { + SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message, + simulation: simulation, + payment: payment, + expiresAt: expiresAt, + ) + } +} + +public extension PaymentData { + static func mock( + provider: PaymentProviderName = .walletConnectPay, + quote: PaymentQuote = .mock(), + quotes: [PaymentQuote]? = .none, + expiresAt: Date = Date(timeIntervalSinceNow: 900), + ) -> PaymentData { + PaymentData( + provider: provider, + quotes: .mock(expiresAt: expiresAt, quotes: quotes ?? [quote]), + quote: quote, + ) + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift new file mode 100644 index 0000000000..74721e4a23 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift @@ -0,0 +1,35 @@ +// 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 private(set) var sentTransferData: [SigningTransferData] = [] + + public func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + if let signMessageError, !failedSignOnce { + failedSignOnce = true + throw signMessageError + } + return signature + } + + public func signTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + sentTransferData.append(transferData) + return transactionHash + } +} diff --git a/ios/Packages/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..59ce37f5fd --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift @@ -0,0 +1,128 @@ +// 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) -> TestSheetType { + .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") + } +} + +private enum TestSheetType: Sendable, Identifiable, SigningRequestRejectable { + case signMessage(SigningRequestCallback) + + var id: String { + switch self { + case let .signMessage(callback): callback.id + } + } + + func reject(_ error: any Error) { + switch self { + case let .signMessage(callback): callback.reject(error) + } + } +} 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..add66cbd06 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 @@ -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..789efb40d9 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 { diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift index 8b8d35a1bb..ddb17c6f93 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift @@ -3,6 +3,7 @@ import Foundation import struct Gemstone.SignMessage import Primitives +import SigningRequestService import WalletConnectSign public protocol WalletConnectorSignable: Sendable { @@ -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 } From a9e013da552b816cdecd4398117d5e8176c8c2fc Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:12 +0300 Subject: [PATCH 08/53] iOS: confirm a payment approval instead of broadcasting it An ERC-20 approval a payment needs now goes through the transfer confirmation the user already knows, so an unlimited allowance is reviewed rather than signed in the background. TransferExecutor returns the hashes it broadcast so the caller can wait for the approval to be mined before the next spend. --- .../TransactionConfirmationWaiter.swift | 54 +++++++++++++ ios/Features/Transfer/Package.swift | 4 +- .../Sources/Errors/ScanTransactionError.swift | 4 +- .../Extensions/TransferData+Available.swift | 1 + .../Sources/Scenes/ConfirmTransferScene.swift | 3 + .../Sources/Services/FeeRateService.swift | 8 +- .../Sources/Services/TransactionFactory.swift | 4 +- .../Sources/Services/TransferExecutor.swift | 20 +++-- .../Types/ConfirmTransferRequest.swift | 6 +- .../Types/ConfirmTransferSection.swift | 3 + .../Types/TransferConfirmationInput.swift | 6 +- .../Validators/ScanTransactionValidator.swift | 4 +- .../ViewModels/ConfirmAppViewModel.swift | 21 +++-- .../ViewModels/ConfirmDetailsViewModel.swift | 1 + .../ViewModels/ConfirmMemoViewModel.swift | 2 +- .../ViewModels/ConfirmNetworkViewModel.swift | 2 +- .../ConfirmRecipientViewModel.swift | 4 +- .../ConfirmTransferSceneViewModel.swift | 28 ++++++- .../ViewModels/RecipientSceneViewModel.swift | 39 ++++----- .../ViewModels/TransferDataViewModel.swift | 11 ++- .../TestKit/TransactionData+TestKit.swift | 1 + .../TestKit/TransferExecutor+TestKit.swift | 8 +- .../Tests/RecipientSceneViewModelTests.swift | 17 +++- .../Tests/Services/ConfirmServiceTests.swift | 6 +- .../ScanTransactionValidatorTests.swift | 1 - .../ViewModels/ConfirmAppViewModelTests.swift | 4 +- .../ConfirmRecipientViewModelTests.swift | 4 +- .../ConfirmTransferSceneViewModelTests.swift | 26 ++++-- .../TransferDataViewModelTests.swift | 4 +- ios/Packages/FeatureServices/Package.swift | 4 + .../TransactionStateScheduler+TestKit.swift | 4 + .../Tests/TransactionStateServiceTests.swift | 80 ++++++++++++++++++- .../TransactionStateService.swift | 24 ++++++ 33 files changed, 322 insertions(+), 86 deletions(-) create mode 100644 ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift diff --git a/ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift b/ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift new file mode 100644 index 0000000000..0f5e7aa88f --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/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/Package.swift b/ios/Features/Transfer/Package.swift index 0ea7735f00..1601d9e1ac 100644 --- a/ios/Features/Transfer/Package.swift +++ b/ios/Features/Transfer/Package.swift @@ -34,7 +34,6 @@ let package = Package( .package(name: "Store", path: "../../Packages/Store"), .package(name: "Stake", path: "../Stake"), - .package(name: "WalletConnector", path: "../WalletConnector"), .package(name: "InfoSheet", path: "../InfoSheet"), .package(name: "Swap", path: "../Swap"), .package(name: "Perpetuals", path: "../Perpetuals"), @@ -63,13 +62,13 @@ let package = Package( "Validators", "Stake", - "WalletConnector", "InfoSheet", "Swap", "Perpetuals", "EventPresenterService", .product(name: "ChainService", package: "ChainServices"), + .product(name: "PaymentService", package: "ChainServices"), .product(name: "WalletSessionService", package: "FeatureServices"), .product(name: "NodeService", package: "ChainServices"), .product(name: "TransactionStateService", package: "FeatureServices"), @@ -105,6 +104,7 @@ let package = Package( .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: "WalletSessionService", package: "FeatureServices"), diff --git a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift b/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift index 0164b65982..eba55b2009 100644 --- a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift +++ b/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift @@ -4,13 +4,13 @@ import Foundation import Localization import Primitives -enum ScanTransactionError: Error, Equatable { +public enum ScanTransactionError: Error, Equatable { case malicious case memoRequired(symbol: String) } extension ScanTransactionError: LocalizedError { - var errorDescription: String? { + public var errorDescription: String? { switch self { case .malicious: Localized.Errors.ScanTransaction.Malicious.description case let .memoRequired(symbol): Localized.Errors.ScanTransaction.memoRequired(symbol.boldMarkdown()) diff --git a/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift b/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift index 94c4a42461..2029990665 100644 --- a/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift +++ b/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift @@ -13,6 +13,7 @@ extension TransferData { .swap, .tokenApprove, .generic, + .payment, .transferNft, .perpetual, .account(_, .activate), diff --git a/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift b/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift index 118043c0eb..fba8911215 100644 --- a/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift +++ b/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift @@ -30,6 +30,7 @@ public struct ConfirmTransferScene: View { .task(id: model.feeModel.selection) { await model.fetch() } + .task { await model.expiryCountdown.start() } .navigationTitle(model.title) .navigationBarTitleDisplayMode(.inline) .activityIndicator(isLoading: model.isConfirming, message: model.progressMessage) @@ -49,6 +50,8 @@ extension ConfirmTransferScene { headerType: model.headerType, showClearHeader: model.showClearHeader, ) + case let .paymentExpiry(title, expiresAt): + ListItemExpiryView(title: title, expiresAt: expiresAt) case let .app(model): ListItemImageView(model: model) .contextMenu( diff --git a/ios/Features/Transfer/Sources/Services/FeeRateService.swift b/ios/Features/Transfer/Sources/Services/FeeRateService.swift index 2317b86fe4..ba4bc88652 100644 --- a/ios/Features/Transfer/Sources/Services/FeeRateService.swift +++ b/ios/Features/Transfer/Sources/Services/FeeRateService.swift @@ -4,20 +4,20 @@ import Blockchain import Foundation import Primitives -protocol FeeRateProviding: Sendable { +public protocol FeeRateProviding: Sendable { func rates(for type: TransferDataType) async throws -> [FeeRate] } -struct FeeRateService: FeeRateProviding { +public struct FeeRateService: FeeRateProviding { private let service: any ChainFeeRateFetchable - init( + public init( service: any ChainFeeRateFetchable, ) { self.service = service } - func rates(for type: TransferDataType) async throws -> [FeeRate] { + public func rates(for type: TransferDataType) async throws -> [FeeRate] { try await service.feeRates(type: type) } } diff --git a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift b/ios/Features/Transfer/Sources/Services/TransactionFactory.swift index 70cfd3c74c..d83b0d9dc0 100644 --- a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift +++ b/ios/Features/Transfer/Sources/Services/TransactionFactory.swift @@ -3,8 +3,8 @@ import Foundation import Primitives -enum TransactionFactory { - static func makePendingTransaction( +public enum TransactionFactory { + public static func makePendingTransaction( wallet: Wallet, transferData: TransferData, transactionData: TransactionData, diff --git a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift b/ios/Features/Transfer/Sources/Services/TransferExecutor.swift index 1f9b822708..560944b7e3 100644 --- a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift +++ b/ios/Features/Transfer/Sources/Services/TransferExecutor.swift @@ -8,7 +8,8 @@ import Signer import TransactionStateService public protocol TransferExecutable: Sendable { - func execute(input: TransferConfirmationInput) async throws + @discardableResult + func execute(input: TransferConfirmationInput) async throws -> [String] } public struct TransferExecutor: TransferExecutable { @@ -33,8 +34,10 @@ public struct TransferExecutor: TransferExecutable { self.transactionStateScheduler = transactionStateScheduler } - public func execute(input: TransferConfirmationInput) async throws { + @discardableResult + public func execute(input: TransferConfirmationInput) async throws -> [String] { let signedData = try await sign(input: input) + var results: [String] = [] for (index, transactionData) in signedData.enumerated() { debugLog("TransferExecutor data \(transactionData)") @@ -42,15 +45,17 @@ public struct TransferExecutor: TransferExecutable { switch input.data.type.outputAction { case .sign: input.delegate?(.success(transactionData)) + results.append(transactionData) case .send: - try await send( + try await results.append(send( input: input, transactionData: transactionData, transactionIndex: index, totalTransactions: signedData.count, - ) + )) } } + return results } } @@ -62,7 +67,7 @@ extension TransferExecutor { transactionData: String, transactionIndex: Int, totalTransactions: Int, - ) async throws { + ) async throws -> String { let hash = try await chainService.broadcast(data: transactionData, options: broadcastOptions(data: input.data)) debugLog("TransferExecutor broadcast response hash \(hash)") @@ -98,6 +103,7 @@ extension TransferExecutor { if totalTransactions > 1, transactionIndex < totalTransactions - 1 { try await Task.sleep(for: transactionDelay(for: input.data.chain.type)) } + return hash } private func sign(input: TransferConfirmationInput) async throws -> [String] { @@ -131,7 +137,7 @@ extension TransferExecutor { && data.quote.providerData.provider == .hyperliquid && transactionIndex < totalTransactions - 1: return [] - case .stake, .perpetual, .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, .generic, .account, .earn: + case .stake, .perpetual, .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, .generic, .payment, .account, .earn: break } default: @@ -152,7 +158,7 @@ extension TransferExecutor { case .transfer, .deposit, .withdrawal, .transferNft, .stake, .account, .tokenApprove, .perpetual, .earn: BroadcastOptions( skipPreflight: false, ) - case .swap, .generic: BroadcastOptions(skipPreflight: true) + case .swap, .generic, .payment: BroadcastOptions(skipPreflight: true) } default: BroadcastOptions(skipPreflight: false) } diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift index ec02edaaef..899bd986c5 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift @@ -1,20 +1,20 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import PaymentService import Primitives -import WalletConnector public struct ConfirmTransferRequest: Sendable { public let wallet: Wallet public let data: TransferData public let simulation: SimulationResult? - public let delegate: TransferDataCallback.ConfirmTransferDelegate? + public let delegate: StringResultAction? public init( wallet: Wallet, data: TransferData, simulation: SimulationResult? = nil, - delegate: TransferDataCallback.ConfirmTransferDelegate? = nil, + delegate: StringResultAction? = nil, ) { self.wallet = wallet self.data = data diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift index b425dd2760..4c668986a8 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift @@ -11,6 +11,7 @@ enum ConfirmTransferSectionType: String, Identifiable, Equatable { case header case warnings case details + case paymentExpiry case balanceChanges case payload case fee @@ -30,6 +31,7 @@ public enum ConfirmTransferItem: Identifiable, Hashable, Sendable { case recipient case memo case details + case paymentExpiry case balanceChange(Int) case payload case networkFee @@ -47,6 +49,7 @@ public enum ConfirmTransferItemModel { case recipient(AddressListItemViewModel) case network(ListItemModel) case memo(ListItemModel) + case paymentExpiry(title: String, expiresAt: Date) case swapDetails(SwapDetailsViewModel) case networkFee(ListItemModel, selectable: Bool) case perpetualDetails(PerpetualDetailsViewModel) diff --git a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift b/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift index 5568a0edf3..a69251024b 100644 --- a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift +++ b/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift @@ -1,8 +1,8 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import PaymentService import Primitives -import WalletConnector public struct TransferConfirmationInput: Sendable { public let data: TransferData @@ -10,7 +10,7 @@ public struct TransferConfirmationInput: Sendable { public let transactionData: TransactionData public let amount: TransferAmount public let simulation: SimulationResult? - public let delegate: TransferDataCallback.ConfirmTransferDelegate? + public let delegate: StringResultAction? public init( data: TransferData, @@ -18,7 +18,7 @@ public struct TransferConfirmationInput: Sendable { transactionData: TransactionData, amount: TransferAmount, simulation: SimulationResult? = nil, - delegate: TransferDataCallback.ConfirmTransferDelegate?, + delegate: StringResultAction?, ) { self.data = data self.wallet = wallet diff --git a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift b/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift index 55268f9441..62ff5d0f2c 100644 --- a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift +++ b/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift @@ -4,8 +4,8 @@ import Foundation import GemstonePrimitives import Primitives -enum ScanTransactionValidator { - static func validate( +public enum ScanTransactionValidator { + public static func validate( transaction: ScanTransaction, asset: Asset, memo: String?, diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift index 097898718d..30a663fb97 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift @@ -23,7 +23,7 @@ public extension ConfirmAppViewModel { return .app( ListItemModel( - title: Localized.WalletConnect.app, + title: title, subtitle: name, imageStyle: .list(assetImage: assetImage), ), @@ -34,6 +34,13 @@ public extension ConfirmAppViewModel { // MARK: - Private extension ConfirmAppViewModel { + private var title: String { + switch type { + case .payment: Localized.Transfer.merchant + default: Localized.WalletConnect.app + } + } + private var appValue: String? { switch type { case .transfer, @@ -46,8 +53,10 @@ extension ConfirmAppViewModel { .account, .perpetual, .earn: .none - case let .generic(_, metadata, _): - metadata.shortName + case let .generic(_, app, _): + app.shortName + case let .payment(_, payment, _): + payment.merchant.name } } @@ -64,8 +73,10 @@ extension ConfirmAppViewModel { .perpetual, .earn: .none - case let .generic(_, session, _): - AssetImage(imageURL: session.icon.asURL) + case let .generic(_, app, _): + AssetImage(imageURL: app.icon?.asURL) + case let .payment(_, payment, _): + AssetImage(imageURL: payment.merchant.iconUrl?.asURL) } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift index 2b7bc05cf4..f80f39207d 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift @@ -44,6 +44,7 @@ extension ConfirmDetailsViewModel: ItemModelProvidable { .stake, .account, .generic, + .payment, .earn: .empty } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift index 2faa2b901a..4a63973784 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift @@ -29,7 +29,7 @@ extension ConfirmMemoViewModel { private var showMemo: Bool { switch type { case .transfer, .deposit, .withdrawal: type.chain.isMemoSupported - case .transferNft, .swap, .tokenApprove, .generic, .account, .stake, .perpetual, .earn: false + case .transferNft, .swap, .tokenApprove, .generic, .payment, .account, .stake, .perpetual, .earn: false } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift index fba7679db9..845b9018fb 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift @@ -35,7 +35,7 @@ extension ConfirmNetworkViewModel { switch type { case .transfer, .deposit, .withdrawal: return model.networkFullName - case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .perpetual, .earn: + case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .payment, .perpetual, .earn: return model.networkName } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift index 7d8e351a9d..a468b04e8f 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift @@ -67,7 +67,7 @@ extension ConfirmRecipientViewModel { case .stake, .unstake, .redelegate, .rewards, .withdraw: Localized.Stake.validator case .freeze, .unfreeze: Localized.Stake.resource } - case .generic: + case .generic, .payment: switch model.type.outputAction { case .sign: Localized.Asset.contract case .send: Localized.Transfer.Recipient.title @@ -90,7 +90,7 @@ extension ConfirmRecipientViewModel { .swap, .perpetual: false case .earn: true - case .generic: + case .generic, .payment: switch model.type.outputAction { case .sign: false case .send: true diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift index 2d8226dbe2..71050e696d 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift @@ -14,7 +14,6 @@ import Store import Swap import SwiftUI import Validators -import WalletConnector @Observable @MainActor @@ -26,6 +25,8 @@ public final class ConfirmTransferSceneViewModel { public var isPresentingSheet: ConfirmTransferSheetType? + public let expiryCountdown: ExpiryCountdown + public var isPresentingAlertMessage: AlertMessage? { get { switch state.confirmation { @@ -55,6 +56,7 @@ public final class ConfirmTransferSceneViewModel { self.request = request self.confirmService = confirmService self.onComplete = onComplete + expiryCountdown = ExpiryCountdown(expiresAt: request.data.type.payment?.expiresAt) let currency = Currency(rawValue: Preferences.standard.currency) ?? .usd self.currency = currency @@ -123,7 +125,7 @@ public final class ConfirmTransferSceneViewModel { } var isButtonDisabled: Bool { - simulationWarnings.contains(where: { $0.severity == .critical }) + expiryCountdown.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } var confirmButtonModel: ConfirmButtonViewModel { @@ -146,11 +148,21 @@ public final class ConfirmTransferSceneViewModel { // MARK: - ListSectionProvideable +extension ConfirmTransferSceneViewModel { + private var paymentExpiryItemModel: ConfirmTransferItemModel { + guard let expiresAt = request.data.type.payment?.expiresAt else { + return .empty + } + return .paymentExpiry(title: Localized.Transfer.paymentExpiresIn, expiresAt: expiresAt) + } +} + extension ConfirmTransferSceneViewModel: ListSectionProvideable { public var sections: [ListSection] { [ ListSection(type: .header, [.header]), ListSection(type: .details, detailItems), + paymentItems.isEmpty ? nil : ListSection(type: .paymentExpiry, paymentItems), simulationWarnings.isEmpty ? nil : ListSection(type: .warnings, [.warnings]), primaryPayloadFields.isEmpty ? nil : ListSection(type: .payload, [.payload]), balanceChangeModels.isEmpty ? nil : ListSection(type: .balanceChanges, balanceChangeModels.indices.map(ConfirmTransferItem.balanceChange)), @@ -163,15 +175,27 @@ extension ConfirmTransferSceneViewModel: ListSectionProvideable { if case .generic = request.data.type { return [.app, .sender, .network] } + if case .payment = request.data.type { + return [.app, .sender, .recipient, .network] + } return [.app, .sender, .recipient, .network, .memo, .details] } + private var paymentItems: [ConfirmTransferItem] { + guard request.data.type.payment != nil else { + return [] + } + return [.paymentExpiry] + } + public func itemModel(for item: ConfirmTransferItem) -> any ItemModelProvidable { switch item { case .header: ConfirmHeaderViewModel(request: request, state: state) case .warnings: ConfirmTransferItemModel.warnings(simulationWarnings) + case .paymentExpiry: + paymentExpiryItemModel case .app: ConfirmAppViewModel(type: request.data.type) case .sender: diff --git a/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift index ed737b5e19..c08304baa8 100644 --- a/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift @@ -182,18 +182,7 @@ extension RecipientSceneViewModel { // MARK: - Private extension RecipientSceneViewModel { - // TODO: Add unit tests, will be added once moved to package - private func paymentScan(string: String) throws -> PaymentScanResult { - let payment = try PaymentURLDecoder.decode(string) - - return PaymentScanResult( - address: payment.address, - amount: payment.amount, - memo: payment.memo, - ) - } - - func getRecipientScanResult(payment: PaymentScanResult) throws -> RecipientScanResult { + func getRecipientScanResult(payment: PaymentRequest) throws -> RecipientScanResult { let address = asset.chain.checksumAddress(payment.address) if let amount = payment.amount, showMemo ? ((payment.memo?.isEmpty) == nil) : true, asset.chain.isValidAddress(address) @@ -252,17 +241,21 @@ extension RecipientSceneViewModel { } private func handleAddressScan(_ string: String) throws { - let payment = try paymentScan(string: string) - let scanResult = try getRecipientScanResult(payment: payment) - switch scanResult { - case let .transferData(data): - handle(transferData: data) - case let .recipient(address, memo, amount): - // TODO: - open if all fields filled - addressInputModel.update(text: address) - - if let memo { self.memo = memo } - if let amount { self.amount = amount } + let type = try PaymentURLDecoder.decode(string) + switch type { + case let .request(payment): + let scanResult = try getRecipientScanResult(payment: payment) + switch scanResult { + case let .transferData(data): + handle(transferData: data) + case let .recipient(address, memo, amount): + // TODO: - open if all fields filled + addressInputModel.update(text: address) + + if let memo { self.memo = memo } + if let amount { self.amount = amount } + } + case .link: throw AnyError(Localized.Errors.notSupported) } } diff --git a/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift index 23aa1635ce..4ae99e7b00 100644 --- a/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift @@ -46,8 +46,10 @@ struct TransferDataViewModel { case .deposit: Localized.Wallet.deposit case .withdrawal: Localized.Wallet.withdraw case .transferNft: Localized.Transfer.Send.title - case .swap, .tokenApprove: Localized.Wallet.swap + case .swap: Localized.Wallet.swap + case .tokenApprove: Localized.Transfer.Approve.title case .generic: Localized.Transfer.reviewRequest + case .payment: Localized.Transfer.paymentTitle case let .stake(_, type): switch type { case .stake: Localized.Transfer.Stake.title @@ -89,9 +91,10 @@ struct TransferDataViewModel { .stake, .account, .perpetual, - .earn: .none - case let .generic(_, metadata, _): - URL(string: metadata.url) + .earn, + .payment: .none + case let .generic(_, app, _): + app.url?.asURL } } diff --git a/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift b/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift index 2eee829c75..d6d4794786 100644 --- a/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift +++ b/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import Transfer @testable import Primitives public extension TransactionData { diff --git a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift b/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift index 011e2d913b..91d7754804 100644 --- a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift +++ b/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift @@ -5,14 +5,18 @@ import Transfer public struct TransferExecutorMock: TransferExecutable { public var error: Error? + public var results: [String] - public init(error: Error? = nil) { + public init(error: Error? = nil, results: [String] = ["1"]) { self.error = error + self.results = results } - public func execute(input _: TransferConfirmationInput) async throws { + @discardableResult + public func execute(input _: TransferConfirmationInput) async throws -> [String] { if let error { throw error } + return results } } diff --git a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift index b068cac662..2e8198034d 100644 --- a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift @@ -93,6 +93,17 @@ struct RecipientSceneViewModelTests { #expect(recipientData?.recipient.address == checksummed) } + @Test + func onHandleScan_paymentLink_keepsAddress() { + let model = RecipientSceneViewModel.mock() + let address = "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326" + model.addressInputModel.update(text: address) + + model.onHandleScan("https://pay.walletconnect.com/?pid=pay_123", for: .address) + + #expect(model.addressInputModel.text == address) + } + @Test func getRecipientScanResult_transferData() throws { let asset = Asset.mockEthereum() @@ -100,10 +111,9 @@ struct RecipientSceneViewModelTests { let address = "0x5615e8ab93b9d695b6d4d6545f7792aa59e1069a" let checksummed = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a" - let payment = PaymentScanResult( + let payment = PaymentRequest.mock( address: " \n\(address)\r ", amount: "1.234", - memo: nil, ) let result = try model.getRecipientScanResult(payment: payment) @@ -121,9 +131,8 @@ struct RecipientSceneViewModelTests { func getRecipientScanResult_recipient() throws { let model = RecipientSceneViewModel.mock() - let payment = PaymentScanResult( + let payment = PaymentRequest.mock( address: "0x123", - amount: nil, memo: "test memo", ) diff --git a/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift b/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift index 50588ce12b..50c89c173e 100644 --- a/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift +++ b/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift @@ -53,7 +53,7 @@ struct ConfirmServiceTests { ) let state = await service.updateState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock(header: SimulationHeader(assetId: Asset.mockEthereumUSDT().id, value: "0", isUnlimited: true)), ) @@ -71,7 +71,7 @@ struct ConfirmServiceTests { ) let state = service.makeState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock( payload: [ SimulationPayloadField.standard(kind: .contract, value: "0x123", fieldType: .address, display: .primary), @@ -153,7 +153,7 @@ struct ConfirmServiceTests { ) let state = await service.updateState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock(payload: [ SimulationPayloadField.standard(kind: .contract, value: "0x123", fieldType: .address, display: .primary), ]), diff --git a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift b/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift index cedcefec04..fc4cc44b4a 100644 --- a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift +++ b/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift @@ -5,7 +5,6 @@ import Primitives import PrimitivesTestKit import Testing @testable import Transfer -import TransferTestKit struct ScanTransactionValidatorTests { @Test diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift index ec59fa3c75..779e3a48ef 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift @@ -11,11 +11,11 @@ import TransferTestKit struct ConfirmAppViewModelTests { @Test func generic() { - let metadata = WalletConnectionSessionAppMetadata.mock( + let appMetadata = TransactionAppMetadata.mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", ) - let model = ConfirmAppViewModel(type: .generic(asset: .mock(), metadata: metadata, extra: .mock())) + let model = ConfirmAppViewModel(type: .generic(asset: .mock(), appMetadata: appMetadata, extra: .mock())) guard case let .app(item) = model.itemModel else { return } #expect(item.title == Localized.WalletConnect.app) diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift index 064c2de4e4..cdc9f65524 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift @@ -52,7 +52,7 @@ struct ConfirmRecipientViewModelTests { @Test func genericSend() { let model = ConfirmRecipientViewModel( - model: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .send))), + model: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .send))), addressName: nil, addressLink: .mock(), ) @@ -64,7 +64,7 @@ struct ConfirmRecipientViewModelTests { @Test func genericSign() { let model = ConfirmRecipientViewModel( - model: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .sign))), + model: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .sign))), addressName: nil, addressLink: .mock(), ) diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift index 4c9c6aedea..d10312a515 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.expiryCountdown.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/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index d72199eb82..c2b18d96ae 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -268,6 +268,7 @@ let package = Package( .target( name: "TransactionStateService", dependencies: [ + .product(name: "PaymentService", package: "ChainServices"), "Primitives", "Store", "Blockchain", @@ -283,6 +284,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"), @@ -723,6 +726,7 @@ let package = Package( .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, From bae0efcc9f6cbeee6f6057e5c2f4391e9c39e25a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:19 +0300 Subject: [PATCH 09/53] iOS: pay payment links The flow the gateway drives: fetch options, let the user pick a quote, collect compliance data in a web view when the quote asks for it, then run the actions. Signatures are gathered before spends because signing consumes no nonce, and each approval is confirmed on chain before the next broadcast. --- ios/Features/Payments/Package.swift | 60 ++++++ .../Scenes/PaymentDataCollectionScene.swift | 53 ++++++ .../Payments/Scenes/PaymentQuotesScene.swift | 83 +++++++++ .../Services/PaymentActionExecutor.swift | 125 +++++++++++++ .../Services/PaymentApprovalExecutor.swift | 20 ++ .../Services/PaymentLinkManager.swift | 70 +++++++ .../Payments/Services/PaymentManager.swift | 132 +++++++++++++ .../Payments/Types/PaymentLinkError.swift | 20 ++ .../Types/PaymentSheetPresentable.swift | 59 ++++++ .../Payments/Types/PaymentSheetType.swift | 34 ++++ .../Types/PaymentTransactionFactory.swift | 35 ++++ .../PaymentQuotesSceneViewModel.swift | 155 ++++++++++++++++ .../ViewModels/PaymentQuotesViewModel.swift | 44 +++++ .../PaymentActionExecutorTests.swift | 142 ++++++++++++++ .../PaymentsTests/PaymentManagerTests.swift | 173 ++++++++++++++++++ .../PaymentQuotesSceneViewModelTests.swift | 61 ++++++ .../PaymentSheetPresentableMock.swift | 52 ++++++ 17 files changed, 1318 insertions(+) create mode 100644 ios/Features/Payments/Package.swift create mode 100644 ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift create mode 100644 ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift create mode 100644 ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift create mode 100644 ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift create mode 100644 ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift create mode 100644 ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift create mode 100644 ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift create mode 100644 ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift create mode 100644 ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift create mode 100644 ios/Features/Payments/Sources/Payments/Types/PaymentTransactionFactory.swift create mode 100644 ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift create mode 100644 ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift diff --git a/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift new file mode 100644 index 0000000000..352cb18ec6 --- /dev/null +++ b/ios/Features/Payments/Package.swift @@ -0,0 +1,60 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Payments", + platforms: [.iOS(.v17), + .macOS(.v15)], + products: [ + .library( + name: "Payments", + targets: ["Payments"], + ), + ], + dependencies: [ + .package(name: "Primitives", path: "../../Packages/Primitives"), + .package(name: "Store", path: "../../Packages/Store"), + .package(name: "ChainServices", path: "../../Packages/ChainServices"), + .package(name: "Components", path: "../../Packages/Components"), + .package(name: "Localization", path: "../../Packages/Localization"), + .package(name: "Style", path: "../../Packages/Style"), + .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), + .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), + .package(name: "GemstonePrimitives", path: "../../Packages/GemstonePrimitives"), + .package(name: "EventPresenterService", path: "../EventPresenterService"), + .package(name: "Formatters", path: "../../Packages/Formatters"), + ], + targets: [ + .target( + name: "Payments", + dependencies: [ + "Primitives", + "Components", + "Localization", + "Style", + "PrimitivesComponents", + "GemstonePrimitives", + "Formatters", + "EventPresenterService", + .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "ChainService", package: "ChainServices"), + .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..14b421e20e --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -0,0 +1,53 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Foundation +import Localization +import PaymentService +import Primitives +import SwiftUI +import SigningRequestService + +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/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..d5796efda5 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -0,0 +1,125 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import PaymentService +import Primitives +import SigningRequestService + +public struct PaymentActionResults: Sendable { + public let results: [String] + public let transactionHash: String? +} + +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 { + var results = [String](repeating: "", count: actions.count) + var transactionHash: String? + var approvals: [String] = [] + for (index, action) in actions.enumerated() { + let value = try await perform( + action: action, + id: "\(paymentId).\(index)", + appMetadata: appMetadata, + payment: payment, + wallet: wallet, + ) + results[index] = value + + switch action { + case .sendTransaction: + transactionHash = value + case .approveToken: + approvals.append(value) + case .signMessage, .signTransaction: + break + } + } + onSubmitted() + for hash in approvals { + try await approvalExecutor.waitForApproval(hash: hash, assetId: payment.quote.amount.assetId, wallet: wallet) + } + return PaymentActionResults(results: results, transactionHash: transactionHash) + } +} + +// MARK: - Private + +extension PaymentActionExecutor { + @MainActor + private func perform(action: PaymentAction, id: String, appMetadata: TransactionAppMetadata, payment: PaymentData, wallet: Wallet) async throws -> String { + switch action { + case let .signMessage(chain, message): + let payload = try await SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message, + simulation: simulator.simulateSignMessage(message: message, sessionDomain: appMetadata.url ?? .empty), + payment: payment, + expiresAt: payment.expiresAt, + ) + return try await interactor.signMessage(payload: payload) + case let .signTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + 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): + let assetId = payment.quote.amount.assetId + guard let asset = assetsProvider.assetsData(walletId: wallet.id, assetIds: [assetId]).first?.asset else { + throw PaymentLinkError.approvalNotBroadcast + } + let transferData = TransferData( + type: .tokenApprove(asset, approval), + recipientData: RecipientData( + recipient: Recipient(name: .none, address: approval.spender, memo: .none), + amount: .none, + ), + amount: .exact(.zero), + ) + return try await interactor.sendTransaction( + transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty), + ) + case let .sendTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + chain: chain, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .send, + payment: payment, + ) + return try await interactor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift new file mode 100644 index 0000000000..208ae22283 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import ChainService +import Foundation +import PaymentService +import Primitives + +public struct PaymentApprovalExecutor: PaymentApprovalExecutable { + private let chainServiceFactory: any ChainServiceFactorable + + public init(chainServiceFactory: any ChainServiceFactorable) { + self.chainServiceFactory = chainServiceFactory + } + + 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) + } +} 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..d6bed41f99 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -0,0 +1,132 @@ +// 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 + + 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 perform(link: link, wallet: wallet) + } +} + +// 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/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/PaymentSheetPresentable.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift new file mode 100644 index 0000000000..a7b9543b81 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift @@ -0,0 +1,59 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import SigningRequestService + +public protocol PaymentSheetPresentable: SigningRequestInteractable { + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String +} + +@Observable +public final class PaymentSheetPresenter: PaymentSheetPresentable, Sendable { + public let sheets = SheetPresenter() + + public init() {} + + @MainActor + public var isPresentingSheet: PaymentSheetType? { + get { sheets.isPresentingSheet } + set { sheets.isPresentingSheet = newValue } + } + + @MainActor + public func complete(type: PaymentSheetType) { + sheets.complete(type: type) + } + + @MainActor + public func cancelSheet(type: PaymentSheetType) { + sheets.cancelSheet(type: type) + } + + @MainActor + public func onSheetDismiss() { + sheets.onSheetDismiss() + } + + public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + try await sheets.present(payload: request, sheet: { .quotes($0) }) + } + + public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + try await sheets.present(payload: request, sheet: { .dataCollection($0) }) + } + + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await sheets.present(payload: payload, sheet: { .signMessage($0) }) + } + + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { .confirm($0) }) + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { .confirm($0) }) + } +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift new file mode 100644 index 0000000000..8d8609db5e --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -0,0 +1,34 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import SigningRequestService + +public enum PaymentSheetType: Sendable, Identifiable { + case quotes(SigningRequestCallback) + case dataCollection(SigningRequestCallback) + case confirm(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 .quotes(callback): callback + case let .dataCollection(callback): callback + case let .confirm(callback): callback + case let .signMessage(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..bf56562294 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -0,0 +1,142 @@ +// 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(assetsData: [.mock()])).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() + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).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 == ["1", "permit-signature"]) + #expect(results.transactionHash == nil) + #expect(executor.confirmedHashes == ["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(assetsData: [.mock()])).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(assetsData: [.mock()])).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 paymentIsRecordedAfterTheApprovalIsBroadcastAndBeforeItIsMined() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock() + var approvalsWhenRecorded: Int? + var confirmationsWhenRecorded: Int? + + _ = try await PaymentActionExecutor( + interactor: interactor, + simulator: SigningSimulatableMock(), + approvalExecutor: executor, + assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()]), + ).perform( + actions: [ + .approveToken(chain: .ethereum, approval: .mock()), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + onSubmitted: { + approvalsWhenRecorded = interactor.sentTransferData.count + confirmationsWhenRecorded = executor.confirmedHashes.count + }, + ) + + #expect(approvalsWhenRecorded == 1) + #expect(confirmationsWhenRecorded == 0) + #expect(executor.confirmedHashes == ["1"]) + } + + @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(assetsData: [.mock()])).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/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..a653da10fd --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift @@ -0,0 +1,52 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +@testable import Payments +import Primitives +import SigningRequestService + +final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sendable { + init() {} + + var collectDataError: Error? + var selectedQuoteId: String? + + private(set) var collectDataRequests: [PaymentDataCollectionRequest] = [] + private(set) var quotesRequests: [PaymentQuotesRequest] = [] + + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + quotesRequests.append(request) + guard let selectedQuoteId else { + throw AnyError("no quote selected") + } + return selectedQuoteId + } + + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + collectDataRequests.append(request) + if let collectDataError { + throw collectDataError + } + return .empty + } + + var transactionHash = "1" + var signature = "signature" + private(set) var signMessagePayloads: [SignMessagePayload] = [] + private(set) var sentTransferData: [SigningTransferData] = [] + + func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + return signature + } + + func signTransaction(transferData: SigningTransferData) async throws -> String { + transactionHash + } + + func sendTransaction(transferData: SigningTransferData) async throws -> String { + sentTransferData.append(transferData) + return transactionHash + } +} From 45d371def2480aebe23ca40db7f1c4e00e9fb4d2 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:24 +0300 Subject: [PATCH 10/53] iOS: move the signing sheets out of WalletConnector Both a session request and a payment ask the user to sign, so the sheet host stops belonging to WalletConnector. The wallet screen scanner reads payment links behind the developer flag. --- .../TransactionExplorerViewModel.swift | 6 +- .../TransactionParticipantViewModel.swift | 9 +- .../TransactionExplorerViewModelTests.swift | 53 +++++++ ios/Features/WalletConnector/Package.swift | 4 + .../WalletConnectorInteractable.swift | 8 +- .../Scenes/ConnectionsScene.swift | 3 +- .../Scenes/SignMessageScene.swift | 19 ++- .../Services/WalletConnectorManager.swift | 43 +----- .../Services/WalletConnectorPresenter.swift | 43 ++++-- .../Services/WalletConnectorSigner.swift | 133 ++++-------------- .../Types/WalletConnectorSheetType.swift | 35 ++--- .../ConnectionProposalViewModel.swift | 4 +- .../SignMessageSceneViewModel.swift | 79 ++++++++--- .../WalletConnectionViewModel.swift | 8 +- .../SignMessageSceneViewModelTests.swift | 104 +++++++------- .../WalletConnectorInteractableMock.swift | 43 ++++++ .../WalletConnectorPresenterTests.swift | 32 ----- .../WalletConnectorSignerTests.swift | 2 + .../ViewModels/WalletSceneViewModel.swift | 32 ++++- .../WalletSceneViewModel+TestKit.swift | 1 + .../Tests/WalletSceneViewModelTests.swift | 15 ++ ios/Gem/App.swift | 3 + .../Wallet/WalletNavigationStack.swift | 12 ++ ios/Gem/Scenes/RootScene.swift | 11 +- 24 files changed, 402 insertions(+), 300 deletions(-) create mode 100644 ios/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionExplorerViewModelTests.swift create mode 100644 ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift delete mode 100644 ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift 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/WalletConnector/Package.swift b/ios/Features/WalletConnector/Package.swift index 38a138deb3..dcff2a5a6b 100644 --- a/ios/Features/WalletConnector/Package.swift +++ b/ios/Features/WalletConnector/Package.swift @@ -32,6 +32,7 @@ let package = Package( name: "WalletConnector", dependencies: [ "Primitives", + .product(name: "SigningRequestService", package: "ChainServices"), .product(name: "WalletConnectorService", package: "ChainServices"), .product(name: "ExplorerService", package: "ChainServices"), "Components", @@ -43,6 +44,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 +61,9 @@ 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: "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/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..433a2a8240 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,22 @@ public struct SignMessageScene: View { ) } + if let expiresAt = model.expiresAt { + Section { + ListItemExpiryView( + title: model.expiresTitle, + expiresAt: expiresAt, + ) + } + } + if model.hasWarnings { Section { SimulationWarningsContent(warnings: model.simulationWarnings) } } - if model.hasPayload { + if model.showsPayload { Section { SimulationPayloadFieldsContent( fields: model.primaryPayloadFields, @@ -63,6 +79,7 @@ public struct SignMessageScene: View { .contentMargins(.top, .scene.top, for: .scrollContent) .listSectionSpacing(.compact) .taskOnce { model.fetch() } + .task { await model.expiryCountdown.start() } .safeAreaButton { StateButton( text: model.buttonTitle, diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/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..0651d90a4a 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,33 +1,50 @@ // Copyright (c). Gem Wallet. All rights reserved. -import SwiftUI +import Foundation +import Primitives +import SigningRequestService @Observable -public final class WalletConnectorPresenter: Sendable { +public final class WalletConnectorPresenter: SigningRequestInteractable, Sendable { + public let sheets = SheetPresenter() + @MainActor public var isPresentingError: String? @MainActor public var isPresentingConnectionBar: Bool = false - @MainActor - public var isPresentingSheet: WalletConnectorSheetType? public init() {} + @MainActor + public var isPresentingSheet: WalletConnectorSheetType? { + get { sheets.isPresentingSheet } + set { sheets.isPresentingSheet = newValue } + } + @MainActor public func complete(type: WalletConnectorSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } - isPresentingSheet = nil + sheets.complete(type: type) } @MainActor public func cancelSheet(type: WalletConnectorSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } + sheets.cancelSheet(type: type) + } + + @MainActor + public func onSheetDismiss() { + sheets.onSheetDismiss() + } + + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await sheets.present(payload: payload, sheet: { .signMessage($0) }) + } + + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { .transferData($0) }) + } - type.reject(ConnectionsError.userCancelled) - isPresentingSheet = nil + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { .transferData($0) }) } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index f54a37769d..7c09912f8f 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,13 +91,14 @@ public final class WalletConnectorSigner: WalletConnectorSignable { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let payload = SignMessagePayload( + id: session.session.id, chain: chain, - session: session.session, + appMetadata: session.session.metadata.transactionAppMetadata, wallet: session.wallet, message: message, simulation: simulation, ) - return try await walletConnectorInteractor.signMessage(payload: payload) + return try await signingInteractor.signMessage(payload: payload) } public func updateSessions(sessions: [WalletConnectionSession]) throws { @@ -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,109 +130,47 @@ public final class WalletConnectorSigner: WalletConnectorSignable { switch transaction { case .ethereum: throw AnyError("Not supported") - case let .solana(transaction, outputType), - let .sui(transaction, outputType), - let .ton(transaction, outputType), - let .tron(transaction, outputType): - let transferData = buildTransferData( + 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, ), ) } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 1132555b94..fd2544b30b 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -1,29 +1,32 @@ // Copyright (c). Gem Wallet. All rights reserved. +import Foundation import Primitives +import SigningRequestService import WalletConnectorService public enum WalletConnectorSheetType: Sendable, Identifiable { - case transferData(TransferDataCallback) - case signMessage(TransferDataCallback) - case connectionProposal(TransferDataCallback) + case connectionProposal(SigningRequestCallback) + case transferData(SigningRequestCallback) + case signMessage(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 + case let .transferData(callback): callback + case let .signMessage(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..25ce8b4230 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift @@ -10,13 +10,13 @@ import SwiftUI import WalletConnectorService public struct ConnectionProposalViewModel { - private let confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate + private let confirmTransferDelegate: StringResultAction private let pairingProposal: WCPairingProposal var walletSelectorModel: SelectWalletViewModel public init( - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, pairingProposal: WCPairingProposal, ) { self.confirmTransferDelegate = confirmTransferDelegate diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift index dbd28ab7ae..9f65d533fb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift @@ -4,11 +4,14 @@ 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 Primitives +import SigningRequestService import PrimitivesComponents import Style import WalletConnectorService @@ -16,28 +19,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 expiryCountdown: ExpiryCountdown private var payloadAddressNames: [ChainAddress: AddressName] = [:] public init( keystore: any Keystore, addressNameService: AddressNameService, payload: SignMessagePayload, - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, ) { self.keystore = keystore self.addressNameService = addressNameService self.payload = payload + expiryCountdown = ExpiryCountdown(expiresAt: payload.expiresAt) let signer = MessageSigner(message: payload.message) self.signer = signer let plainMessage = signer.plainPreview() @@ -56,12 +65,31 @@ public final class SignMessageSceneViewModel { self.confirmTransferDelegate = confirmTransferDelegate } + public var priceText: String? { + guard let price = payload.payment?.price, let value = BigInt(price.value) else { + return .none + } + return Self.priceFormatter.string(value, decimals: price.decimals.asInt, currency: price.symbol) + } + + public var expiresAt: Date? { + payload.expiresAt + } + + public var expiresTitle: String { + Localized.Transfer.paymentExpiresIn + } + + public var selectedQuoteItem: PaymentQuoteItem? { + payload.payment.map { PaymentQuoteItem(quote: $0.quote, formatter: Self.amountFormatter) } + } + public var networkText: String { payload.chain.networkName } public var title: String { - Localized.Transfer.reviewRequest + isPayment ? Localized.Transfer.paymentTitle : Localized.Transfer.reviewRequest } public var walletText: String { @@ -72,20 +100,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 +133,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 +177,20 @@ public final class SignMessageSceneViewModel { !payloadFields.isEmpty } + public var showsPayload: Bool { + hasPayload && !isPayment + } + + public var isPayment: Bool { + payload.payment != nil + } + public var hasWarnings: Bool { !simulationWarnings.isEmpty } public var isButtonDisabled: Bool { - simulationWarnings.contains(where: { $0.severity == .critical }) + expiryCountdown.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } public var buttonType: ButtonType { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/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/SignMessageSceneViewModelTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift index a72975f853..a0219975f8 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift @@ -5,8 +5,10 @@ import Foundation import struct Gemstone.SignMessage import KeystoreTestKit import Primitives +import SigningRequestService import PrimitivesComponents import PrimitivesTestKit +import SigningRequestServiceTestKit import Testing @testable import WalletConnector import WalletConnectorService @@ -17,9 +19,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 +37,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 +60,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 +81,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 +96,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 +117,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 +136,34 @@ struct SignMessageSceneViewModelTests { #expect(viewModel.networkAssetImage == AssetIdViewModel(assetId: payload.chain.asset.id).networkAssetImage) } + @Test + @MainActor + func buttonDisabledOnceThePaymentQuoteExpires() async throws { + let payload = try SignMessagePayload.mock( + message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), + expiresAt: Date(timeIntervalSinceNow: 0.2), + ) + + let viewModel = SignMessageSceneViewModel( + keystore: KeystoreMock(), + addressNameService: .mock(), + payload: payload, + confirmTransferDelegate: { _ in }, + ) + + #expect(!viewModel.isButtonDisabled) + + await viewModel.expiryCountdown.start() + + #expect(viewModel.isButtonDisabled) + } + @Test @MainActor func buttonEnabledWithNoWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -182,9 +182,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 +207,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 +240,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 +261,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 +335,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 +367,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 +414,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..2ac3b510ef 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: WalletConnectorPresenter(), ) } diff --git a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift index 984b80c1fd..bbd26a5cda 100644 --- a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift +++ b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift @@ -26,6 +26,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { let balanceService: BalanceService private let bannerService: BannerService private let walletSessionService: any WalletSessionManageable + private let payService: any PaymentLinkPayable private let balanceCalculator = BalanceCalculator() let observablePreferences: ObservablePreferences @@ -42,6 +43,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { public var isPresentingSelectedAssetInput: Binding public var isPresentingSheet: WalletSheetType? public var isPresentingSearch = false + public var isPresentingScanner = false public var isPresentingUrl: URL? public var isPresentingToastMessage: ToastMessage? @@ -52,6 +54,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { balanceService: BalanceService, bannerService: BannerService, walletSessionService: any WalletSessionManageable, + payService: any PaymentLinkPayable, nftService: NFTService, observablePreferences: ObservablePreferences, wallet: Wallet, @@ -62,8 +65,9 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { self.balanceService = balanceService self.bannerService = bannerService self.walletSessionService = walletSessionService + self.payService = payService self.observablePreferences = observablePreferences - self.collectionsModel = CollectionsViewModel( + collectionsModel = CollectionsViewModel( nftService: nftService, walletSessionService: walletSessionService, wallet: wallet, @@ -74,7 +78,7 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { bannersQuery = ObservableQuery(BannersRequest(walletId: wallet.id, assetId: .none, chain: .none, events: [.accountBlockedMultiSignature, .onboarding]), initialValue: []) self.isPresentingSelectedAssetInput = isPresentingSelectedAssetInput } - + public var totalFiatValue: TotalFiatValue { balanceCalculator.totalFiatValue(fiatValuesQuery.value) } @@ -111,6 +115,14 @@ public final class WalletSceneViewModel: Sendable, AssetBalanceActions { Images.System.search } + public var scannerImage: Image { + Images.System.qrCodeViewfinder + } + + public var showScanner: Bool { + observablePreferences.isDeveloperEnabled + } + public var manageImage: Image { Images.Actions.manage } @@ -187,6 +199,22 @@ public extension WalletSceneViewModel { isPresentingSearch.toggle() } + func onSelectScanner() { + isPresentingScanner = true + } + + func onHandleScan(_ result: String) { + guard let action = try? URLParser.from(string: result) else { + return isPresentingToastMessage = .error(Localized.Errors.notSupported) + } + switch action { + case .deeplink, .walletConnect: + isPresentingToastMessage = .error(Localized.Errors.notSupported) + case let .payment(link): + Task { await payService.pay(link: link, wallet: wallet) } + } + } + func onSelectAddCustomToken() { isPresentingSheet = .addAsset } diff --git a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift index 1effc3fd57..31b8206a4b 100644 --- a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift +++ b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift @@ -19,6 +19,7 @@ public extension WalletSceneViewModel { balanceService: .mock(), bannerService: .mock(), walletSessionService: WalletSessionService.mock(), + payService: .mock(), nftService: .mock(), observablePreferences: .mock(), wallet: wallet, diff --git a/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift b/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift index 084ee7f487..4401d1ae42 100644 --- a/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift +++ b/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import BannerServiceTestKit +import Localization import Primitives import PrimitivesTestKit @testable import Store @@ -47,4 +48,18 @@ struct WalletSceneViewModelTests { #expect(model.wallet.id == .multicoin(address: "0x2")) } + + @Test + func onHandleScan() { + let model = WalletSceneViewModel.mock() + + model.onHandleScan("https://pay.walletconnect.com/?pid=pay_123") + #expect(model.isPresentingToastMessage == nil) + + model.onHandleScan("wc:abc@2?relay-protocol=irn&symKey=123") + #expect(model.isPresentingToastMessage?.title == Localized.Errors.notSupported) + + model.onHandleScan("WIFI:S:MyNet;T:WPA;P:secret;;") + #expect(model.isPresentingToastMessage?.title == Localized.Errors.notSupported) + } } diff --git a/ios/Gem/App.swift b/ios/Gem/App.swift index d3d5869c20..0e4416ddf9 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -12,6 +12,7 @@ import Store import Style import SwiftUI import WalletService +import SigningRequestService @main struct GemApp: App { @@ -29,6 +30,7 @@ struct GemApp: App { model: RootSceneViewModel( observablePreferences: resolver.storages.observablePreferences, walletConnectorPresenter: resolver.services.walletConnectorManager.presenter, + paymentSheetPresenter: resolver.services.paymentSheetPresenter, onstartService: resolver.services.onstartService, onstartWalletService: resolver.services.onstartWalletService, transactionStateScheduler: resolver.services.transactionStateScheduler, @@ -38,6 +40,7 @@ struct GemApp: App { lockWindowManager: LockWindowManager(lockModel: LockSceneViewModel()), walletService: resolver.services.walletService, walletSessionService: resolver.services.walletSessionService, + payService: resolver.services.paymentLinkManager, walletSetupService: resolver.services.walletSetupService, nameService: resolver.services.nameService, releaseAlertService: resolver.services.releaseAlertService, diff --git a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift index dfb28d7304..d931506100 100644 --- a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift +++ b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift @@ -19,6 +19,7 @@ import Transactions import Transfer import WalletSessionService import WalletTab +import QRScanner struct WalletNavigationStack: View { @Environment(\.assetsEnabler) private var assetsEnabler @@ -81,6 +82,14 @@ struct WalletNavigationStack: View { .navigationBarTitleDisplayMode(.inline) .toolbar { if !model.isPresentingSearch { + if model.showScanner { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: model.onSelectScanner) { + model.scannerImage + } + .accessibilityIdentifier("scan") + } + } ToolbarItem(placement: .principal) { WalletBarView( model: model.walletBarModel, @@ -225,6 +234,9 @@ struct WalletNavigationStack: View { ), ) } + .sheet(isPresented: $model.isPresentingScanner) { + ScanQRCodeNavigationStack(action: model.onHandleScan(_:)) + } .sheet(item: $model.isPresentingSheet) { sheet in Group { switch sheet { diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index 4f44aaaa72..924b208aea 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -4,10 +4,13 @@ import Components import GemstonePrimitives import Localization import Onboarding +import Payments import PriceService import Primitives +import SigningRequestService import Style import SwiftUI +import WalletConnector struct RootScene: View { @Environment(\.scenePhase) private var scenePhase @@ -34,12 +37,18 @@ struct RootScene: View { await model.handleOpenUrl(url) } } - .sheet(item: $model.isPresentingConnectorSheet) { type in + .sheet(item: $model.isPresentingConnectorSheet, onDismiss: model.walletConnectorPresenter.onSheetDismiss) { type in WalletConnectorNavigationStack( type: type, presenter: model.walletConnectorPresenter, ) } + .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.onSheetDismiss) { type in + PaymentNavigationStack( + type: type, + presenter: model.paymentSheetPresenter, + ) + } .sheet(isPresented: $model.isPresentingCreateWalletSheet) { CreateWalletNavigationStack( model: CreateWalletModel( From 0783be4593ae841bd453a891409280f5c0ed2c3b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:32 +0300 Subject: [PATCH 11/53] iOS: wire payments into the app The app composes the payment flow and hosts its sheets, and a deep link or a scanned link reaches it through the shared URL action. Payment errors map to localized copy rather than core's English messages. --- .../ScanQRCodeNavigationStack.swift | 8 +- ios/Gem.xcodeproj/project.pbxproj | 35 +++++++++ ios/Gem/Navigation/NavigationHandler.swift | 4 +- .../Payments/PaymentNavigationStack.swift | 73 +++++++++++++++++++ ios/Gem/Services/AppResolver+Services.swift | 9 +++ .../Services/AppResolver+ViewInjection.swift | 1 + ios/Gem/Services/ServicesFactory.swift | 33 +++++++++ ios/Gem/Services/ViewModelFactory.swift | 6 +- ios/Gem/Types/Environment.swift | 2 + ios/Gem/Types/Errors.swift | 19 +++++ ios/Gem/ViewModels/RootSceneViewModel.swift | 25 +++++++ ios/Gem/Views/MainTabView.swift | 2 + ios/GemTests/unit_frameworks.xctestplan | 21 ++++++ .../Sources/Protocols/ChainServiceable.swift | 2 +- 14 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 ios/Gem/Navigation/Payments/PaymentNavigationStack.swift 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/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index e01c6582a7..00d3d88af1 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 */; }; @@ -35,6 +38,7 @@ 836281B02DEF34DC00CA5C75 /* Assets in Frameworks */ = {isa = PBXBuildFile; productRef = 836281AF2DEF34DC00CA5C75 /* Assets */; }; 837A1E6B2D0B029200733AC1 /* AppResolver+ViewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */; }; 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */; }; + 83AA01012E4A000100000001 /* PaymentNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */; }; 83863EE72D19B93E005048A7 /* NodeService in Frameworks */ = {isa = PBXBuildFile; productRef = 83863EE62D19B93E005048A7 /* NodeService */; }; 839C794B2D1C42A500E32072 /* PrimitivesComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 839C794A2D1C42A500E32072 /* PrimitivesComponents */; }; 83B08BDB2D0B4AE200CA1B33 /* AppResolver+Storages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B08BDA2D0B4AE200CA1B33 /* AppResolver+Storages.swift */; }; @@ -215,6 +219,7 @@ 836281AD2DEF32F600CA5C75 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Assets; sourceTree = ""; }; 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppResolver+ViewInjection.swift"; sourceTree = ""; }; 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectorNavigationStack.swift; sourceTree = ""; }; + 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentNavigationStack.swift; sourceTree = ""; }; 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 +231,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 +330,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 */, @@ -475,6 +484,14 @@ path = Transactions; sourceTree = ""; }; + 83AA01032E4A000100000003 /* Payments */ = { + isa = PBXGroup; + children = ( + 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */, + ); + path = Payments; + sourceTree = ""; + }; 8361BB6F2D402C63008D89CF /* WalletConnector */ = { isa = PBXGroup; children = ( @@ -486,6 +503,7 @@ 8361BB702D402C9D008D89CF /* Navigation */ = { isa = PBXGroup; children = ( + 83AA01032E4A000100000003 /* Payments */, 83A35C612DF2054B00360060 /* Transfer */, 836281B22DEF423400CA5C75 /* Assets */, 83ACA9792DEE01F00085B133 /* Swap */, @@ -712,6 +730,7 @@ 833931A32D2D54060063BB6A /* PriceAlerts */, D9F015752F10654000000001 /* InAppNotifications */, 83FE37B22D2715BF0048D54C /* WalletConnector */, + 0A11FA0000000000000000B3 /* Payments */, D8D83C822CECF90A0083AA53 /* Swap */, 8393897A2CCA8F0000735088 /* InfoSheet */, E1B4C7082E85B00000EARN01 /* Stake */, @@ -855,7 +874,10 @@ 833931A72D2D663C0063BB6A /* PriceService */, B6STREAM02F5000000000002 /* StreamService */, 83385DDD2D27147900D76803 /* WalletConnectorService */, + 0A11FA0000000000000000A2 /* PaymentService */, + 0A11FA0000000000000000E2 /* SigningRequestService */, 83FE37B32D273CC80048D54C /* WalletConnector */, + 0A11FA0000000000000000B2 /* Payments */, 83EB5E2F2D2ECF00006A7CFB /* Onboarding */, 8352C8D42D393E05000DEDD9 /* ExplorerService */, 07A9FE3C2D3565CD00ACDB94 /* NFTService */, @@ -1180,6 +1202,7 @@ B67ED5482DDB5DCD009F74E6 /* NavigationPresenter.swift in Sources */, C366790A2A0B7E5800F1D74D /* Environment.swift in Sources */, 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */, + 83AA01012E4A000100000001 /* PaymentNavigationStack.swift in Sources */, D8D5C1D12E6CAC16007628A1 /* Errors.swift in Sources */, D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */, D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */, @@ -2000,6 +2023,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/Navigation/NavigationHandler.swift b/ios/Gem/Navigation/NavigationHandler.swift index 5bea5ec63b..f983329be1 100644 --- a/ios/Gem/Navigation/NavigationHandler.swift +++ b/ios/Gem/Navigation/NavigationHandler.swift @@ -57,7 +57,7 @@ final class NavigationHandler: Sendable { guard let action = try? URLParser.from(url: url) else { return false } switch action { - case .walletConnect: + case .payment, .walletConnect: return false case .deeplink: Task { await handle(action) } @@ -72,7 +72,7 @@ final class NavigationHandler: Sendable { extension NavigationHandler { private func handleURLAction(_ action: URLAction) async throws { switch action { - case .walletConnect: break + case .payment, .walletConnect: break case let .deeplink(deeplink): try await handleDeepLink(deeplink) } } diff --git a/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift new file mode 100644 index 0000000000..6c08cd46f0 --- /dev/null +++ b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift @@ -0,0 +1,73 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Payments +import Primitives +import SigningRequestService +import Style +import SwiftUI +import Transfer +import WalletConnector + +struct PaymentNavigationStack: View { + @Environment(\.viewModelFactory) private var viewModelFactory + + private let type: PaymentSheetType + private let presenter: PaymentSheetPresenter + + init( + type: PaymentSheetType, + presenter: PaymentSheetPresenter, + ) { + self.type = type + self.presenter = presenter + } + + var body: some View { + NavigationStack { + Group { + switch type { + case let .quotes(data): + PaymentQuotesScene( + model: PaymentQuotesSceneViewModel( + request: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: { presenter.complete(type: type) }, + ) + case let .dataCollection(data): + PaymentDataCollectionScene( + callback: data, + onComplete: { presenter.complete(type: type) }, + ) + case let .confirm(data): + ConfirmTransferNavigationView( + model: viewModelFactory.confirmTransferScene( + wallet: data.payload.wallet, + data: data.payload.transferData, + confirmTransferDelegate: data.delegate, + simulation: data.payload.simulation, + onComplete: { presenter.complete(type: type) }, + ), + ) + case let .signMessage(data): + SignMessageScene( + model: viewModelFactory.signMessageScene( + payload: data.payload, + confirmTransferDelegate: data.delegate, + ), + 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/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index b0c6cb6b7d..54a4433234 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,8 @@ extension AppResolver { let onstartAsyncService: OnstartAsyncService let onstartWalletService: OnstartWalletService let walletConnectorManager: WalletConnectorManager + let paymentLinkManager: PaymentLinkManager + let paymentSheetPresenter: PaymentSheetPresenter let perpetualService: PerpetualService let hyperliquidObserverService: any PerpetualObservable let nameService: NameService @@ -132,6 +137,8 @@ extension AppResolver { onstartAsyncService: OnstartAsyncService, onstartWalletService: OnstartWalletService, walletConnectorManager: WalletConnectorManager, + paymentLinkManager: PaymentLinkManager, + paymentSheetPresenter: PaymentSheetPresenter, perpetualService: PerpetualService, hyperliquidObserverService: any PerpetualObservable, nameService: NameService, @@ -187,6 +194,8 @@ extension AppResolver { self.onstartAsyncService = onstartAsyncService self.onstartWalletService = onstartWalletService self.walletConnectorManager = walletConnectorManager + self.paymentLinkManager = paymentLinkManager + self.paymentSheetPresenter = paymentSheetPresenter self.perpetualService = perpetualService self.hyperliquidObserverService = hyperliquidObserverService self.nameService = nameService diff --git a/ios/Gem/Services/AppResolver+ViewInjection.swift b/ios/Gem/Services/AppResolver+ViewInjection.swift index ef21e00709..722a9f5337 100644 --- a/ios/Gem/Services/AppResolver+ViewInjection.swift +++ b/ios/Gem/Services/AppResolver+ViewInjection.swift @@ -14,6 +14,7 @@ extension View { private func inject(services: AppResolver.Services) -> some View { environment(\.nodeService, services.nodeService) .environment(\.serviceStatusService, services.serviceStatusService) + .environment(\.payService, services.paymentLinkManager) .environment(\.walletService, services.walletService) .environment(\.walletSessionService, services.walletSessionService) .environment(\.assetsEnabler, services.assetsEnabler) diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index 3a8865762b..9a214b5704 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -29,6 +29,8 @@ import NativeProviderService import NFTService import NodeService import NotificationService +import PaymentService +import Payments import PerpetualService import Preferences import PriceAlertService @@ -37,6 +39,7 @@ import Primitives import RewardsService import ScanService import ServiceStatusService +import SigningRequestService import StakeService import Store import StreamService @@ -148,6 +151,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 +163,7 @@ struct ServicesFactory { earnService: earnService, nftService: nftService, balanceService: balanceService, + paymentStatusService: paymentService, ) let preferences = storages.observablePreferences.preferences @@ -233,6 +242,7 @@ struct ServicesFactory { connectionsStore: storeManager.connectionsStore, walletSessionService: walletSessionService, interactor: walletConnectorManager, + signingInteractor: presenter, nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider, ) @@ -295,6 +305,23 @@ struct ServicesFactory { let authService = AuthService(apiService: apiService, keystore: storages.keystore) let rewardsService = RewardsService(apiService: apiService, authService: authService) let eventPresenterService = EventPresenterService() + let paymentAssetsProvider = PaymentAssetsProvider(assetStore: storeManager.assetStore) + let paymentSheetPresenter = PaymentSheetPresenter() + let paymentLinkManager = PaymentLinkManager( + paymentManager: PaymentManager( + service: paymentService, + executor: PaymentActionExecutor( + interactor: paymentSheetPresenter, + simulator: SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), + approvalExecutor: PaymentApprovalExecutor(chainServiceFactory: chainServiceFactory), + assetsProvider: paymentAssetsProvider, + ), + presenter: paymentSheetPresenter, + assetsProvider: paymentAssetsProvider, + transactionStateScheduler: transactionStateScheduler, + ), + eventPresenterService: eventPresenterService, + ) let walletSearchService = WalletSearchService( assetsService: assetsService, searchStore: storeManager.searchStore, @@ -388,6 +415,8 @@ struct ServicesFactory { onstartAsyncService: onstartAsyncService, onstartWalletService: onstartWalletService, walletConnectorManager: walletConnectorManager, + paymentLinkManager: paymentLinkManager, + paymentSheetPresenter: paymentSheetPresenter, perpetualService: perpetualService, hyperliquidObserverService: hyperliquidObserverService, nameService: nameService, @@ -440,6 +469,7 @@ extension ServicesFactory { earnService: EarnService, nftService: NFTService, balanceService: BalanceService, + paymentStatusService: any PaymentStatusServiceable, ) -> TransactionStateScheduler { let postProcessingService = TransactionPostProcessingService( transactionStore: transactionStore, @@ -452,6 +482,7 @@ extension ServicesFactory { transactionStore: transactionStore, gatewayService: gatewayService, postProcessingService: postProcessingService, + paymentStatusService: paymentStatusService, ) return TransactionStateScheduler( transactionStore: transactionStore, @@ -480,6 +511,7 @@ extension ServicesFactory { connectionsStore: ConnectionsStore, walletSessionService: WalletSessionService, interactor: any WalletConnectorInteractable, + signingInteractor: any SigningRequestInteractable, nodeProvider: any NodeURLFetchable, requestInterceptor: any RequestInterceptable, ) -> ConnectionsService { @@ -489,6 +521,7 @@ extension ServicesFactory { connectionsStore: connectionsStore, walletSessionService: walletSessionService, walletConnectorInteractor: interactor, + signingInteractor: signingInteractor, ), nodeProvider: nodeProvider, requestInterceptor: requestInterceptor, diff --git a/ios/Gem/Services/ViewModelFactory.swift b/ios/Gem/Services/ViewModelFactory.swift index 919e8139c4..f1b3868ddb 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 f384fd5f75..1c0bfa66cd 100644 --- a/ios/Gem/Types/Errors.swift +++ b/ios/Gem/Types/Errors.swift @@ -4,6 +4,7 @@ import Foundation import Gemstone import Localization import Primitives +import WalletConnector extension Gemstone.GatewayError: @retroactive LocalizedError { public var errorDescription: String? { @@ -36,6 +37,24 @@ extension Gemstone.SwapperError: @retroactive LocalizedError { } } +extension Gemstone.PaymentError: @retroactive LocalizedError { + public var errorDescription: String? { + switch self { + case .PaymentExpired, .QuoteExpired: + return Localized.Errors.paymentExpired + case .Rejected: + return Localized.Errors.paymentNotAllowed + case .PaymentNotFound, .RateLimited: + return Localized.Transaction.Status.failed + case .NoPaymentOptions, .UnsupportedAccounts, .NotSupported: + return Localized.Errors.notSupported + case let .InvalidRequest(message), let .Network(message): + debugLog("PaymentError \(message)") + return Localized.Errors.errorOccurred + } + } +} + extension Gemstone.AlienError: @retroactive LocalizedError { public var errorDescription: String? { switch self { diff --git a/ios/Gem/ViewModels/RootSceneViewModel.swift b/ios/Gem/ViewModels/RootSceneViewModel.swift index 213cdb75b6..95f9cb1b0e 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 @@ -39,9 +41,11 @@ final class RootSceneViewModel { let walletSetupService: WalletSetupService let walletService: WalletService let walletSessionService: any WalletSessionManageable + let payService: any PaymentLinkPayable let nameService: NameService let avatarService: AvatarService let walletConnectorPresenter: WalletConnectorPresenter + let paymentSheetPresenter: PaymentSheetPresenter let lockManager: any LockWindowManageable var currentWallet: Wallet? { walletSessionService.currentWallet @@ -64,6 +68,11 @@ final class RootSceneViewModel { set { walletConnectorPresenter.isPresentingSheet = newValue } } + var isPresentingPaymentSheet: PaymentSheetType? { + get { paymentSheetPresenter.isPresentingSheet } + set { paymentSheetPresenter.isPresentingSheet = newValue } + } + var isPresentingConnectorBar: Bool { get { walletConnectorPresenter.isPresentingConnectionBar } set { walletConnectorPresenter.isPresentingConnectionBar = newValue } @@ -79,6 +88,7 @@ final class RootSceneViewModel { init( observablePreferences: ObservablePreferences, walletConnectorPresenter: WalletConnectorPresenter, + paymentSheetPresenter: PaymentSheetPresenter, onstartService: OnstartService, onstartWalletService: OnstartWalletService, transactionStateScheduler: TransactionStateScheduler, @@ -88,6 +98,7 @@ final class RootSceneViewModel { lockWindowManager: any LockWindowManageable, walletService: WalletService, walletSessionService: any WalletSessionManageable, + payService: any PaymentLinkPayable, walletSetupService: WalletSetupService, nameService: NameService, releaseAlertService: ReleaseAlertService, @@ -98,6 +109,7 @@ final class RootSceneViewModel { ) { self.observablePreferences = observablePreferences self.walletConnectorPresenter = walletConnectorPresenter + self.paymentSheetPresenter = paymentSheetPresenter self.onstartService = onstartService self.onstartWalletService = onstartWalletService self.transactionStateScheduler = transactionStateScheduler @@ -107,6 +119,7 @@ final class RootSceneViewModel { lockManager = lockWindowManager self.walletService = walletService self.walletSessionService = walletSessionService + self.payService = payService self.walletSetupService = walletSetupService self.nameService = nameService self.releaseAlertService = releaseAlertService @@ -159,6 +172,11 @@ extension RootSceneViewModel { switch action { case let .walletConnect(walletConnectAction): try await handleWalletConnect(walletConnectAction) + case let .payment(link): + guard observablePreferences.isDeveloperEnabled else { + throw AnyError(Localized.Errors.notSupported) + } + await handlePayment(link) case .deeplink: await navigationHandler.handle(action) } @@ -231,6 +249,13 @@ extension RootSceneViewModel { ) } + private func handlePayment(_ link: PaymentLink) async { + guard let wallet = currentWallet else { + return + } + await payService.pay(link: link, wallet: wallet) + } + private func handleWalletConnect(_ action: WalletConnectAction) async throws { isPresentingConnectorBar = true switch action { diff --git a/ios/Gem/Views/MainTabView.swift b/ios/Gem/Views/MainTabView.swift index 0e8cd1dd85..e4b91e32fb 100644 --- a/ios/Gem/Views/MainTabView.swift +++ b/ios/Gem/Views/MainTabView.swift @@ -19,6 +19,7 @@ struct MainTabView: View { @Environment(\.navigationState) private var navigationState @Environment(\.navigationPresenter) private var presenter @Environment(\.nftService) private var nftService + @Environment(\.payService) private var payService @Environment(\.priceService) private var priceService @Environment(\.observablePreferences) private var observablePreferences @Environment(\.walletSessionService) private var walletSessionService @@ -47,6 +48,7 @@ struct MainTabView: View { balanceService: balanceService, bannerService: bannerService, walletSessionService: walletSessionService, + payService: payService, nftService: nftService, observablePreferences: observablePreferences, wallet: model.wallet, diff --git a/ios/GemTests/unit_frameworks.xctestplan b/ios/GemTests/unit_frameworks.xctestplan index 078ffca49e..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 } } } From 71a71943e415737765cfdbd3b312903d0200a70c Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:44 +0300 Subject: [PATCH 12/53] Android: consume the payment models and reconcile payments Mappers keep the generated uniffi types out of feature code. A relayed payment has no hash the wallet broadcast, so the repository records it against the payment id and reconciles it when the gateway settles. --- .../WalletConnectSimulationService.kt | 4 +- .../transaction/GetTransactionDetailsImpl.kt | 5 +- .../transaction/GetTransactionsImpl.kt | 3 +- .../repositories/di/TransactionsModule.kt | 3 + .../TransactionsRepositoryImpl.kt | 57 +++++++++++ .../reown/ReownWalletConnectClient.kt | 4 +- .../kotlin/com/gemwallet/android/Constants.kt | 1 + .../values/TransactionDetailsValue.kt | 1 + .../com/gemwallet/android/ext/GemPayment.kt | 37 +++++++ .../com/gemwallet/android/ext/Payment.kt | 17 ++++ .../android/ext/SignableTransaction.kt | 98 +++++++++++++++++++ .../android/ext/TransactionExtendedExt.kt | 9 ++ .../gemwallet/android/ext/WalletConnector.kt | 10 +- .../gemwallet/android/model/ConfirmParams.kt | 6 +- .../android/ui/components/QRScanner.kt | 15 ++- .../components/message/SignMessageContent.kt | 93 ++++++++++++++++++ 16 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Payment.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/message/SignMessageContent.kt 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/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/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt index b3cf24af8e..cb72175757 100644 --- a/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt +++ b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt @@ -3,6 +3,7 @@ package com.gemwallet.android.data.service.walletconnect.reown import android.app.Application import android.content.Context import android.util.Log +import com.gemwallet.android.Constants import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthObject import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthPayloadParams import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthenticationRequest @@ -50,7 +51,7 @@ class ReownWalletConnectClient @Inject constructor( override fun initialize(onSuccess: () -> Unit, onError: (String) -> Unit) { CoreClient.initialize( application = context as Application, - projectId = PROJECT_ID, + projectId = Constants.WALLET_CONNECT_PROJECT_ID, metaData = Core.Model.AppMetaData( name = "Gem Wallet", description = "Gem Web3 Wallet", @@ -286,7 +287,6 @@ class ReownWalletConnectClient @Inject constructor( private companion object { const val TAG = "WalletConnect" - const val PROJECT_ID = "3bc07cd7179d11ea65335fb9377702b6" } } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt index 3aff2031c9..f2db336ac9 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/Constants.kt @@ -6,4 +6,5 @@ object Constants { const val ASSETS_URL = "https://assets.gemwallet.com" const val DEVICE_STREAM_PATH = "/v2/devices/stream" const val DEVICE_STREAM_WEBSOCKET_URL = "wss://$API_HOST$DEVICE_STREAM_PATH" + const val WALLET_CONNECT_PROJECT_ID = "3bc07cd7179d11ea65335fb9377702b6" } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/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..7e16976de3 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt @@ -2,16 +2,8 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.Account import com.wallet.core.primitives.WalletConnectionSessionAppMetadata -import uniffi.gemstone.GemWalletConnectionSessionAppMetadata import uniffi.gemstone.walletConnectAppShortName -fun WalletConnectionSessionAppMetadata.toGem() = GemWalletConnectionSessionAppMetadata( - name = name, - description = description, - url = url, - icon = icon, -) - fun Account.toGem() = uniffi.gemstone.Account( chain = chain.string, address = address, @@ -20,7 +12,7 @@ fun Account.toGem() = uniffi.gemstone.Account( ) val WalletConnectionSessionAppMetadata.shortName: String - get() = walletConnectAppShortName(toGem()) + get() = walletConnectAppShortName(name) fun List?.walletConnectIcon(): String { return this?.firstOrNull { it.endsWith("png", ignoreCase = true) || it.endsWith("jpg", ignoreCase = true) } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt index 97decda16e..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/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, + ) + } + } + } +} From f72393cc2313312d4c1f1e31ce3a186d5d639b01 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:16:49 +0300 Subject: [PATCH 13/53] Android: pay payment links The payment screen mirrors the iOS flow: quote selection, the compliance web view, then the actions. The sign message review components are shared with the WalletConnect bridge instead of duplicated. --- android/app/build.gradle.kts | 2 + android/app/src/debug/AndroidManifest.xml | 18 + .../android/PendingNavigationCoordinator.kt | 5 + .../com/gemwallet/android/di/GatewayModule.kt | 20 + .../android/features/main/views/MainScreen.kt | 18 + .../android/ui/navigation/RootRoute.kt | 3 + .../android/ui/navigation/WalletNavGraph.kt | 3 + .../android/ui/navigation/routes/Payment.kt | 21 + .../components/DestinationPropertyItem.kt | 5 + .../features/assets/views/AssetsAction.kt | 1 + .../features/assets/views/AssetsScreen.kt | 9 +- .../features/assets/views/AssetsTopBar.kt | 15 + .../assets/viewmodels/AssetsViewModel.kt | 4 + .../features/bridge/views/AuthRequestScene.kt | 9 +- .../features/bridge/views/RequestScene.kt | 14 +- .../views/WalletConnectReviewContent.kt | 64 --- .../bridge/viewmodels/model/WCRequest.kt | 119 +----- .../confirm/viewmodels/ConfirmErrorMapper.kt | 5 +- .../payment/presents/build.gradle.kts | 71 ++++ .../payment/presents/consumer-rules.pro | 0 .../payment/presents/proguard-rules.pro | 21 + .../presents/PaymentDataCollectionScene.kt | 110 ++++++ .../features/payment/presents/PaymentScene.kt | 369 ++++++++++++++++++ .../payment/viewmodels/build.gradle.kts | 67 ++++ .../payment/viewmodels/consumer-rules.pro | 0 .../payment/viewmodels/proguard-rules.pro | 21 + .../payment/viewmodels/ActivePayment.kt | 44 +++ .../payment/viewmodels/PaymentSceneState.kt | 66 ++++ .../payment/viewmodels/PaymentViewModel.kt | 316 +++++++++++++++ .../payment/viewmodels/RecordPayment.kt | 70 ++++ .../model/PaymentMerchantUIModel.kt | 13 + .../viewmodels/model/PaymentOutcomeUIModel.kt | 20 + .../viewmodels/model/PaymentQuoteUIModel.kt | 35 ++ .../payment/viewmodels/ActivePaymentTest.kt | 77 ++++ .../recipient/viewmodel/RecipientViewModel.kt | 12 +- .../viewmodels/ManageContactViewModel.kt | 8 +- android/settings.gradle.kts | 2 + 37 files changed, 1474 insertions(+), 183 deletions(-) create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt create mode 100644 android/features/payment/presents/build.gradle.kts create mode 100644 android/features/payment/presents/consumer-rules.pro create mode 100644 android/features/payment/presents/proguard-rules.pro create mode 100644 android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt create mode 100644 android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt create mode 100644 android/features/payment/viewmodels/build.gradle.kts create mode 100644 android/features/payment/viewmodels/consumer-rules.pro create mode 100644 android/features/payment/viewmodels/proguard-rules.pro create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt create mode 100644 android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt create mode 100644 android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a68fde4469..1e39755b31 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -261,6 +261,8 @@ dependencies { implementation(project(":features:wallet-details:presents")) implementation(project(":features:bridge:presents")) implementation(project(":features:bridge:viewmodels")) + implementation(project(":features:payment:presents")) + implementation(project(":features:payment:viewmodels")) implementation(project(":features:assets:presents")) implementation(project(":features:assets:viewmodels")) implementation(project(":features:perpetual:presents")) diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..db5d48d386 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt index 8416866fab..36481d302b 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import com.gemwallet.android.ui.navigation.routes.PaymentRoute import uniffi.gemstone.UrlAction import uniffi.gemstone.WalletConnectLink import uniffi.gemstone.urlAction @@ -56,6 +57,10 @@ class PendingNavigationCoordinator @Inject constructor( return } } + is UrlAction.Payment -> { + 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..bbfe265087 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 @@ -22,10 +22,15 @@ import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit import uniffi.gemstone.AlienProvider import uniffi.gemstone.GemGateway +import uniffi.gemstone.GemPaymentConfig import uniffi.gemstone.GemPreferences import uniffi.gemstone.GemServiceStatus +import uniffi.gemstone.GemWalletConnectPayAuth import uniffi.gemstone.serviceStatusTimeoutSeconds import uniffi.gemstone.WalletConnectSimulationClient +import com.gemwallet.android.application.device.coordinators.GetDeviceId +import kotlinx.coroutines.runBlocking +import uniffi.gemstone.GemPaymentService import uniffi.gemstone.WalletConnectSimulationClientInterface import javax.inject.Singleton @@ -105,6 +110,21 @@ object GatewayModule { return ServiceStatusService(GemServiceStatus(provider)) } + @Provides + @Singleton + fun provideGemPaymentService( + alienProvider: AlienProvider, + getDeviceId: GetDeviceId, + ): GemPaymentService = GemPaymentService( + provider = alienProvider, + config = GemPaymentConfig( + walletConnectPay = GemWalletConnectPayAuth( + appId = Constants.WALLET_CONNECT_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/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt b/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt index c10c4ebf9d..86e7510bfd 100644 --- a/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt +++ b/android/features/activities/presents/src/main/kotlin/com/gemwallet/android/features/activities/presents/details/components/DestinationPropertyItem.kt @@ -35,5 +35,10 @@ fun DestinationPropertyItem(property: TransactionDetailsValue.Destination, listP data = { PropertyDataText(text = property.data) }, listPosition = listPosition, ) + is TransactionDetailsValue.Destination.Merchant -> PropertyItem( + title = { PropertyTitleText(R.string.transaction_recipient) }, + data = { PropertyDataText(text = property.data) }, + listPosition = listPosition, + ) } } diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt index 8f10b90b2e..0ffe139c56 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt @@ -7,6 +7,7 @@ sealed interface AssetsAction { data object ShowWallets : AssetsAction data object Manage : AssetsAction data object Search : AssetsAction + data object Scan : AssetsAction data object Send : AssetsAction data object Receive : AssetsAction data object Buy : AssetsAction diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt index d7211afa37..6e302997a6 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt @@ -110,7 +110,14 @@ fun AssetsScreen( Scaffold( modifier = Modifier.fillMaxSize(), - topBar = { AssetsTopBar(walletSummary, { onAction(AssetsAction.ShowWallets) }, { onAction(AssetsAction.Search) }) }, + topBar = { + AssetsTopBar( + walletSummary, + { onAction(AssetsAction.ShowWallets) }, + { onAction(AssetsAction.Search) }, + { onAction(AssetsAction.Scan) }.takeIf { viewModel.showScanner }, + ) + }, snackbarHost = { SnackbarHost(snackbar) }, contentWindowInsets = WindowInsets(0, 0, 0, 0), containerColor = MaterialTheme.colorScheme.surface, diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt index 5b443caf3d..f9ad6c0802 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,20 @@ internal fun AssetsTopBar( } } }, + navigationIcon = { + if (onScan != null) { + 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/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt index fa664b379c..8e85e42668 100644 --- a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt @@ -18,6 +18,7 @@ import com.gemwallet.android.ui.models.AssetToast import com.gemwallet.android.ui.models.AssetToastEmitter import com.gemwallet.android.ui.models.AssetToastEmitterImpl import com.gemwallet.android.ext.isNftSupported +import com.gemwallet.android.data.repositories.config.UserConfig import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletType @@ -44,8 +45,11 @@ class AssetsViewModel @Inject constructor( getHideBalancesState: GetHideBalancesState, getShowWelcomeBanner: GetShowWelcomeBanner, getSession: GetSession, + userConfig: UserConfig, ) : ViewModel(), AssetToastEmitter by AssetToastEmitterImpl() { + val showScanner: Boolean = userConfig.developEnabled() + val currentWalletId = getSession() .map { it?.wallet?.id } .distinctUntilChanged() diff --git a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt index ab0496f2d5..fe46a4b622 100644 --- a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt +++ b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/AuthRequestScene.kt @@ -22,6 +22,9 @@ import com.gemwallet.android.model.AuthRequest import com.gemwallet.android.data.repositories.bridge.WalletConnectAuthenticationRequest import com.gemwallet.android.data.repositories.bridge.WalletConnectVerifyContext import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.message.SignMessageFullMessageSheet +import com.gemwallet.android.ui.components.message.SignMessagePayloadDetailsSheet +import com.gemwallet.android.ui.components.message.signMessageText import com.gemwallet.android.ui.components.buttons.MainActionButton import com.gemwallet.android.ui.models.ButtonState import com.gemwallet.android.ui.components.list_head.CenteredListHead @@ -163,14 +166,14 @@ private fun AuthRequestContent( onDetailsClick = { sheetType = AuthRequestSheetType.Details }, ) } else { - walletConnectTextMessage(state.approval.message) + signMessageText(state.approval.message) } } } when (sheetType) { AuthRequestSheetType.Details -> { - WalletConnectPayloadDetailsSheet( + SignMessagePayloadDetailsSheet( primaryFields = state.approval.primaryPayloadFields, secondaryFields = state.approval.secondaryPayloadFields, onViewFullMessage = { sheetType = AuthRequestSheetType.FullMessage }, @@ -178,7 +181,7 @@ private fun AuthRequestContent( ) } AuthRequestSheetType.FullMessage -> { - WalletConnectFullMessageSheet( + SignMessageFullMessageSheet( message = state.approval.message, onDismissRequest = { sheetType = null }, ) diff --git a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt index b7fbd4fec7..c7addb809e 100644 --- a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt +++ b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/RequestScene.kt @@ -32,6 +32,10 @@ import com.gemwallet.android.ui.components.list_item.property.PropertyItem import com.gemwallet.android.ui.components.list_item.property.PropertyNetworkItem import com.gemwallet.android.ui.components.screen.LoadingScene import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.components.message.SignMessageFullMessageSheet +import com.gemwallet.android.ui.components.message.SignMessageSheetType +import com.gemwallet.android.ui.components.message.SignMessagePayloadDetailsSheet +import com.gemwallet.android.ui.components.message.signMessageText import com.gemwallet.android.ui.components.simulation.simulationPayloadFieldsContent import com.gemwallet.android.ui.components.simulation.simulationWarningsContent import com.gemwallet.android.ui.models.ListPosition @@ -155,13 +159,13 @@ private fun SignMessageScene( } if (!request.hasPayload) { - walletConnectTextMessage(request.plainMessage) + signMessageText(request.plainMessage) } } when (sheetType) { SignMessageSheetType.Details -> { - WalletConnectPayloadDetailsSheet( + SignMessagePayloadDetailsSheet( primaryFields = request.primaryPayloadFields, secondaryFields = request.secondaryPayloadFields, onViewFullMessage = { sheetType = SignMessageSheetType.FullMessage }, @@ -170,7 +174,7 @@ private fun SignMessageScene( } SignMessageSheetType.FullMessage -> { - WalletConnectFullMessageSheet( + SignMessageFullMessageSheet( message = request.plainMessage, onDismissRequest = { sheetType = null }, ) @@ -181,7 +185,3 @@ private fun SignMessageScene( } } -private enum class SignMessageSheetType { - Details, - FullMessage, -} diff --git a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt index ba82566c75..1baee2d114 100644 --- a/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt +++ b/android/features/bridge/presents/src/main/kotlin/com/gemwallet/android/features/bridge/views/WalletConnectReviewContent.kt @@ -29,70 +29,6 @@ import com.gemwallet.android.ui.theme.paddingDefault import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletId -internal fun LazyListScope.walletConnectTextMessage(message: String) { - item { - SubheaderItem(R.string.sign_message_message) - Text( - modifier = Modifier - .fillMaxWidth() - .listItem() - .padding(paddingDefault), - text = message, - ) - } -} - -@Composable -internal fun WalletConnectPayloadDetailsSheet( - primaryFields: List, - secondaryFields: List, - onViewFullMessage: () -> Unit, - onDismissRequest: () -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - title = stringResource(R.string.common_details), - ) { - LazyColumn { - simulationPayloadDetailsContent( - primaryFields = primaryFields, - secondaryFields = secondaryFields, - ) - item { - PropertyItem( - action = R.string.sign_message_view_full_message, - listPosition = ListPosition.Single, - onClick = onViewFullMessage, - ) - } - } - } -} - -@Composable -internal fun WalletConnectFullMessageSheet( - message: String, - onDismissRequest: () -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - title = stringResource(R.string.sign_message_view_full_message), - ) { - LazyColumn( - contentPadding = PaddingValues(paddingDefault), - ) { - item { - Text( - modifier = Modifier.fillMaxWidth(), - text = message, - ) - } - } - } -} - @Composable internal fun WalletSelectionSheet( isVisible: Boolean, diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt index a2833f4db9..207ee7fdd4 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 @@ -23,8 +25,8 @@ import uniffi.gemstone.TransferDataOutputType import uniffi.gemstone.WalletConnect import uniffi.gemstone.WalletConnectAction import uniffi.gemstone.WalletConnectResponseType -import uniffi.gemstone.WalletConnectTransaction -import uniffi.gemstone.WalletConnectTransactionType +import uniffi.gemstone.SignableTransaction +import uniffi.gemstone.SignableTransactionType import java.math.BigInteger sealed class WCRequest( @@ -103,7 +105,7 @@ sealed class WCRequest( appMetadata: WalletConnectionSessionAppMetadata, val isSendable: Boolean, val inputType: ConfirmParams.TransferParams.InputType, - val transactionType: WalletConnectTransactionType, + val transactionType: SignableTransactionType, val data: String, val simulation: SimulationResult, ) : WCRequest(sessionRequest, account, appMetadata) { @@ -117,7 +119,7 @@ sealed class WCRequest( sessionRequest: WalletConnectSessionRequest, account: Account, appMetadata: WalletConnectionSessionAppMetadata, - transactionType: WalletConnectTransactionType, + transactionType: SignableTransactionType, data: String, simulation: SimulationResult, ) : Transaction( @@ -154,7 +156,7 @@ sealed class WCRequest( sessionRequest: WalletConnectSessionRequest, account: Account, appMetadata: WalletConnectionSessionAppMetadata, - transactionType: WalletConnectTransactionType, + transactionType: SignableTransactionType, data: String, simulation: SimulationResult, ) : Signing( @@ -198,99 +200,18 @@ internal fun WalletConnectResponseType.payload(): String = when (this) { is WalletConnectResponseType.String -> value } -private fun WalletConnectTransaction.map( +private fun SignableTransaction.map( request: WCRequest.Transaction, isSendable: Boolean, -): Generic { - val asset = request.chain.asset() - return when (this) { - is WalletConnectTransaction.Ethereum -> Generic( - requestId = request.requestId.toString(), - asset = asset, - from = request.account, - memo = data.data, - name = request.name, - description = request.description, - url = request.url, - icon = request.icon, - gasLimit = data.gasLimit, - inputType = request.inputType, - destination = DestinationAddress(data.to), - amount = data.value?.hexToBigInteger() ?: BigInteger.ZERO, - isSendable = isSendable, - decodedTransactionType = transactionType.toPrimitives(), - ) - is WalletConnectTransaction.Solana -> Generic( - requestId = request.requestId.toString(), - asset = asset, - from = request.account, - memo = data.transaction, - name = request.name, - description = request.description, - url = request.url, - icon = request.icon, - gasLimit = "", - inputType = when (outputType) { - TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction - TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature - }, - destination = DestinationAddress(""), - amount = BigInteger.ZERO, - isSendable = isSendable, - ) - is WalletConnectTransaction.Sui -> Generic( - requestId = request.requestId.toString(), - asset = asset, - from = request.account, - memo = data.transaction, - name = request.name, - description = request.description, - url = request.url, - icon = request.icon, - gasLimit = "", - inputType = when (outputType) { - TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction - TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature - }, - destination = DestinationAddress(""), - amount = BigInteger.ZERO, - isSendable = isSendable, - ) - is WalletConnectTransaction.Ton -> Generic( - requestId = request.requestId.toString(), - asset = asset, - from = request.account, - memo = data, - name = request.name, - description = request.description, - url = request.url, - icon = request.icon, - gasLimit = "", - inputType = when (outputType) { - TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction - TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature - }, - destination = DestinationAddress(""), - amount = BigInteger.ZERO, - isSendable = isSendable, - ) - is WalletConnectTransaction.Tron -> Generic( - requestId = request.requestId.toString(), - asset = asset, - memo = data, - from = request.account, - name = request.name, - description = request.description, - url = request.url, - icon = request.icon, - gasLimit = "", - inputType = when (outputType) { - TransferDataOutputType.ENCODED_TRANSACTION -> ConfirmParams.TransferParams.InputType.EncodeTransaction - TransferDataOutputType.SIGNATURE -> ConfirmParams.TransferParams.InputType.Signature - }, - destination = DestinationAddress(""), - amount = BigInteger.ZERO, - isSendable = isSendable, - ) - } -} +): Generic = toConfirmParams( + requestId = request.requestId.toString(), + account = request.account, + 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 f33a591b54..598a9b675c 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt @@ -1,12 +1,15 @@ package com.gemwallet.android.features.confirm.viewmodels +import android.util.Log import com.gemwallet.android.domains.confirm.ConfirmError import com.gemwallet.android.ext.toGemNetworkError internal fun Throwable.toPreloadConfirmError(): ConfirmError = toGemNetworkError() ?.let { ConfirmError.NetworkError(it) } - ?: ConfirmError.PreloadError + ?: ConfirmError.PreloadError.also { Log.e(TAG, "Preload failed", this) } + +private const val TAG = "ConfirmPreload" internal fun Throwable.toBroadcastConfirmError(): ConfirmError = when (this) { is ConfirmError -> this diff --git a/android/features/payment/presents/build.gradle.kts b/android/features/payment/presents/build.gradle.kts new file mode 100644 index 0000000000..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..30a6505d86 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt @@ -0,0 +1,44 @@ +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 results: List = emptyList(), + val completed: Int = 0, +) { + val step: Step? + get() = actions.getOrNull(completed)?.let { Step(it, completed) } + + fun collecting(quote: GemPaymentQuote) = copy(collecting = quote) + + fun prepared(quote: GemPaymentQuote, actions: List) = copy( + quote = quote, + collecting = null, + actions = actions, + results = List(actions.size) { "" }, + completed = 0, + ) + + fun completing(result: String): ActivePayment { + val index = completed.takeIf { it in actions.indices } ?: return this + return copy( + results = results.toMutableList().also { it[index] = result }, + completed = completed + 1, + ) + } + + val isRelayed: Boolean + get() = actions.none { it is PaymentAction.SendTransaction } + + data class Step(val action: PaymentAction, val index: Int) +} diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt new file mode 100644 index 0000000000..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..a004e60099 --- /dev/null +++ b/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt @@ -0,0 +1,77 @@ +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 `results keep the gateway's action order`() { + var payment = payment(listOf(approve(), signMessage())) + + payment = payment.completing("approval-hash") + payment = payment.completing("signature") + + assertEquals(listOf("approval-hash", "signature"), payment.results) + assertNull(payment.step) + } + + @Test + fun `completing past the last action is a no-op`() { + var payment = payment(listOf(signMessage())) + payment = payment.completing("signature") + + val settled = payment.completing("duplicate") + + assertEquals(listOf("signature"), settled.results) + assertEquals(1, settled.completed) + } + + private fun payment(actions: List): ActivePayment = + ActivePayment( + provider = 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/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") From 769e06fe8957b060dec1e157097bb3b8b865fad7 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:34:49 +0300 Subject: [PATCH 14/53] Android: approve only what the payment asked for The gateway parses a bounded approval and the wallet was discarding it, requesting an unlimited allowance on every payment. TokenApprovalParams now carries the parsed approval; swap keeps the unlimited default it relies on. --- .../android/features/payment/viewmodels/PaymentViewModel.kt | 2 ++ .../main/kotlin/com/gemwallet/android/model/ConfirmParams.kt | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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 index bfc08b2afb..d5b4b67e01 100644 --- 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 @@ -19,6 +19,7 @@ import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIM 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.model.toModel import com.gemwallet.android.ui.models.withExplorerLinks import com.wallet.core.primitives.Account import com.wallet.core.primitives.Asset @@ -243,6 +244,7 @@ class PaymentViewModel @Inject constructor( data = "", provider = current.quotes.merchant.name, contract = action.approval.spender, + approval = action.approval.toModel(), ) ) } 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 059ebb53db..f308208182 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 @@ -312,6 +312,7 @@ sealed class ConfirmParams() { val data: String, val provider: String, val contract: String, + val approval: ApprovalData? = null, ) : ConfirmParams() { override val useMaxAmount: Boolean = false @@ -323,8 +324,8 @@ sealed class ConfirmParams() { GemApprovalData( assetId.tokenId!!, spender = contract, - value = amount.toString(), - isUnlimited = true, + value = approval?.value ?: amount.toString(), + isUnlimited = approval?.isUnlimited ?: true, ) ) From 1b6764eb023332bf33d732c8aca408264366b888 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:36:57 +0300 Subject: [PATCH 15/53] core: keep the buyer's asset when a payment quote is requoted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quote that expires mid-flow was refetched and, if the chosen asset was no longer offered, silently replaced with the first one — a different asset and amount than the buyer picked. It now fails as an expired quote instead. --- .../payment/src/wallet_connect_pay/service.rs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/core/crates/payment/src/wallet_connect_pay/service.rs b/core/crates/payment/src/wallet_connect_pay/service.rs index 875fd322ca..3f1af1bf6c 100644 --- a/core/crates/payment/src/wallet_connect_pay/service.rs +++ b/core/crates/payment/src/wallet_connect_pay/service.rs @@ -97,9 +97,8 @@ impl WalletConnectPayService { .quotes .iter() .find(|quote| "e.amount.asset_id == asset_id) - .or(quotes.quotes.first()) .cloned() - .ok_or(PaymentError::NoPaymentOptions) + .ok_or(PaymentError::QuoteExpired) } async fn payment_actions(&self, quote: &PaymentQuote) -> Result, PaymentError> { @@ -158,7 +157,7 @@ mod tests { use chrono::Duration; use gem_client::ClientError; use gem_client::testkit::MockClient; - use primitives::{AssetId, Chain, PaymentAmount}; + use primitives::{AssetId, Chain, PaymentAmount, PaymentMerchant}; use std::sync::{Arc, Mutex}; fn service(mock: MockClient) -> WalletConnectPayService { @@ -228,6 +227,38 @@ mod tests { assert_eq!(paths.iter().filter(|path| path.contains("/fetch")).count(), 1); } + #[test] + fn test_get_quote_keeps_the_chosen_asset() { + let quote = |chain: Chain| PaymentQuote { + id: chain.as_ref().to_string(), + payment_id: "pay_123".to_string(), + amount: PaymentAmount { + asset_id: AssetId::from_chain(chain), + value: "1".to_string(), + symbol: chain.as_ref().to_string(), + decimals: 6, + }, + expires_at: None, + collect_data_url: None, + provider_data: "{}".to_string(), + }; + let quotes = PaymentQuotes { + merchant: PaymentMerchant { + name: "Merchant".to_string(), + icon_url: None, + }, + price: None, + expires_at: None, + quotes: vec![quote(Chain::Ethereum), quote(Chain::Bitcoin)], + }; + + let chosen = WalletConnectPayService::::get_quote("es, &AssetId::from_chain(Chain::Bitcoin)).unwrap(); + assert_eq!(chosen.amount.asset_id, AssetId::from_chain(Chain::Bitcoin)); + + let gone = WalletConnectPayService::::get_quote("es, &AssetId::from_chain(Chain::Solana)); + assert_eq!(gone, Err(PaymentError::QuoteExpired)); + } + fn addresses() -> Vec { vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string()), ChainAddress::new(Chain::Bitcoin, "bc1".to_string())] } From e197bba674b76c129cc172538171ca1554f979b8 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:53:39 +0300 Subject: [PATCH 16/53] Android: match the iOS payment screens Carry the payment metadata on Generic confirm params instead of a flag, so the confirmation titles itself as a payment on every chain, and build that metadata in one place for both the recorded transaction and the confirmation. Lead the sign screen with the amount and merchant, list merchant, wallet, network and expiry, and title the quote picker. Give the data collection WebView a definite height and honour the page viewport meta, so CSS viewport units no longer resolve to zero and clip the form away. Open disallowed hosts outside the payment as iOS does, keep third-party cookies, and log why a page failed. --- .../confirm/presents/ConfirmScreen.kt | 10 +-- .../presents/PaymentDataCollectionScene.kt | 67 ++++++++++++++--- .../features/payment/presents/PaymentScene.kt | 71 ++++++++++++------- .../payment/viewmodels/ActivePayment.kt | 8 +++ .../payment/viewmodels/PaymentSceneState.kt | 3 + .../payment/viewmodels/PaymentViewModel.kt | 17 +++-- .../payment/viewmodels/RecordPayment.kt | 12 +--- .../viewmodels/model/PaymentQuoteUIModel.kt | 15 +++- .../com/gemwallet/android/ext/GemPayment.kt | 7 ++ .../android/ext/SignableTransaction.kt | 15 ++-- .../gemwallet/android/model/ConfirmParams.kt | 2 + 11 files changed, 171 insertions(+), 56 deletions(-) diff --git a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt index d98b67076e..5305260610 100644 --- a/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt +++ b/android/features/confirm/presents/src/main/kotlin/com/gemwallet/android/features/confirm/presents/ConfirmScreen.kt @@ -102,7 +102,8 @@ fun ConfirmScreen( val detailElements by viewModel.detailElements.collectAsStateWithLifecycle() val payloadAddressNames by viewModel.payloadAddressNames.collectAsStateWithLifecycle() val buttonState by viewModel.buttonState.collectAsStateWithLifecycle() - val isWalletConnect = params is ConfirmParams.TransferParams.Generic + val isPayment = (params as? ConfirmParams.TransferParams.Generic)?.payment != null + val isWalletConnect = params is ConfirmParams.TransferParams.Generic && !isPayment val displayTxProperties = if (isWalletConnect) txProperties.reorderWalletConnectProperties() else txProperties var showSelectTxSpeed by remember { mutableStateOf(false) } @@ -129,7 +130,7 @@ fun ConfirmScreen( val perpetualType by viewModel.perpetualType.collectAsStateWithLifecycle() Scene( - title = confirmTitle(isWalletConnect, amountModel?.transactionType, perpetualType), + title = confirmTitle(params, amountModel?.transactionType, perpetualType), closeIcon = isWalletConnect, onClose = { cancelAction() }, mainAction = { @@ -403,11 +404,12 @@ fun ConfirmError.toLabel() = when (this) { @Composable private fun confirmTitle( - isWalletConnect: Boolean, + params: ConfirmParams?, transactionType: TransactionType?, perpetualType: PerpetualType?, ): String = when { - isWalletConnect -> stringResource(R.string.transfer_review_request) + params is ConfirmParams.TransferParams.Generic && params.payment != null -> stringResource(R.string.transfer_payment_title) + params is ConfirmParams.TransferParams.Generic -> stringResource(R.string.transfer_review_request) perpetualType != null -> perpetualType.title() else -> stringResource(transactionType?.getTitle() ?: R.string.transfer_title) } diff --git a/android/features/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 index 22b4fbf124..cac80f7564 100644 --- 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 @@ -1,21 +1,36 @@ package com.gemwallet.android.features.payment.presents import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ApplicationInfo +import android.graphics.Bitmap +import android.util.Log +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.webkit.ConsoleMessage +import android.webkit.CookieManager import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.open import org.json.JSONObject private const val MESSAGE_HANDLER = "payDataCollectionComplete" @@ -24,6 +39,7 @@ 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" +private const val TAG = "PaymentDataCollection" @SuppressLint("SetJavaScriptEnabled") @Composable @@ -34,6 +50,7 @@ fun PaymentDataCollectionScene( onCancel: () -> Unit, ) { var webView by remember { mutableStateOf(null) } + val uriHandler = LocalUriHandler.current BackHandler { val view = webView @@ -45,12 +62,22 @@ fun PaymentDataCollectionScene( onClose = onCancel, ) { AndroidView( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxWidth() + .weight(1f), factory = { context -> + if (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + WebView.setWebContentsDebuggingEnabled(true) + } WebView(context).apply { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) settings.javaScriptEnabled = true settings.domStorageEnabled = true - webViewClient = AllowedHostWebViewClient() + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webViewClient = AllowedHostWebViewClient(context, uriHandler) + webChromeClient = LoggingWebChromeClient() addJavascriptInterface(CollectDataBridge(onComplete, onError), MESSAGE_HANDLER) loadUrl(url) webView = this @@ -74,21 +101,45 @@ private class CollectDataBridge( } } -private class AllowedHostWebViewClient : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView?, request: android.webkit.WebResourceRequest?): Boolean { +private class AllowedHostWebViewClient( + private val context: Context, + private val uriHandler: UriHandler, +) : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val uri = request?.url ?: return true + if (uri.scheme != "https" && uri.scheme != "http") return true val host = uri.host?.lowercase() ?: return true - val allowed = uri.scheme == "https" && (host == ALLOWED_HOST || host.endsWith(".$ALLOWED_HOST")) - return !allowed + if (uri.scheme == "https" && (host == ALLOWED_HOST || host.endsWith(".$ALLOWED_HOST"))) return false + uriHandler.open(context, uri.toString()) + return true } - override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { view?.evaluateJavascript(BRIDGE_SHIM, null) } override fun onPageFinished(view: WebView?, url: String?) { view?.evaluateJavascript(BRIDGE_SHIM, null) } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + if (request?.isForMainFrame != true) return + Log.e(TAG, "Load ${request.url}: ${error?.errorCode} ${error?.description}") + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, response: WebResourceResponse?) { + if (request?.isForMainFrame != true) return + Log.e(TAG, "Load ${request.url}: HTTP ${response?.statusCode}") + } +} + +private class LoggingWebChromeClient : WebChromeClient() { + override fun onConsoleMessage(message: ConsoleMessage?): Boolean { + if (message?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { + Log.e(TAG, "Console ${message.sourceId()}:${message.lineNumber()} ${message.message()}") + } + return false + } } private val BRIDGE_SHIM = """ 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 index ee085c0a39..b6674c0f74 100644 --- 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 @@ -30,10 +30,14 @@ 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 androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import com.gemwallet.android.ui.components.buttons.MainActionButton import com.gemwallet.android.ui.components.image.IconWithBadge import com.gemwallet.android.ui.components.list_head.CenteredListHead +import com.gemwallet.android.ui.components.list_head.CenteredListHeadSubtitleLayout import com.gemwallet.android.ui.components.list_item.ListItem +import com.gemwallet.android.ui.components.list_item.getBalanceInfo import com.gemwallet.android.ui.components.list_item.ListItemSupportText import com.gemwallet.android.ui.components.list_item.ListItemTitleText import com.gemwallet.android.ui.components.list_item.SelectionCheckmark @@ -213,6 +217,7 @@ private fun PaymentQuotesSelectModal( ) { ModalBottomSheet( isVisible = isVisible, + title = stringResource(R.string.transfer_pay_with), onDismissRequest = onDismissRequest, ) { LazyColumn { @@ -220,22 +225,21 @@ private fun PaymentQuotesSelectModal( ListItem( modifier = Modifier.clickable { onSelect(quote.id) }, leading = { + IconWithBadge( + icon = quote.iconUrl, + placeholder = quote.symbol, + supportIcon = quote.supportIconUrl, + ) + }, + title = { ListItemTitleText(quote.name) }, + subtitle = { ListItemSupportText(quote.networkName) }, + trailing = { + getBalanceInfo(quote.amountText, quote.balance, false).invoke() if (quote.id == selected) { - IconWithBadge( - icon = quote.iconUrl, - placeholder = quote.symbol, - badge = { SelectionCheckmark() }, - ) - } else { - IconWithBadge( - icon = quote.iconUrl, - placeholder = quote.symbol, - supportIcon = quote.supportIconUrl, - ) + Spacer(modifier = Modifier.width(paddingSmall)) + SelectionCheckmark() } }, - title = { ListItemTitleText(quote.symbol) }, - trailing = { ListItemSupportText(quote.amount) }, listPosition = ListPosition.getPosition(index, quotes.size), ) } @@ -274,7 +278,7 @@ private fun PaymentSignMessageScene( var sheetType by remember { mutableStateOf(null) } Scene( - title = stringResource(R.string.transfer_review_request), + title = stringResource(R.string.transfer_payment_title), backHandle = true, closeIcon = true, onClose = onCancel, @@ -290,20 +294,39 @@ private fun PaymentSignMessageScene( ) { item { CenteredListHead( - icon = state.merchant.iconUrl, - title = state.merchant.name, + icon = state.quote?.iconUrl ?: state.merchant.iconUrl, + title = state.quote?.amountText ?: state.merchant.name, + subtitle = state.price, placeholderText = state.merchant.name.firstOrNull()?.uppercaseChar()?.toString(), + subtitleLayout = CenteredListHeadSubtitleLayout.Vertical, ) } - item { PropertyItem(R.string.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 }, + item { PropertyItem(R.string.transfer_merchant, state.merchant.name, listPosition = ListPosition.First) } + item { PropertyItem(R.string.common_wallet, state.walletName, listPosition = ListPosition.Middle) } + item { + PropertyNetworkItem( + state.chain, + listPosition = if (state.expiresAt == null) ListPosition.Last else ListPosition.Middle, ) - } else { - signMessageText(state.plainMessage) + } + state.expiresAt?.let { expiresAt -> + item { + PropertyExpiryItem( + title = stringResource(R.string.transfer_payment_expires_in), + expiresAt = expiresAt, + listPosition = ListPosition.Last, + ) + } + } + if (state.quote == null) { + if (state.hasPayload) { + simulationPayloadFieldsContent( + fields = state.primaryPayloadFields, + onDetailsClick = { sheetType = SignMessageSheetType.Details }, + ) + } else { + signMessageText(state.plainMessage) + } } } 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 index 30a6505d86..e3504dd38a 100644 --- 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 @@ -1,5 +1,7 @@ package com.gemwallet.android.features.payment.viewmodels +import com.gemwallet.android.ext.toPrimitives +import com.wallet.core.primitives.TransactionPaymentMetadata import com.wallet.core.primitives.Wallet import uniffi.gemstone.GemPaymentProviderName import uniffi.gemstone.GemPaymentQuote @@ -19,6 +21,12 @@ internal data class ActivePayment( val step: Step? get() = actions.getOrNull(completed)?.let { Step(it, completed) } + fun paymentMetadata(quote: GemPaymentQuote) = TransactionPaymentMetadata( + paymentId = quote.paymentId, + merchant = quotes.merchant.toPrimitives(), + provider = provider.toPrimitives(), + ) + fun collecting(quote: GemPaymentQuote) = copy(collecting = quote) fun prepared(quote: GemPaymentQuote, actions: List) = copy( 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 index 5e36302e04..248a0b533d 100644 --- 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 @@ -41,6 +41,9 @@ sealed interface PaymentSceneState { val merchant: PaymentMerchantUIModel, val chain: Chain, val walletName: String, + val quote: PaymentQuoteUIModel?, + val price: String?, + val expiresAt: Long?, val plainMessage: String, val primaryPayloadFields: List, val secondaryPayloadFields: List, 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 index d5b4b67e01..e7a6d48424 100644 --- 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 @@ -18,6 +18,7 @@ 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.AssetInfo import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.toModel import com.gemwallet.android.ui.models.withExplorerLinks @@ -142,13 +143,13 @@ class PaymentViewModel @Inject constructor( } } - private fun GemPaymentQuotes.toSceneState(wallet: Wallet) = PaymentSceneState.Quotes( + private suspend 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() }, + quotes = quotes.map { it.toUIModel(it.amount.assetId.toAssetId()?.let { id -> assetInfo(id) }) }, selected = quotes.firstOrNull()?.id, expiresAt = expiresAt?.times(1000), expired = false, @@ -194,7 +195,7 @@ class PaymentViewModel @Inject constructor( if (step == null) { val quote = current.quote ?: return if (current.isRelayed) { - recordPayment(current.provider.toPrimitives(), current.quotes, quote, current.wallet) + recordPayment(current.paymentMetadata(quote), quote, current.wallet) } val settled = runCatchingCancellable { paymentService.confirmPayment(current.provider, quote, current.results) @@ -221,6 +222,9 @@ class PaymentViewModel @Inject constructor( merchant = current.quotes.merchant.toUIModel(), chain = chain, walletName = current.wallet.name, + quote = current.quote?.toUIModel(), + price = current.quotes.price?.toPriceText(), + expiresAt = current.quotes.expiresAt?.times(1000), plainMessage = signer?.let { runCatching { it.plainPreview() }.getOrNull() }.orEmpty(), primaryPayloadFields = preview?.primary?.map { it.toPrimitives() }.orEmpty() .withExplorerLinks(chain, null), @@ -267,6 +271,7 @@ class PaymentViewModel @Inject constructor( icon = current.quotes.merchant.iconUrl.orEmpty(), ), isSendable = isSendable, + payment = current.quote?.let(current::paymentMetadata), inputType = if (isSendable) { ConfirmParams.TransferParams.InputType.EncodeTransaction } else { @@ -281,10 +286,12 @@ class PaymentViewModel @Inject constructor( return PaymentSceneState.Error(error) } - private suspend fun asset(assetId: AssetId): Asset? = getAssetInfo(assetId).firstOrNull()?.asset + private suspend fun asset(assetId: AssetId): Asset? = assetInfo(assetId)?.asset + + private suspend fun assetInfo(assetId: AssetId): AssetInfo? = getAssetInfo(assetId).firstOrNull() ?: sessionRepository.session().firstOrNull()?.currency ?.also { searchTokensCase.search(assetId, it) } - ?.let { getAssetInfo(assetId).firstOrNull()?.asset } + ?.let { getAssetInfo(assetId).firstOrNull() } private suspend fun wallet(): Wallet? { val wallet = sessionRepository.session().firstOrNull()?.wallet 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 index 968fd1ffc9..bc6e9fea49 100644 --- 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 @@ -7,15 +7,12 @@ 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 @@ -23,8 +20,7 @@ class RecordPayment @Inject constructor( private val createTransaction: CreateTransaction, ) { suspend operator fun invoke( - provider: PaymentProviderName, - quotes: GemPaymentQuotes, + payment: TransactionPaymentMetadata, quote: GemPaymentQuote, wallet: Wallet, ) { @@ -54,11 +50,7 @@ class RecordPayment @Inject constructor( 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(), + metadata = payment.toJson(), direction = TransactionDirection.Outgoing, blockNumber = "0", ) 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 index ad5227e2b9..8d23cddaca 100644 --- 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 @@ -2,7 +2,9 @@ package com.gemwallet.android.features.payment.viewmodels.model import com.gemwallet.android.domains.asset.getIconUrl import com.gemwallet.android.domains.asset.getSupportIconUrl +import com.gemwallet.android.ext.asset import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.model.Crypto import com.gemwallet.android.model.ValueFormatter import uniffi.gemstone.GemPaymentPrice @@ -13,23 +15,34 @@ private val priceFormatter = ValueFormatter(ValueFormatter.Style.Full) data class PaymentQuoteUIModel( val id: String, + val name: String, + val networkName: String, val symbol: String, val amount: String, + val balance: String, val iconUrl: String?, val supportIconUrl: String?, ) { val amountText: String get() = "$amount $symbol" } -fun GemPaymentQuote.toUIModel(): PaymentQuoteUIModel { +fun GemPaymentQuote.toUIModel(assetInfo: AssetInfo? = null): PaymentQuoteUIModel { val assetId = amount.assetId.toAssetId() return PaymentQuoteUIModel( id = id, + name = assetInfo?.asset?.name ?: amount.symbol, + networkName = assetId?.chain?.asset()?.name.orEmpty(), symbol = amount.symbol, amount = amountFormatter.string(Crypto(amount.value).value(amount.decimals)), + balance = assetInfo?.balanceText().orEmpty(), iconUrl = assetId?.getIconUrl(), supportIconUrl = assetId?.getSupportIconUrl(), ) } fun GemPaymentPrice.toPriceText(): String = priceFormatter.string(Crypto(value).value(decimals), currency = symbol) + +private fun AssetInfo.balanceText(): String = amountFormatter.string( + Crypto(balance.balance.available).value(asset.decimals), + currency = asset.symbol, +) diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt index 794e8f71ca..c65e3ae911 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt @@ -2,10 +2,12 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.Payment import com.wallet.core.primitives.PaymentLink +import com.wallet.core.primitives.PaymentMerchant import com.wallet.core.primitives.PaymentProviderName import com.wallet.core.primitives.PaymentRequest import uniffi.gemstone.GemPayment import uniffi.gemstone.GemPaymentLink +import uniffi.gemstone.GemPaymentMerchant import uniffi.gemstone.GemPaymentProviderName import uniffi.gemstone.GemPaymentRequest @@ -26,6 +28,11 @@ fun GemPaymentLink.toPrimitives(): PaymentLink = PaymentLink( id = id, ) +fun GemPaymentMerchant.toPrimitives(): PaymentMerchant = PaymentMerchant( + name = name, + iconUrl = iconUrl, +) + fun GemPaymentProviderName.toPrimitives(): PaymentProviderName = when (this) { GemPaymentProviderName.SOLANA_PAY -> PaymentProviderName.SolanaPay GemPaymentProviderName.WALLET_CONNECT_PAY -> PaymentProviderName.WalletConnectPay 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 index c3b2605152..897203a85a 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt @@ -4,6 +4,7 @@ import com.gemwallet.android.math.hexToBigInteger import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.DestinationAddress import com.wallet.core.primitives.Account +import com.wallet.core.primitives.TransactionPaymentMetadata import uniffi.gemstone.SignableTransaction import uniffi.gemstone.TransferDataOutputType import java.math.BigInteger @@ -21,6 +22,7 @@ fun SignableTransaction.toConfirmParams( app: SigningRequestApp, isSendable: Boolean, inputType: ConfirmParams.TransferParams.InputType?, + payment: TransactionPaymentMetadata? = null, ): ConfirmParams.TransferParams.Generic { val asset = account.chain.asset() return when (this) { @@ -36,11 +38,12 @@ fun SignableTransaction.toConfirmParams( amount = data.value?.hexToBigInteger() ?: BigInteger.ZERO, isSendable = isSendable, transactionType = transactionType.toPrimitives(), + payment = payment, ) - 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) + is SignableTransaction.Solana -> encoded(requestId, asset, account, app, data.transaction, outputType, isSendable, payment) + is SignableTransaction.Sui -> encoded(requestId, asset, account, app, data.transaction, outputType, isSendable, payment) + is SignableTransaction.Ton -> encoded(requestId, asset, account, app, data, outputType, isSendable, payment) + is SignableTransaction.Tron -> encoded(requestId, asset, account, app, data, outputType, isSendable, payment) } } @@ -52,6 +55,7 @@ private fun encoded( payload: String, outputType: TransferDataOutputType, isSendable: Boolean, + payment: TransactionPaymentMetadata?, ) = generic( requestId = requestId, asset = asset, @@ -66,6 +70,7 @@ private fun encoded( destination = DestinationAddress(""), amount = BigInteger.ZERO, isSendable = isSendable, + payment = payment, ) private fun generic( @@ -80,6 +85,7 @@ private fun generic( amount: BigInteger, isSendable: Boolean, transactionType: com.wallet.core.primitives.TransactionType = com.wallet.core.primitives.TransactionType.SmartContractCall, + payment: TransactionPaymentMetadata? = null, ) = ConfirmParams.TransferParams.Generic( requestId = requestId, asset = asset, @@ -95,4 +101,5 @@ private fun generic( amount = amount, isSendable = isSendable, decodedTransactionType = transactionType, + payment = payment, ) 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 f308208182..b772977d33 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt @@ -22,6 +22,7 @@ import com.wallet.core.primitives.DelegationValidator import com.wallet.core.primitives.NFTAsset import com.wallet.core.primitives.PerpetualType import com.wallet.core.primitives.Resource +import com.wallet.core.primitives.TransactionPaymentMetadata import com.wallet.core.primitives.TransactionType import com.wallet.core.primitives.ApprovalData import kotlinx.serialization.Serializable @@ -190,6 +191,7 @@ sealed class ConfirmParams() { val icon: String, val gasLimit: String?, val decodedTransactionType: TransactionType = TransactionType.SmartContractCall, + val payment: TransactionPaymentMetadata? = null, ) : TransferParams() { override fun toDto(): GemTransactionInputType { val type = requireNotNull(inputType) { "inputType is required for Generic transactions" } From 0a48c628f408cfccba7d506ebcc6f99b1cccc0b4 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:01:58 +0300 Subject: [PATCH 17/53] core: validate where a payment collects data The gateway names the page that collects personal data, and both apps allowed whatever host it named. Reject a collection url that is not on a WalletConnect host, so a compromised response cannot hand the wallet webview an arbitrary origin along with its completion bridge. --- .../payment/src/wallet_connect_pay/service.rs | 65 ++++++++++++++++--- .../src/payment_decoder/wallet_connect_pay.rs | 7 ++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/core/crates/payment/src/wallet_connect_pay/service.rs b/core/crates/payment/src/wallet_connect_pay/service.rs index 3f1af1bf6c..9f633220d7 100644 --- a/core/crates/payment/src/wallet_connect_pay/service.rs +++ b/core/crates/payment/src/wallet_connect_pay/service.rs @@ -10,6 +10,7 @@ use crate::wallet_connect_pay::model::{PaymentOption, WalletConnectPayAction, Wa use crate::wallet_connect_pay::payment_mapper; use crate::wallet_connect_pay::quote::QuotedOption; use crate::{PaymentAction, PreparedPayment}; +use primitives::payment_decoder::wallet_connect_pay::{WALLET_CONNECT_HOST, is_wallet_connect_url}; #[derive(Debug)] pub struct WalletConnectPayService { @@ -75,7 +76,7 @@ impl WalletConnectPayService { return Err(PaymentError::PaymentExpired); } - let quotes = Self::payment_quotes(payment_id, quoted.options); + let quotes = Self::payment_quotes(payment_id, quoted.options)?; if quotes.is_empty() { return Err(PaymentError::NoPaymentOptions); } @@ -120,17 +121,24 @@ impl WalletConnectPayService { Ok(payment_mapper::map_payment_outcome(response)) } - fn payment_quotes(payment_id: &str, options: Vec) -> Vec { + fn payment_quotes(payment_id: &str, options: Vec) -> Result, PaymentError> { let (open, collecting): (Vec, Vec) = options.into_iter().partition(|option| option.collect_data_url.is_none()); open.into_iter() .chain(collecting) - .map(|option| 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, + .map(|option| { + if let Some(url) = &option.collect_data_url + && !is_wallet_connect_url(url) + { + return Err(PaymentError::InvalidRequest(format!("Payment collects data outside {WALLET_CONNECT_HOST}"))); + } + Ok(PaymentQuote { + id: option.id, + payment_id: payment_id.to_string(), + amount: option.amount, + expires_at: option.expires_at, + collect_data_url: option.collect_data_url, + provider_data: option.provider_data, + }) }) .collect() } @@ -360,11 +368,48 @@ mod tests { provider_data: format!("{{\"id\":\"{id}\"}}"), }; - let quotes = WalletConnectPayService::::payment_quotes("pay_123", vec![option("opt_form", Some("https://form")), option("opt_plain", None)]); + let quotes = WalletConnectPayService::::payment_quotes( + "pay_123", + vec![option("opt_form", Some("https://pay.walletconnect.com/collect?pid=pay_123")), option("opt_plain", None)], + ) + .unwrap(); assert_eq!(quotes.len(), 2); assert!(quotes[0].collect_data_url.is_none()); assert!(quotes[1].collect_data_url.is_some()); assert!(quotes.iter().all(|quote| quote.payment_id == "pay_123")); } + + #[test] + fn test_payment_quotes_reject_a_collection_url_off_the_payment_host() { + let option = |url: &str| QuotedOption { + id: "opt_form".to_string(), + expires_at: None, + chain: Chain::Ethereum, + amount: PaymentAmount { + asset_id: AssetId::from(Chain::Ethereum, None), + value: "1".to_string(), + symbol: "ETH".to_string(), + decimals: 18, + }, + collect_data_url: Some(url.to_string()), + provider_data: "{}".to_string(), + }; + + for url in [ + "https://evil.com/collect", + "http://pay.walletconnect.com/collect", + "https://pay.walletconnect.com.evil.com/collect", + "https://notwalletconnect.com/collect", + "not a url", + ] { + assert!( + WalletConnectPayService::::payment_quotes("pay_123", vec![option(url)]).is_err(), + "{url} was accepted" + ); + } + + assert!(WalletConnectPayService::::payment_quotes("pay_123", vec![option("https://pay.walletconnect.com/collect")]).is_ok()); + assert!(WalletConnectPayService::::payment_quotes("pay_123", vec![option("https://data-collection.walletconnect.com/ic/pay_123")]).is_ok()); + } } diff --git a/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs b/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs index 4dc63a1f1b..b7157b8ea5 100644 --- a/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs +++ b/core/crates/primitives/src/payment_decoder/wallet_connect_pay.rs @@ -5,6 +5,8 @@ use crate::url_query::query_value; use crate::{HTTP_URL_SCHEME, HTTPS_URL_SCHEME, WalletConnectLink}; pub const WALLET_CONNECT_PAY_HOST: &str = "pay.walletconnect.com"; +pub const WALLET_CONNECT_HOST: &str = "walletconnect.com"; +const WALLET_CONNECT_HOST_SUFFIX: &str = ".walletconnect.com"; const WALLET_CONNECT_PAY_HOST_SUFFIX: &str = ".pay.walletconnect.com"; const PAYMENT_ID_PREFIX: &str = "pay_"; const PAYMENT_ID_EXTRA_CHARACTERS: &str = "-._~"; @@ -55,6 +57,11 @@ fn from_pairing_uri(url: &Url) -> Option { from_payment_url(&payment_url) } +pub fn is_wallet_connect_url(uri: &str) -> bool { + Url::parse(uri) + .is_ok_and(|url| url.scheme() == HTTPS_URL_SCHEME && url.host_str().is_some_and(|host| host == WALLET_CONNECT_HOST || host.ends_with(WALLET_CONNECT_HOST_SUFFIX))) +} + fn is_payment_host(url: &Url) -> bool { url.scheme() == "https" && url From 6ef9645f5bbe228881cad5af0ebccf62edc3514d Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:01:58 +0300 Subject: [PATCH 18/53] core: format the payment models --- core/crates/primitives/src/lib.rs | 4 ++-- .../primitives/src/testkit/transaction_load_input_mock.rs | 4 ++-- core/gemstone/src/payment/mod.rs | 5 +---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index ae16acd6ee..c97fa50d38 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -210,8 +210,8 @@ pub mod wallet_import; pub use self::wallet_import::WalletImport; pub mod wallet_connector; pub use self::wallet_connector::{ - WCPairingProposal, WalletConnectionSessionAppMetadata, WalletConnectionSession, WalletConnectionSessionProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, - WalletConnectionState, WalletConnectionVerificationStatus, + WCPairingProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, WalletConnectionSession, WalletConnectionSessionAppMetadata, + WalletConnectionSessionProposal, WalletConnectionState, WalletConnectionVerificationStatus, }; pub mod nft; pub use self::nft::{NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollection, NFTCollectionId, NFTData, NFTImages, NFTResource, NFTType, ReportNft}; 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 ecc00f2854..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, - TransactionAppMetadata, TransferDataOutputType, + Asset, Chain, GasPriceType, SignerInput, TransactionAppMetadata, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, + TransferDataOutputAction, TransferDataOutputType, }; use num_bigint::BigInt; use std::collections::HashMap; diff --git a/core/gemstone/src/payment/mod.rs b/core/gemstone/src/payment/mod.rs index 00c06599b9..081f6b2458 100644 --- a/core/gemstone/src/payment/mod.rs +++ b/core/gemstone/src/payment/mod.rs @@ -60,10 +60,7 @@ pub struct GemPaymentConfig { impl From for PaymentConfig { fn from(config: GemPaymentConfig) -> Self { - PaymentConfig::new(WalletConnectPayAuth::new( - config.wallet_connect_pay.app_id, - config.wallet_connect_pay.client_id, - )) + PaymentConfig::new(WalletConnectPayAuth::new(config.wallet_connect_pay.app_id, config.wallet_connect_pay.client_id)) } } From e1fcf670eb666f86bdfc2d03320868f4e6a2c8c4 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:31:22 +0300 Subject: [PATCH 19/53] iOS: let the gateway confirm a payment approval The approve is broadcast through ConfirmTransferScene and the gateway's /confirm long-polls while it mines, so waiting for it on device duplicates work the server already does. Matches Android, which never waited. Removes PaymentApprovalExecutor, TransactionConfirmationWaiter, their protocol and mock, and the ChainService and GemstonePrimitives dependencies they were the only users of. --- ios/Features/Payments/Package.swift | 3 -- .../Services/PaymentActionExecutor.swift | 15 +----- .../Services/PaymentApprovalExecutor.swift | 20 ------- .../TransactionConfirmationWaiter.swift | 54 ------------------- .../PaymentActionExecutorTests.swift | 24 ++++----- .../PaymentsTests/PaymentManagerTests.swift | 2 +- ios/Gem/Services/ServicesFactory.swift | 1 - .../PaymentApprovalExecutable.swift | 8 --- .../PaymentApprovalExecutableMock.swift | 15 ------ 9 files changed, 11 insertions(+), 131 deletions(-) delete mode 100644 ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift delete mode 100644 ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift delete mode 100644 ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift delete mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift diff --git a/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift index 352cb18ec6..b3a3e91ad8 100644 --- a/ios/Features/Payments/Package.swift +++ b/ios/Features/Payments/Package.swift @@ -21,7 +21,6 @@ let package = Package( .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: "EventPresenterService", path: "../EventPresenterService"), .package(name: "Formatters", path: "../../Packages/Formatters"), ], @@ -34,11 +33,9 @@ let package = Package( "Localization", "Style", "PrimitivesComponents", - "GemstonePrimitives", "Formatters", "EventPresenterService", .product(name: "SigningRequestService", package: "ChainServices"), - .product(name: "ChainService", package: "ChainServices"), .product(name: "PaymentService", package: "ChainServices"), .product(name: "TransactionStateService", package: "FeatureServices"), ], diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift index d5796efda5..822966d59d 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -1,6 +1,5 @@ // Copyright (c). Gem Wallet. All rights reserved. -import BigInt import Foundation import PaymentService import Primitives @@ -13,18 +12,15 @@ public struct PaymentActionResults: Sendable { 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 } @@ -40,7 +36,6 @@ public struct PaymentActionExecutor: Sendable { ) async throws -> PaymentActionResults { var results = [String](repeating: "", count: actions.count) var transactionHash: String? - var approvals: [String] = [] for (index, action) in actions.enumerated() { let value = try await perform( action: action, @@ -51,19 +46,11 @@ public struct PaymentActionExecutor: Sendable { ) results[index] = value - switch action { - case .sendTransaction: + if case .sendTransaction = action { transactionHash = value - case .approveToken: - approvals.append(value) - case .signMessage, .signTransaction: - break } } onSubmitted() - for hash in approvals { - try await approvalExecutor.waitForApproval(hash: hash, assetId: payment.quote.amount.assetId, wallet: wallet) - } return PaymentActionResults(results: results, transactionHash: transactionHash) } } diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift deleted file mode 100644 index 208ae22283..0000000000 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import ChainService -import Foundation -import PaymentService -import Primitives - -public struct PaymentApprovalExecutor: PaymentApprovalExecutable { - private let chainServiceFactory: any ChainServiceFactorable - - public init(chainServiceFactory: any ChainServiceFactorable) { - self.chainServiceFactory = chainServiceFactory - } - - 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) - } -} diff --git a/ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift b/ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift deleted file mode 100644 index 0f5e7aa88f..0000000000 --- a/ios/Features/Payments/Sources/Payments/Services/TransactionConfirmationWaiter.swift +++ /dev/null @@ -1,54 +0,0 @@ -// 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/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift index bf56562294..8b2450f4f5 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -1,6 +1,5 @@ // Copyright (c). Gem Wallet. All rights reserved. -import BigInt import Foundation import PaymentService import Primitives @@ -18,7 +17,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() interactor.transactionHash = "transaction-hash" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [PaymentAction.sendTransaction(chain: .ethereum, transaction: .sui("transaction", .encodedTransaction))], paymentId: "pay_1", appMetadata: .mock(), @@ -34,9 +33,8 @@ struct PaymentActionExecutorTests { func relayedPaymentHasNoTransactionOfItsOwn() async throws { let interactor = SigningRequestInteractableMock() interactor.signature = "permit-signature" - let executor = PaymentApprovalExecutableMock() - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [ .approveToken(chain: .ethereum, approval: ApprovalData(token: "0xtoken", spender: "0xspender", value: "1", isUnlimited: true)), .mockSignMessage(data: Data("permit".utf8)), @@ -49,7 +47,6 @@ struct PaymentActionExecutorTests { #expect(results.results == ["1", "permit-signature"]) #expect(results.transactionHash == nil) - #expect(executor.confirmedHashes == ["1"]) } @Test @@ -58,7 +55,7 @@ struct PaymentActionExecutorTests { interactor.signature = "permit-signature" interactor.transactionHash = "approval-hash" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [ .sendTransaction(chain: .ethereum, transaction: .sui("approval", .encodedTransaction)), .mockSignMessage(data: Data("permit".utf8)), @@ -78,7 +75,7 @@ struct PaymentActionExecutorTests { 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(assetsData: [.mock()])).perform( + _ = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [PaymentAction.mockSignMessage(data: Data("pay".utf8))], paymentId: "pay_1", appMetadata: .mock(), @@ -90,16 +87,14 @@ struct PaymentActionExecutorTests { } @Test - func paymentIsRecordedAfterTheApprovalIsBroadcastAndBeforeItIsMined() async throws { + func paymentIsRecordedOnlyAfterEveryActionIsSubmitted() async throws { let interactor = SigningRequestInteractableMock() - let executor = PaymentApprovalExecutableMock() var approvalsWhenRecorded: Int? - var confirmationsWhenRecorded: Int? + var signaturesWhenRecorded: Int? _ = try await PaymentActionExecutor( interactor: interactor, simulator: SigningSimulatableMock(), - approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()]), ).perform( actions: [ @@ -112,13 +107,12 @@ struct PaymentActionExecutorTests { wallet: .mock(), onSubmitted: { approvalsWhenRecorded = interactor.sentTransferData.count - confirmationsWhenRecorded = executor.confirmedHashes.count + signaturesWhenRecorded = interactor.signMessagePayloads.count }, ) #expect(approvalsWhenRecorded == 1) - #expect(confirmationsWhenRecorded == 0) - #expect(executor.confirmedHashes == ["1"]) + #expect(signaturesWhenRecorded == 1) } @Test @@ -129,7 +123,7 @@ struct PaymentActionExecutorTests { result: SimulationResult(warnings: [warning], balanceChanges: [], payload: [], header: .none), ) - _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [PaymentAction.mockSignMessage(data: Data("{}".utf8))], paymentId: "pay_1", appMetadata: .mock(), diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift index 1e850ce9f1..50d26c6333 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -24,7 +24,7 @@ struct PaymentManagerTests { ) -> PaymentManager { PaymentManager( service: service, - executor: PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()), + executor: PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock()), presenter: presenter, assetsProvider: PaymentAssetsProvidableMock(), transactionStateScheduler: .mock( diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index 9a214b5704..5c440899ef 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -313,7 +313,6 @@ struct ServicesFactory { executor: PaymentActionExecutor( interactor: paymentSheetPresenter, simulator: SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), - approvalExecutor: PaymentApprovalExecutor(chainServiceFactory: chainServiceFactory), assetsProvider: paymentAssetsProvider, ), presenter: paymentSheetPresenter, diff --git a/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift b/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift deleted file mode 100644 index f502ef6232..0000000000 --- a/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public protocol PaymentApprovalExecutable: Sendable { - func waitForApproval(hash: String, assetId: AssetId, wallet: Wallet) async throws -} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift deleted file mode 100644 index 0e8a465a94..0000000000 --- a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import PaymentService -import Primitives - -public final class PaymentApprovalExecutableMock: PaymentApprovalExecutable, @unchecked Sendable { - public private(set) var confirmedHashes: [String] = [] - - public init() {} - - public func waitForApproval(hash: String, assetId _: AssetId, wallet _: Wallet) async throws { - confirmedHashes.append(hash) - } -} From f33395571f57b137132469b44d4e6ddc4dbb757c Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:34:34 +0300 Subject: [PATCH 20/53] Stop identifying the wallet to the payment gateway The gateway requires a Client-Id alongside the project App-Id but never validates it, so sending the device id shipped the identity we use for our own device auth to a third party. Send a per-process random id instead: stable across a payment's calls, tied to nothing. --- .../main/kotlin/com/gemwallet/android/di/GatewayModule.kt | 6 ++---- ios/Gem/Services/ServicesFactory.swift | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) 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 bbfe265087..a1585c7393 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 @@ -28,11 +28,10 @@ import uniffi.gemstone.GemServiceStatus import uniffi.gemstone.GemWalletConnectPayAuth import uniffi.gemstone.serviceStatusTimeoutSeconds import uniffi.gemstone.WalletConnectSimulationClient -import com.gemwallet.android.application.device.coordinators.GetDeviceId -import kotlinx.coroutines.runBlocking import uniffi.gemstone.GemPaymentService import uniffi.gemstone.WalletConnectSimulationClientInterface import javax.inject.Singleton +import java.util.UUID @InstallIn(SingletonComponent::class) @Module @@ -114,13 +113,12 @@ object GatewayModule { @Singleton fun provideGemPaymentService( alienProvider: AlienProvider, - getDeviceId: GetDeviceId, ): GemPaymentService = GemPaymentService( provider = alienProvider, config = GemPaymentConfig( walletConnectPay = GemWalletConnectPayAuth( appId = Constants.WALLET_CONNECT_PROJECT_ID, - clientId = runBlocking { getDeviceId.getDeviceId() }, + clientId = UUID.randomUUID().toString(), ), ), ) diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index 5c440899ef..87d4c53e63 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -154,7 +154,7 @@ struct ServicesFactory { let paymentService = PaymentService( provider: nativeProvider, appId: Constants.WalletConnect.projectId, - clientId: (try? securePreferences.getDeviceId()) ?? .empty, + clientId: UUID().uuidString, ) let transactionStateScheduler = Self.makeTransactionService( transactionStore: storeManager.transactionStore, From 60c53f0f159ae4d04d725f91569a350a1253f777 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:42:25 +0300 Subject: [PATCH 21/53] iOS: name the payment asset failure after its cause The approval error claimed a broadcast that no longer happens and hid two different failures behind one name: a store read that failed and an asset the wallet does not hold. Let the store throw, keep the guard for the asset, and name it unknownAsset as Android already does. Check the approval is on the chain the gateway asked for while we are here. --- .../Payments/Services/PaymentActionExecutor.swift | 8 +++++--- .../Sources/Payments/Services/PaymentManager.swift | 2 +- .../Sources/Payments/Types/PaymentLinkError.swift | 4 ++-- .../PaymentService/PaymentAssetsProvidable.swift | 2 +- .../PaymentService/PaymentAssetsProvider.swift | 9 ++------- .../TestKit/PaymentAssetsProvidableMock.swift | 2 +- 6 files changed, 12 insertions(+), 15 deletions(-) diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift index 822966d59d..ec4d692649 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -82,10 +82,12 @@ extension PaymentActionExecutor { payment: payment, ) return try await interactor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) - case let .approveToken(_, approval): + case let .approveToken(chain, approval): let assetId = payment.quote.amount.assetId - guard let asset = assetsProvider.assetsData(walletId: wallet.id, assetIds: [assetId]).first?.asset else { - throw PaymentLinkError.approvalNotBroadcast + guard assetId.chain == chain, + let asset = try assetsProvider.assetsData(walletId: wallet.id, assetIds: [assetId]).first?.asset + else { + throw PaymentLinkError.unknownAsset } let transferData = TransferData( type: .tokenApprove(asset, approval), diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift index d6bed41f99..acdf8ef653 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -59,7 +59,7 @@ extension PaymentManager { guard quotes.quotes.count > 1 else { return first } - let assetsData = assetsProvider.assetsData(walletId: wallet.id, assetIds: quotes.quotes.map(\.amount.assetId)) + let assetsData = try assetsProvider.assetsData(walletId: wallet.id, assetIds: quotes.quotes.map(\.amount.assetId)) let selected = try await presenter.selectPaymentQuote( request: PaymentQuotesRequest( id: first.paymentId, diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift index 42af742ed4..ac39788ba8 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift @@ -7,14 +7,14 @@ public enum PaymentLinkError: Error, Equatable { case noQuotes case quoteUnavailable case invalidDataCollectionUrl - case approvalNotBroadcast + case unknownAsset } extension PaymentLinkError: LocalizedError { public var errorDescription: String? { switch self { case .noQuotes, .quoteUnavailable, .invalidDataCollectionUrl: Localized.Errors.notSupported - case .approvalNotBroadcast: Localized.Errors.errorOccurred + case .unknownAsset: Localized.Errors.errorOccurred } } } diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift index 05a2e32c4f..a817d8c96b 100644 --- a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift +++ b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift @@ -4,5 +4,5 @@ import Foundation import Primitives public protocol PaymentAssetsProvidable: Sendable { - func assetsData(walletId: WalletId, assetIds: [AssetId]) -> [AssetData] + func assetsData(walletId: WalletId, assetIds: [AssetId]) throws -> [AssetData] } diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift index b4707c4499..4b75a2f276 100644 --- a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift +++ b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift @@ -11,12 +11,7 @@ public struct PaymentAssetsProvider: PaymentAssetsProvidable { 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 [] - } + public func assetsData(walletId: WalletId, assetIds: [AssetId]) throws -> [AssetData] { + try assetStore.getAssetsData(walletId: walletId, filters: [.chainsOrAssets([], assetIds.map(\.identifier))]) } } diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift index 81ae6f2231..ef1dea8421 100644 --- a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift @@ -11,7 +11,7 @@ public struct PaymentAssetsProvidableMock: PaymentAssetsProvidable { self.assetsData = assetsData } - public func assetsData(walletId _: WalletId, assetIds: [AssetId]) -> [AssetData] { + public func assetsData(walletId _: WalletId, assetIds: [AssetId]) throws -> [AssetData] { assetsData.filter { assetIds.contains($0.asset.id) } } } From 0170f89b17202c4cfc720ce120d58abe08af18f2 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:45:02 +0300 Subject: [PATCH 22/53] iOS: share one sheet presenter implementation WalletConnector and Payments each carried the same presenter: the same pass-throughs to SheetPresenter and the same three signing entry points, differing only in which sheet case they built. Move that to a protocol with default implementations so the two rails cannot drift apart. --- .../Types/PaymentSheetPresentable.swift | 35 ++------------ .../Services/WalletConnectorPresenter.swift | 35 ++------------ .../SigningRequestSheetPresentable.swift | 48 +++++++++++++++++++ 3 files changed, 58 insertions(+), 60 deletions(-) create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift index a7b9543b81..e23135a807 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift @@ -11,30 +11,17 @@ public protocol PaymentSheetPresentable: SigningRequestInteractable { } @Observable -public final class PaymentSheetPresenter: PaymentSheetPresentable, Sendable { +public final class PaymentSheetPresenter: PaymentSheetPresentable, SigningRequestSheetPresentable, Sendable { public let sheets = SheetPresenter() public init() {} - @MainActor - public var isPresentingSheet: PaymentSheetType? { - get { sheets.isPresentingSheet } - set { sheets.isPresentingSheet = newValue } + public static func signMessageSheet(_ callback: SigningRequestCallback) -> PaymentSheetType { + .signMessage(callback) } - @MainActor - public func complete(type: PaymentSheetType) { - sheets.complete(type: type) - } - - @MainActor - public func cancelSheet(type: PaymentSheetType) { - sheets.cancelSheet(type: type) - } - - @MainActor - public func onSheetDismiss() { - sheets.onSheetDismiss() + public static func transferSheet(_ callback: SigningRequestCallback) -> PaymentSheetType { + .confirm(callback) } public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { @@ -44,16 +31,4 @@ public final class PaymentSheetPresenter: PaymentSheetPresentable, Sendable { public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { try await sheets.present(payload: request, sheet: { .dataCollection($0) }) } - - public func signMessage(payload: SignMessagePayload) async throws -> String { - try await sheets.present(payload: payload, sheet: { .signMessage($0) }) - } - - public func signTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { .confirm($0) }) - } - - public func sendTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { .confirm($0) }) - } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift index 0651d90a4a..cfe2b17409 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -5,7 +5,7 @@ import Primitives import SigningRequestService @Observable -public final class WalletConnectorPresenter: SigningRequestInteractable, Sendable { +public final class WalletConnectorPresenter: SigningRequestSheetPresentable, Sendable { public let sheets = SheetPresenter() @MainActor @@ -15,36 +15,11 @@ public final class WalletConnectorPresenter: SigningRequestInteractable, Sendabl public init() {} - @MainActor - public var isPresentingSheet: WalletConnectorSheetType? { - get { sheets.isPresentingSheet } - set { sheets.isPresentingSheet = newValue } - } - - @MainActor - public func complete(type: WalletConnectorSheetType) { - sheets.complete(type: type) - } - - @MainActor - public func cancelSheet(type: WalletConnectorSheetType) { - sheets.cancelSheet(type: type) - } - - @MainActor - public func onSheetDismiss() { - sheets.onSheetDismiss() - } - - public func signMessage(payload: SignMessagePayload) async throws -> String { - try await sheets.present(payload: payload, sheet: { .signMessage($0) }) - } - - public func signTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { .transferData($0) }) + public static func signMessageSheet(_ callback: SigningRequestCallback) -> WalletConnectorSheetType { + .signMessage(callback) } - public func sendTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { .transferData($0) }) + public static func transferSheet(_ callback: SigningRequestCallback) -> WalletConnectorSheetType { + .transferData(callback) } } diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift new file mode 100644 index 0000000000..63936f7e9f --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift @@ -0,0 +1,48 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol SigningRequestSheetPresentable: SigningRequestInteractable { + associatedtype Sheet: SigningRequestRejectable & Identifiable where Sheet.ID == String + + var sheets: SheetPresenter { get } + + static func signMessageSheet(_ callback: SigningRequestCallback) -> Sheet + static func transferSheet(_ callback: SigningRequestCallback) -> Sheet +} + +public extension SigningRequestSheetPresentable { + @MainActor + var isPresentingSheet: Sheet? { + get { sheets.isPresentingSheet } + nonmutating set { sheets.isPresentingSheet = newValue } + } + + @MainActor + func complete(type: Sheet) { + sheets.complete(type: type) + } + + @MainActor + func cancelSheet(type: Sheet) { + sheets.cancelSheet(type: type) + } + + @MainActor + func onSheetDismiss() { + sheets.onSheetDismiss() + } + + func signMessage(payload: SignMessagePayload) async throws -> String { + try await sheets.present(payload: payload, sheet: { Self.signMessageSheet($0) }) + } + + func signTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { Self.transferSheet($0) }) + } + + func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { Self.transferSheet($0) }) + } +} From 49d5171b33198f8a7d35d60569df29c4856949f6 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:47:40 +0300 Subject: [PATCH 23/53] iOS: share the signing request sheet between rails Both navigation stacks wrapped their cases in the same navigation chrome and built the confirm and sign-message scenes identically. Give the shared cases one view and the chrome one modifier, so a payment and a WalletConnect request cannot present them differently. --- ios/Gem.xcodeproj/project.pbxproj | 4 ++ .../Payments/PaymentNavigationStack.swift | 72 ++++++------------- .../Navigation/SigningRequestSheetView.swift | 67 +++++++++++++++++ .../WalletConnectorNavigationStack.swift | 66 +++++------------ 4 files changed, 111 insertions(+), 98 deletions(-) create mode 100644 ios/Gem/Navigation/SigningRequestSheetView.swift diff --git a/ios/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index 00d3d88af1..2a7dd96a58 100644 --- a/ios/Gem.xcodeproj/project.pbxproj +++ b/ios/Gem.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 837A1E6B2D0B029200733AC1 /* AppResolver+ViewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */; }; 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */; }; 83AA01012E4A000100000001 /* PaymentNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */; }; + 83AA01042E4A000100000004 /* SigningRequestSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83AA01052E4A000100000005 /* SigningRequestSheetView.swift */; }; 83863EE72D19B93E005048A7 /* NodeService in Frameworks */ = {isa = PBXBuildFile; productRef = 83863EE62D19B93E005048A7 /* NodeService */; }; 839C794B2D1C42A500E32072 /* PrimitivesComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 839C794A2D1C42A500E32072 /* PrimitivesComponents */; }; 83B08BDB2D0B4AE200CA1B33 /* AppResolver+Storages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B08BDA2D0B4AE200CA1B33 /* AppResolver+Storages.swift */; }; @@ -220,6 +221,7 @@ 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppResolver+ViewInjection.swift"; sourceTree = ""; }; 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectorNavigationStack.swift; sourceTree = ""; }; 83AA01022E4A000100000002 /* PaymentNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentNavigationStack.swift; sourceTree = ""; }; + 83AA01052E4A000100000005 /* SigningRequestSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SigningRequestSheetView.swift; sourceTree = ""; }; 837D7DE62E005D2800BBBDDA /* Formatters */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Formatters; sourceTree = ""; }; 838383112D5E677A00D1CAF2 /* WalletTab */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WalletTab; sourceTree = ""; }; 838B9B9C2F0C120100380F29 /* Recents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Recents; sourceTree = ""; }; @@ -503,6 +505,7 @@ 8361BB702D402C9D008D89CF /* Navigation */ = { isa = PBXGroup; children = ( + 83AA01052E4A000100000005 /* SigningRequestSheetView.swift */, 83AA01032E4A000100000003 /* Payments */, 83A35C612DF2054B00360060 /* Transfer */, 836281B22DEF423400CA5C75 /* Assets */, @@ -1203,6 +1206,7 @@ C366790A2A0B7E5800F1D74D /* Environment.swift in Sources */, 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */, 83AA01012E4A000100000001 /* PaymentNavigationStack.swift in Sources */, + 83AA01042E4A000100000004 /* SigningRequestSheetView.swift in Sources */, D8D5C1D12E6CAC16007628A1 /* Errors.swift in Sources */, D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */, D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */, diff --git a/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift index 6c08cd46f0..798d1ad5e9 100644 --- a/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift +++ b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift @@ -1,16 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. import Payments -import Primitives -import SigningRequestService -import Style import SwiftUI -import Transfer -import WalletConnector struct PaymentNavigationStack: View { - @Environment(\.viewModelFactory) private var viewModelFactory - private let type: PaymentSheetType private let presenter: PaymentSheetPresenter @@ -23,51 +16,28 @@ struct PaymentNavigationStack: View { } 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) }, - ) - case let .confirm(data): - ConfirmTransferNavigationView( - model: viewModelFactory.confirmTransferScene( - wallet: data.payload.wallet, - data: data.payload.transferData, - confirmTransferDelegate: data.delegate, - simulation: data.payload.simulation, - onComplete: { presenter.complete(type: type) }, - ), - ) - case let .signMessage(data): - SignMessageScene( - model: viewModelFactory.signMessageScene( - payload: data.payload, - confirmTransferDelegate: data.delegate, - ), - onComplete: { presenter.complete(type: type) }, - ) - } - } - .interactiveDismissDisabled(true) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("", systemImage: SystemImage.xmark) { - presenter.cancelSheet(type: type) - } - } + Group { + switch type { + case let .quotes(data): + PaymentQuotesScene( + model: PaymentQuotesSceneViewModel( + request: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: complete, + ) + case let .dataCollection(data): + PaymentDataCollectionScene(callback: data, onComplete: complete) + case let .confirm(data): + SigningRequestSheetView(content: .transfer(data), onComplete: complete) + case let .signMessage(data): + SigningRequestSheetView(content: .signMessage(data), onComplete: complete) } } + .signingRequestSheet(onCancel: { presenter.cancelSheet(type: type) }) + } + + private func complete() { + presenter.complete(type: type) } } diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift new file mode 100644 index 0000000000..8e908791fd --- /dev/null +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -0,0 +1,67 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SigningRequestService +import Style +import SwiftUI +import Transfer +import WalletConnector + +struct SigningRequestSheetView: View { + @Environment(\.viewModelFactory) private var viewModelFactory + + private let content: SigningRequestSheetContent + private let onComplete: () -> Void + + init( + content: SigningRequestSheetContent, + onComplete: @escaping () -> Void, + ) { + self.content = content + self.onComplete = onComplete + } + + var body: some View { + switch content { + case let .transfer(data): + ConfirmTransferNavigationView( + model: viewModelFactory.confirmTransferScene( + wallet: data.payload.wallet, + data: data.payload.transferData, + confirmTransferDelegate: data.delegate, + simulation: data.payload.simulation, + onComplete: onComplete, + ), + ) + case let .signMessage(data): + SignMessageScene( + model: viewModelFactory.signMessageScene( + payload: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: onComplete, + ) + } + } +} + +enum SigningRequestSheetContent { + case transfer(SigningRequestCallback) + case signMessage(SigningRequestCallback) +} + +// MARK: - Chrome + +extension View { + func signingRequestSheet(onCancel: @escaping () -> Void) -> some View { + NavigationStack { + self + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark, action: onCancel) + } + } + } + } +} diff --git a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift b/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift index 82f8fb9edb..2c0ba0943d 100644 --- a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift +++ b/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift @@ -1,17 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. -import ExplorerService -import Primitives -import Signer -import Style import SwiftUI -import TransactionStateService -import Transfer import WalletConnector struct WalletConnectorNavigationStack: View { - @Environment(\.viewModelFactory) private var viewModelFactory - private let type: WalletConnectorSheetType private let presenter: WalletConnectorPresenter @@ -24,46 +16,26 @@ struct WalletConnectorNavigationStack: View { } var body: some View { - NavigationStack { - Group { - switch type { - case let .transferData(data): - ConfirmTransferNavigationView( - model: viewModelFactory.confirmTransferScene( - wallet: data.payload.wallet, - data: data.payload.transferData, - confirmTransferDelegate: data.delegate, - simulation: data.payload.simulation, - onComplete: { presenter.complete(type: type) }, - ), - ) - case let .signMessage(data): - SignMessageScene( - model: viewModelFactory.signMessageScene( - payload: data.payload, - confirmTransferDelegate: data.delegate, - ), - 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) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("", systemImage: SystemImage.xmark) { - presenter.cancelSheet(type: type) - } - } + Group { + switch type { + case let .transferData(data): + SigningRequestSheetView(content: .transfer(data), onComplete: complete) + case let .signMessage(data): + SigningRequestSheetView(content: .signMessage(data), onComplete: complete) + case let .connectionProposal(data): + ConnectionProposalScene( + model: ConnectionProposalViewModel( + confirmTransferDelegate: data.delegate, + pairingProposal: data.payload, + ), + onComplete: complete, + ) } } + .signingRequestSheet(onCancel: { presenter.cancelSheet(type: type) }) + } + + private func complete() { + presenter.complete(type: type) } } From 91f65d9b2f761680cdd43a31759b0a9a7ed4e000 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:57:42 +0300 Subject: [PATCH 24/53] iOS: read the presented sheet through the presenter --- .../Sources/WalletConnector/Scenes/ConnectionsScene.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift index 7a677fde7e..da3f04d577 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift @@ -75,6 +75,6 @@ public struct ConnectionsScene: View { ) .navigationTitle(model.title) .taskOnce { model.fetch() } - .onChange(of: model.walletConnectorPresenter?.sheets.isPresentingSheet?.id, model.hideConnectionBar) + .onChange(of: model.walletConnectorPresenter?.isPresentingSheet?.id, model.hideConnectionBar) } } From b4097ba96d63bfb37df4a8b5e906c7553f04c0f4 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:57:42 +0300 Subject: [PATCH 25/53] iOS: keep the navigation stack visible in the sheet views The shared chrome hid a NavigationStack inside a modifier, so views named for a stack no longer showed one. Wrap explicitly and let the modifier apply only the chrome. --- .../Payments/PaymentNavigationStack.swift | 36 ++++++++++--------- .../Navigation/SigningRequestSheetView.swift | 17 ++++----- .../WalletConnectorNavigationStack.swift | 32 +++++++++-------- 3 files changed, 43 insertions(+), 42 deletions(-) diff --git a/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift index 798d1ad5e9..24c6b2b84c 100644 --- a/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift +++ b/ios/Gem/Navigation/Payments/PaymentNavigationStack.swift @@ -16,25 +16,27 @@ struct PaymentNavigationStack: View { } var body: some View { - Group { - switch type { - case let .quotes(data): - PaymentQuotesScene( - model: PaymentQuotesSceneViewModel( - request: data.payload, - confirmTransferDelegate: data.delegate, - ), - onComplete: complete, - ) - case let .dataCollection(data): - PaymentDataCollectionScene(callback: data, onComplete: complete) - case let .confirm(data): - SigningRequestSheetView(content: .transfer(data), onComplete: complete) - case let .signMessage(data): - SigningRequestSheetView(content: .signMessage(data), onComplete: complete) + NavigationStack { + Group { + switch type { + case let .quotes(data): + PaymentQuotesScene( + model: PaymentQuotesSceneViewModel( + request: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: complete, + ) + case let .dataCollection(data): + PaymentDataCollectionScene(callback: data, onComplete: complete) + case let .confirm(data): + SigningRequestSheetView(content: .transfer(data), onComplete: complete) + case let .signMessage(data): + SigningRequestSheetView(content: .signMessage(data), onComplete: complete) + } } + .signingRequestChrome(onCancel: { presenter.cancelSheet(type: type) }) } - .signingRequestSheet(onCancel: { presenter.cancelSheet(type: type) }) } private func complete() { diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift index 8e908791fd..3a84694b47 100644 --- a/ios/Gem/Navigation/SigningRequestSheetView.swift +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -52,16 +52,13 @@ enum SigningRequestSheetContent { // MARK: - Chrome extension View { - func signingRequestSheet(onCancel: @escaping () -> Void) -> some View { - NavigationStack { - self - .interactiveDismissDisabled(true) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("", systemImage: SystemImage.xmark, action: onCancel) - } + func signingRequestChrome(onCancel: @escaping () -> Void) -> some View { + interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark, action: onCancel) } - } + } } } diff --git a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift b/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift index 2c0ba0943d..3f8c5e9cc9 100644 --- a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift +++ b/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift @@ -16,23 +16,25 @@ struct WalletConnectorNavigationStack: View { } var body: some View { - Group { - switch type { - case let .transferData(data): - SigningRequestSheetView(content: .transfer(data), onComplete: complete) - case let .signMessage(data): - SigningRequestSheetView(content: .signMessage(data), onComplete: complete) - case let .connectionProposal(data): - ConnectionProposalScene( - model: ConnectionProposalViewModel( - confirmTransferDelegate: data.delegate, - pairingProposal: data.payload, - ), - onComplete: complete, - ) + NavigationStack { + Group { + switch type { + case let .transferData(data): + SigningRequestSheetView(content: .transfer(data), onComplete: complete) + case let .signMessage(data): + SigningRequestSheetView(content: .signMessage(data), onComplete: complete) + case let .connectionProposal(data): + ConnectionProposalScene( + model: ConnectionProposalViewModel( + confirmTransferDelegate: data.delegate, + pairingProposal: data.payload, + ), + onComplete: complete, + ) + } } + .signingRequestChrome(onCancel: { presenter.cancelSheet(type: type) }) } - .signingRequestSheet(onCancel: { presenter.cancelSheet(type: type) }) } private func complete() { From 4e15f8dceddfd2b9333a243680dd678bbd562f4e Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:03:42 +0300 Subject: [PATCH 26/53] Android: reuse the shared wallet account lookup in payments PaymentViewModel carried its own stringly-typed Wallet.account(chain) that duplicated getAccount(Chain) in :gemcore. Convert the FFI chain string at the boundary and delegate instead. Moves the wallet-to-gemstone address mapping next to it as gemChainAddresses(), matching iOS's wallet.chainAddresses. --- .../payment/viewmodels/PaymentViewModel.kt | 20 +++++++++---------- .../com/gemwallet/android/ext/Wallet.kt | 4 ++++ 2 files changed, 13 insertions(+), 11 deletions(-) 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 index e7a6d48424..957f52d3b1 100644 --- 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 @@ -9,6 +9,8 @@ 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.gemChainAddresses +import com.gemwallet.android.ext.getAccount import com.gemwallet.android.ext.SigningRequestApp import com.gemwallet.android.ext.runCatchingCancellable import com.gemwallet.android.ext.toAssetId @@ -38,7 +40,6 @@ 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 @@ -73,7 +74,7 @@ class PaymentViewModel @Inject constructor( state.value = PaymentSceneState.Loading viewModelScope.launch(Dispatchers.IO) { val wallet = wallet() ?: return@launch - val options = runGateway { paymentService.getPaymentOptions(link, wallet.addresses()) } ?: return@launch + val options = runGateway { paymentService.getPaymentOptions(link, wallet.gemChainAddresses()) } ?: return@launch when (options) { is GemPaymentOptions.Outcome -> state.value = PaymentSceneState.Outcome(options.v1.status.toUIModel()) is GemPaymentOptions.Quotes -> { @@ -183,7 +184,7 @@ class PaymentViewModel @Inject constructor( private suspend fun prepare(quote: GemPaymentQuote) { val current = payment.value ?: return val prepared = runGateway { - paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet.addresses()) + paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet.gemChainAddresses()) } ?: return payment.value = current.prepared(prepared.quote, prepared.actions) advance() @@ -237,7 +238,7 @@ class PaymentViewModel @Inject constructor( action: PaymentAction.ApproveToken, current: ActivePayment, ): PaymentSceneState { - val account = current.wallet.account(action.chain) ?: return failure(PaymentError.NoAccount, "approval: no ${action.chain} account") + val account = current.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}") @@ -259,7 +260,7 @@ class PaymentViewModel @Inject constructor( isSendable: Boolean, current: ActivePayment, ): PaymentSceneState { - val account = current.wallet.account(chain) ?: return PaymentSceneState.Error(PaymentError.NoAccount) + val account = current.account(chain) ?: return PaymentSceneState.Error(PaymentError.NoAccount) return PaymentSceneState.Confirm( transaction.toConfirmParams( requestId = current.quote?.paymentId.orEmpty(), @@ -293,6 +294,9 @@ class PaymentViewModel @Inject constructor( ?.also { searchTokensCase.search(assetId, it) } ?.let { getAssetInfo(assetId).firstOrNull() } + private fun ActivePayment.account(chain: String): Account? = + chain.toChain()?.let { wallet.getAccount(it) } + private suspend fun wallet(): Wallet? { val wallet = sessionRepository.session().firstOrNull()?.wallet if (wallet == null) { @@ -313,12 +317,6 @@ class PaymentViewModel @Inject constructor( } .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/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt index fee70d6f0c..a9d5951ab4 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/Wallet.kt @@ -8,6 +8,7 @@ import com.wallet.core.primitives.AssetType import com.wallet.core.primitives.Chain import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletType +import uniffi.gemstone.ChainAddress as GemChainAddress fun Wallet.getAccount(chain: Chain): Account? { return accounts.firstOrNull { it.chain == chain } @@ -15,6 +16,9 @@ fun Wallet.getAccount(chain: Chain): Account? { fun Wallet.getAccount(assetId: AssetId): Account? = getAccount(assetId.chain) +fun Wallet.gemChainAddresses(): List = + accounts.map { GemChainAddress(chain = it.chain.string, address = it.address) } + val WalletType.isViewOnly: Boolean get() = this == WalletType.View val WalletType.canSign: Boolean get() = !isViewOnly From d1c08e96eae50e1e26b7814b007f96b87a88fdb6 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:03:58 +0300 Subject: [PATCH 27/53] Android: share the expiry countdown as a property item The payment scene hand-rolled a countdown that WalletConnect and transfer will want too; iOS already shares the equivalent from its components package. Moves it to :ui beside the other property items and names the tick interval. --- .../features/payment/presents/PaymentScene.kt | 23 +----------- .../list_item/property/PropertyExpiryItem.kt | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt 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 index b6674c0f74..960658046c 100644 --- 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 @@ -12,7 +12,6 @@ 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 @@ -43,6 +42,7 @@ import com.gemwallet.android.ui.components.list_item.ListItemTitleText import com.gemwallet.android.ui.components.list_item.SelectionCheckmark import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron import com.gemwallet.android.ui.components.list_item.property.PropertyDataText +import com.gemwallet.android.ui.components.list_item.property.PropertyExpiryItem import com.gemwallet.android.ui.components.list_item.property.PropertyItem import com.gemwallet.android.ui.components.list_item.property.PropertyNetworkItem import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText @@ -60,7 +60,6 @@ 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 @@ -247,26 +246,6 @@ private fun PaymentQuotesSelectModal( } } -@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( diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt new file mode 100644 index 0000000000..a716fa4bea --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PropertyExpiryItem.kt @@ -0,0 +1,35 @@ +package com.gemwallet.android.ui.components.list_item.property + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.gemwallet.android.ui.models.ListPosition +import kotlinx.coroutines.delay + +private const val TICK_MS = 1000L + +@Composable +fun PropertyExpiryItem( + title: String, + expiresAt: Long, + listPosition: ListPosition, +) { + var remaining by remember(expiresAt) { mutableLongStateOf(expiresAt - System.currentTimeMillis()) } + + LaunchedEffect(expiresAt) { + while (remaining > 0) { + delay(TICK_MS) + remaining = expiresAt - System.currentTimeMillis() + } + } + + val seconds = (remaining / TICK_MS).coerceAtLeast(0) + PropertyItem( + title = title, + data = "%d:%02d".format(seconds / 60, seconds % 60), + listPosition = listPosition, + ) +} From f7044f825d784125510495f60c44a08c5fa934f5 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:04:14 +0300 Subject: [PATCH 28/53] Android: let a payment offer to acquire a missing asset The payment scene passed a no-op where every other ConfirmScreen caller threads navigator::openAcquireAsset, so the buy affordance ConfirmErrorInfo shows on an insufficient balance did nothing. --- .../com/gemwallet/android/ui/navigation/WalletNavGraph.kt | 5 ++++- .../com/gemwallet/android/ui/navigation/routes/Payment.kt | 8 +++++++- .../android/features/payment/presents/PaymentScene.kt | 7 +++++-- 3 files changed, 16 insertions(+), 4 deletions(-) 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 d46be51edb..5dc105ca8c 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt @@ -173,7 +173,10 @@ fun WalletNavGraph( cancelAction = onCancel, ) - payment(cancelAction = onCancel) + payment( + onAcquireAsset = navigator::openAcquireAsset, + cancelAction = onCancel, + ) nftCollection( cancelAction = onCancel, 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 index 96252a8038..f837e47fd0 100644 --- 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 @@ -2,19 +2,25 @@ package com.gemwallet.android.ui.navigation.routes import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.gemwallet.android.features.confirm.presents.AcquireAssetAction import com.gemwallet.android.features.payment.presents.PaymentScene import com.gemwallet.android.ui.models.actions.CancelAction +import com.wallet.core.primitives.AssetId import kotlinx.serialization.Serializable import uniffi.gemstone.GemPaymentProviderName @Serializable data class PaymentRoute(val provider: String, val paymentId: String) : NavKey -fun EntryProviderScope.payment(cancelAction: CancelAction) { +fun EntryProviderScope.payment( + onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit, + cancelAction: CancelAction, +) { entry { key -> PaymentScene( provider = GemPaymentProviderName.valueOf(key.provider), paymentId = key.paymentId, + onAcquireAsset = onAcquireAsset, onCancel = { cancelAction() }, ) } 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 index 960658046c..aa5431d2f9 100644 --- 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.confirm.presents.AcquireAssetAction import com.gemwallet.android.features.confirm.presents.ConfirmScreen import com.gemwallet.android.features.payment.viewmodels.PaymentError import com.gemwallet.android.features.payment.viewmodels.PaymentSceneState @@ -28,6 +29,7 @@ import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIMod import com.gemwallet.android.features.payment.viewmodels.PaymentViewModel import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel import com.gemwallet.android.model.AuthRequest +import com.wallet.core.primitives.AssetId import com.gemwallet.android.ui.R import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width @@ -67,6 +69,7 @@ import uniffi.gemstone.PaymentException fun PaymentScene( provider: GemPaymentProviderName, paymentId: String, + onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit, onCancel: () -> Unit, viewModel: PaymentViewModel = hiltViewModel(), ) { @@ -95,13 +98,13 @@ fun PaymentScene( params = sceneState.params, finishAction = { hash -> viewModel.onActionResult(hash) }, cancelAction = onCancel, - onAcquireAsset = { _, _ -> }, + onAcquireAsset = onAcquireAsset, ) is PaymentSceneState.Confirm -> ConfirmScreen( params = sceneState.params, finishAction = { hash -> viewModel.onActionResult(hash) }, cancelAction = onCancel, - onAcquireAsset = { _, _ -> }, + onAcquireAsset = onAcquireAsset, ) is PaymentSceneState.SignMessage -> PaymentSignMessageScene( state = sceneState, From 87c1b7bfde256bbca82d3c39dd37c0727f6d33b4 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:09:09 +0300 Subject: [PATCH 29/53] iOS: move the sign message screen out of WalletConnector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payments imported the WalletConnect feature only to show a sign message screen that knows nothing about WalletConnect — its one tie, an import of WalletConnectorService, was unused. Move the screen and the four types only it uses next to the confirm screen they belong with, so neither rail reaches into the other. --- ios/Features/Transfer/Package.swift | 1 + .../Sources}/Scenes/SignMessageScene.swift | 0 .../Sources}/Scenes/TextMessageScene.swift | 0 .../Sources}/Types/SignMessageDisplayType.swift | 0 .../Sources}/ViewModels/SignMessageSceneViewModel.swift | 1 - .../Sources}/ViewModels/TextMessageViewModel.swift | 0 .../Tests/ViewModels}/SignMessageSceneViewModelTests.swift | 0 ios/Features/WalletConnector/Package.swift | 6 ------ ios/Gem/Navigation/SigningRequestSheetView.swift | 1 - 9 files changed, 1 insertion(+), 8 deletions(-) rename ios/Features/{WalletConnector/Sources/WalletConnector => Transfer/Sources}/Scenes/SignMessageScene.swift (100%) rename ios/Features/{WalletConnector/Sources/WalletConnector => Transfer/Sources}/Scenes/TextMessageScene.swift (100%) rename ios/Features/{WalletConnector/Sources/WalletConnector => Transfer/Sources}/Types/SignMessageDisplayType.swift (100%) rename ios/Features/{WalletConnector/Sources/WalletConnector => Transfer/Sources}/ViewModels/SignMessageSceneViewModel.swift (99%) rename ios/Features/{WalletConnector/Sources/WalletConnector => Transfer/Sources}/ViewModels/TextMessageViewModel.swift (100%) rename ios/Features/{WalletConnector/Tests/WalletConnectorTests => Transfer/Tests/ViewModels}/SignMessageSceneViewModelTests.swift (100%) diff --git a/ios/Features/Transfer/Package.swift b/ios/Features/Transfer/Package.swift index 1601d9e1ac..72399516a8 100644 --- a/ios/Features/Transfer/Package.swift +++ b/ios/Features/Transfer/Package.swift @@ -79,6 +79,7 @@ let package = Package( .product(name: "EarnService", package: "FeatureServices"), .product(name: "PerpetualService", package: "FeatureServices"), .product(name: "ExplorerService", package: "ChainServices"), + .product(name: "SigningRequestService", package: "ChainServices"), .product(name: "NameService", package: "ChainServices"), .product(name: "AddressNameService", package: "FeatureServices"), .product(name: "ActivityService", package: "FeatureServices"), diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift b/ios/Features/Transfer/Sources/Scenes/SignMessageScene.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift rename to ios/Features/Transfer/Sources/Scenes/SignMessageScene.swift diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/TextMessageScene.swift b/ios/Features/Transfer/Sources/Scenes/TextMessageScene.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/Scenes/TextMessageScene.swift rename to ios/Features/Transfer/Sources/Scenes/TextMessageScene.swift diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/SignMessageDisplayType.swift b/ios/Features/Transfer/Sources/Types/SignMessageDisplayType.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/Types/SignMessageDisplayType.swift rename to ios/Features/Transfer/Sources/Types/SignMessageDisplayType.swift diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift similarity index 99% rename from ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift rename to ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift index 9f65d533fb..e4dd733099 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift @@ -14,7 +14,6 @@ import Primitives import SigningRequestService import PrimitivesComponents import Style -import WalletConnectorService @Observable @MainActor diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/TextMessageViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/TextMessageViewModel.swift similarity index 100% rename from ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/TextMessageViewModel.swift rename to ios/Features/Transfer/Sources/ViewModels/TextMessageViewModel.swift diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift similarity index 100% rename from ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift rename to ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift diff --git a/ios/Features/WalletConnector/Package.swift b/ios/Features/WalletConnector/Package.swift index dcff2a5a6b..6cd9dc0bf0 100644 --- a/ios/Features/WalletConnector/Package.swift +++ b/ios/Features/WalletConnector/Package.swift @@ -22,7 +22,6 @@ let package = Package( .package(name: "Preferences", path: "../../Packages/Preferences"), .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), .package(name: "QRScanner", path: "../QRScanner"), - .package(name: "Keystore", path: "../../Packages/Keystore"), .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), .package(name: "Gemstone", path: "../../Packages/Gemstone"), .package(name: "Formatters", path: "../../Packages/Formatters"), @@ -34,7 +33,6 @@ let package = Package( "Primitives", .product(name: "SigningRequestService", package: "ChainServices"), .product(name: "WalletConnectorService", package: "ChainServices"), - .product(name: "ExplorerService", package: "ChainServices"), "Components", "Localization", "Style", @@ -42,11 +40,9 @@ let package = Package( "Preferences", "PrimitivesComponents", "QRScanner", - .product(name: "AddressNameService", package: "FeatureServices"), .product(name: "WalletSessionService", package: "FeatureServices"), .product(name: "TransactionStateService", package: "FeatureServices"), .product(name: "ConnectionsService", package: "FeatureServices"), - "Keystore", "Gemstone", "Formatters", ], @@ -60,11 +56,9 @@ let package = Package( .product(name: "PreferencesTestKit", package: "Preferences"), .product(name: "WalletSessionServiceTestKit", package: "FeatureServices"), .product(name: "ConnectionsServiceTestKit", package: "FeatureServices"), - .product(name: "AddressNameServiceTestKit", package: "FeatureServices"), .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), .product(name: "WalletConnectorServiceTestKit", package: "ChainServices"), .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), - .product(name: "KeystoreTestKit", package: "Keystore"), "WalletConnector", "Gemstone", ], diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift index 3a84694b47..937876d7a4 100644 --- a/ios/Gem/Navigation/SigningRequestSheetView.swift +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -4,7 +4,6 @@ import SigningRequestService import Style import SwiftUI import Transfer -import WalletConnector struct SigningRequestSheetView: View { @Environment(\.viewModelFactory) private var viewModelFactory From 4d87dfec534c4dfdca122c35e30af20f8ee84388 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:57:42 +0300 Subject: [PATCH 30/53] iOS: point the sign message test at its new module The screen moved to Transfer but its test still asked WalletConnector for it, which only the test build catches. Drop the imports the move left behind while here. --- .../SignMessageSceneViewModel.swift | 3 +- .../SignMessageSceneViewModelTests.swift | 7 +- .../SheetPresenter.swift | 98 ------------------- .../SigningRequestCallback.swift | 30 ------ .../SigningRequestSheetPresentable.swift | 48 --------- .../Tests/SheetPresenterTests.swift | 0 6 files changed, 3 insertions(+), 183 deletions(-) delete mode 100644 ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift delete mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift delete mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift rename ios/Packages/{ChainServices/SigningRequestService => PrimitivesComponents}/Tests/SheetPresenterTests.swift (100%) diff --git a/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift index e4dd733099..b1baa6c5b2 100644 --- a/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift @@ -1,7 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. -import AddressNameService import Components +import AddressNameService import ExplorerService import Foundation import Formatters @@ -9,7 +9,6 @@ import BigInt import class Gemstone.MessageSigner import Keystore import Localization -import Preferences import Primitives import SigningRequestService import PrimitivesComponents diff --git a/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift index a0219975f8..fa246dbc55 100644 --- a/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift @@ -1,18 +1,15 @@ // Copyright (c). Gem Wallet. All rights reserved. -import AddressNameServiceTestKit import Foundation import struct Gemstone.SignMessage import KeystoreTestKit import Primitives -import SigningRequestService import PrimitivesComponents +import SigningRequestService import PrimitivesTestKit import SigningRequestServiceTestKit import Testing -@testable import WalletConnector -import WalletConnectorService -import WalletConnectorServiceTestKit +@testable import Transfer struct SignMessageSceneViewModelTests { @Test diff --git a/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift b/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift deleted file mode 100644 index c31a5402c6..0000000000 --- a/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -@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/ChainServices/SigningRequestService/SigningRequestCallback.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift deleted file mode 100644 index 6d85359efa..0000000000 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -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/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift deleted file mode 100644 index 63936f7e9f..0000000000 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetPresentable.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public protocol SigningRequestSheetPresentable: SigningRequestInteractable { - associatedtype Sheet: SigningRequestRejectable & Identifiable where Sheet.ID == String - - var sheets: SheetPresenter { get } - - static func signMessageSheet(_ callback: SigningRequestCallback) -> Sheet - static func transferSheet(_ callback: SigningRequestCallback) -> Sheet -} - -public extension SigningRequestSheetPresentable { - @MainActor - var isPresentingSheet: Sheet? { - get { sheets.isPresentingSheet } - nonmutating set { sheets.isPresentingSheet = newValue } - } - - @MainActor - func complete(type: Sheet) { - sheets.complete(type: type) - } - - @MainActor - func cancelSheet(type: Sheet) { - sheets.cancelSheet(type: type) - } - - @MainActor - func onSheetDismiss() { - sheets.onSheetDismiss() - } - - func signMessage(payload: SignMessagePayload) async throws -> String { - try await sheets.present(payload: payload, sheet: { Self.signMessageSheet($0) }) - } - - func signTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { Self.transferSheet($0) }) - } - - func sendTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { Self.transferSheet($0) }) - } -} diff --git a/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift similarity index 100% rename from ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift rename to ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift From 8e0f5943e3e1f0c7261e8f54dac586701da247f2 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:16:49 +0300 Subject: [PATCH 31/53] iOS: move sheet presentation out of the signing service SigningRequestService is a low level chain service, but it also held the sheet presenter, its callback and the presenting protocol, none of which touch a chain. Move them to PrimitivesComponents as generic types named for what they are, so the service keeps only what needs Gemstone and the node provider, and each rail keeps just its three signing entry points. --- .../Scenes/PaymentDataCollectionScene.swift | 6 +- .../Types/PaymentSheetPresentable.swift | 20 +++--- .../Payments/Types/PaymentSheetType.swift | 16 ++--- .../Services/WalletConnectorManager.swift | 2 +- .../Services/WalletConnectorPresenter.swift | 16 +++-- .../Types/WalletConnectorSheetType.swift | 14 ++-- .../Navigation/SigningRequestSheetView.swift | 5 +- ios/Packages/ChainServices/Package.swift | 11 +-- .../Sources/Protocols/SheetPresenting.swift | 61 +++++++++++++++++ .../Sources/Types/SheetCallback.swift | 30 ++++++++ .../Sources/ViewModels/SheetPresenter.swift | 68 +++++++++++++++++++ .../Tests/SheetPresenterTests.swift | 68 ++++++++++--------- 12 files changed, 239 insertions(+), 78 deletions(-) create mode 100644 ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift create mode 100644 ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift create mode 100644 ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift index 14b421e20e..6fedb8a022 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -6,7 +6,7 @@ import Localization import PaymentService import Primitives import SwiftUI -import SigningRequestService +import PrimitivesComponents public struct PaymentDataCollectionScene: View { private static let messageHandlerName = "payDataCollectionComplete" @@ -15,11 +15,11 @@ public struct PaymentDataCollectionScene: View { private static let messageTypeKey = "type" private static let messageErrorKey = "error" - private let callback: SigningRequestCallback + private let callback: SheetCallback private let onComplete: () -> Void public init( - callback: SigningRequestCallback, + callback: SheetCallback, onComplete: @escaping () -> Void, ) { self.callback = callback diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift index e23135a807..bf7333d043 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift @@ -2,7 +2,7 @@ import Foundation import PaymentService -import Primitives +import PrimitivesComponents import SigningRequestService public protocol PaymentSheetPresentable: SigningRequestInteractable { @@ -11,24 +11,28 @@ public protocol PaymentSheetPresentable: SigningRequestInteractable { } @Observable -public final class PaymentSheetPresenter: PaymentSheetPresentable, SigningRequestSheetPresentable, Sendable { +public final class PaymentSheetPresenter: PaymentSheetPresentable, SheetPresenting, Sendable { public let sheets = SheetPresenter() public init() {} - public static func signMessageSheet(_ callback: SigningRequestCallback) -> PaymentSheetType { - .signMessage(callback) + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await present(payload: payload) { .signMessage($0) } } - public static func transferSheet(_ callback: SigningRequestCallback) -> PaymentSheetType { - .confirm(callback) + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .confirm($0) } + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .confirm($0) } } public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { - try await sheets.present(payload: request, sheet: { .quotes($0) }) + try await present(payload: request) { .quotes($0) } } public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { - try await sheets.present(payload: request, sheet: { .dataCollection($0) }) + try await present(payload: request) { .dataCollection($0) } } } diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift index 8d8609db5e..14dd522894 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -2,14 +2,14 @@ import Foundation import PaymentService -import Primitives import SigningRequestService +import PrimitivesComponents public enum PaymentSheetType: Sendable, Identifiable { - case quotes(SigningRequestCallback) - case dataCollection(SigningRequestCallback) - case confirm(SigningRequestCallback) - case signMessage(SigningRequestCallback) + case quotes(SheetCallback) + case dataCollection(SheetCallback) + case confirm(SheetCallback) + case signMessage(SheetCallback) public var id: String { callback.id @@ -19,7 +19,7 @@ public enum PaymentSheetType: Sendable, Identifiable { callback.reject(error) } - private var callback: any SigningRequestRejectable { + private var callback: any SheetRejectable { switch self { case let .quotes(callback): callback case let .dataCollection(callback): callback @@ -29,6 +29,6 @@ public enum PaymentSheetType: Sendable, Identifiable { } } -// MARK: - SigningRequestRejectable +// MARK: - SheetRejectable -extension PaymentSheetType: SigningRequestRejectable {} +extension PaymentSheetType: SheetRejectable {} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index c0252b974d..377bf114d5 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift @@ -26,7 +26,7 @@ extension WalletConnectorManager: WalletConnectorInteractable { } public func sessionApproval(payload: WCPairingProposal) async throws -> WalletId { - let value = try await presenter.sheets.present(payload: payload, sheet: { .connectionProposal($0) }) + let value = try await presenter.present(payload: payload) { .connectionProposal($0) } return try WalletId.from(id: value) } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift index cfe2b17409..f41a811507 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,11 +1,11 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives +import PrimitivesComponents import SigningRequestService @Observable -public final class WalletConnectorPresenter: SigningRequestSheetPresentable, Sendable { +public final class WalletConnectorPresenter: SheetPresenting, SigningRequestInteractable, Sendable { public let sheets = SheetPresenter() @MainActor @@ -15,11 +15,15 @@ public final class WalletConnectorPresenter: SigningRequestSheetPresentable, Sen public init() {} - public static func signMessageSheet(_ callback: SigningRequestCallback) -> WalletConnectorSheetType { - .signMessage(callback) + public func signMessage(payload: SignMessagePayload) async throws -> String { + try await present(payload: payload) { .signMessage($0) } } - public static func transferSheet(_ callback: SigningRequestCallback) -> WalletConnectorSheetType { - .transferData(callback) + public func signTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .transferData($0) } + } + + public func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await present(payload: transferData) { .transferData($0) } } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index fd2544b30b..146583b2a0 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -3,12 +3,12 @@ import Foundation import Primitives import SigningRequestService -import WalletConnectorService +import PrimitivesComponents public enum WalletConnectorSheetType: Sendable, Identifiable { - case connectionProposal(SigningRequestCallback) - case transferData(SigningRequestCallback) - case signMessage(SigningRequestCallback) + case connectionProposal(SheetCallback) + case transferData(SheetCallback) + case signMessage(SheetCallback) public var id: String { callback.id @@ -18,7 +18,7 @@ public enum WalletConnectorSheetType: Sendable, Identifiable { callback.reject(error) } - private var callback: any SigningRequestRejectable { + private var callback: any SheetRejectable { switch self { case let .connectionProposal(callback): callback case let .transferData(callback): callback @@ -27,6 +27,6 @@ public enum WalletConnectorSheetType: Sendable, Identifiable { } } -// MARK: - SigningRequestRejectable +// MARK: - SheetRejectable -extension WalletConnectorSheetType: SigningRequestRejectable {} +extension WalletConnectorSheetType: SheetRejectable {} diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift index 937876d7a4..22beae04b6 100644 --- a/ios/Gem/Navigation/SigningRequestSheetView.swift +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -4,6 +4,7 @@ import SigningRequestService import Style import SwiftUI import Transfer +import PrimitivesComponents struct SigningRequestSheetView: View { @Environment(\.viewModelFactory) private var viewModelFactory @@ -44,8 +45,8 @@ struct SigningRequestSheetView: View { } enum SigningRequestSheetContent { - case transfer(SigningRequestCallback) - case signMessage(SigningRequestCallback) + case transfer(SheetCallback) + case signMessage(SheetCallback) } // MARK: - Chrome diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 11332095d1..8b7151f230 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -115,16 +115,7 @@ let package = Package( "NativeProviderService", ], path: "SigningRequestService", - exclude: ["TestKit", "Tests"], - ), - .testTarget( - name: "SigningRequestServiceTests", - dependencies: [ - "SigningRequestService", - "SigningRequestServiceTestKit", - .product(name: "PrimitivesTestKit", package: "Primitives"), - ], - path: "SigningRequestService/Tests", + exclude: ["TestKit"], ), .target( name: "SigningRequestServiceTestKit", diff --git a/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift b/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift new file mode 100644 index 0000000000..a825fe4840 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift @@ -0,0 +1,61 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol SheetPresenting: Sendable { + associatedtype Sheet: SheetRejectable & Identifiable where Sheet.ID == String + + var sheets: SheetPresenter { get } +} + +public extension SheetPresenting { + @MainActor + var isPresentingSheet: Sheet? { + get { sheets.isPresentingSheet } + nonmutating set { sheets.isPresentingSheet = newValue } + } + + @MainActor + func complete(type: Sheet) { + sheets.dismiss(id: type.id) + } + + @MainActor + func cancelSheet(type: Sheet) { + guard sheets.isPresentingSheet?.id == type.id else { + return + } + type.reject(SigningRequestError.userCancelled) + sheets.dismiss(id: type.id) + } + + @MainActor + func onSheetDismiss() { + sheets.onSheetDismiss() + } + + func present( + payload: Payload, + sheet: @Sendable @escaping (SheetCallback) -> Sheet, + ) async throws -> String where Payload.ID == String { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) + let callback = SheetCallback(payload: payload) { + continuation.yield(with: $0) + continuation.finish() + } + await sheets.show(sheet: sheet(callback)) + + do { + for try await value in stream { + await sheets.dismissPresented() + return value + } + } catch { + await sheets.dismissPresented() + throw error + } + await sheets.dismissPresented() + throw SigningRequestError.userCancelled + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift b/ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift new file mode 100644 index 0000000000..7a970ed762 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Types/SheetCallback.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol SheetRejectable: Sendable { + var id: String { get } + func reject(_ error: any Error) +} + +public final class SheetCallback: SheetRejectable, Identifiable where T.ID == String { + public let payload: T + public let delegate: StringResultAction + + public init( + payload: T, + delegate: @escaping StringResultAction, + ) { + self.payload = payload + self.delegate = delegate + } + + public var id: String { + payload.id + } + + public func reject(_ error: any Error) { + delegate(.failure(error)) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift new file mode 100644 index 0000000000..d7eccb02fb --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/SheetPresenter.swift @@ -0,0 +1,68 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +@Observable +public final class SheetPresenter: Sendable where Sheet.ID == String { + @MainActor + public var isPresentingSheet: Sheet? + @MainActor + private var isDismissingSheet: Bool = false + @MainActor + private var dismissals: [CheckedContinuation] = [] + + public init() {} + + @MainActor + public func onSheetDismiss() { + isDismissingSheet = false + let waiting = dismissals + dismissals = [] + waiting.forEach { $0.resume() } + } +} + +// MARK: - Internal + +extension SheetPresenter { + @MainActor + func show(sheet: Sheet) async { + await waitForDismiss() + isPresentingSheet = sheet + } + + @MainActor + func dismissPresented() async { + dismiss() + await waitForDismiss() + } + + @MainActor + func dismiss(id: String) { + guard isPresentingSheet?.id == id else { + return + } + dismiss() + } +} + +// MARK: - Private + +extension SheetPresenter { + @MainActor + private func dismiss() { + guard isPresentingSheet != nil else { + return + } + isDismissingSheet = true + isPresentingSheet = .none + } + + @MainActor + private func waitForDismiss() async { + guard isPresentingSheet != nil || isDismissingSheet else { + return + } + await withCheckedContinuation { dismissals.append($0) } + } +} diff --git a/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift index 59ce37f5fd..b692d526c6 100644 --- a/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift +++ b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift @@ -2,39 +2,37 @@ import Foundation import Primitives -import PrimitivesTestKit -@testable import SigningRequestService -import SigningRequestServiceTestKit +@testable import PrimitivesComponents import Testing struct SheetPresenterTests { @Test @MainActor func completeDismissesThePresentedSheet() { - let presenter = SheetPresenter() - let type = Self.sheet(id: "request") + let presenter = TestPresenter() + let type = TestSheetType.request(SheetCallback(payload: TestPayload(id: "request"), delegate: { _ in })) - presenter.isPresentingSheet = type + presenter.sheets.isPresentingSheet = type presenter.complete(type: type) - #expect(presenter.isPresentingSheet == nil) + #expect(presenter.sheets.isPresentingSheet == nil) } @Test @MainActor func presentReturnsTheAnswerOnlyAfterTheSheetReportsItClosed() async throws { - let presenter = SheetPresenter() + let presenter = TestPresenter() let answer = Task { @MainActor in - try await presenter.present(payload: SignMessagePayload.mock(id: "request"), sheet: { .signMessage($0) }) + try await presenter.present(payload: TestPayload(id: "request")) { .request($0) } } - try await Self.wait { presenter.isPresentingSheet != nil } + try await Self.wait { presenter.sheets.isPresentingSheet != nil } - guard case let .signMessage(callback) = presenter.isPresentingSheet else { + guard case let .request(callback) = presenter.sheets.isPresentingSheet else { Issue.record("sheet is not presented") return } callback.delegate(.success("signature")) - try await Self.wait { presenter.isPresentingSheet == nil } + try await Self.wait { presenter.sheets.isPresentingSheet == nil } presenter.onSheetDismiss() #expect(try await answer.value == "signature") @@ -43,30 +41,30 @@ struct SheetPresenterTests { @Test @MainActor func presentQueuesBehindTheSheetThatIsStillClosing() async throws { - let presenter = SheetPresenter() + let presenter = TestPresenter() let first = Task { @MainActor in - try await presenter.present(payload: SignMessagePayload.mock(id: "first"), sheet: { .signMessage($0) }) + try await presenter.present(payload: TestPayload(id: "first")) { .request($0) } } - try await Self.wait { presenter.isPresentingSheet?.id == "first" } + try await Self.wait { presenter.sheets.isPresentingSheet?.id == "first" } - guard case let .signMessage(callback) = presenter.isPresentingSheet else { + guard case let .request(callback) = presenter.sheets.isPresentingSheet else { Issue.record("sheet is not presented") return } let second = Task { @MainActor in - try await presenter.present(payload: SignMessagePayload.mock(id: "second"), sheet: { .signMessage($0) }) + try await presenter.present(payload: TestPayload(id: "second")) { .request($0) } } callback.delegate(.success("signature")) - try await Self.wait { presenter.isPresentingSheet == nil } + try await Self.wait { presenter.sheets.isPresentingSheet == nil } - #expect(presenter.isPresentingSheet == nil) + #expect(presenter.sheets.isPresentingSheet == nil) presenter.onSheetDismiss() - try await Self.wait { presenter.isPresentingSheet?.id == "second" } + try await Self.wait { presenter.sheets.isPresentingSheet?.id == "second" } #expect(try await first.value == "signature") - guard let sheet = presenter.isPresentingSheet else { + guard let sheet = presenter.sheets.isPresentingSheet else { Issue.record("queued sheet is not presented") return } @@ -78,13 +76,13 @@ struct SheetPresenterTests { @Test @MainActor func cancelSheetFailsTheRequestWithUserCancelled() async throws { - let presenter = SheetPresenter() + let presenter = TestPresenter() let answer = Task { @MainActor in - try await presenter.present(payload: SignMessagePayload.mock(id: "request"), sheet: { .signMessage($0) }) + try await presenter.present(payload: TestPayload(id: "request")) { .request($0) } } - try await Self.wait { presenter.isPresentingSheet != nil } + try await Self.wait { presenter.sheets.isPresentingSheet != nil } - guard let sheet = presenter.isPresentingSheet else { + guard let sheet = presenter.sheets.isPresentingSheet else { Issue.record("sheet is not presented") return } @@ -96,10 +94,6 @@ struct SheetPresenterTests { } } - private static func sheet(id: String) -> TestSheetType { - .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() { @@ -111,18 +105,26 @@ struct SheetPresenterTests { } } -private enum TestSheetType: Sendable, Identifiable, SigningRequestRejectable { - case signMessage(SigningRequestCallback) +private struct TestPayload: Identifiable, Sendable { + let id: String +} + +private struct TestPresenter: SheetPresenting { + let sheets = SheetPresenter() +} + +private enum TestSheetType: Sendable, Identifiable, SheetRejectable { + case request(SheetCallback) var id: String { switch self { - case let .signMessage(callback): callback.id + case let .request(callback): callback.id } } func reject(_ error: any Error) { switch self { - case let .signMessage(callback): callback.reject(error) + case let .request(callback): callback.reject(error) } } } From a8bb41b2e222a2ec6f539f1bb6518232ee19a4a4 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:16:50 +0300 Subject: [PATCH 32/53] iOS: hold the payment manager by value It owns no mutable state, so a struct says that plainly. Its private steps inherit isolation from the caller and no longer declare it themselves. --- .../Sources/Payments/Services/PaymentManager.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift index acdf8ef653..71f30896a2 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -5,7 +5,7 @@ import PaymentService import Primitives import TransactionStateService -public final class PaymentManager: Sendable { +public struct PaymentManager: Sendable { private let service: any PaymentServiceable private let executor: PaymentActionExecutor private let presenter: any PaymentSheetPresentable @@ -34,7 +34,6 @@ public final class PaymentManager: Sendable { // MARK: - Private extension PaymentManager { - @MainActor private func perform(link: PaymentLink, wallet: Wallet) async throws -> PaymentOutcome { do { let quotes: PaymentQuotes @@ -51,7 +50,6 @@ extension PaymentManager { } } - @MainActor private func select(quotes: PaymentQuotes, wallet: Wallet) async throws -> PaymentQuote { guard let first = quotes.quotes.first else { throw PaymentLinkError.noQuotes @@ -74,7 +72,6 @@ extension PaymentManager { 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) @@ -107,7 +104,6 @@ extension PaymentManager { } } - @MainActor private func collectData(paymentId: String, url: String) async throws { guard let url = url.asURL else { throw PaymentLinkError.invalidDataCollectionUrl @@ -115,7 +111,6 @@ extension PaymentManager { _ = 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( From 2ad9ad973433916170c6bc2d25639b9f3092e8cc Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:22:26 +0300 Subject: [PATCH 33/53] iOS: let a dismissed sheet report its own cancellation The sheet presenter is generic but threw a signing request error, so any feature adopting it inherited a vocabulary it has no part in. Give it SheetDismissal and let each rail read that as its own cancellation. --- .../Sources/Payments/Services/PaymentManager.swift | 3 ++- .../Tests/PaymentsTests/PaymentManagerTests.swift | 13 ++++++++----- .../Services/WalletConnectorManager.swift | 3 ++- .../Sources/Protocols/SheetPresenting.swift | 4 ++-- .../Sources/Types/SheetDismissal.swift | 8 ++++++++ .../Tests/SheetPresenterTests.swift | 6 +++--- 6 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift index 71f30896a2..cfc172a5e2 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -3,6 +3,7 @@ import Foundation import PaymentService import Primitives +import PrimitivesComponents import TransactionStateService public struct PaymentManager: Sendable { @@ -45,7 +46,7 @@ extension PaymentManager { } 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 { + } catch SheetDismissal.cancelled { return PaymentOutcome(status: .cancelled, transactionId: .none) } } diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift index 50d26c6333..e5a5d129ef 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -1,17 +1,18 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +@testable import Payments import PaymentService +import PaymentServiceTestKit import Primitives -import SigningRequestService +import PrimitivesComponents import PrimitivesTestKit +import SigningRequestService +import SigningRequestServiceTestKit import Store import StoreTestKit -import PaymentServiceTestKit -import SigningRequestServiceTestKit import Testing import TransactionStateServiceTestKit -@testable import Payments @MainActor struct PaymentManagerTests { @@ -59,6 +60,7 @@ struct PaymentManagerTests { #expect(await service.confirmedResults == [["signature"]]) #expect(outcome.status == .succeeded) } + @Test func payStaysPendingWhenConfirmFails() async throws { let service = PaymentServiceableMock( @@ -92,9 +94,10 @@ struct PaymentManagerTests { #expect(presenter.collectDataRequests.isEmpty) #expect(await service.confirmedResults == [[]]) } + @Test func payKeepsThePaymentAliveWhenUserClosesDataCollection() async throws { - presenter.collectDataError = SigningRequestError.userCancelled + presenter.collectDataError = SheetDismissal.cancelled let quote = PaymentQuote.mock(collectDataUrl: "https://data-collection.walletconnect.com/ic/pay_1") let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [quote]))]) diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index 377bf114d5..e8553d1bfc 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Primitives +import PrimitivesComponents import SigningRequestService import WalletConnectorService @@ -16,7 +17,7 @@ public final class WalletConnectorManager { extension WalletConnectorManager: WalletConnectorInteractable { public func sessionReject(error: any Error) async { - if let error = error as? SigningRequestError, case .userCancelled = error { + if let error = error as? SheetDismissal, case .cancelled = error { return } await MainActor.run { [weak self] in diff --git a/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift b/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift index a825fe4840..7279a83a2c 100644 --- a/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift +++ b/ios/Packages/PrimitivesComponents/Sources/Protocols/SheetPresenting.swift @@ -26,7 +26,7 @@ public extension SheetPresenting { guard sheets.isPresentingSheet?.id == type.id else { return } - type.reject(SigningRequestError.userCancelled) + type.reject(SheetDismissal.cancelled) sheets.dismiss(id: type.id) } @@ -56,6 +56,6 @@ public extension SheetPresenting { throw error } await sheets.dismissPresented() - throw SigningRequestError.userCancelled + throw SheetDismissal.cancelled } } diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift b/ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift new file mode 100644 index 0000000000..1d307d2d96 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/Types/SheetDismissal.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +/// Thrown when a sheet closes without producing a value. +public enum SheetDismissal: Error, Equatable { + case cancelled +} diff --git a/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift index b692d526c6..1e89581b26 100644 --- a/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift +++ b/ios/Packages/PrimitivesComponents/Tests/SheetPresenterTests.swift @@ -89,7 +89,7 @@ struct SheetPresenterTests { presenter.cancelSheet(type: sheet) presenter.onSheetDismiss() - await #expect(throws: SigningRequestError.userCancelled) { + await #expect(throws: SheetDismissal.cancelled) { try await answer.value } } @@ -105,7 +105,7 @@ struct SheetPresenterTests { } } -private struct TestPayload: Identifiable, Sendable { +private struct TestPayload: Identifiable { let id: String } @@ -113,7 +113,7 @@ private struct TestPresenter: SheetPresenting { let sheets = SheetPresenter() } -private enum TestSheetType: Sendable, Identifiable, SheetRejectable { +private enum TestSheetType: Identifiable, SheetRejectable { case request(SheetCallback) var id: String { From 647df013efceef1e172a57b3410b7ede48e4d2c8 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:22:26 +0300 Subject: [PATCH 34/53] iOS: format the payment and signing sources --- .../Payments/Scenes/PaymentDataCollectionScene.swift | 2 +- .../Sources/Payments/Types/PaymentSheetType.swift | 2 +- .../Sources/ViewModels/SignMessageSceneViewModel.swift | 8 ++++---- .../Tests/ViewModels/SignMessageSceneViewModelTests.swift | 2 +- .../WalletConnector/Types/WalletConnectorSheetType.swift | 2 +- ios/Gem/Navigation/SigningRequestSheetView.swift | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift index 6fedb8a022..f9eb2b4e60 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -5,8 +5,8 @@ import Foundation import Localization import PaymentService import Primitives -import SwiftUI import PrimitivesComponents +import SwiftUI public struct PaymentDataCollectionScene: View { private static let messageHandlerName = "payDataCollectionComplete" diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift index 14dd522894..a20b3208a4 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -2,8 +2,8 @@ import Foundation import PaymentService -import SigningRequestService import PrimitivesComponents +import SigningRequestService public enum PaymentSheetType: Sendable, Identifiable { case quotes(SheetCallback) diff --git a/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift index b1baa6c5b2..c52baca9fd 100644 --- a/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift @@ -1,17 +1,17 @@ // Copyright (c). Gem Wallet. All rights reserved. -import Components import AddressNameService +import BigInt +import Components import ExplorerService -import Foundation import Formatters -import BigInt +import Foundation import class Gemstone.MessageSigner import Keystore import Localization import Primitives -import SigningRequestService import PrimitivesComponents +import SigningRequestService import Style @Observable diff --git a/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift index fa246dbc55..52a630f825 100644 --- a/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift @@ -5,8 +5,8 @@ import struct Gemstone.SignMessage import KeystoreTestKit import Primitives import PrimitivesComponents -import SigningRequestService import PrimitivesTestKit +import SigningRequestService import SigningRequestServiceTestKit import Testing @testable import Transfer diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 146583b2a0..48094a539c 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -2,8 +2,8 @@ import Foundation import Primitives -import SigningRequestService import PrimitivesComponents +import SigningRequestService public enum WalletConnectorSheetType: Sendable, Identifiable { case connectionProposal(SheetCallback) diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift index 22beae04b6..afc025e5f2 100644 --- a/ios/Gem/Navigation/SigningRequestSheetView.swift +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -1,10 +1,10 @@ // Copyright (c). Gem Wallet. All rights reserved. +import PrimitivesComponents import SigningRequestService import Style import SwiftUI import Transfer -import PrimitivesComponents struct SigningRequestSheetView: View { @Environment(\.viewModelFactory) private var viewModelFactory From e81bb56cd30226a997d0043f6809a0d8451e4f6e Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:31:49 +0300 Subject: [PATCH 35/53] iOS: approve on the chain the payment quote is priced in The approval tests asked to approve an Ethereum token against a quote priced in Bitcoin, which nothing checked until the executor started comparing them. Give both a matching asset so they exercise the guard instead of tripping over it. --- .../PaymentActionExecutorTests.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift index 8b2450f4f5..c0fc6f9cf8 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -1,14 +1,14 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +@testable import Payments import PaymentService +import PaymentServiceTestKit import Primitives -import SigningRequestService import PrimitivesTestKit -import PaymentServiceTestKit +import SigningRequestService import SigningRequestServiceTestKit import Testing -@testable import Payments @MainActor struct PaymentActionExecutorTests { @@ -34,14 +34,14 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() interactor.signature = "permit-signature" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: Self.approvalAssets()).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(), + payment: Self.approvalPayment(), wallet: .mock(), ) @@ -95,7 +95,7 @@ struct PaymentActionExecutorTests { _ = try await PaymentActionExecutor( interactor: interactor, simulator: SigningSimulatableMock(), - assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()]), + assetsProvider: Self.approvalAssets(), ).perform( actions: [ .approveToken(chain: .ethereum, approval: .mock()), @@ -103,7 +103,7 @@ struct PaymentActionExecutorTests { ], paymentId: "pay_1", appMetadata: .mock(), - payment: .mock(), + payment: Self.approvalPayment(), wallet: .mock(), onSubmitted: { approvalsWhenRecorded = interactor.sentTransferData.count @@ -133,4 +133,14 @@ struct PaymentActionExecutorTests { #expect(interactor.signMessagePayloads.first?.simulation.warnings == [warning]) } + + private static let approvalAssetId = AssetId(chain: .ethereum, tokenId: "0xtoken") + + private static func approvalPayment() -> PaymentData { + .mock(quote: .mock(amount: .mock(assetId: approvalAssetId))) + } + + private static func approvalAssets() -> PaymentAssetsProvidableMock { + PaymentAssetsProvidableMock(assetsData: [.mock(asset: .mock(id: approvalAssetId))]) + } } From bc258f75b9fa787739f3e878e04dff97c606c464 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:42:04 +0300 Subject: [PATCH 36/53] iOS: keep the payment gateway with the feature services Nothing in ChainServices used it: every consumer is a feature, the app, or TransactionStateService. It is a gateway client like SwapService, not chain infrastructure like the node, explorer or signing services, and it was the only member there reaching into the database. Moving it also makes the TransactionStateService edge intra package. --- ios/Features/Payments/Package.swift | 4 +-- ios/Features/Transfer/Package.swift | 2 +- ios/Packages/ChainServices/Package.swift | 24 --------------- ios/Packages/FeatureServices/Package.swift | 30 +++++++++++++++++-- .../PaymentAction+Mapping.swift | 0 .../PaymentService/PaymentAction.swift | 0 .../PaymentAssetsProvidable.swift | 0 .../PaymentAssetsProvider.swift | 0 .../PaymentDataCollectionRequest.swift | 0 .../PaymentService/PaymentQuotesRequest.swift | 0 .../PaymentService/PaymentService.swift | 0 .../PaymentService/PreparedPayment.swift | 0 .../TestKit/PaymentAction+TestKit.swift | 0 .../TestKit/PaymentAssetsProvidableMock.swift | 0 .../TestKit/PaymentServiceableMock.swift | 0 .../PaymentStatusServiceableMock.swift | 0 16 files changed, 30 insertions(+), 30 deletions(-) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentAction+Mapping.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentAction.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentAssetsProvidable.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentAssetsProvider.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentDataCollectionRequest.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentQuotesRequest.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PaymentService.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/PreparedPayment.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/TestKit/PaymentAction+TestKit.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/TestKit/PaymentAssetsProvidableMock.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/TestKit/PaymentServiceableMock.swift (100%) rename ios/Packages/{ChainServices => FeatureServices}/PaymentService/TestKit/PaymentStatusServiceableMock.swift (100%) diff --git a/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift index b3a3e91ad8..65fe866163 100644 --- a/ios/Features/Payments/Package.swift +++ b/ios/Features/Payments/Package.swift @@ -36,7 +36,7 @@ let package = Package( "Formatters", "EventPresenterService", .product(name: "SigningRequestService", package: "ChainServices"), - .product(name: "PaymentService", package: "ChainServices"), + .product(name: "PaymentService", package: "FeatureServices"), .product(name: "TransactionStateService", package: "FeatureServices"), ], path: "Sources/Payments", @@ -47,7 +47,7 @@ let package = Package( "Payments", .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), - .product(name: "PaymentServiceTestKit", package: "ChainServices"), + .product(name: "PaymentServiceTestKit", package: "FeatureServices"), .product(name: "StoreTestKit", package: "Store"), .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), ], diff --git a/ios/Features/Transfer/Package.swift b/ios/Features/Transfer/Package.swift index 72399516a8..23d8561d43 100644 --- a/ios/Features/Transfer/Package.swift +++ b/ios/Features/Transfer/Package.swift @@ -68,7 +68,7 @@ let package = Package( "EventPresenterService", .product(name: "ChainService", package: "ChainServices"), - .product(name: "PaymentService", package: "ChainServices"), + .product(name: "PaymentService", package: "FeatureServices"), .product(name: "WalletSessionService", package: "FeatureServices"), .product(name: "NodeService", package: "ChainServices"), .product(name: "TransactionStateService", package: "FeatureServices"), diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 8b7151f230..6d8a6de8c0 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -17,8 +17,6 @@ let package = Package( .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"]), @@ -125,28 +123,6 @@ let package = Package( ], 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: [ diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index c2b18d96ae..fc3af47b0e 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -24,6 +24,8 @@ let package = Package( .library(name: "StreamServiceTestKit", targets: ["StreamServiceTestKit"]), .library(name: "PriceAlertService", targets: ["PriceAlertService"]), .library(name: "PriceAlertServiceTestKit", targets: ["PriceAlertServiceTestKit"]), + .library(name: "PaymentService", targets: ["PaymentService"]), + .library(name: "PaymentServiceTestKit", targets: ["PaymentServiceTestKit"]), .library(name: "TransactionStateService", targets: ["TransactionStateService"]), .library(name: "TransactionStateServiceTestKit", targets: ["TransactionStateServiceTestKit"]), .library(name: "TransactionsService", targets: ["TransactionsService"]), @@ -265,10 +267,32 @@ let package = Package( ], path: "PriceAlertService/TestKit", ), + .target( + name: "PaymentService", + dependencies: [ + "Primitives", + "Store", + .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "Gemstone", package: "Gemstone"), + .product(name: "GemstonePrimitives", package: "GemstonePrimitives"), + .product(name: "NativeProviderService", package: "NativeProviderService"), + ], + path: "PaymentService", + exclude: ["TestKit"], + ), + .target( + name: "PaymentServiceTestKit", + dependencies: [ + "PaymentService", + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "PaymentService/TestKit", + ), .target( name: "TransactionStateService", dependencies: [ - .product(name: "PaymentService", package: "ChainServices"), + "PaymentService", "Primitives", "Store", "Blockchain", @@ -284,7 +308,7 @@ let package = Package( .target( name: "TransactionStateServiceTestKit", dependencies: [ - .product(name: "PaymentServiceTestKit", package: "ChainServices"), + "PaymentServiceTestKit", .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "StoreTestKit", package: "Store"), .product(name: "StakeServiceTestKit", package: "ChainServices"), @@ -726,7 +750,7 @@ let package = Package( .testTarget( name: "TransactionStateServiceTests", dependencies: [ - .product(name: "PaymentServiceTestKit", package: "ChainServices"), + "PaymentServiceTestKit", "TransactionStateService", "TransactionStateServiceTestKit", "BalanceServiceTestKit", diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAction.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentAction.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentAction.swift diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvidable.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvidable.swift diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvider.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentAssetsProvider.swift diff --git a/ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift b/ios/Packages/FeatureServices/PaymentService/PaymentDataCollectionRequest.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentDataCollectionRequest.swift diff --git a/ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift b/ios/Packages/FeatureServices/PaymentService/PaymentQuotesRequest.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentQuotesRequest.swift diff --git a/ios/Packages/ChainServices/PaymentService/PaymentService.swift b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PaymentService.swift rename to ios/Packages/FeatureServices/PaymentService/PaymentService.swift diff --git a/ios/Packages/ChainServices/PaymentService/PreparedPayment.swift b/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/PreparedPayment.swift rename to ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAction+TestKit.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift rename to ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAction+TestKit.swift diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift rename to ios/Packages/FeatureServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift rename to ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift similarity index 100% rename from ios/Packages/ChainServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift rename to ios/Packages/FeatureServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift From 675d24bbe45f65963a52b0a00b620a89fcb15fab Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:03:49 +0300 Subject: [PATCH 37/53] iOS: declare the dependencies the Gemstone package imports --- ios/Packages/Gemstone/Package.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/Packages/Gemstone/Package.swift b/ios/Packages/Gemstone/Package.swift index 8c8cae6cc1..266c418fc1 100644 --- a/ios/Packages/Gemstone/Package.swift +++ b/ios/Packages/Gemstone/Package.swift @@ -14,11 +14,17 @@ let package = Package( ) ], dependencies: [ + .package(name: "Primitives", path: "../Primitives"), + .package(name: "BigInt", path: "../../Submodules/BigInt"), ], targets: [ .target( name: "Gemstone", - dependencies: ["GemstoneFFI"], + dependencies: [ + "GemstoneFFI", + "Primitives", + .product(name: "BigInt", package: "BigInt"), + ], swiftSettings: [ .swiftLanguageMode(.v5) // TODO: - remove when GemstoneFFI will support swift6 fully ] From 2a76ac2b91a9a479431e42774ac005d40c230c63 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:03:52 +0300 Subject: [PATCH 38/53] Core: typeshare the sign digest type --- .../core/primitives/generated/Signing.kt | 18 ++++++++++++++++++ core/crates/primitives/src/signing.rs | 4 +++- .../Primitives/Sources/Generated/Signing.swift | 10 ++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) 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 index 8dda616939..137982d3dc 100644 --- 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 @@ -22,3 +22,21 @@ data class EthereumTransactionData ( val data: String? = null ) +@Serializable +enum class SignDigestType(val string: String) { + @SerialName("eip191") + Eip191("eip191"), + @SerialName("eip712") + Eip712("eip712"), + @SerialName("base58") + Base58("base58"), + @SerialName("suiPersonal") + SuiPersonal("suiPersonal"), + @SerialName("siwe") + Siwe("siwe"), + @SerialName("tonPersonal") + TonPersonal("tonPersonal"), + @SerialName("tronPersonal") + TronPersonal("tronPersonal"), +} + diff --git a/core/crates/primitives/src/signing.rs b/core/crates/primitives/src/signing.rs index 47d058fd47..d34d3f02d8 100644 --- a/core/crates/primitives/src/signing.rs +++ b/core/crates/primitives/src/signing.rs @@ -2,7 +2,9 @@ use crate::{Chain, TransactionType, TransferDataOutputType, UInt64, WCEthereumTr use serde::{Deserialize, Serialize}; use typeshare::typeshare; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] pub enum SignDigestType { Eip191, Eip712, diff --git a/ios/Packages/Primitives/Sources/Generated/Signing.swift b/ios/Packages/Primitives/Sources/Generated/Signing.swift index 748558a4eb..100b6e8d38 100644 --- a/ios/Packages/Primitives/Sources/Generated/Signing.swift +++ b/ios/Packages/Primitives/Sources/Generated/Signing.swift @@ -31,3 +31,13 @@ public struct EthereumTransactionData: Codable, Equatable, Hashable, Sendable { self.data = data } } + +public enum SignDigestType: String, Codable, Equatable, Hashable, Sendable { + case eip191 + case eip712 + case base58 + case suiPersonal + case siwe + case tonPersonal + case tronPersonal +} From f1342256d03748474d3f18c69051ae50bad761f4 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:04:17 +0300 Subject: [PATCH 39/53] iOS: dissolve the signing request service It was a package with no service in it: three domain types, a protocol and a factory kept in ChainServices only because the simulator sat beside them. The types move to Primitives, their Gemstone mappings to GemstonePrimitives, and the simulator becomes SimulationService, named for the one thing it does. The factory now takes an asset rather than a chain, which is all it ever looked up, so building signing data no longer needs Gemstone to resolve one. That drops GemstonePrimitives from fourteen files, from the Payments package outright and from the WalletConnectorService target. --- ios/Features/Payments/Package.swift | 4 +- .../Services/PaymentActionExecutor.swift | 12 ++--- .../Types/PaymentSheetPresentable.swift | 2 +- .../Payments/Types/PaymentSheetType.swift | 2 +- .../PaymentActionExecutorTests.swift | 15 +++--- .../PaymentsTests/PaymentManagerTests.swift | 5 +- .../PaymentSheetPresentableMock.swift | 5 +- ios/Features/Transfer/Package.swift | 2 - .../SignMessageSceneViewModel.swift | 3 +- .../ConfirmTransferSceneViewModelTests.swift | 1 - .../SignMessageSceneViewModelTests.swift | 3 -- ios/Features/WalletConnector/Package.swift | 4 +- .../WalletConnectorInteractable.swift | 1 - .../Services/WalletConnectorManager.swift | 1 - .../Services/WalletConnectorPresenter.swift | 2 +- .../Services/WalletConnectorSigner.swift | 9 ++-- .../Types/WalletConnectorSheetType.swift | 1 - .../WalletConnectorInteractableMock.swift | 1 - .../WalletConnectorSignerTests.swift | 1 - ios/Gem.xcodeproj/project.pbxproj | 7 --- ios/Gem/App.swift | 1 - .../Navigation/SigningRequestSheetView.swift | 2 +- ios/Gem/Scenes/RootScene.swift | 2 - ios/Gem/Services/AppResolver+Services.swift | 7 ++- ios/Gem/Services/ServicesFactory.swift | 7 ++- ios/Gem/Services/ViewModelFactory.swift | 3 +- ios/Gem/ViewModels/RootSceneViewModel.swift | 1 - ios/Packages/ChainServices/Package.swift | 19 ++++--- .../SimulationService.swift} | 10 ++-- .../TestKit/SimulationServiceableMock.swift} | 6 +-- .../TestKit/WalletConnectorSignableMock.swift | 1 - .../WalletConnectorService.swift | 11 ++-- .../WalletConnectorSignable.swift | 1 - ios/Packages/FeatureServices/Package.swift | 4 +- .../PaymentAction+Mapping.swift | 1 + .../PaymentService/PaymentAction.swift | 1 - .../PaymentService/PaymentService.swift | 1 + .../SignMessage+GemstonePrimitives.swift | 53 +++++++++++++++++++ ...nableTransaction+GemstonePrimitives.swift} | 2 +- .../Primitives/Sources/SignMessage.swift | 19 +++++++ .../Sources}/SignMessagePayload.swift | 2 - .../Sources}/SignableTransaction.swift | 1 - .../Sources}/SigningRequestInteractable.swift | 1 - .../Sources}/SigningTransferData.swift | 1 - .../Sources}/SigningTransferDataFactory.swift | 17 +++--- .../TestKit/SignMessagePayload+TestKit.swift | 3 -- .../SigningRequestInteractableMock.swift | 1 - 47 files changed, 143 insertions(+), 116 deletions(-) rename ios/Packages/ChainServices/{SigningRequestService/SigningSimulator.swift => SimulationService/SimulationService.swift} (92%) rename ios/Packages/ChainServices/{SigningRequestService/TestKit/SigningSimulatableMock.swift => SimulationService/TestKit/SimulationServiceableMock.swift} (88%) create mode 100644 ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift rename ios/Packages/{ChainServices/SigningRequestService/SignableTransaction+Mapping.swift => GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift} (95%) create mode 100644 ios/Packages/Primitives/Sources/SignMessage.swift rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SignMessagePayload.swift (94%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SignableTransaction.swift (95%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SigningRequestInteractable.swift (95%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SigningTransferData.swift (96%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SigningTransferDataFactory.swift (90%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives}/TestKit/SignMessagePayload+TestKit.swift (93%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives}/TestKit/SigningRequestInteractableMock.swift (97%) diff --git a/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift index 65fe866163..17e002f32e 100644 --- a/ios/Features/Payments/Package.swift +++ b/ios/Features/Payments/Package.swift @@ -35,7 +35,7 @@ let package = Package( "PrimitivesComponents", "Formatters", "EventPresenterService", - .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "SimulationService", package: "ChainServices"), .product(name: "PaymentService", package: "FeatureServices"), .product(name: "TransactionStateService", package: "FeatureServices"), ], @@ -46,7 +46,7 @@ let package = Package( dependencies: [ "Payments", .product(name: "PrimitivesTestKit", package: "Primitives"), - .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), + .product(name: "SimulationServiceTestKit", package: "ChainServices"), .product(name: "PaymentServiceTestKit", package: "FeatureServices"), .product(name: "StoreTestKit", package: "Store"), .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift index ec4d692649..b85615470f 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -3,7 +3,7 @@ import Foundation import PaymentService import Primitives -import SigningRequestService +import SimulationService public struct PaymentActionResults: Sendable { public let results: [String] @@ -12,12 +12,12 @@ public struct PaymentActionResults: Sendable { public struct PaymentActionExecutor: Sendable { private let interactor: any SigningRequestInteractable - private let simulator: any SigningSimulatable + private let simulator: any SimulationServiceable private let assetsProvider: any PaymentAssetsProvidable public init( interactor: any SigningRequestInteractable, - simulator: any SigningSimulatable, + simulator: any SimulationServiceable, assetsProvider: any PaymentAssetsProvidable, ) { self.interactor = interactor @@ -67,7 +67,7 @@ extension PaymentActionExecutor { chain: chain, appMetadata: appMetadata, wallet: wallet, - message: message, + message: message.map(), simulation: simulator.simulateSignMessage(message: message, sessionDomain: appMetadata.url ?? .empty), payment: payment, expiresAt: payment.expiresAt, @@ -75,7 +75,7 @@ extension PaymentActionExecutor { return try await interactor.signMessage(payload: payload) case let .signTransaction(chain, transaction): let transferData = try SigningTransferDataFactory.transferData( - chain: chain, + asset: chain.asset, appMetadata: appMetadata, transaction: transaction, outputAction: .sign, @@ -102,7 +102,7 @@ extension PaymentActionExecutor { ) case let .sendTransaction(chain, transaction): let transferData = try SigningTransferDataFactory.transferData( - chain: chain, + asset: chain.asset, appMetadata: appMetadata, transaction: transaction, outputAction: .send, diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift index bf7333d043..51e48a3050 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetPresentable.swift @@ -2,8 +2,8 @@ import Foundation import PaymentService +import Primitives import PrimitivesComponents -import SigningRequestService public protocol PaymentSheetPresentable: SigningRequestInteractable { func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift index a20b3208a4..d2bf85e2b3 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -2,8 +2,8 @@ import Foundation import PaymentService +import Primitives import PrimitivesComponents -import SigningRequestService public enum PaymentSheetType: Sendable, Identifiable { case quotes(SheetCallback) diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift index c0fc6f9cf8..5b4ea37a1e 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -6,8 +6,7 @@ import PaymentService import PaymentServiceTestKit import Primitives import PrimitivesTestKit -import SigningRequestService -import SigningRequestServiceTestKit +import SimulationServiceTestKit import Testing @MainActor @@ -17,7 +16,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() interactor.transactionHash = "transaction-hash" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [PaymentAction.sendTransaction(chain: .ethereum, transaction: .sui("transaction", .encodedTransaction))], paymentId: "pay_1", appMetadata: .mock(), @@ -34,7 +33,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() interactor.signature = "permit-signature" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: Self.approvalAssets()).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: Self.approvalAssets()).perform( actions: [ .approveToken(chain: .ethereum, approval: ApprovalData(token: "0xtoken", spender: "0xspender", value: "1", isUnlimited: true)), .mockSignMessage(data: Data("permit".utf8)), @@ -55,7 +54,7 @@ struct PaymentActionExecutorTests { interactor.signature = "permit-signature" interactor.transactionHash = "approval-hash" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [ .sendTransaction(chain: .ethereum, transaction: .sui("approval", .encodedTransaction)), .mockSignMessage(data: Data("permit".utf8)), @@ -75,7 +74,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() let payment = PaymentData.mock(quote: .mock(amount: .mock(value: "25000", symbol: "USDT"))) - _ = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + _ = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( actions: [PaymentAction.mockSignMessage(data: Data("pay".utf8))], paymentId: "pay_1", appMetadata: .mock(), @@ -94,7 +93,7 @@ struct PaymentActionExecutorTests { _ = try await PaymentActionExecutor( interactor: interactor, - simulator: SigningSimulatableMock(), + simulator: SimulationServiceableMock(), assetsProvider: Self.approvalAssets(), ).perform( actions: [ @@ -119,7 +118,7 @@ struct PaymentActionExecutorTests { func signMessageIsSimulatedBeforeItIsShown() async throws { let interactor = SigningRequestInteractableMock() let warning = SimulationWarning(severity: .critical, warning: .suspiciousSpender, message: "suspicious spender") - let simulator = SigningSimulatableMock( + let simulator = SimulationServiceableMock( result: SimulationResult(warnings: [warning], balanceChanges: [], payload: [], header: .none), ) diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift index e5a5d129ef..4a6fd5a8d3 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -7,8 +7,7 @@ import PaymentServiceTestKit import Primitives import PrimitivesComponents import PrimitivesTestKit -import SigningRequestService -import SigningRequestServiceTestKit +import SimulationServiceTestKit import Store import StoreTestKit import Testing @@ -25,7 +24,7 @@ struct PaymentManagerTests { ) -> PaymentManager { PaymentManager( service: service, - executor: PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), assetsProvider: PaymentAssetsProvidableMock()), + executor: PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock()), presenter: presenter, assetsProvider: PaymentAssetsProvidableMock(), transactionStateScheduler: .mock( diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift index a653da10fd..7ada586415 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift @@ -1,10 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import PaymentService @testable import Payments +import PaymentService import Primitives -import SigningRequestService final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sendable { init() {} @@ -41,7 +40,7 @@ final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sen return signature } - func signTransaction(transferData: SigningTransferData) async throws -> String { + func signTransaction(transferData _: SigningTransferData) async throws -> String { transactionHash } diff --git a/ios/Features/Transfer/Package.swift b/ios/Features/Transfer/Package.swift index 23d8561d43..da31cd526b 100644 --- a/ios/Features/Transfer/Package.swift +++ b/ios/Features/Transfer/Package.swift @@ -79,7 +79,6 @@ let package = Package( .product(name: "EarnService", package: "FeatureServices"), .product(name: "PerpetualService", package: "FeatureServices"), .product(name: "ExplorerService", package: "ChainServices"), - .product(name: "SigningRequestService", package: "ChainServices"), .product(name: "NameService", package: "ChainServices"), .product(name: "AddressNameService", package: "FeatureServices"), .product(name: "ActivityService", package: "FeatureServices"), @@ -105,7 +104,6 @@ let package = Package( .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: "WalletSessionService", package: "FeatureServices"), diff --git a/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift index c52baca9fd..d9cd9d241b 100644 --- a/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/SignMessageSceneViewModel.swift @@ -11,7 +11,6 @@ import Keystore import Localization import Primitives import PrimitivesComponents -import SigningRequestService import Style @Observable @@ -45,7 +44,7 @@ public final class SignMessageSceneViewModel { self.addressNameService = addressNameService self.payload = payload expiryCountdown = ExpiryCountdown(expiresAt: payload.expiresAt) - let signer = MessageSigner(message: payload.message) + let signer = MessageSigner(message: payload.message.map()) self.signer = signer let plainMessage = signer.plainPreview() self.plainMessage = plainMessage diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift index d10312a515..3f0014a586 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift @@ -25,7 +25,6 @@ import Primitives import PrimitivesComponents import PrimitivesTestKit import ScanServiceTestKit -import SigningRequestServiceTestKit import Store import Testing import TransactionStateServiceTestKit diff --git a/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift index 52a630f825..4eaeb909f3 100644 --- a/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/SignMessageSceneViewModelTests.swift @@ -1,13 +1,10 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import struct Gemstone.SignMessage import KeystoreTestKit import Primitives import PrimitivesComponents import PrimitivesTestKit -import SigningRequestService -import SigningRequestServiceTestKit import Testing @testable import Transfer diff --git a/ios/Features/WalletConnector/Package.swift b/ios/Features/WalletConnector/Package.swift index 6cd9dc0bf0..a73f9cbc0e 100644 --- a/ios/Features/WalletConnector/Package.swift +++ b/ios/Features/WalletConnector/Package.swift @@ -14,6 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "Primitives", path: "../../Packages/Primitives"), + .package(name: "GemstonePrimitives", path: "../../Packages/GemstonePrimitives"), .package(name: "ChainServices", path: "../../Packages/ChainServices"), .package(name: "Components", path: "../../Packages/Components"), .package(name: "Localization", path: "../../Packages/Localization"), @@ -31,7 +32,7 @@ let package = Package( name: "WalletConnector", dependencies: [ "Primitives", - .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "GemstonePrimitives", package: "GemstonePrimitives"), .product(name: "WalletConnectorService", package: "ChainServices"), "Components", "Localization", @@ -56,7 +57,6 @@ let package = Package( .product(name: "PreferencesTestKit", package: "Preferences"), .product(name: "WalletSessionServiceTestKit", package: "FeatureServices"), .product(name: "ConnectionsServiceTestKit", package: "FeatureServices"), - .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), .product(name: "WalletConnectorServiceTestKit", package: "ChainServices"), .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), "WalletConnector", diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift index e157c6abbb..897a67b955 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift @@ -2,7 +2,6 @@ import Foundation import Primitives -import SigningRequestService public protocol WalletConnectorInteractable: Sendable { func sessionReject(error: any Error) async diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index e8553d1bfc..f3df001a1f 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift @@ -2,7 +2,6 @@ import Primitives import PrimitivesComponents -import SigningRequestService import WalletConnectorService public final class WalletConnectorManager { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift index f41a811507..fe6f50bcd2 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,8 +1,8 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import Primitives import PrimitivesComponents -import SigningRequestService @Observable public final class WalletConnectorPresenter: SheetPresenting, SigningRequestInteractable, Sendable { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index 7c09912f8f..8ce238eb26 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift @@ -6,7 +6,6 @@ import class Gemstone.MessageSigner import struct Gemstone.SignMessage import Preferences import Primitives -import SigningRequestService import Store import WalletConnectorService import WalletConnectSign @@ -95,7 +94,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { chain: chain, appMetadata: session.session.metadata.transactionAppMetadata, wallet: session.wallet, - message: message, + message: message.map(), simulation: simulation, ) return try await signingInteractor.signMessage(payload: payload) @@ -132,7 +131,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { throw AnyError("Not supported") case .solana, .sui, .ton, .tron: let transferData = try SigningTransferDataFactory.transferData( - chain: chain, + asset: chain.asset, appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, outputAction: .sign, @@ -147,7 +146,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { let wallet = try getWallet(id: session.wallet.id) let transferData = try SigningTransferDataFactory.transferData( - chain: chain, + asset: chain.asset, appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, outputAction: .send, @@ -160,7 +159,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { try validate(chain: chain, session: session.session) let wallet = try getWallet(id: session.wallet.id) let transferData = SigningTransferDataFactory.encodedTransferData( - chain: chain, + asset: chain.asset, appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, outputType: .encodedTransaction, diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 48094a539c..f051fbf7ad 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -3,7 +3,6 @@ import Foundation import Primitives import PrimitivesComponents -import SigningRequestService public enum WalletConnectorSheetType: Sendable, Identifiable { case connectionProposal(SheetCallback) diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift index 141a954407..50fcfdd0a4 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift @@ -2,7 +2,6 @@ import Foundation import Primitives -import SigningRequestService @testable import WalletConnector import WalletConnectorService diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift index 2ac3b510ef..62f31ae8c6 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift @@ -13,7 +13,6 @@ import WalletConnectorService import WalletConnectSign import WalletSessionService import WalletSessionServiceTestKit -import SigningRequestService struct WalletConnectorSignerTests { @Test diff --git a/ios/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index 2a7dd96a58..b9fbce1a08 100644 --- a/ios/Gem.xcodeproj/project.pbxproj +++ b/ios/Gem.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 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 */; }; @@ -335,7 +334,6 @@ 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 */, @@ -878,7 +876,6 @@ B6STREAM02F5000000000002 /* StreamService */, 83385DDD2D27147900D76803 /* WalletConnectorService */, 0A11FA0000000000000000A2 /* PaymentService */, - 0A11FA0000000000000000E2 /* SigningRequestService */, 83FE37B32D273CC80048D54C /* WalletConnector */, 0A11FA0000000000000000B2 /* Payments */, 83EB5E2F2D2ECF00006A7CFB /* Onboarding */, @@ -2031,10 +2028,6 @@ isa = XCSwiftPackageProductDependency; productName = PaymentService; }; - 0A11FA0000000000000000E2 /* SigningRequestService */ = { - isa = XCSwiftPackageProductDependency; - productName = SigningRequestService; - }; 0A11FA0000000000000000B2 /* Payments */ = { isa = XCSwiftPackageProductDependency; productName = Payments; diff --git a/ios/Gem/App.swift b/ios/Gem/App.swift index 0e4416ddf9..f9323122e7 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -12,7 +12,6 @@ import Store import Style import SwiftUI import WalletService -import SigningRequestService @main struct GemApp: App { diff --git a/ios/Gem/Navigation/SigningRequestSheetView.swift b/ios/Gem/Navigation/SigningRequestSheetView.swift index afc025e5f2..177d252bb9 100644 --- a/ios/Gem/Navigation/SigningRequestSheetView.swift +++ b/ios/Gem/Navigation/SigningRequestSheetView.swift @@ -1,7 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. +import Primitives import PrimitivesComponents -import SigningRequestService import Style import SwiftUI import Transfer diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index 924b208aea..e68f613960 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -1,13 +1,11 @@ // Copyright (c). Gem Wallet. All rights reserved. import Components -import GemstonePrimitives import Localization import Onboarding import Payments import PriceService import Primitives -import SigningRequestService import Style import SwiftUI import WalletConnector diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index 54a4433234..d98b31e354 100644 --- a/ios/Gem/Services/AppResolver+Services.swift +++ b/ios/Gem/Services/AppResolver+Services.swift @@ -17,18 +17,18 @@ import EventPresenterService import ExplorerService import FiatService import Foundation -import Payments import NameService import NFTService import NodeService import NotificationService +import Payments import PerpetualService import PriceAlertService import PriceService +import Primitives import RewardsService -import ServiceStatusService import ScanService -import SigningRequestService +import ServiceStatusService import StakeService import StreamService import SupportChatService @@ -38,7 +38,6 @@ import TransactionStateService import WalletConnector import WalletService import WalletSessionService -import Primitives extension AppResolver { struct Services { diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index 87d4c53e63..0a9fd94d28 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -22,15 +22,14 @@ import FiatService import Foundation import GemAPI import GemAPIDevice -import GemstonePrimitives import Keystore import NameService import NativeProviderService import NFTService import NodeService import NotificationService -import PaymentService import Payments +import PaymentService import PerpetualService import Preferences import PriceAlertService @@ -39,7 +38,7 @@ import Primitives import RewardsService import ScanService import ServiceStatusService -import SigningRequestService +import SimulationService import StakeService import Store import StreamService @@ -312,7 +311,7 @@ struct ServicesFactory { service: paymentService, executor: PaymentActionExecutor( interactor: paymentSheetPresenter, - simulator: SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), + simulator: SimulationService(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), assetsProvider: paymentAssetsProvider, ), presenter: paymentSheetPresenter, diff --git a/ios/Gem/Services/ViewModelFactory.swift b/ios/Gem/Services/ViewModelFactory.swift index f1b3868ddb..b616d4328e 100644 --- a/ios/Gem/Services/ViewModelFactory.swift +++ b/ios/Gem/Services/ViewModelFactory.swift @@ -13,13 +13,12 @@ import FiatService import Foundation import Keystore import NameService +import PaymentService import PerpetualService import Preferences import PriceAlertService import PriceService -import PaymentService import Primitives -import SigningRequestService import PrimitivesComponents import ScanService import Stake diff --git a/ios/Gem/ViewModels/RootSceneViewModel.swift b/ios/Gem/ViewModels/RootSceneViewModel.swift index 95f9cb1b0e..f2b4b0b36c 100644 --- a/ios/Gem/ViewModels/RootSceneViewModel.swift +++ b/ios/Gem/ViewModels/RootSceneViewModel.swift @@ -15,7 +15,6 @@ import Onboarding import Payments import Preferences import Primitives -import SigningRequestService import SwiftUI import TransactionsService import TransactionStateService diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 6d8a6de8c0..be8ff0900c 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -15,8 +15,8 @@ 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: "SimulationService", targets: ["SimulationService"]), + .library(name: "SimulationServiceTestKit", targets: ["SimulationServiceTestKit"]), .library(name: "WalletConnectorService", targets: ["WalletConnectorService"]), .library(name: "WalletConnectorServiceTestKit", targets: ["WalletConnectorServiceTestKit"]), .library(name: "ScanService", targets: ["ScanService"]), @@ -105,31 +105,30 @@ let package = Package( path: "NodeService/Tests", ), .target( - name: "SigningRequestService", + name: "SimulationService", dependencies: [ "Primitives", "Gemstone", "GemstonePrimitives", "NativeProviderService", ], - path: "SigningRequestService", + path: "SimulationService", exclude: ["TestKit"], ), .target( - name: "SigningRequestServiceTestKit", + name: "SimulationServiceTestKit", dependencies: [ - "SigningRequestService", + "SimulationService", .product(name: "PrimitivesTestKit", package: "Primitives"), ], - path: "SigningRequestService/TestKit", + path: "SimulationService/TestKit", ), .target( name: "WalletConnectorService", dependencies: [ "Primitives", - "SigningRequestService", + "SimulationService", "Gemstone", - "GemstonePrimitives", "NativeProviderService", .product(name: "WalletConnect", package: "reown-swift"), .product(name: "ReownWalletKit", package: "reown-swift"), @@ -142,7 +141,7 @@ let package = Package( name: "WalletConnectorServiceTestKit", dependencies: [ "WalletConnectorService", - "SigningRequestService", + "SimulationService", .product(name: "PrimitivesTestKit", package: "Primitives"), ], path: "WalletConnectorService/TestKit", diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift b/ios/Packages/ChainServices/SimulationService/SimulationService.swift similarity index 92% rename from ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift rename to ios/Packages/ChainServices/SimulationService/SimulationService.swift index befabe34f4..f82e891a7d 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift +++ b/ios/Packages/ChainServices/SimulationService/SimulationService.swift @@ -2,20 +2,20 @@ import Foundation import struct Gemstone.Chain +import enum Gemstone.SignableTransactionType import enum Gemstone.SignDigestType import struct Gemstone.SignMessage import class Gemstone.WalletConnectSimulationClient -import enum Gemstone.SignableTransactionType +import GemstonePrimitives import NativeProviderService import Primitives -import GemstonePrimitives -public protocol SigningSimulatable: Sendable { +public protocol SimulationServiceable: Sendable { func simulateSignMessage(chain: Gemstone.Chain, signType: SignDigestType, data: String, sessionDomain: String) async throws -> SimulationResult func simulateSendTransaction(chain: Gemstone.Chain, transactionType: SignableTransactionType, data: String) async throws -> SimulationResult } -public extension SigningSimulatable { +public extension SimulationServiceable { func simulateSignMessage(message: SignMessage, sessionDomain: String) async throws -> SimulationResult { try await simulateSignMessage( chain: message.chain, @@ -26,7 +26,7 @@ public extension SigningSimulatable { } } -public final class SigningSimulator: SigningSimulatable, Sendable { +public final class SimulationService: SimulationServiceable, Sendable { private let client: WalletConnectSimulationClient public init(nodeProvider: any NodeURLFetchable, requestInterceptor: any RequestInterceptable = EmptyRequestInterceptor()) { diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift b/ios/Packages/ChainServices/SimulationService/TestKit/SimulationServiceableMock.swift similarity index 88% rename from ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift rename to ios/Packages/ChainServices/SimulationService/TestKit/SimulationServiceableMock.swift index 6737fea789..8d769f6e96 100644 --- a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift +++ b/ios/Packages/ChainServices/SimulationService/TestKit/SimulationServiceableMock.swift @@ -1,13 +1,13 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import enum Gemstone.SignDigestType import struct Gemstone.Chain import enum Gemstone.SignableTransactionType -import SigningRequestService +import enum Gemstone.SignDigestType import Primitives +import SimulationService -public struct SigningSimulatableMock: SigningSimulatable { +public struct SimulationServiceableMock: SimulationServiceable { private let result: SimulationResult public init(result: SimulationResult = .empty) { diff --git a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift index add66cbd06..73c9450ab3 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift @@ -3,7 +3,6 @@ import Foundation import struct Gemstone.SignMessage import Primitives -import SigningRequestService import WalletConnectorService import WalletConnectSign diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift index 789efb40d9..1c8934285c 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift @@ -2,6 +2,8 @@ import Foundation import Gemstone +import enum Gemstone.SignableTransaction +import enum Gemstone.SignableTransactionType import enum Gemstone.SignDigestType import struct Gemstone.SignMessage import class Gemstone.WalletConnect @@ -9,13 +11,10 @@ import enum Gemstone.WalletConnectAction import enum Gemstone.WalletConnectChainOperation import enum Gemstone.WalletConnectResponseType import class Gemstone.WalletConnectSimulationClient -import enum Gemstone.SignableTransaction -import enum Gemstone.SignableTransactionType -import GemstonePrimitives import NativeProviderService import Primitives -import SigningRequestService @preconcurrency import ReownWalletKit +import SimulationService @preconcurrency import WalletConnectPairing public final class WalletConnectorService { @@ -23,7 +22,7 @@ public final class WalletConnectorService { private let signer: WalletConnectorSignable private let messageTracker = MessageTracker() private let walletConnect = WalletConnect() - private let simulator: SigningSimulator + private let simulator: SimulationService public init( signer: WalletConnectorSignable, @@ -31,7 +30,7 @@ public final class WalletConnectorService { requestInterceptor: any RequestInterceptable = EmptyRequestInterceptor(), ) { self.signer = signer - simulator = SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor) + simulator = SimulationService(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor) } } diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift index ddb17c6f93..d8cca29656 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift @@ -3,7 +3,6 @@ import Foundation import struct Gemstone.SignMessage import Primitives -import SigningRequestService import WalletConnectSign public protocol WalletConnectorSignable: Sendable { diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index fc3af47b0e..19c09d6186 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -272,9 +272,8 @@ let package = Package( dependencies: [ "Primitives", "Store", - .product(name: "SigningRequestService", package: "ChainServices"), - .product(name: "Gemstone", package: "Gemstone"), .product(name: "GemstonePrimitives", package: "GemstonePrimitives"), + .product(name: "Gemstone", package: "Gemstone"), .product(name: "NativeProviderService", package: "NativeProviderService"), ], path: "PaymentService", @@ -284,7 +283,6 @@ let package = Package( name: "PaymentServiceTestKit", dependencies: [ "PaymentService", - .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), .product(name: "PrimitivesTestKit", package: "Primitives"), ], path: "PaymentService/TestKit", diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift index bbc8107787..01134c9ebe 100644 --- a/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift +++ b/ios/Packages/FeatureServices/PaymentService/PaymentAction+Mapping.swift @@ -2,6 +2,7 @@ import Foundation import enum Gemstone.PaymentAction +import GemstonePrimitives import Primitives typealias GemPaymentAction = Gemstone.PaymentAction diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift b/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift index 85f599029e..f15d5e8049 100644 --- a/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift +++ b/ios/Packages/FeatureServices/PaymentService/PaymentAction.swift @@ -3,7 +3,6 @@ import Foundation import struct Gemstone.SignMessage import Primitives -import SigningRequestService public enum PaymentAction: Sendable { case signMessage(chain: Chain, message: SignMessage) diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift index 4ce303d086..780dddd0a7 100644 --- a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift +++ b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift @@ -2,6 +2,7 @@ import Foundation import Gemstone +import GemstonePrimitives import NativeProviderService import Primitives diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift new file mode 100644 index 0000000000..2645426937 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignMessage+GemstonePrimitives.swift @@ -0,0 +1,53 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension Gemstone.SignMessage { + func map() -> Primitives.SignMessage { + Primitives.SignMessage( + chain: chain, + signType: signType.map(), + data: data, + ) + } +} + +public extension Primitives.SignMessage { + func map() -> Gemstone.SignMessage { + Gemstone.SignMessage( + chain: chain, + signType: signType.map(), + data: data, + ) + } +} + +public extension Gemstone.SignDigestType { + func map() -> Primitives.SignDigestType { + switch self { + case .eip191: .eip191 + case .eip712: .eip712 + case .base58: .base58 + case .suiPersonal: .suiPersonal + case .siwe: .siwe + case .tonPersonal: .tonPersonal + case .tronPersonal: .tronPersonal + } + } +} + +public extension Primitives.SignDigestType { + func map() -> Gemstone.SignDigestType { + switch self { + case .eip191: .eip191 + case .eip712: .eip712 + case .base58: .base58 + case .suiPersonal: .suiPersonal + case .siwe: .siwe + case .tonPersonal: .tonPersonal + case .tronPersonal: .tronPersonal + } + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift similarity index 95% rename from ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift rename to ios/Packages/GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift index 5c27ba5f9f..b155be9d09 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/SignableTransaction+GemstonePrimitives.swift @@ -5,7 +5,7 @@ import Gemstone import Primitives public extension Gemstone.SignableTransaction { - func map() -> SignableTransaction { + func map() -> Primitives.SignableTransaction { switch self { case let .ethereum(data, transactionType): .ethereum(data.map(), transactionType.map()) case let .solana(data, outputType): .solana(data.transaction, outputType.map()) diff --git a/ios/Packages/Primitives/Sources/SignMessage.swift b/ios/Packages/Primitives/Sources/SignMessage.swift new file mode 100644 index 0000000000..d0b3d92da4 --- /dev/null +++ b/ios/Packages/Primitives/Sources/SignMessage.swift @@ -0,0 +1,19 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct SignMessage: Sendable, Equatable, Hashable { + public let chain: String + public let signType: SignDigestType + public let data: Data + + public init( + chain: String, + signType: SignDigestType, + data: Data, + ) { + self.chain = chain + self.signType = signType + self.data = data + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift b/ios/Packages/Primitives/Sources/SignMessagePayload.swift similarity index 94% rename from ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift rename to ios/Packages/Primitives/Sources/SignMessagePayload.swift index db8ba9b57b..85726c04e7 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift +++ b/ios/Packages/Primitives/Sources/SignMessagePayload.swift @@ -1,8 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import struct Gemstone.SignMessage -import Primitives public struct SignMessagePayload: Sendable, Identifiable { public let id: String diff --git a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift b/ios/Packages/Primitives/Sources/SignableTransaction.swift similarity index 95% rename from ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift rename to ios/Packages/Primitives/Sources/SignableTransaction.swift index 7a7bccfbaa..91fec7eec2 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift +++ b/ios/Packages/Primitives/Sources/SignableTransaction.swift @@ -1,7 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives public enum SignableTransaction: Sendable { case ethereum(EthereumTransactionData, TransactionType) diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestInteractable.swift b/ios/Packages/Primitives/Sources/SigningRequestInteractable.swift similarity index 95% rename from ios/Packages/ChainServices/SigningRequestService/SigningRequestInteractable.swift rename to ios/Packages/Primitives/Sources/SigningRequestInteractable.swift index 9bc3b26f9e..83d07e5880 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestInteractable.swift +++ b/ios/Packages/Primitives/Sources/SigningRequestInteractable.swift @@ -1,7 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives public protocol SigningRequestInteractable: Sendable { func signMessage(payload: SignMessagePayload) async throws -> String diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift b/ios/Packages/Primitives/Sources/SigningTransferData.swift similarity index 96% rename from ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift rename to ios/Packages/Primitives/Sources/SigningTransferData.swift index 77f1df068c..3c52ce8080 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningTransferData.swift +++ b/ios/Packages/Primitives/Sources/SigningTransferData.swift @@ -1,7 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives public struct SigningTransferData: Identifiable, Sendable { public let transferData: TransferData diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift b/ios/Packages/Primitives/Sources/SigningTransferDataFactory.swift similarity index 90% rename from ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift rename to ios/Packages/Primitives/Sources/SigningTransferDataFactory.swift index f84cd56e33..3c4ab55bf7 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningTransferDataFactory.swift +++ b/ios/Packages/Primitives/Sources/SigningTransferDataFactory.swift @@ -2,11 +2,10 @@ import BigInt import Foundation -import Primitives public enum SigningTransferDataFactory { public static func transferData( - chain: Chain, + asset: Asset, appMetadata: TransactionAppMetadata, transaction: SignableTransaction, outputAction: TransferDataOutputAction, @@ -14,17 +13,17 @@ public enum SigningTransferDataFactory { ) throws -> TransferData { switch transaction { case let .ethereum(transaction, transactionType): - try ethereumTransferData(chain: chain, appMetadata: appMetadata, transaction: transaction, transactionType: transactionType, payment: payment) + try ethereumTransferData(asset: asset, appMetadata: appMetadata, transaction: transaction, transactionType: transactionType, payment: payment) case let .solana(transaction, outputType), let .sui(transaction, outputType), let .ton(transaction, outputType), let .tron(transaction, outputType): - encodedTransferData(chain: chain, appMetadata: appMetadata, transaction: transaction, outputType: outputType, outputAction: outputAction, payment: payment) + encodedTransferData(asset: asset, appMetadata: appMetadata, transaction: transaction, outputType: outputType, outputAction: outputAction, payment: payment) } } public static func ethereumTransferData( - chain: Chain, + asset: Asset, appMetadata: TransactionAppMetadata, transaction: EthereumTransactionData, transactionType: TransactionType, @@ -60,7 +59,7 @@ public enum SigningTransferDataFactory { }() return TransferData( - type: Self.type(asset: chain.asset, appMetadata: appMetadata, payment: payment, extra: TransferDataExtra( + type: Self.type(asset: asset, appMetadata: appMetadata, payment: payment, extra: TransferDataExtra( to: address, gasLimit: gasLimit, gasPrice: gasPrice, @@ -76,7 +75,7 @@ public enum SigningTransferDataFactory { } public static func encodedTransferData( - chain: Chain, + asset: Asset, appMetadata: TransactionAppMetadata, transaction: String, outputType: TransferDataOutputType, @@ -84,8 +83,8 @@ public enum SigningTransferDataFactory { payment: PaymentData? = .none, ) -> TransferData { TransferData( - type: Self.type( - asset: chain.asset, + type: type( + asset: asset, appMetadata: appMetadata, payment: payment, extra: TransferDataExtra( diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift b/ios/Packages/Primitives/TestKit/SignMessagePayload+TestKit.swift similarity index 93% rename from ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift rename to ios/Packages/Primitives/TestKit/SignMessagePayload+TestKit.swift index c29484f343..14442c36a4 100644 --- a/ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/SignMessagePayload+TestKit.swift @@ -1,10 +1,7 @@ // 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( diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift b/ios/Packages/Primitives/TestKit/SigningRequestInteractableMock.swift similarity index 97% rename from ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift rename to ios/Packages/Primitives/TestKit/SigningRequestInteractableMock.swift index 74721e4a23..b06d59bb79 100644 --- a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift +++ b/ios/Packages/Primitives/TestKit/SigningRequestInteractableMock.swift @@ -2,7 +2,6 @@ import Foundation import Primitives -import SigningRequestService public final class SigningRequestInteractableMock: SigningRequestInteractable, @unchecked Sendable { public init() {} From dbb2e7ce7874120306961f64d2e3d252cded501c Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:04:21 +0300 Subject: [PATCH 40/53] iOS: drop test plan entries for targets that do not exist TransferServiceTests and SigningRequestServiceTests were never declared in any package, so the plan asked xcodebuild for two targets it could not resolve. --- ios/GemTests/unit_frameworks.xctestplan | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/ios/GemTests/unit_frameworks.xctestplan b/ios/GemTests/unit_frameworks.xctestplan index 3321110e30..5e365a1476 100644 --- a/ios/GemTests/unit_frameworks.xctestplan +++ b/ios/GemTests/unit_frameworks.xctestplan @@ -175,13 +175,6 @@ "name" : "TransferTests" } }, - { - "target" : { - "containerPath" : "container:Packages\/FeatureServices", - "identifier" : "TransferServiceTests", - "name" : "TransferServiceTests" - } - }, { "target" : { "containerPath" : "container:Features\/Payments", @@ -353,13 +346,6 @@ "name" : "WalletConnectorServiceTests" } }, - { - "target" : { - "containerPath" : "container:Packages\/ChainServices", - "identifier" : "SigningRequestServiceTests", - "name" : "SigningRequestServiceTests" - } - }, { "target" : { "containerPath" : "container:Packages\/ChainServices", From 34031ad1e9f76f5453bb8913ebc18b850450e9e1 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:51 +0300 Subject: [PATCH 41/53] core: tighten the payment service surface Whether a payment relays its own transaction, and whether a provider reports status, are product rules both apps had reimplemented; core already knew both, so expose them and let the apps read one answer. Cancellation was never called by either app, so the client, the service and the gemstone export go with it. Each method also matched on the provider to reach the one implementation behind it, five times over. The helpers are named for what they return now. normalize said nothing about what it produces, and the service carried the crate's own name on three of them: inside the payment crate, options are payment options. --- core/crates/payment/src/action.rs | 21 ++++--- core/crates/payment/src/service.rs | 32 ++++------ .../src/wallet_connect_pay/action_mapper.rs | 2 +- .../payment/src/wallet_connect_pay/client.rs | 6 -- .../payment/src/wallet_connect_pay/params.rs | 16 ++--- .../payment/src/wallet_connect_pay/service.rs | 58 +++++++++---------- core/crates/primitives/src/payment.rs | 9 +++ core/gemstone/src/payment/mod.rs | 6 +- core/gemstone/src/payment/remote_types.rs | 5 ++ 9 files changed, 76 insertions(+), 79 deletions(-) diff --git a/core/crates/payment/src/action.rs b/core/crates/payment/src/action.rs index 0cd44e0093..43212ab596 100644 --- a/core/crates/payment/src/action.rs +++ b/core/crates/payment/src/action.rs @@ -31,6 +31,10 @@ impl PreparedPayment { pub fn validate(&self, addresses: &[ChainAddress]) -> Result<(), PaymentError> { validate_actions(&self.actions, addresses) } + + pub fn is_relayed(&self) -> bool { + !self.actions.iter().any(|action| matches!(action, PaymentAction::SendTransaction { .. })) + } } fn validate_actions(actions: &[PaymentAction], addresses: &[ChainAddress]) -> Result<(), PaymentError> { @@ -68,10 +72,8 @@ mod tests { } } - #[test] - fn test_actions_keep_the_order_the_gateway_sent() { - let actions = vec![send(Chain::Ethereum), sign(Chain::Ethereum)]; - let prepared = PreparedPayment { + fn prepared(actions: Vec) -> PreparedPayment { + PreparedPayment { quotes: PaymentQuotes { merchant: PaymentMerchant { name: "Merchant".to_string(), @@ -95,10 +97,15 @@ mod tests { provider_data: "{}".to_string(), }, actions, - }; + } + } - assert!(matches!(prepared.actions[0], PaymentAction::SendTransaction { .. })); - assert!(matches!(prepared.actions[1], PaymentAction::SignTransaction { .. })); + #[test] + fn test_is_relayed() { + assert!(prepared(vec![sign(Chain::Ethereum)]).is_relayed()); + assert!(prepared(vec![]).is_relayed()); + assert!(!prepared(vec![send(Chain::Ethereum)]).is_relayed()); + assert!(!prepared(vec![sign(Chain::Ethereum), send(Chain::Ethereum)]).is_relayed()); } #[test] diff --git a/core/crates/payment/src/service.rs b/core/crates/payment/src/service.rs index daa126a21f..ba1b0dc97c 100644 --- a/core/crates/payment/src/service.rs +++ b/core/crates/payment/src/service.rs @@ -19,13 +19,17 @@ impl PaymentService { } } - pub async fn get_options(&self, link: &PaymentLink, addresses: &[ChainAddress]) -> Result { - match link.provider { - PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.payment_options(&link.id, addresses).await, + fn provider(&self, provider: PaymentProviderName) -> Result<&WalletConnectPayService, PaymentError> { + match provider { + PaymentProviderName::WalletConnectPay => Ok(&self.wallet_connect_pay), PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), } } + pub async fn get_options(&self, link: &PaymentLink, addresses: &[ChainAddress]) -> Result { + self.provider(link.provider)?.options(&link.id, addresses).await + } + pub async fn get_prepared_payment( &self, provider: PaymentProviderName, @@ -33,32 +37,16 @@ impl PaymentService { quote: &PaymentQuote, addresses: &[ChainAddress], ) -> Result { - let payment = match provider { - PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.prepare_payment(quotes, quote, addresses).await?, - PaymentProviderName::SolanaPay => return Err(PaymentError::NotSupported), - }; + let payment = self.provider(provider)?.prepare_payment(quotes, quote, addresses).await?; payment.validate(addresses)?; Ok(payment) } pub async fn confirm(&self, provider: PaymentProviderName, quote: &PaymentQuote, action_results: Vec) -> Result { - match provider { - PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.confirm_payment(quote, action_results).await, - PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), - } + self.provider(provider)?.confirm_payment(quote, action_results).await } pub async fn get_status(&self, provider: PaymentProviderName, payment_id: &str) -> Result { - match provider { - PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.get_payment_status(payment_id).await, - PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), - } - } - - pub async fn cancel(&self, provider: PaymentProviderName, payment_id: &str) -> Result<(), PaymentError> { - match provider { - PaymentProviderName::WalletConnectPay => self.wallet_connect_pay.cancel_payment(payment_id).await, - PaymentProviderName::SolanaPay => Err(PaymentError::NotSupported), - } + self.provider(provider)?.get_payment_status(payment_id).await } } diff --git a/core/crates/payment/src/wallet_connect_pay/action_mapper.rs b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs index 19db1b0906..a6d1ebe71f 100644 --- a/core/crates/payment/src/wallet_connect_pay/action_mapper.rs +++ b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs @@ -15,7 +15,7 @@ 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 params = params::map_signer_params(&wallet_rpc.method, &wallet_rpc.params)?; let action = WalletConnectRequestHandler::parse_action( wallet_rpc.method.clone(), params.to_string(), diff --git a/core/crates/payment/src/wallet_connect_pay/client.rs b/core/crates/payment/src/wallet_connect_pay/client.rs index 2c7408ccce..a025370b05 100644 --- a/core/crates/payment/src/wallet_connect_pay/client.rs +++ b/core/crates/payment/src/wallet_connect_pay/client.rs @@ -72,12 +72,6 @@ impl WalletConnectPayClient { Ok(self.client.post_with(&path, &request, self.headers()).await?) } - pub async fn cancel(&self, payment_id: &str) -> Result<(), PaymentError> { - 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?) diff --git a/core/crates/payment/src/wallet_connect_pay/params.rs b/core/crates/payment/src/wallet_connect_pay/params.rs index d2b8dbd249..f71a51346b 100644 --- a/core/crates/payment/src/wallet_connect_pay/params.rs +++ b/core/crates/payment/src/wallet_connect_pay/params.rs @@ -9,7 +9,7 @@ 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 { +pub fn map_signer_params(method: &str, params: &Value) -> Result { if method.starts_with(SOLANA_METHOD_PREFIX) { return Ok(unwrapped_solana_transaction(params)); } @@ -113,13 +113,13 @@ mod tests { } #[test] - fn test_normalize() { + fn test_map_signer_params() { 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); + assert_eq!(map_signer_params("solana_signTransaction", &solana_params).unwrap(), transaction); + assert_eq!(map_signer_params("eth_sendTransaction", &solana_params).unwrap(), solana_params); - let params = normalize(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(permit2_typed_data())).unwrap(); + let params = map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(permit2_typed_data())).unwrap(); assert_eq!( domain_schema_of(¶ms), serde_json::json!([ @@ -130,7 +130,7 @@ mod tests { ); 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(); + let params = map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(as_string)).unwrap(); assert!(params[1].is_string()); assert_eq!(domain_schema_of(¶ms).as_array().unwrap().len(), 3); @@ -138,12 +138,12 @@ mod tests { "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); + assert_eq!(map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &with_schema).unwrap(), with_schema); } #[test] fn test_wallet_connect_params_rejects_unusable_typed_data() { - let rejected = |typed_data: Value| normalize(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(typed_data)).is_err(); + let rejected = |typed_data: Value| map_signer_params(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(typed_data)).is_err(); assert!(rejected(serde_json::json!({"domain": {"chainId": 1, "unexpected": "1"}, "types": {}}))); assert!(rejected(serde_json::json!({"domain": {"chainId": 1, "salt": "0x00"}, "types": {}}))); diff --git a/core/crates/payment/src/wallet_connect_pay/service.rs b/core/crates/payment/src/wallet_connect_pay/service.rs index 9f633220d7..6fc06c7ac8 100644 --- a/core/crates/payment/src/wallet_connect_pay/service.rs +++ b/core/crates/payment/src/wallet_connect_pay/service.rs @@ -28,7 +28,7 @@ impl WalletConnectPayService { if Self::is_expired(quote) { return self.get_requoted_actions(quote, addresses).await; } - match self.payment_actions(quote).await { + match self.actions(quote).await { Ok(actions) => Ok(PreparedPayment { quotes: quotes.clone(), quote: quote.clone(), @@ -40,12 +40,12 @@ impl WalletConnectPayService { } async fn get_requoted_actions(&self, expired: &PaymentQuote, addresses: &[ChainAddress]) -> Result { - let quotes = match self.payment_options(&expired.payment_id, addresses).await? { + let quotes = match self.options(&expired.payment_id, addresses).await? { PaymentOptions::Quotes(quotes) => quotes, PaymentOptions::Outcome(_) => return Err(PaymentError::PaymentExpired), }; let quote = Self::get_quote("es, &expired.amount.asset_id)?; - let actions = self.payment_actions("e).await?; + let actions = self.actions("e).await?; Ok(PreparedPayment { quotes, quote, actions }) } @@ -54,7 +54,7 @@ impl WalletConnectPayService { Ok(payment_mapper::map_payment_outcome(response)) } - pub(crate) async fn payment_options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result { + pub(crate) async fn options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result { let identifiers: Vec = addresses.iter().filter_map(|address| account_identifier(address.chain, &address.address)).collect(); if identifiers.is_empty() { return Err(PaymentError::UnsupportedAccounts); @@ -76,7 +76,7 @@ impl WalletConnectPayService { return Err(PaymentError::PaymentExpired); } - let quotes = Self::payment_quotes(payment_id, quoted.options)?; + let quotes = Self::quotes(payment_id, quoted.options)?; if quotes.is_empty() { return Err(PaymentError::NoPaymentOptions); } @@ -102,7 +102,7 @@ impl WalletConnectPayService { .ok_or(PaymentError::QuoteExpired) } - async fn payment_actions(&self, quote: &PaymentQuote) -> Result, PaymentError> { + async fn actions(&self, quote: &PaymentQuote) -> Result, PaymentError> { let option = Self::option(quote)?; let actions = self.wallet_rpc_actions("e.payment_id, &option).await?; if actions.is_empty() { @@ -112,16 +112,12 @@ impl WalletConnectPayService { actions.iter().map(|action| map_wallet_rpc(&option.account, action)).collect() } - pub(crate) async fn cancel_payment(&self, payment_id: &str) -> Result<(), PaymentError> { - self.client.cancel(payment_id).await - } - pub(crate) async fn get_payment_status(&self, payment_id: &str) -> Result { let response = self.client.get_status(payment_id).await?; Ok(payment_mapper::map_payment_outcome(response)) } - fn payment_quotes(payment_id: &str, options: Vec) -> Result, PaymentError> { + fn quotes(payment_id: &str, options: Vec) -> Result, PaymentError> { let (open, collecting): (Vec, Vec) = options.into_iter().partition(|option| option.collect_data_url.is_none()); open.into_iter() .chain(collecting) @@ -197,7 +193,7 @@ mod tests { Ok(response.to_string().into_bytes()) }); let service = service(client); - let PaymentOptions::Quotes(quotes) = service.payment_options("pay_123", &addresses()).await.unwrap() else { + let PaymentOptions::Quotes(quotes) = service.options("pay_123", &addresses()).await.unwrap() else { panic!("Expected quotes"); }; let selected = quotes.quotes.first().unwrap().clone(); @@ -220,7 +216,7 @@ mod tests { Ok(response.to_string().into_bytes()) }); let service = service(client); - let PaymentOptions::Quotes(quotes) = service.payment_options("pay_123", &addresses()).await.unwrap() else { + let PaymentOptions::Quotes(quotes) = service.options("pay_123", &addresses()).await.unwrap() else { panic!("Expected quotes"); }; let expired = PaymentQuote { @@ -276,9 +272,9 @@ mod tests { } #[tokio::test] - async fn test_get_payment_options() { + async fn test_options() { let service = service_with_response(far_future); - let prepared = service.payment_options("pay_123", &addresses()).await.unwrap(); + let prepared = service.options("pay_123", &addresses()).await.unwrap(); let PaymentOptions::Quotes(quotes) = prepared else { panic!("Expected Ready, got {prepared:?}"); @@ -292,13 +288,13 @@ mod tests { } #[tokio::test] - async fn test_get_payment_options_collect_data() { + async fn test_options_collect_data() { let service = service_with_response(|response| { far_future(response); response["options"][0]["collectData"] = serde_json::json!({"url": "https://data-collection.walletconnect.com/ic/pay_123"}); }); - let prepared = service.payment_options("pay_123", &addresses()).await.unwrap(); + let prepared = service.options("pay_123", &addresses()).await.unwrap(); let PaymentOptions::Quotes(quotes) = prepared else { panic!("Expected Ready, got {prepared:?}"); }; @@ -309,51 +305,51 @@ mod tests { } #[tokio::test] - async fn test_get_payment_options_settled() { + async fn test_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(); + let options = service.options("pay_123", &addresses()).await.unwrap(); assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Succeeded)); let processing = service_with_response(|response| { far_future(response); response["info"]["status"] = serde_json::json!("processing"); }); - let options = processing.payment_options("pay_123", &addresses()).await.unwrap(); + let options = processing.options("pay_123", &addresses()).await.unwrap(); assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Processing)); } #[tokio::test] - async fn test_get_payment_options_rejects_unpayable() { + async fn test_options_rejects_unpayable() { let failed = service_with_response(|response| { far_future(response); response["info"]["status"] = serde_json::json!("failed"); }); - assert_eq!(failed.payment_options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired)); + assert_eq!(failed.options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired)); let expired = service_with_response(|response| { response["info"]["expiresAt"] = serde_json::json!(1); }); - assert_eq!(expired.payment_options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired)); + assert_eq!(expired.options("pay_123", &addresses()).await, Err(PaymentError::PaymentExpired)); let no_options = service_with_response(|response| { far_future(response); response["options"] = serde_json::json!([]); }); - assert_eq!(no_options.payment_options("pay_123", &addresses()).await, Err(PaymentError::NoPaymentOptions)); + assert_eq!(no_options.options("pay_123", &addresses()).await, Err(PaymentError::NoPaymentOptions)); let unsupported_addresses = vec![ChainAddress::new(Chain::Bitcoin, "bc1".to_string())]; assert_eq!( - service_with_response(far_future).payment_options("pay_123", &unsupported_addresses).await, + service_with_response(far_future).options("pay_123", &unsupported_addresses).await, Err(PaymentError::UnsupportedAccounts) ); } #[test] - fn test_payment_quotes_offer_options_asking_for_no_personal_data_first() { + fn test_quotes_offer_options_asking_for_no_personal_data_first() { let option = |id: &str, collect_data_url: Option<&str>| QuotedOption { id: id.to_string(), expires_at: None, @@ -368,7 +364,7 @@ mod tests { provider_data: format!("{{\"id\":\"{id}\"}}"), }; - let quotes = WalletConnectPayService::::payment_quotes( + let quotes = WalletConnectPayService::::quotes( "pay_123", vec![option("opt_form", Some("https://pay.walletconnect.com/collect?pid=pay_123")), option("opt_plain", None)], ) @@ -381,7 +377,7 @@ mod tests { } #[test] - fn test_payment_quotes_reject_a_collection_url_off_the_payment_host() { + fn test_quotes_reject_a_collection_url_off_the_payment_host() { let option = |url: &str| QuotedOption { id: "opt_form".to_string(), expires_at: None, @@ -404,12 +400,12 @@ mod tests { "not a url", ] { assert!( - WalletConnectPayService::::payment_quotes("pay_123", vec![option(url)]).is_err(), + WalletConnectPayService::::quotes("pay_123", vec![option(url)]).is_err(), "{url} was accepted" ); } - assert!(WalletConnectPayService::::payment_quotes("pay_123", vec![option("https://pay.walletconnect.com/collect")]).is_ok()); - assert!(WalletConnectPayService::::payment_quotes("pay_123", vec![option("https://data-collection.walletconnect.com/ic/pay_123")]).is_ok()); + assert!(WalletConnectPayService::::quotes("pay_123", vec![option("https://pay.walletconnect.com/collect")]).is_ok()); + assert!(WalletConnectPayService::::quotes("pay_123", vec![option("https://data-collection.walletconnect.com/ic/pay_123")]).is_ok()); } } diff --git a/core/crates/primitives/src/payment.rs b/core/crates/primitives/src/payment.rs index 5015a272db..ad5f032af9 100644 --- a/core/crates/primitives/src/payment.rs +++ b/core/crates/primitives/src/payment.rs @@ -111,6 +111,15 @@ pub enum PaymentProviderName { WalletConnectPay, } +impl PaymentProviderName { + pub fn has_status(&self) -> bool { + match self { + Self::WalletConnectPay => true, + Self::SolanaPay => false, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] diff --git a/core/gemstone/src/payment/mod.rs b/core/gemstone/src/payment/mod.rs index 081f6b2458..d1e34f035f 100644 --- a/core/gemstone/src/payment/mod.rs +++ b/core/gemstone/src/payment/mod.rs @@ -19,6 +19,7 @@ pub struct GemPreparedPayment { pub quotes: PaymentQuotes, pub quote: PaymentQuote, pub actions: Vec, + pub is_relayed: bool, } #[derive(Debug, uniffi::Enum)] @@ -91,6 +92,7 @@ impl GemPaymentService { ) -> Result { let payment = self.service.get_prepared_payment(provider, "es, "e, &addresses).await?; Ok(GemPreparedPayment { + is_relayed: payment.is_relayed(), quotes: payment.quotes, quote: payment.quote, actions: payment.actions.into_iter().map(Into::into).collect(), @@ -101,10 +103,6 @@ impl GemPaymentService { 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 index 49dd45768c..ef5abc5492 100644 --- a/core/gemstone/src/payment/remote_types.rs +++ b/core/gemstone/src/payment/remote_types.rs @@ -105,6 +105,11 @@ pub fn payment_wallet_connect_url() -> String { format!("https://{WALLET_CONNECT_PAY_HOST}") } +#[uniffi::export] +pub fn payment_provider_has_status(provider: GemPaymentProviderName) -> bool { + provider.has_status() +} + #[uniffi::export] pub fn payment_decode_url(string: &str) -> Result { Ok(PaymentURLDecoder::decode(string)?) From 363cf4a0cb094f965e7f2692a03268318892805c Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:12:21 +0300 Subject: [PATCH 42/53] core: reject an approval on a chain the quote is not priced in The gateway hands back the actions to run and the quote they belong to, and nothing tied the two together: an approval could name one chain while the quote was priced on another. iOS caught it in the executor, Android did not, so it went to the confirm screen showing one chain's asset against another chain's account. Validate it where the prepared payment is already validated, so neither app has to remember to. --- core/crates/payment/src/action.rs | 46 +++++++++++++++++++++-- core/gemstone/src/payment/remote_types.rs | 7 +++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/core/crates/payment/src/action.rs b/core/crates/payment/src/action.rs index 43212ab596..a3e2c436da 100644 --- a/core/crates/payment/src/action.rs +++ b/core/crates/payment/src/action.rs @@ -1,5 +1,5 @@ use primitives::swap::ApprovalData; -use primitives::{Chain, ChainAddress, PaymentQuote, PaymentQuotes, SignMessage, SignableTransaction}; +use primitives::{AssetId, Chain, ChainAddress, PaymentQuote, PaymentQuotes, SignMessage, SignableTransaction}; use crate::error::PaymentError; @@ -29,7 +29,8 @@ pub struct PreparedPayment { impl PreparedPayment { pub fn validate(&self, addresses: &[ChainAddress]) -> Result<(), PaymentError> { - validate_actions(&self.actions, addresses) + validate_actions(&self.actions, addresses)?; + validate_approvals(&self.actions, &self.quote.amount.asset_id) } pub fn is_relayed(&self) -> bool { @@ -47,10 +48,25 @@ fn validate_actions(actions: &[PaymentAction], addresses: &[ChainAddress]) -> Re } } +fn validate_approvals(actions: &[PaymentAction], asset_id: &AssetId) -> Result<(), PaymentError> { + for action in actions { + if let PaymentAction::ApproveToken { chain, .. } = action + && *chain != asset_id.chain + { + return Err(PaymentError::InvalidRequest(format!( + "Payment asks to approve on {} for an asset on {}", + chain.as_ref(), + asset_id.chain.as_ref() + ))); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use primitives::{AssetId, PaymentAmount, PaymentMerchant, TransferDataOutputType}; + use primitives::{PaymentAmount, PaymentMerchant, TransferDataOutputType}; fn send(chain: Chain) -> PaymentAction { PaymentAction::SendTransaction { @@ -72,6 +88,18 @@ mod tests { } } + fn approve(chain: Chain) -> PaymentAction { + PaymentAction::ApproveToken { + chain, + approval: ApprovalData { + token: "0xtoken".to_string(), + spender: "0xspender".to_string(), + value: "1".to_string(), + is_unlimited: false, + }, + } + } + fn prepared(actions: Vec) -> PreparedPayment { PreparedPayment { quotes: PaymentQuotes { @@ -108,6 +136,18 @@ mod tests { assert!(!prepared(vec![sign(Chain::Ethereum), send(Chain::Ethereum)]).is_relayed()); } + #[test] + fn test_validate_approvals() { + let addresses = vec![ + ChainAddress::new(Chain::Ethereum, "0x1".to_string()), + ChainAddress::new(Chain::Polygon, "0x1".to_string()), + ]; + + assert!(prepared(vec![approve(Chain::Ethereum)]).validate(&addresses).is_ok()); + assert!(prepared(vec![approve(Chain::Polygon)]).validate(&addresses).is_err()); + assert!(prepared(vec![approve(Chain::Ethereum), send(Chain::Ethereum)]).validate(&addresses).is_ok()); + } + #[test] fn test_validate_actions() { let addresses = vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string())]; diff --git a/core/gemstone/src/payment/remote_types.rs b/core/gemstone/src/payment/remote_types.rs index ef5abc5492..1591992e16 100644 --- a/core/gemstone/src/payment/remote_types.rs +++ b/core/gemstone/src/payment/remote_types.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use crate::GemstoneError; -use primitives::payment_decoder::wallet_connect_pay::WALLET_CONNECT_PAY_HOST; +use primitives::payment_decoder::wallet_connect_pay::{WALLET_CONNECT_HOST, WALLET_CONNECT_PAY_HOST}; use primitives::{ AssetId, Payment, PaymentAmount, PaymentLink, PaymentMerchant, PaymentOptions, PaymentOutcome, PaymentPrice, PaymentProviderName, PaymentQuote, PaymentQuotes, PaymentRequest, PaymentStatus, PaymentURLDecoder, @@ -105,6 +105,11 @@ pub fn payment_wallet_connect_url() -> String { format!("https://{WALLET_CONNECT_PAY_HOST}") } +#[uniffi::export] +pub fn payment_wallet_connect_host() -> String { + WALLET_CONNECT_HOST.to_string() +} + #[uniffi::export] pub fn payment_provider_has_status(provider: GemPaymentProviderName) -> bool { provider.has_status() From 653fee3b13ea778d0cd6a8e085e29bb210490a5d Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:58 +0300 Subject: [PATCH 43/53] iOS: read the payment relay and status rules from core Both were decided here as well as in core and on Android. Take core's answer, and drop the cancellation the app never called. The mock kept deciding the relay rule for itself, which would have left the tests agreeing with themselves the day the rule changed. Let it be told instead. --- .../PaymentService/PaymentService.swift | 11 ++--------- .../PaymentService/PreparedPayment.swift | 4 +++- .../TestKit/PaymentServiceableMock.swift | 15 +++++++++------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift index 780dddd0a7..71afd0f1b7 100644 --- a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift +++ b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift @@ -15,7 +15,6 @@ 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 { @@ -43,6 +42,7 @@ public final class PaymentService: PaymentServiceable { quotes: payment.quotes.map(), quote: payment.quote.map(), actions: payment.actions.map { try $0.map() }, + isRelayed: payment.isRelayed, ) } @@ -50,15 +50,8 @@ public final class PaymentService: PaymentServiceable { 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 - } + Gemstone.paymentProviderHasStatus(provider: provider.map()) } public func getPaymentStatus(provider: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome { diff --git a/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift b/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift index c0f9bdbdc3..5117786a48 100644 --- a/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift +++ b/ios/Packages/FeatureServices/PaymentService/PreparedPayment.swift @@ -7,10 +7,12 @@ public struct PreparedPayment: Sendable { public let quotes: PaymentQuotes public let quote: PaymentQuote public let actions: [PaymentAction] + public let isRelayed: Bool - public init(quotes: PaymentQuotes, quote: PaymentQuote, actions: [PaymentAction]) { + public init(quotes: PaymentQuotes, quote: PaymentQuote, actions: [PaymentAction], isRelayed: Bool) { self.quotes = quotes self.quote = quote self.actions = actions + self.isRelayed = isRelayed } } diff --git a/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift index 72a32fab63..3a9847b6f5 100644 --- a/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift +++ b/ios/Packages/FeatureServices/PaymentService/TestKit/PaymentServiceableMock.swift @@ -8,23 +8,25 @@ import PrimitivesTestKit public actor PaymentServiceableMock: PaymentServiceable { private var options: [PaymentOptions] private let actions: [PaymentAction] + private let isRelayed: Bool private let confirmOutcome: PaymentOutcome private let confirmError: (any Error)? private let statusOutcome: PaymentOutcome - public private(set) var cancelledPaymentIds: [String] = [] public private(set) var confirmedResults: [[String]] = [] public private(set) var requestedQuotes: [PaymentQuote] = [] public init( options: [PaymentOptions], actions: [PaymentAction] = [], + isRelayed: Bool = true, confirmOutcome: PaymentOutcome = .mock(), confirmError: (any Error)? = .none, statusOutcome: PaymentOutcome = .mock(), ) { self.options = options self.actions = actions + self.isRelayed = isRelayed self.confirmOutcome = confirmOutcome self.confirmError = confirmError self.statusOutcome = statusOutcome @@ -39,7 +41,12 @@ public actor PaymentServiceableMock: PaymentServiceable { 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) + return PreparedPayment( + quotes: quotes, + quote: quote, + actions: actions, + isRelayed: isRelayed, + ) } public func confirmPayment(provider _: PaymentProviderName, quote _: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome { @@ -50,10 +57,6 @@ public actor PaymentServiceableMock: PaymentServiceable { return confirmOutcome } - public func cancelPayment(provider _: PaymentProviderName, paymentId: String) async throws { - cancelledPaymentIds.append(paymentId) - } - public nonisolated func hasStatus(provider _: PaymentProviderName) -> Bool { true } From 6591828a03fbac74f29722a37eb4e468b0decd05 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:58 +0300 Subject: [PATCH 44/53] iOS: execute payment actions behind a protocol The manager held the executor concretely, so its own tests had to build a real one over a real database to reach the code they were checking, and they asserted on the interactor the executor happens to use. Give it a protocol like TransferExecutable, and let the manager tests ask manager questions: which quote was prepared, what reached confirm. One of those questions had never been asked: when the gateway fails to confirm after the transaction is already out, the outcome carries the hash so the payment can still be followed. The method is execute for the same reason TransferExecutor's is. --- .../Services/PaymentActionExecutor.swift | 20 ++++++++++--- .../Payments/Services/PaymentManager.swift | 14 +++------ .../PaymentActionExecutableMock.swift | 29 +++++++++++++++++++ .../PaymentActionExecutorTests.swift | 12 ++++---- .../PaymentsTests/PaymentManagerTests.swift | 23 ++++++++------- 5 files changed, 68 insertions(+), 30 deletions(-) create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift index b85615470f..16a5410c54 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -10,7 +10,19 @@ public struct PaymentActionResults: Sendable { public let transactionHash: String? } -public struct PaymentActionExecutor: Sendable { +public protocol PaymentActionExecutable: Sendable { + @MainActor + func execute( + actions: [PaymentAction], + paymentId: String, + appMetadata: TransactionAppMetadata, + payment: PaymentData, + wallet: Wallet, + onSubmitted: @MainActor () -> Void, + ) async throws -> PaymentActionResults +} + +public struct PaymentActionExecutor: PaymentActionExecutable { private let interactor: any SigningRequestInteractable private let simulator: any SimulationServiceable private let assetsProvider: any PaymentAssetsProvidable @@ -26,7 +38,7 @@ public struct PaymentActionExecutor: Sendable { } @MainActor - public func perform( + public func execute( actions: [PaymentAction], paymentId: String, appMetadata: TransactionAppMetadata, @@ -37,7 +49,7 @@ public struct PaymentActionExecutor: Sendable { var results = [String](repeating: "", count: actions.count) var transactionHash: String? for (index, action) in actions.enumerated() { - let value = try await perform( + let value = try await execute( action: action, id: "\(paymentId).\(index)", appMetadata: appMetadata, @@ -59,7 +71,7 @@ public struct PaymentActionExecutor: Sendable { extension PaymentActionExecutor { @MainActor - private func perform(action: PaymentAction, id: String, appMetadata: TransactionAppMetadata, payment: PaymentData, wallet: Wallet) async throws -> String { + private func execute(action: PaymentAction, id: String, appMetadata: TransactionAppMetadata, payment: PaymentData, wallet: Wallet) async throws -> String { switch action { case let .signMessage(chain, message): let payload = try await SignMessagePayload( diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift index cfc172a5e2..e9f0ed8946 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -8,14 +8,14 @@ import TransactionStateService public struct PaymentManager: Sendable { private let service: any PaymentServiceable - private let executor: PaymentActionExecutor + private let executor: any PaymentActionExecutable private let presenter: any PaymentSheetPresentable private let assetsProvider: any PaymentAssetsProvidable private let transactionStateScheduler: TransactionStateScheduler public init( service: any PaymentServiceable, - executor: PaymentActionExecutor, + executor: any PaymentActionExecutable, presenter: any PaymentSheetPresentable, assetsProvider: any PaymentAssetsProvidable, transactionStateScheduler: TransactionStateScheduler, @@ -78,20 +78,14 @@ extension PaymentManager { 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( + let results = try await executor.execute( actions: payment.actions, paymentId: payment.quote.paymentId, appMetadata: TransactionAppMetadata(merchant: payment.quotes.merchant), payment: PaymentData(provider: provider, quotes: payment.quotes, quote: payment.quote), wallet: wallet, onSubmitted: { [self] in - guard isRelayed else { + guard payment.isRelayed else { return } save(provider: provider, payment: payment, wallet: wallet) diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift new file mode 100644 index 0000000000..5b00a9af5a --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutableMock.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +@testable import Payments +import PaymentService +import Primitives + +struct PaymentActionExecutableMock: PaymentActionExecutable { + let results: [String] + let transactionHash: String? + + init(results: [String] = [], transactionHash: String? = .none) { + self.results = results + self.transactionHash = transactionHash + } + + @MainActor + func execute( + actions _: [PaymentAction], + paymentId _: String, + appMetadata _: TransactionAppMetadata, + payment _: PaymentData, + wallet _: Wallet, + onSubmitted: @MainActor () -> Void, + ) async throws -> PaymentActionResults { + onSubmitted() + return PaymentActionResults(results: results, transactionHash: transactionHash) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift index 5b4ea37a1e..d7f29c476d 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -16,7 +16,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() interactor.transactionHash = "transaction-hash" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( actions: [PaymentAction.sendTransaction(chain: .ethereum, transaction: .sui("transaction", .encodedTransaction))], paymentId: "pay_1", appMetadata: .mock(), @@ -33,7 +33,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() interactor.signature = "permit-signature" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: Self.approvalAssets()).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: Self.approvalAssets()).execute( actions: [ .approveToken(chain: .ethereum, approval: ApprovalData(token: "0xtoken", spender: "0xspender", value: "1", isUnlimited: true)), .mockSignMessage(data: Data("permit".utf8)), @@ -54,7 +54,7 @@ struct PaymentActionExecutorTests { interactor.signature = "permit-signature" interactor.transactionHash = "approval-hash" - let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( actions: [ .sendTransaction(chain: .ethereum, transaction: .sui("approval", .encodedTransaction)), .mockSignMessage(data: Data("permit".utf8)), @@ -74,7 +74,7 @@ struct PaymentActionExecutorTests { let interactor = SigningRequestInteractableMock() let payment = PaymentData.mock(quote: .mock(amount: .mock(value: "25000", symbol: "USDT"))) - _ = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + _ = try await PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( actions: [PaymentAction.mockSignMessage(data: Data("pay".utf8))], paymentId: "pay_1", appMetadata: .mock(), @@ -95,7 +95,7 @@ struct PaymentActionExecutorTests { interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: Self.approvalAssets(), - ).perform( + ).execute( actions: [ .approveToken(chain: .ethereum, approval: .mock()), .mockSignMessage(data: Data("permit".utf8)), @@ -122,7 +122,7 @@ struct PaymentActionExecutorTests { result: SimulationResult(warnings: [warning], balanceChanges: [], payload: [], header: .none), ) - _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).perform( + _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock()])).execute( actions: [PaymentAction.mockSignMessage(data: Data("{}".utf8))], paymentId: "pay_1", appMetadata: .mock(), diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift index 4a6fd5a8d3..ccc741056e 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -15,16 +15,16 @@ import TransactionStateServiceTestKit @MainActor struct PaymentManagerTests { - private let interactor = SigningRequestInteractableMock() private let presenter = PaymentSheetPresentableMock() private func makeManager( service: PaymentServiceableMock, + executor: PaymentActionExecutableMock = PaymentActionExecutableMock(), transactionStore: TransactionStore = .mock(), ) -> PaymentManager { PaymentManager( service: service, - executor: PaymentActionExecutor(interactor: interactor, simulator: SimulationServiceableMock(), assetsProvider: PaymentAssetsProvidableMock()), + executor: executor, presenter: presenter, assetsProvider: PaymentAssetsProvidableMock(), transactionStateScheduler: .mock( @@ -41,7 +41,7 @@ struct PaymentManagerTests { let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) #expect(outcome.status == .succeeded) - #expect(interactor.signMessagePayloads.isEmpty) + #expect(await service.requestedQuotes.isEmpty) #expect(await service.confirmedResults.isEmpty) } @@ -52,10 +52,11 @@ struct PaymentManagerTests { actions: [.mockSignMessage(data: Data("pay".utf8))], ) - let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + let outcome = try await makeManager( + service: service, + executor: PaymentActionExecutableMock(results: ["signature"]), + ).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) } @@ -68,9 +69,13 @@ struct PaymentManagerTests { confirmError: AnyError("gateway timeout"), ) - let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + let outcome = try await makeManager( + service: service, + executor: PaymentActionExecutableMock(transactionHash: "0xsent"), + ).pay(link: .mock(), wallet: .mock()) #expect(outcome.status == .processing) + #expect(outcome.transactionId == "0xsent") } @Test @@ -103,7 +108,6 @@ struct PaymentManagerTests { 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) } @@ -135,8 +139,7 @@ struct PaymentManagerTests { _ = 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) + #expect(await service.requestedQuotes == [other]) } @Test From eef55e2e7fe75f44d82df5cf2d61068013f70b7a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:37:01 +0300 Subject: [PATCH 45/53] iOS: cover the payment mapping and tighten what the feature exposes The gateway counts a quote's expiry in seconds and carries its asset as a string, and both are converted on the way in and back out again on the way to the gateway. Android tests that; iOS did not, and could not easily, because the service builds its own client the way SimulationService does. The conversions live in the mappers, so test them there, next to the other extension tests. Two files had grown to three types where the package keeps to one, or two when a protocol sits with its implementation; the status protocol is consumed on its own by the transaction state service. The rest is what was in the way of reading it: pay went through a private perform with the same signature, the executor sized an array to write into it by index, and the link error was public without a reader outside the feature. --- .../Scenes/PaymentDataCollectionScene.swift | 4 ++ .../Payments/Scenes/PaymentQuotesScene.swift | 4 ++ .../Services/PaymentActionExecutor.swift | 9 +---- .../Payments/Services/PaymentManager.swift | 16 +++----- .../Payments/Types/PaymentActionResults.swift | 8 ++++ .../Payments/Types/PaymentLinkError.swift | 2 +- .../PaymentService/PaymentService.swift | 5 --- .../PaymentStatusServiceable.swift | 9 +++++ .../Extensions/GemPaymentQuoteTests.swift | 37 +++++++++++++++++++ 9 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift create mode 100644 ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift create mode 100644 ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift index f9eb2b4e60..40fd6b94ac 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -34,7 +34,11 @@ public struct PaymentDataCollectionScene: View { ) .ignoresSafeArea(edges: .bottom) } +} + +// MARK: - Actions +extension PaymentDataCollectionScene { private func onMessage(_ payload: [String: Any]) { switch payload[Self.messageTypeKey] as? String { case Self.completeMessageType: diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift index 30768866d0..b9c097ad30 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift @@ -75,7 +75,11 @@ public struct PaymentQuotesScene: View { .navigationTitle(model.title) .navigationBarTitleDisplayMode(.inline) } +} + +// MARK: - Actions +extension PaymentQuotesScene { private func confirm() { model.onConfirm() onComplete() diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift index 16a5410c54..939d43280d 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -5,11 +5,6 @@ import PaymentService import Primitives import SimulationService -public struct PaymentActionResults: Sendable { - public let results: [String] - public let transactionHash: String? -} - public protocol PaymentActionExecutable: Sendable { @MainActor func execute( @@ -46,7 +41,7 @@ public struct PaymentActionExecutor: PaymentActionExecutable { wallet: Wallet, onSubmitted: @MainActor () -> Void = {}, ) async throws -> PaymentActionResults { - var results = [String](repeating: "", count: actions.count) + var results: [String] = [] var transactionHash: String? for (index, action) in actions.enumerated() { let value = try await execute( @@ -56,7 +51,7 @@ public struct PaymentActionExecutor: PaymentActionExecutable { payment: payment, wallet: wallet, ) - results[index] = value + results.append(value) if case .sendTransaction = action { transactionHash = value diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift index e9f0ed8946..7827f01320 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -28,14 +28,6 @@ public struct PaymentManager: Sendable { } public func pay(link: PaymentLink, wallet: Wallet) async throws -> PaymentOutcome { - try await perform(link: link, wallet: wallet) - } -} - -// MARK: - Private - -extension PaymentManager { - private func perform(link: PaymentLink, wallet: Wallet) async throws -> PaymentOutcome { do { let quotes: PaymentQuotes switch try await service.getPaymentOptions(link: link, wallet: wallet) { @@ -50,7 +42,11 @@ extension PaymentManager { return PaymentOutcome(status: .cancelled, transactionId: .none) } } +} +// MARK: - Private + +extension PaymentManager { private func select(quotes: PaymentQuotes, wallet: Wallet) async throws -> PaymentQuote { guard let first = quotes.quotes.first else { throw PaymentLinkError.noQuotes @@ -88,7 +84,7 @@ extension PaymentManager { guard payment.isRelayed else { return } - save(provider: provider, payment: payment, wallet: wallet) + addPendingPayment(provider: provider, payment: payment, wallet: wallet) }, ) do { @@ -106,7 +102,7 @@ extension PaymentManager { _ = try await presenter.collectPaymentData(request: PaymentDataCollectionRequest(id: paymentId, url: url)) } - private func save(provider: PaymentProviderName, payment: PreparedPayment, wallet: Wallet) { + private func addPendingPayment(provider: PaymentProviderName, payment: PreparedPayment, wallet: Wallet) { do { let transaction = try PaymentTransactionFactory.makePendingPayment( provider: provider, diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift new file mode 100644 index 0000000000..795ff501b8 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentActionResults.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PaymentActionResults: Sendable { + public let results: [String] + public let transactionHash: String? +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift index ac39788ba8..c9dbc0fbb4 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift @@ -3,7 +3,7 @@ import Foundation import Localization -public enum PaymentLinkError: Error, Equatable { +enum PaymentLinkError: Error, Equatable { case noQuotes case quoteUnavailable case invalidDataCollectionUrl diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift index 71afd0f1b7..0d9b18d82a 100644 --- a/ios/Packages/FeatureServices/PaymentService/PaymentService.swift +++ b/ios/Packages/FeatureServices/PaymentService/PaymentService.swift @@ -6,11 +6,6 @@ import GemstonePrimitives 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 diff --git a/ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift b/ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift new file mode 100644 index 0000000000..d5b516c01f --- /dev/null +++ b/ios/Packages/FeatureServices/PaymentService/PaymentStatusServiceable.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentStatusServiceable: Sendable { + func hasStatus(provider: PaymentProviderName) -> Bool + func getPaymentStatus(provider: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome +} diff --git a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift new file mode 100644 index 0000000000..aff1897889 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/Extensions/GemPaymentQuoteTests.swift @@ -0,0 +1,37 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +@testable import GemstonePrimitives +import Primitives +import Testing + +final class GemPaymentQuoteTests { + private let expiresAtSeconds: Int64 = 1_700_000_000 + + private func gemQuote(assetId: String = "ethereum_0xtoken") -> GemPaymentQuote { + GemPaymentQuote( + id: "option_1", + paymentId: "pay_1", + amount: GemPaymentAmount(assetId: assetId, value: "10", symbol: "USDT", decimals: 6), + expiresAt: expiresAtSeconds, + collectDataUrl: .none, + providerData: #"{"opaque":true}"#, + ) + } + + @Test + func gatewaySecondsBecomeADate() throws { + let quote = try gemQuote().map() + + #expect(quote.expiresAt == Date(timeIntervalSince1970: TimeInterval(expiresAtSeconds))) + #expect(quote.amount.assetId.chain == .ethereum) + #expect(quote.amount.assetId.tokenId == "0xtoken") + } + + @Test + func quoteReturnsToTheGatewayUnchanged() throws { + #expect(try gemQuote().map().map() == gemQuote()) + #expect(try gemQuote(assetId: "ethereum").map().map() == gemQuote(assetId: "ethereum")) + } +} From 092b86e2c220518d58eec4bcac625de2b9773605 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:33:46 +0300 Subject: [PATCH 46/53] Android: speak payment primitives and wrap the gateway The uniffi GemPaymentService was injected straight into a view model and a repository, so the FFI object crossed three layers and left nothing to fake in a test. Wrap it the way ServiceStatusService and WalletConnectSimulationService wrap theirs: the client stays behind the service, and callers get typeshared models back, named the way iOS names them. The service takes the gateway client as its interface, which lets the mapping be tested: the gateway counts expiry in seconds and the app in milliseconds, and a quote has to reach the gateway byte for byte after a round trip through our own asset ids. SigningRequestApp was a hand written copy of TransactionAppMetadata with every field made non null, which the FFI type it ends up building does not require, so the payment call site had to invent a description and an empty url to satisfy them. Carry the typeshared model instead, with the merchant mapping iOS uses: no description, and the WalletConnect Pay url gemstone already exposes. Relay and status come from core now rather than being decided here too. --- .../android/PendingNavigationCoordinator.kt | 3 +- .../com/gemwallet/android/di/GatewayModule.kt | 17 +-- .../android/features/main/views/MainScreen.kt | 3 +- .../android/ui/navigation/RootRoute.kt | 4 +- .../android/ui/navigation/routes/Payment.kt | 6 +- .../gemstone/ScanTransactionPayloadMapper.kt | 2 +- .../blockchain/services/PaymentService.kt | 54 +++++++++ .../blockchain/services/PaymentServiceTest.kt | 83 +++++++++++++ .../repositories/di/TransactionsModule.kt | 4 +- .../TransactionsRepositoryImpl.kt | 30 ++--- .../bridge/viewmodels/model/WCRequest.kt | 4 +- .../payment/presents/build.gradle.kts | 3 +- .../features/payment/presents/PaymentScene.kt | 24 ++-- .../payment/viewmodels/ActivePayment.kt | 37 +++--- .../payment/viewmodels/PaymentSceneState.kt | 20 ++-- .../payment/viewmodels/PaymentViewModel.kt | 87 +++++++------- .../payment/viewmodels/RecordPayment.kt | 13 +-- .../model/PaymentMerchantUIModel.kt | 4 +- .../viewmodels/model/PaymentOutcomeUIModel.kt | 16 +-- .../viewmodels/model/PaymentQuoteUIModel.kt | 30 +++-- .../payment/viewmodels/ActivePaymentTest.kt | 27 +++-- .../domains/confirm/ConfirmProperty.kt | 2 +- .../com/gemwallet/android/ext/GemPayment.kt | 110 ++++++++++++++++++ .../android/ext/SignableTransaction.kt | 39 +++---- .../gemwallet/android/model/ConfirmParams.kt | 18 ++- .../android/model/PreparedPayment.kt | 12 ++ .../android/model/ConfirmParamsTest.kt | 11 +- 27 files changed, 452 insertions(+), 211 deletions(-) create mode 100644 android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt create mode 100644 android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt diff --git a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt index 36481d302b..bc602e6137 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import com.gemwallet.android.ext.toPrimitives import com.gemwallet.android.ui.navigation.routes.PaymentRoute import uniffi.gemstone.UrlAction import uniffi.gemstone.WalletConnectLink @@ -58,7 +59,7 @@ class PendingNavigationCoordinator @Inject constructor( } } is UrlAction.Payment -> { - replace(pendingIntent, PendingNavigation.Route(PaymentRoute(action.link.provider.name, action.link.id))) + replace(pendingIntent, PendingNavigation.Route(PaymentRoute(action.link.provider.toPrimitives(), 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 a1585c7393..6652e6a16f 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.gemwallet.android.Constants import com.gemwallet.android.NodeAuthInterceptor import com.gemwallet.android.NodeAuthTokenService +import com.gemwallet.android.blockchain.services.PaymentService import com.gemwallet.android.blockchain.services.ServiceStatusService import com.gemwallet.android.cases.device.IsDeviceRegistered import com.gemwallet.android.cases.nodes.GetNodeUrlCase @@ -111,14 +112,16 @@ object GatewayModule { @Provides @Singleton - fun provideGemPaymentService( + fun providePaymentService( alienProvider: AlienProvider, - ): GemPaymentService = GemPaymentService( - provider = alienProvider, - config = GemPaymentConfig( - walletConnectPay = GemWalletConnectPayAuth( - appId = Constants.WALLET_CONNECT_PROJECT_ID, - clientId = UUID.randomUUID().toString(), + ): PaymentService = PaymentService( + GemPaymentService( + provider = alienProvider, + config = GemPaymentConfig( + walletConnectPay = GemWalletConnectPayAuth( + appId = Constants.WALLET_CONNECT_PROJECT_ID, + clientId = UUID.randomUUID().toString(), + ), ), ), ) 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 40dcac175f..1d4db85ab7 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt @@ -57,6 +57,7 @@ import com.gemwallet.android.ui.theme.alpha10 import kotlinx.coroutines.launch import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.gemwallet.android.ext.toPrimitives import com.gemwallet.android.ui.components.QrCodeScannerModal import uniffi.gemstone.UrlAction import uniffi.gemstone.urlAction @@ -79,7 +80,7 @@ fun MainScreen( onResult = { scanned -> isPresentingScanner = false (runCatching { urlAction(scanned) }.getOrNull() as? UrlAction.Payment)?.let { - navigator.openPayment(it.link) + navigator.openPayment(it.link.toPrimitives()) } }, ) 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 ee90e2cb75..cd8ec740b5 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -41,7 +41,7 @@ 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.wallet.core.primitives.PaymentLink import com.gemwallet.android.ui.navigation.routes.ContactsRoute import com.gemwallet.android.ui.navigation.routes.CurrenciesRoute import com.gemwallet.android.ui.navigation.routes.EditContactRoute @@ -281,7 +281,7 @@ class WalletNavigator( val pack = params.pack() ?: return push(ConfirmRoute(pack)) } - fun openPayment(link: GemPaymentLink) = push(PaymentRoute(link.provider.name, link.id)) + fun openPayment(link: PaymentLink) = push(PaymentRoute(link.provider, link.id)) fun openNftList() = push(NftListRoute) fun openNftCollection(nftCollectionId: String) = push(NftCollectionRoute(nftCollectionId)) fun openNftUnverifiedCollections() = push(NftUnverifiedCollectionsRoute) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt index f837e47fd0..45fbbed5b3 100644 --- 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 @@ -6,11 +6,11 @@ import com.gemwallet.android.features.confirm.presents.AcquireAssetAction import com.gemwallet.android.features.payment.presents.PaymentScene import com.gemwallet.android.ui.models.actions.CancelAction import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PaymentProviderName import kotlinx.serialization.Serializable -import uniffi.gemstone.GemPaymentProviderName @Serializable -data class PaymentRoute(val provider: String, val paymentId: String) : NavKey +data class PaymentRoute(val provider: PaymentProviderName, val paymentId: String) : NavKey fun EntryProviderScope.payment( onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit, @@ -18,7 +18,7 @@ fun EntryProviderScope.payment( ) { entry { key -> PaymentScene( - provider = GemPaymentProviderName.valueOf(key.provider), + provider = key.provider, paymentId = key.paymentId, onAcquireAsset = onAcquireAsset, onCancel = { cancelAction() }, diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt index 5d5b9e79fb..396d2820c9 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/ScanTransactionPayloadMapper.kt @@ -15,7 +15,7 @@ internal fun ConfirmParams.toScanTransactionPayload(destination: String): ScanTr assetId = if (this is ConfirmParams.SwapParams) toAsset.id else assetId, address = destination, ), - website = (this as? ConfirmParams.TransferParams.Generic)?.url, + website = (this as? ConfirmParams.TransferParams.Generic)?.appMetadata?.url, type = getTransactionType(), ) diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt new file mode 100644 index 0000000000..3aaf40ffb2 --- /dev/null +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PaymentService.kt @@ -0,0 +1,54 @@ +package com.gemwallet.android.blockchain.services + +import com.gemwallet.android.ext.gemChainAddresses +import com.gemwallet.android.ext.toGem +import com.gemwallet.android.ext.toPrimitives +import com.gemwallet.android.model.PreparedPayment +import com.wallet.core.primitives.PaymentLink +import com.wallet.core.primitives.PaymentOptions +import com.wallet.core.primitives.PaymentOutcome +import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.PaymentQuote +import com.wallet.core.primitives.PaymentQuotes +import com.wallet.core.primitives.Wallet +import uniffi.gemstone.GemPaymentServiceInterface +import uniffi.gemstone.paymentProviderHasStatus + +class PaymentService( + private val client: GemPaymentServiceInterface, +) { + + fun hasStatus(provider: PaymentProviderName): Boolean = paymentProviderHasStatus(provider.toGem()) + + suspend fun getPaymentOptions(link: PaymentLink, wallet: Wallet): PaymentOptions = + client.getPaymentOptions(link.toGem(), wallet.gemChainAddresses()).toPrimitives() + + suspend fun getPreparedPayment( + provider: PaymentProviderName, + quotes: PaymentQuotes, + quote: PaymentQuote, + wallet: Wallet, + ): PreparedPayment { + val payment = client.getPreparedPayment( + provider.toGem(), + quotes.toGem(), + quote.toGem(), + wallet.gemChainAddresses(), + ) + return PreparedPayment( + quotes = payment.quotes.toPrimitives(), + quote = payment.quote.toPrimitives(), + actions = payment.actions, + isRelayed = payment.isRelayed, + ) + } + + suspend fun confirmPayment( + provider: PaymentProviderName, + quote: PaymentQuote, + actionResults: List, + ): PaymentOutcome = client.confirmPayment(provider.toGem(), quote.toGem(), actionResults).toPrimitives() + + suspend fun getPaymentStatus(provider: PaymentProviderName, paymentId: String): PaymentOutcome = + client.getPaymentStatus(provider.toGem(), paymentId).toPrimitives() +} diff --git a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt new file mode 100644 index 0000000000..f46f329bcd --- /dev/null +++ b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/PaymentServiceTest.kt @@ -0,0 +1,83 @@ +package com.gemwallet.android.blockchain.services + +import com.gemwallet.android.testkit.mockWallet +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.PaymentLink +import com.wallet.core.primitives.PaymentOptions +import com.wallet.core.primitives.PaymentProviderName +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import uniffi.gemstone.GemPaymentAmount +import uniffi.gemstone.GemPaymentMerchant +import uniffi.gemstone.GemPaymentOptions +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.GemPaymentServiceInterface +import uniffi.gemstone.GemPreparedPayment + +private const val EXPIRES_AT_SECONDS = 1_700_000_000L + +class PaymentServiceTest { + + private val client = mockk() + private val service = PaymentService(client) + + private fun gemQuote(assetId: String = "ethereum_0xtoken") = GemPaymentQuote( + id = "option_1", + paymentId = "pay_1", + amount = GemPaymentAmount(assetId = assetId, value = "10", symbol = "USDT", decimals = 6), + expiresAt = EXPIRES_AT_SECONDS, + collectDataUrl = null, + providerData = "{\"opaque\":true}", + ) + + private fun gemQuotes() = GemPaymentQuotes( + merchant = GemPaymentMerchant(name = "Merchant", iconUrl = null), + price = null, + expiresAt = EXPIRES_AT_SECONDS, + quotes = listOf(gemQuote()), + ) + + @Test + fun getPaymentOptions_readsGatewaySecondsAsMillis() = runBlocking { + coEvery { client.getPaymentOptions(any(), any()) } returns GemPaymentOptions.Quotes(gemQuotes()) + + val options = service.getPaymentOptions(PaymentLink(PaymentProviderName.WalletConnectPay, "pay_1"), mockWallet()) + + val quotes = (options as PaymentOptions.Quotes).content + assertEquals(EXPIRES_AT_SECONDS * 1000, quotes.expiresAt) + assertEquals(EXPIRES_AT_SECONDS * 1000, quotes.quotes.first().expiresAt) + assertEquals(Chain.Ethereum, quotes.quotes.first().amount.assetId.chain) + assertEquals("0xtoken", quotes.quotes.first().amount.assetId.tokenId) + } + + @Test + fun getPreparedPayment_returnsTheQuoteToTheGatewayUnchanged() = runBlocking { + coEvery { client.getPaymentOptions(any(), any()) } returns GemPaymentOptions.Quotes(gemQuotes()) + val options = service.getPaymentOptions(PaymentLink(PaymentProviderName.WalletConnectPay, "pay_1"), mockWallet()) + val quotes = (options as PaymentOptions.Quotes).content + + val sentQuote = slot() + coEvery { client.getPreparedPayment(any(), any(), capture(sentQuote), any()) } returns GemPreparedPayment( + quotes = gemQuotes(), + quote = gemQuote(), + actions = emptyList(), + isRelayed = true, + ) + + val prepared = service.getPreparedPayment( + PaymentProviderName.WalletConnectPay, + quotes, + quotes.quotes.first(), + mockWallet(), + ) + + assertEquals(gemQuote(), sentQuote.captured) + assertTrue(prepared.isRelayed) + } +} diff --git a/android/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 e16f2f5c94..87263f5372 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TransactionsModule.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/di/TransactionsModule.kt @@ -2,6 +2,7 @@ package com.gemwallet.android.data.repositories.di import com.gemwallet.android.application.transactions.coordinators.GetChangedTransactions import com.gemwallet.android.application.transactions.coordinators.GetPendingTransactionsCount +import com.gemwallet.android.blockchain.services.PaymentService import com.gemwallet.android.blockchain.services.TransactionStatusService import com.gemwallet.android.cases.transactions.ClearPendingTransactions import com.gemwallet.android.cases.transactions.CreateTransaction @@ -9,7 +10,6 @@ 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 @@ -28,7 +28,7 @@ object TransactionsModule { sessionRepository: SessionRepository, transactionsDao: TransactionsDao, gateway: GemGateway, - paymentService: GemPaymentService, + paymentService: PaymentService, ): TransactionsRepositoryImpl = TransactionsRepositoryImpl( sessionRepository = sessionRepository, transactionsDao = transactionsDao, 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 8528df6277..ed721ff9a2 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.gemwallet.android.application.transactions.coordinators.GetChangedTra import com.gemwallet.android.application.transactions.coordinators.GetPendingTransactionsCount import com.gemwallet.android.application.transactions.coordinators.TransactionsRequestFilter import com.gemwallet.android.blockchain.model.ServiceUnavailable +import com.gemwallet.android.blockchain.services.PaymentService import com.gemwallet.android.blockchain.services.TransactionStatusService import com.gemwallet.android.cases.transactions.ClearPendingTransactions import com.gemwallet.android.cases.transactions.CreateTransaction @@ -18,10 +19,6 @@ import com.gemwallet.android.data.service.store.database.entities.DbTxSwapMetada 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 @@ -31,9 +28,11 @@ import com.gemwallet.android.model.TransactionExtended import com.gemwallet.android.serializer.jsonEncoder import com.wallet.core.primitives.Account import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PaymentStatus import com.wallet.core.primitives.Transaction import com.wallet.core.primitives.TransactionDirection import com.wallet.core.primitives.TransactionId +import com.wallet.core.primitives.TransactionPaymentMetadata import com.wallet.core.primitives.TransactionState import com.wallet.core.primitives.TransactionStateRequest import com.wallet.core.primitives.TransactionSwapMetadata @@ -69,7 +68,7 @@ class TransactionsRepositoryImpl( private val sessionRepository: SessionRepository, private val transactionsDao: TransactionsDao, private val transactionStatusService: TransactionStatusService, - private val paymentService: GemPaymentService, + private val paymentService: PaymentService, private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) : TransactionRepository, GetChangedTransactions, @@ -270,21 +269,18 @@ class TransactionsRepositoryImpl( metadata: TransactionPaymentMetadata, ): DbTransactionExtended? { val outcome = try { - paymentService.getPaymentStatus(metadata.provider.toGem(), metadata.paymentId) + paymentService.getPaymentStatus(metadata.provider, metadata.paymentId) } catch (_: Throwable) { - return transaction.copy(transaction = transaction.transaction.copy(updatedAt = System.currentTimeMillis())) + return transaction.withUpdatedAt() } 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()), - ) + PaymentStatus.Succeeded -> TransactionState.Confirmed + PaymentStatus.Failed, PaymentStatus.Expired, PaymentStatus.Cancelled -> TransactionState.Failed + PaymentStatus.Processing, PaymentStatus.RequiresAction -> return transaction.withUpdatedAt() } 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 settledHash = outcome.transactionId ?: return transaction.withUpdatedAt() val walletId = transaction.transaction.walletId val settledId = TransactionId(transaction.transaction.assetId.chain, settledHash) if (transactionsDao.getTransactionState(settledId, walletId) != null) { @@ -311,11 +307,17 @@ class TransactionsRepositoryImpl( ) } + private fun DbTransactionExtended.withUpdatedAt(): DbTransactionExtended = + copy(transaction = transaction.copy(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) { + if (!paymentService.hasStatus(paymentMetadata.provider)) { + return null + } return checkPayment(transaction, paymentMetadata) } val swapMetadata = getTransactionSwapMetadata(transactionRecord.type, transactionRecord.metadata) 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 207ee7fdd4..515f9b351a 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt @@ -4,7 +4,6 @@ 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 @@ -21,6 +20,7 @@ import com.gemwallet.android.blockchain.services.GemSignMessageOperator import com.gemwallet.android.blockchain.gemstone.toGem import com.gemwallet.android.blockchain.gemstone.toPrimitives import com.wallet.core.primitives.SimulationResult +import com.wallet.core.primitives.TransactionAppMetadata import uniffi.gemstone.TransferDataOutputType import uniffi.gemstone.WalletConnect import uniffi.gemstone.WalletConnectAction @@ -206,7 +206,7 @@ private fun SignableTransaction.map( ): Generic = toConfirmParams( requestId = request.requestId.toString(), account = request.account, - app = SigningRequestApp( + appMetadata = TransactionAppMetadata( name = request.name, description = request.description, url = request.url, diff --git a/android/features/payment/presents/build.gradle.kts b/android/features/payment/presents/build.gradle.kts index be829de046..ec6f0e5e43 100644 --- a/android/features/payment/presents/build.gradle.kts +++ b/android/features/payment/presents/build.gradle.kts @@ -53,7 +53,7 @@ android { dependencies { implementation(project(":ui")) - api(project(":data:repositories")) + implementation(project(":gemcore")) implementation(project(":features:payment:viewmodels")) implementation(project(":features:confirm:presents")) @@ -61,7 +61,6 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.hilt.lifecycle.viewmodel.compose) - debugImplementation(libs.androidx.ui.tooling) implementation(libs.androidx.ui.tooling.preview) 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 index aa5431d2f9..ed4c4a0e99 100644 --- 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 @@ -23,13 +23,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.features.confirm.presents.AcquireAssetAction import com.gemwallet.android.features.confirm.presents.ConfirmScreen -import com.gemwallet.android.features.payment.viewmodels.PaymentError +import com.gemwallet.android.features.payment.viewmodels.PaymentLinkError import com.gemwallet.android.features.payment.viewmodels.PaymentSceneState import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIModel import com.gemwallet.android.features.payment.viewmodels.PaymentViewModel import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel import com.gemwallet.android.model.AuthRequest import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PaymentProviderName import com.gemwallet.android.ui.R import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width @@ -62,12 +63,11 @@ import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.requestAuth import com.gemwallet.android.ui.theme.paddingDefault import com.gemwallet.android.ui.theme.paddingSmall -import uniffi.gemstone.GemPaymentProviderName import uniffi.gemstone.PaymentException @Composable fun PaymentScene( - provider: GemPaymentProviderName, + provider: PaymentProviderName, paymentId: String, onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit, onCancel: () -> Unit, @@ -340,15 +340,15 @@ private fun PaymentToastEffect( } } -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 PaymentLinkError.messageRes(): Int = when (this) { + PaymentLinkError.NoWallet, + PaymentLinkError.NoQuotes, + PaymentLinkError.QuoteUnavailable, + PaymentLinkError.NoAccount -> R.string.errors_not_supported + PaymentLinkError.WatchWallet -> R.string.wallet_watch_tooltip_title + PaymentLinkError.DataCollection -> R.string.errors_error_occurred + PaymentLinkError.UnknownAsset -> R.string.errors_error_occurred + is PaymentLinkError.Gateway -> error.messageRes() } private fun PaymentException?.messageRes(): Int = when (this) { 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 index e3504dd38a..ecac8b2dc3 100644 --- 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 @@ -1,40 +1,42 @@ package com.gemwallet.android.features.payment.viewmodels -import com.gemwallet.android.ext.toPrimitives +import com.gemwallet.android.model.PreparedPayment +import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.PaymentQuote +import com.wallet.core.primitives.PaymentQuotes import com.wallet.core.primitives.TransactionPaymentMetadata import com.wallet.core.primitives.Wallet -import uniffi.gemstone.GemPaymentProviderName -import uniffi.gemstone.GemPaymentQuote -import uniffi.gemstone.GemPaymentQuotes import uniffi.gemstone.PaymentAction internal data class ActivePayment( - val provider: GemPaymentProviderName, - val quotes: GemPaymentQuotes, + val provider: PaymentProviderName, + val quotes: PaymentQuotes, val wallet: Wallet, - val quote: GemPaymentQuote? = null, - val collecting: GemPaymentQuote? = null, + val quote: PaymentQuote? = null, + val collecting: PaymentQuote? = null, val actions: List = emptyList(), val results: List = emptyList(), val completed: Int = 0, + val isRelayed: Boolean = false, ) { val step: Step? get() = actions.getOrNull(completed)?.let { Step(it, completed) } - fun paymentMetadata(quote: GemPaymentQuote) = TransactionPaymentMetadata( + fun paymentMetadata(quote: PaymentQuote) = TransactionPaymentMetadata( paymentId = quote.paymentId, - merchant = quotes.merchant.toPrimitives(), - provider = provider.toPrimitives(), + merchant = quotes.merchant, + provider = provider, ) - fun collecting(quote: GemPaymentQuote) = copy(collecting = quote) + fun collecting(quote: PaymentQuote) = copy(collecting = quote) - fun prepared(quote: GemPaymentQuote, actions: List) = copy( - quote = quote, + fun prepared(payment: PreparedPayment) = copy( + quote = payment.quote, collecting = null, - actions = actions, - results = List(actions.size) { "" }, + actions = payment.actions, + results = List(payment.actions.size) { "" }, completed = 0, + isRelayed = payment.isRelayed, ) fun completing(result: String): ActivePayment { @@ -45,8 +47,5 @@ internal data class ActivePayment( ) } - val isRelayed: Boolean - get() = actions.none { it is PaymentAction.SendTransaction } - data class Step(val action: PaymentAction, val index: Int) } diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt index 248a0b533d..7ed1abbf69 100644 --- 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 @@ -54,16 +54,16 @@ sealed interface PaymentSceneState { data class Outcome(val outcome: PaymentOutcomeUIModel) : PaymentSceneState - data class Error(val error: PaymentError) : PaymentSceneState + data class Error(val error: PaymentLinkError) : 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 +sealed interface PaymentLinkError { + data object NoWallet : PaymentLinkError + data object WatchWallet : PaymentLinkError + data object NoQuotes : PaymentLinkError + data object QuoteUnavailable : PaymentLinkError + data object NoAccount : PaymentLinkError + data object DataCollection : PaymentLinkError + data object UnknownAsset : PaymentLinkError + data class Gateway(val error: PaymentException?) : PaymentLinkError } diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt index 957f52d3b1..2e80d6d063 100644 --- 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 @@ -7,16 +7,14 @@ 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.blockchain.services.PaymentService import com.gemwallet.android.cases.tokens.SearchTokensCase import com.gemwallet.android.data.repositories.session.SessionRepository -import com.gemwallet.android.ext.gemChainAddresses import com.gemwallet.android.ext.getAccount -import com.gemwallet.android.ext.SigningRequestApp import com.gemwallet.android.ext.runCatchingCancellable -import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.ext.toAppMetadata 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 @@ -27,6 +25,11 @@ import com.gemwallet.android.ui.models.withExplorerLinks import com.wallet.core.primitives.Account import com.wallet.core.primitives.Asset import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.PaymentLink +import com.wallet.core.primitives.PaymentOptions +import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.PaymentQuote +import com.wallet.core.primitives.PaymentQuotes import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletType import dagger.hilt.android.lifecycle.HiltViewModel @@ -40,12 +43,6 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -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 @@ -53,7 +50,7 @@ import uniffi.gemstone.SignableTransaction @HiltViewModel class PaymentViewModel @Inject constructor( - private val paymentService: GemPaymentService, + private val paymentService: PaymentService, private val sessionRepository: SessionRepository, private val signMessageOperator: GemSignMessageOperator, private val passwordStore: PasswordStore, @@ -69,17 +66,17 @@ class PaymentViewModel @Inject constructor( private val lock = Mutex() private var expiryJob: Job? = null - fun onPayment(provider: GemPaymentProviderName, paymentId: String) { - val link = GemPaymentLink(provider = provider, id = paymentId) + fun onPayment(provider: PaymentProviderName, paymentId: String) { + val link = PaymentLink(provider = provider, id = paymentId) state.value = PaymentSceneState.Loading viewModelScope.launch(Dispatchers.IO) { val wallet = wallet() ?: return@launch - val options = runGateway { paymentService.getPaymentOptions(link, wallet.gemChainAddresses()) } ?: return@launch + val options = runGateway { paymentService.getPaymentOptions(link, wallet) } ?: 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 + is PaymentOptions.Outcome -> state.value = PaymentSceneState.Outcome(options.content.status.toUIModel()) + is PaymentOptions.Quotes -> { + val quotes = options.content + payment.value = ActivePayment(link.provider, quotes, wallet) if (quotes.quotes.size > 1) { state.value = quotes.toSceneState(wallet) watchExpiry(quotes) @@ -100,7 +97,7 @@ class PaymentViewModel @Inject constructor( 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") + state.value = failure(PaymentLinkError.QuoteUnavailable, "confirm: quote $selected is gone") return } expiryJob?.cancel() @@ -116,7 +113,7 @@ class PaymentViewModel @Inject constructor( fun onDataCollectionError(message: String?) { Log.e(TAG, "Payment data collection failed: $message") - state.value = PaymentSceneState.Error(PaymentError.DataCollection) + state.value = PaymentSceneState.Error(PaymentLinkError.DataCollection) } fun onActionResult(result: String) { @@ -144,22 +141,22 @@ class PaymentViewModel @Inject constructor( } } - private suspend fun GemPaymentQuotes.toSceneState(wallet: Wallet) = PaymentSceneState.Quotes( + private suspend fun PaymentQuotes.toSceneState(wallet: Wallet) = PaymentSceneState.Quotes( merchant = merchant.toUIModel(), walletName = wallet.name, walletType = wallet.type, walletChain = wallet.accounts.firstOrNull()?.chain, price = price?.toPriceText(), - quotes = quotes.map { it.toUIModel(it.amount.assetId.toAssetId()?.let { id -> assetInfo(id) }) }, + quotes = quotes.map { it.toUIModel(assetInfo(it.amount.assetId)) }, selected = quotes.firstOrNull()?.id, - expiresAt = expiresAt?.times(1000), + expiresAt = expiresAt, expired = false, ) - private fun watchExpiry(quotes: GemPaymentQuotes) { + private fun watchExpiry(quotes: PaymentQuotes) { val expiresAt = quotes.expiresAt ?: return expiryJob = viewModelScope.launch(Dispatchers.IO) { - delay((expiresAt * 1000 - System.currentTimeMillis()).coerceAtLeast(0)) + delay((expiresAt - System.currentTimeMillis()).coerceAtLeast(0)) val current = state.value if (current is PaymentSceneState.Quotes) { state.value = current.copy(expired = true) @@ -167,9 +164,9 @@ class PaymentViewModel @Inject constructor( } } - private suspend fun select(quote: GemPaymentQuote?) { + private suspend fun select(quote: PaymentQuote?) { if (quote == null) { - state.value = PaymentSceneState.Error(PaymentError.NoQuotes) + state.value = PaymentSceneState.Error(PaymentLinkError.NoQuotes) return } val collectDataUrl = quote.collectDataUrl @@ -181,12 +178,12 @@ class PaymentViewModel @Inject constructor( state.value = PaymentSceneState.CollectData(collectDataUrl) } - private suspend fun prepare(quote: GemPaymentQuote) { + private suspend fun prepare(quote: PaymentQuote) { val current = payment.value ?: return val prepared = runGateway { - paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet.gemChainAddresses()) + paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet) } ?: return - payment.value = current.prepared(prepared.quote, prepared.actions) + payment.value = current.prepared(prepared) advance() } @@ -196,7 +193,7 @@ class PaymentViewModel @Inject constructor( if (step == null) { val quote = current.quote ?: return if (current.isRelayed) { - recordPayment(current.paymentMetadata(quote), quote, current.wallet) + recordPayment.recordPayment(current.paymentMetadata(quote), quote, current.wallet) } val settled = runCatchingCancellable { paymentService.confirmPayment(current.provider, quote, current.results) @@ -216,7 +213,7 @@ class PaymentViewModel @Inject constructor( action: PaymentAction.SignMessage, current: ActivePayment, ): PaymentSceneState { - val chain = action.message.chain.toChain() ?: return PaymentSceneState.Error(PaymentError.NoAccount) + val chain = action.message.chain.toChain() ?: return PaymentSceneState.Error(PaymentLinkError.NoAccount) val signer = runCatching { MessageSigner(action.message) }.getOrNull() val preview = signer?.let { runCatching { it.payloadPreview(emptyList()) }.getOrNull() } return PaymentSceneState.SignMessage( @@ -225,7 +222,7 @@ class PaymentViewModel @Inject constructor( walletName = current.wallet.name, quote = current.quote?.toUIModel(), price = current.quotes.price?.toPriceText(), - expiresAt = current.quotes.expiresAt?.times(1000), + expiresAt = current.quotes.expiresAt, plainMessage = signer?.let { runCatching { it.plainPreview() }.getOrNull() }.orEmpty(), primaryPayloadFields = preview?.primary?.map { it.toPrimitives() }.orEmpty() .withExplorerLinks(chain, null), @@ -238,10 +235,9 @@ class PaymentViewModel @Inject constructor( action: PaymentAction.ApproveToken, current: ActivePayment, ): PaymentSceneState { - val account = current.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}") + val account = current.account(action.chain) ?: return failure(PaymentLinkError.NoAccount, "approval: no ${action.chain} account") + val assetId = current.quote?.amount?.assetId ?: return failure(PaymentLinkError.QuoteUnavailable, "approval: no prepared quote") + val asset = asset(assetId) ?: return failure(PaymentLinkError.UnknownAsset, "approval: unresolved asset ${action.approval.token}") return PaymentSceneState.Approve( ConfirmParams.TokenApprovalParams( asset = asset, @@ -260,17 +256,12 @@ class PaymentViewModel @Inject constructor( isSendable: Boolean, current: ActivePayment, ): PaymentSceneState { - val account = current.account(chain) ?: return PaymentSceneState.Error(PaymentError.NoAccount) + val account = current.account(chain) ?: return PaymentSceneState.Error(PaymentLinkError.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(), - ), + appMetadata = current.quotes.merchant.toAppMetadata(), isSendable = isSendable, payment = current.quote?.let(current::paymentMetadata), inputType = if (isSendable) { @@ -282,7 +273,7 @@ class PaymentViewModel @Inject constructor( ) } - private fun failure(error: PaymentError, reason: String): PaymentSceneState { + private fun failure(error: PaymentLinkError, reason: String): PaymentSceneState { Log.e(TAG, reason) return PaymentSceneState.Error(error) } @@ -300,11 +291,11 @@ class PaymentViewModel @Inject constructor( private suspend fun wallet(): Wallet? { val wallet = sessionRepository.session().firstOrNull()?.wallet if (wallet == null) { - state.value = PaymentSceneState.Error(PaymentError.NoWallet) + state.value = PaymentSceneState.Error(PaymentLinkError.NoWallet) return null } if (wallet.type == WalletType.View) { - state.value = PaymentSceneState.Error(PaymentError.WatchWallet) + state.value = PaymentSceneState.Error(PaymentLinkError.WatchWallet) return null } return wallet @@ -313,7 +304,7 @@ class PaymentViewModel @Inject constructor( 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)) + state.value = PaymentSceneState.Error(PaymentLinkError.Gateway(err as? PaymentException)) } .getOrNull() 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 index bc6e9fea49..b687f45b08 100644 --- 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 @@ -2,33 +2,28 @@ 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.PaymentQuote import com.wallet.core.primitives.TransactionDirection import com.wallet.core.primitives.TransactionPaymentMetadata import com.wallet.core.primitives.TransactionState import com.wallet.core.primitives.TransactionType import com.wallet.core.primitives.Wallet -import uniffi.gemstone.GemPaymentQuote import java.math.BigInteger import javax.inject.Inject class RecordPayment @Inject constructor( private val createTransaction: CreateTransaction, ) { - suspend operator fun invoke( + suspend fun recordPayment( payment: TransactionPaymentMetadata, - quote: GemPaymentQuote, + quote: PaymentQuote, wallet: Wallet, ) { - val assetId = quote.amount.assetId.toAssetId() - if (assetId == null) { - Log.e(TAG, "Record payment: bad asset ${quote.amount.assetId}") - return - } + val assetId = quote.amount.assetId val account = wallet.accounts.firstOrNull { it.chain == assetId.chain } if (account == null) { Log.e(TAG, "Record payment: no ${assetId.chain} account") 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 index d63dc2269a..516333f41d 100644 --- 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 @@ -1,13 +1,13 @@ package com.gemwallet.android.features.payment.viewmodels.model -import uniffi.gemstone.GemPaymentMerchant +import com.wallet.core.primitives.PaymentMerchant data class PaymentMerchantUIModel( val name: String, val iconUrl: String?, ) -fun GemPaymentMerchant.toUIModel() = PaymentMerchantUIModel( +fun PaymentMerchant.toUIModel() = PaymentMerchantUIModel( name = name, iconUrl = iconUrl, ) diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt index dd5e843e93..68a11d1888 100644 --- 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 @@ -1,6 +1,6 @@ package com.gemwallet.android.features.payment.viewmodels.model -import uniffi.gemstone.GemPaymentStatus +import com.wallet.core.primitives.PaymentStatus enum class PaymentOutcomeUIModel { Success, @@ -10,11 +10,11 @@ enum class PaymentOutcomeUIModel { 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 +fun PaymentStatus.toUIModel() = when (this) { + PaymentStatus.Succeeded -> PaymentOutcomeUIModel.Success + PaymentStatus.Processing -> PaymentOutcomeUIModel.Pending + PaymentStatus.Cancelled -> PaymentOutcomeUIModel.Cancelled + PaymentStatus.Expired -> PaymentOutcomeUIModel.Expired + PaymentStatus.Failed, + PaymentStatus.RequiresAction -> PaymentOutcomeUIModel.Failed } diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt index 8d23cddaca..697bb30ddf 100644 --- 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 @@ -3,12 +3,11 @@ package com.gemwallet.android.features.payment.viewmodels.model import com.gemwallet.android.domains.asset.getIconUrl import com.gemwallet.android.domains.asset.getSupportIconUrl import com.gemwallet.android.ext.asset -import com.gemwallet.android.ext.toAssetId import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.model.Crypto import com.gemwallet.android.model.ValueFormatter -import uniffi.gemstone.GemPaymentPrice -import uniffi.gemstone.GemPaymentQuote +import com.wallet.core.primitives.PaymentPrice +import com.wallet.core.primitives.PaymentQuote private val amountFormatter = ValueFormatter(ValueFormatter.Style.Short) private val priceFormatter = ValueFormatter(ValueFormatter.Style.Full) @@ -26,21 +25,18 @@ data class PaymentQuoteUIModel( val amountText: String get() = "$amount $symbol" } -fun GemPaymentQuote.toUIModel(assetInfo: AssetInfo? = null): PaymentQuoteUIModel { - val assetId = amount.assetId.toAssetId() - return PaymentQuoteUIModel( - id = id, - name = assetInfo?.asset?.name ?: amount.symbol, - networkName = assetId?.chain?.asset()?.name.orEmpty(), - symbol = amount.symbol, - amount = amountFormatter.string(Crypto(amount.value).value(amount.decimals)), - balance = assetInfo?.balanceText().orEmpty(), - iconUrl = assetId?.getIconUrl(), - supportIconUrl = assetId?.getSupportIconUrl(), - ) -} +fun PaymentQuote.toUIModel(assetInfo: AssetInfo? = null): PaymentQuoteUIModel = PaymentQuoteUIModel( + id = id, + name = assetInfo?.asset?.name ?: amount.symbol, + networkName = amount.assetId.chain.asset().name, + symbol = amount.symbol, + amount = amountFormatter.string(Crypto(amount.value).value(amount.decimals)), + balance = assetInfo?.balanceText().orEmpty(), + iconUrl = amount.assetId.getIconUrl(), + supportIconUrl = amount.assetId.getSupportIconUrl(), +) -fun GemPaymentPrice.toPriceText(): String = priceFormatter.string(Crypto(value).value(decimals), currency = symbol) +fun PaymentPrice.toPriceText(): String = priceFormatter.string(Crypto(value).value(decimals), currency = symbol) private fun AssetInfo.balanceText(): String = amountFormatter.string( Crypto(balance.balance.available).value(asset.decimals), 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 index a004e60099..ec25b5e580 100644 --- 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 @@ -1,15 +1,18 @@ package com.gemwallet.android.features.payment.viewmodels +import com.gemwallet.android.model.PreparedPayment import com.gemwallet.android.testkit.mockWallet +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.PaymentAmount +import com.wallet.core.primitives.PaymentMerchant +import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.PaymentQuote +import com.wallet.core.primitives.PaymentQuotes import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test import uniffi.gemstone.GemApprovalData -import uniffi.gemstone.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 @@ -40,23 +43,23 @@ class ActivePaymentTest { private fun payment(actions: List): ActivePayment = ActivePayment( - provider = GemPaymentProviderName.WALLET_CONNECT_PAY, + provider = PaymentProviderName.WalletConnectPay, quotes = quotes(), wallet = mockWallet(), - ).prepared(quote(), actions) + ).prepared(PreparedPayment(quotes(), quote(), actions, isRelayed = true)) - private fun quotes() = GemPaymentQuotes( - merchant = GemPaymentMerchant(name = "Gem Wallet Test Merchant", iconUrl = null), + private fun quotes() = PaymentQuotes( + merchant = PaymentMerchant(name = "Gem Wallet Test Merchant", iconUrl = null), price = null, expiresAt = null, quotes = listOf(quote()), ) - private fun quote() = GemPaymentQuote( + private fun quote() = PaymentQuote( id = "opt_1", paymentId = "pay_1", - amount = GemPaymentAmount( - assetId = "ethereum", + amount = PaymentAmount( + assetId = AssetId(Chain.Ethereum), value = "1", symbol = "USDT", decimals = 6, diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt index 9260c6b2e5..7e489d2103 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/confirm/ConfirmProperty.kt @@ -53,7 +53,7 @@ sealed interface ConfirmProperty { is ConfirmParams.TransferParams.Native -> params.destination()?.let { Transfer(domain = it.name ?: addressName?.name, address = it.address, chain = params.assetId.chain) } ?: throw ConfirmError.RecipientEmpty - is ConfirmParams.TransferParams.Generic -> Generic(params.name) + is ConfirmParams.TransferParams.Generic -> Generic(params.appMetadata.name) } } } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt index c65e3ae911..4d256d05e0 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt @@ -1,15 +1,31 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.Payment +import com.wallet.core.primitives.PaymentAmount import com.wallet.core.primitives.PaymentLink import com.wallet.core.primitives.PaymentMerchant +import com.wallet.core.primitives.PaymentOptions +import com.wallet.core.primitives.PaymentOutcome +import com.wallet.core.primitives.PaymentPrice import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.PaymentQuote +import com.wallet.core.primitives.PaymentQuotes import com.wallet.core.primitives.PaymentRequest +import com.wallet.core.primitives.PaymentStatus +import com.wallet.core.primitives.TransactionAppMetadata import uniffi.gemstone.GemPayment +import uniffi.gemstone.GemPaymentAmount import uniffi.gemstone.GemPaymentLink import uniffi.gemstone.GemPaymentMerchant +import uniffi.gemstone.GemPaymentOptions +import uniffi.gemstone.GemPaymentOutcome +import uniffi.gemstone.GemPaymentPrice import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes import uniffi.gemstone.GemPaymentRequest +import uniffi.gemstone.GemPaymentStatus +import uniffi.gemstone.paymentWalletConnectUrl fun GemPayment.toPrimitives(): Payment = when (this) { is GemPayment.Request -> Payment.Request(v1.toPrimitives()) @@ -28,11 +44,28 @@ fun GemPaymentLink.toPrimitives(): PaymentLink = PaymentLink( id = id, ) +fun PaymentLink.toGem(): GemPaymentLink = GemPaymentLink( + provider = provider.toGem(), + id = id, +) + fun GemPaymentMerchant.toPrimitives(): PaymentMerchant = PaymentMerchant( name = name, iconUrl = iconUrl, ) +fun PaymentMerchant.toGem(): GemPaymentMerchant = GemPaymentMerchant( + name = name, + iconUrl = iconUrl, +) + +fun PaymentMerchant.toAppMetadata(): TransactionAppMetadata = TransactionAppMetadata( + name = name, + description = null, + url = paymentWalletConnectUrl(), + icon = iconUrl, +) + fun GemPaymentProviderName.toPrimitives(): PaymentProviderName = when (this) { GemPaymentProviderName.SOLANA_PAY -> PaymentProviderName.SolanaPay GemPaymentProviderName.WALLET_CONNECT_PAY -> PaymentProviderName.WalletConnectPay @@ -42,3 +75,80 @@ fun PaymentProviderName.toGem(): GemPaymentProviderName = when (this) { PaymentProviderName.SolanaPay -> GemPaymentProviderName.SOLANA_PAY PaymentProviderName.WalletConnectPay -> GemPaymentProviderName.WALLET_CONNECT_PAY } + +fun GemPaymentAmount.toPrimitives(): PaymentAmount = PaymentAmount( + assetId = requireNotNull(assetId.toAssetId()) { "unknown payment asset $assetId" }, + value = value, + symbol = symbol, + decimals = decimals, +) + +fun PaymentAmount.toGem(): GemPaymentAmount = GemPaymentAmount( + assetId = assetId.toIdentifier(), + value = value, + symbol = symbol, + decimals = decimals, +) + +fun GemPaymentPrice.toPrimitives(): PaymentPrice = PaymentPrice( + symbol = symbol, + value = value, + decimals = decimals, +) + +fun PaymentPrice.toGem(): GemPaymentPrice = GemPaymentPrice( + symbol = symbol, + value = value, + decimals = decimals, +) + +fun GemPaymentQuote.toPrimitives(): PaymentQuote = PaymentQuote( + id = id, + paymentId = paymentId, + amount = amount.toPrimitives(), + expiresAt = expiresAt?.secondsToMillis(), + collectDataUrl = collectDataUrl, + providerData = providerData, +) + +fun PaymentQuote.toGem(): GemPaymentQuote = GemPaymentQuote( + id = id, + paymentId = paymentId, + amount = amount.toGem(), + expiresAt = expiresAt?.millisToSeconds(), + collectDataUrl = collectDataUrl, + providerData = providerData, +) + +fun GemPaymentQuotes.toPrimitives(): PaymentQuotes = PaymentQuotes( + merchant = merchant.toPrimitives(), + price = price?.toPrimitives(), + expiresAt = expiresAt?.secondsToMillis(), + quotes = quotes.map { it.toPrimitives() }, +) + +fun PaymentQuotes.toGem(): GemPaymentQuotes = GemPaymentQuotes( + merchant = merchant.toGem(), + price = price?.toGem(), + expiresAt = expiresAt?.millisToSeconds(), + quotes = quotes.map { it.toGem() }, +) + +fun GemPaymentStatus.toPrimitives(): PaymentStatus = when (this) { + GemPaymentStatus.REQUIRES_ACTION -> PaymentStatus.RequiresAction + GemPaymentStatus.PROCESSING -> PaymentStatus.Processing + GemPaymentStatus.SUCCEEDED -> PaymentStatus.Succeeded + GemPaymentStatus.FAILED -> PaymentStatus.Failed + GemPaymentStatus.EXPIRED -> PaymentStatus.Expired + GemPaymentStatus.CANCELLED -> PaymentStatus.Cancelled +} + +fun GemPaymentOutcome.toPrimitives(): PaymentOutcome = PaymentOutcome( + status = status.toPrimitives(), + transactionId = transactionId, +) + +fun GemPaymentOptions.toPrimitives(): PaymentOptions = when (this) { + is GemPaymentOptions.Quotes -> PaymentOptions.Quotes(v1.toPrimitives()) + is GemPaymentOptions.Outcome -> PaymentOptions.Outcome(v1.toPrimitives()) +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt index 897203a85a..d7afa20a72 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt @@ -4,22 +4,18 @@ import com.gemwallet.android.math.hexToBigInteger import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.DestinationAddress import com.wallet.core.primitives.Account +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.TransactionAppMetadata import com.wallet.core.primitives.TransactionPaymentMetadata +import com.wallet.core.primitives.TransactionType import uniffi.gemstone.SignableTransaction import uniffi.gemstone.TransferDataOutputType import java.math.BigInteger -data class SigningRequestApp( - val name: String, - val description: String, - val url: String, - val icon: String, -) - fun SignableTransaction.toConfirmParams( requestId: String, account: Account, - app: SigningRequestApp, + appMetadata: TransactionAppMetadata, isSendable: Boolean, inputType: ConfirmParams.TransferParams.InputType?, payment: TransactionPaymentMetadata? = null, @@ -30,7 +26,7 @@ fun SignableTransaction.toConfirmParams( requestId = requestId, asset = asset, account = account, - app = app, + appMetadata = appMetadata, memo = data.data, gasLimit = data.gasLimit, inputType = inputType, @@ -40,18 +36,18 @@ fun SignableTransaction.toConfirmParams( transactionType = transactionType.toPrimitives(), payment = payment, ) - is SignableTransaction.Solana -> encoded(requestId, asset, account, app, data.transaction, outputType, isSendable, payment) - is SignableTransaction.Sui -> encoded(requestId, asset, account, app, data.transaction, outputType, isSendable, payment) - is SignableTransaction.Ton -> encoded(requestId, asset, account, app, data, outputType, isSendable, payment) - is SignableTransaction.Tron -> encoded(requestId, asset, account, app, data, outputType, isSendable, payment) + is SignableTransaction.Solana -> encoded(requestId, asset, account, appMetadata, data.transaction, outputType, isSendable, payment) + is SignableTransaction.Sui -> encoded(requestId, asset, account, appMetadata, data.transaction, outputType, isSendable, payment) + is SignableTransaction.Ton -> encoded(requestId, asset, account, appMetadata, data, outputType, isSendable, payment) + is SignableTransaction.Tron -> encoded(requestId, asset, account, appMetadata, data, outputType, isSendable, payment) } } private fun encoded( requestId: String, - asset: com.wallet.core.primitives.Asset, + asset: Asset, account: Account, - app: SigningRequestApp, + appMetadata: TransactionAppMetadata, payload: String, outputType: TransferDataOutputType, isSendable: Boolean, @@ -60,7 +56,7 @@ private fun encoded( requestId = requestId, asset = asset, account = account, - app = app, + appMetadata = appMetadata, memo = payload, gasLimit = "", inputType = when (outputType) { @@ -75,26 +71,23 @@ private fun encoded( private fun generic( requestId: String, - asset: com.wallet.core.primitives.Asset, + asset: Asset, account: Account, - app: SigningRequestApp, + appMetadata: TransactionAppMetadata, 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, + transactionType: TransactionType = TransactionType.SmartContractCall, payment: TransactionPaymentMetadata? = null, ) = ConfirmParams.TransferParams.Generic( requestId = requestId, asset = asset, from = account, memo = memo, - name = app.name, - description = app.description, - url = app.url, - icon = app.icon, + appMetadata = appMetadata, gasLimit = gasLimit, inputType = inputType, destination = destination, 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 b772977d33..fb9f99f348 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt @@ -23,6 +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.TransactionPaymentMetadata +import com.wallet.core.primitives.TransactionAppMetadata import com.wallet.core.primitives.TransactionType import com.wallet.core.primitives.ApprovalData import kotlinx.serialization.Serializable @@ -185,10 +186,7 @@ sealed class ConfirmParams() { override val useMaxAmount: Boolean = false, override val inputType: InputType? = null, val isSendable: Boolean, - val name: String, - val description: String, - val url: String, - val icon: String, + val appMetadata: TransactionAppMetadata, val gasLimit: String?, val decodedTransactionType: TransactionType = TransactionType.SmartContractCall, val payment: TransactionPaymentMetadata? = null, @@ -198,10 +196,10 @@ sealed class ConfirmParams() { return Generic( asset = asset.toGem(), appMetadata = GemTransactionAppMetadata( - name = name, - description = description, - url = url, - icon = icon, + name = appMetadata.name, + description = appMetadata.description, + url = appMetadata.url, + icon = appMetadata.icon, ), extra = GemTransferDataExtra( gasLimit = null, @@ -235,10 +233,8 @@ sealed class ConfirmParams() { result = 31 * result + destination.hashCode() result = 31 * result + memo.hashCode() result = 31 * result + useMaxAmount.hashCode() - result = 31 * result + name.hashCode() + result = 31 * result + appMetadata.hashCode() result = 31 * result + destination.hashCode() - result = 31 * result + url.hashCode() - result = 31 * result + icon.hashCode() result = 31 * result + (gasLimit?.hashCode() ?: 0) result = 31 * result + decodedTransactionType.hashCode() return result diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt new file mode 100644 index 0000000000..9ab1eb709a --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/PreparedPayment.kt @@ -0,0 +1,12 @@ +package com.gemwallet.android.model + +import com.wallet.core.primitives.PaymentQuote +import com.wallet.core.primitives.PaymentQuotes +import uniffi.gemstone.PaymentAction + +data class PreparedPayment( + val quotes: PaymentQuotes, + val quote: PaymentQuote, + val actions: List, + val isRelayed: Boolean, +) diff --git a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt index 406e30a7b1..e7b744a35a 100644 --- a/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt +++ b/android/gemcore/src/test/kotlin/com/gemwallet/android/model/ConfirmParamsTest.kt @@ -15,6 +15,7 @@ import com.gemwallet.android.testkit.mockSwapParams import com.wallet.core.primitives.Chain import com.wallet.core.primitives.PerpetualType import com.wallet.core.primitives.Resource +import com.wallet.core.primitives.TransactionAppMetadata import com.wallet.core.primitives.TransactionType import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -52,10 +53,12 @@ class ConfirmParamsTest { memo = "0x01", inputType = ConfirmParams.TransferParams.InputType.EncodeTransaction, isSendable = true, - name = "App", - description = "Description", - url = "https://example.com", - icon = "https://example.com/icon.png", + appMetadata = TransactionAppMetadata( + name = "App", + description = "Description", + url = "https://example.com", + icon = "https://example.com/icon.png", + ), gasLimit = "21000", decodedTransactionType = TransactionType.SmartContractCall, ), From 66506bdd3c82b55aa53dede54c671d4796078389 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:08:08 +0300 Subject: [PATCH 47/53] Android: dispatch payment scene actions through one sealed type The scene took a callback per intent while every recent feature takes a single onAction. Follow AmountScreen: the screen owns the dispatcher, the scenes report what the user did. The scanner gate read the developer flag once when the view model was built, so turning developer mode on in settings left the scan button hidden until the view model was recreated. Read it the way SettingsViewModel does, and label the button with a string. --- .../features/assets/views/AssetsTopBar.kt | 3 +- .../assets/viewmodels/AssetsViewModel.kt | 4 +- .../presents/PaymentDataCollectionScene.kt | 19 +++---- .../features/payment/presents/PaymentScene.kt | 54 ++++++++++--------- .../payment/presents/PaymentSceneAction.kt | 11 ++++ 5 files changed, 52 insertions(+), 39 deletions(-) create mode 100644 android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt 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 f9ad6c0802..f6b4e5e3a1 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.gemwallet.android.domains.wallet.aggregates.WalletSummaryAggregate import com.gemwallet.android.ui.R @@ -72,7 +73,7 @@ internal fun AssetsTopBar( Icon( imageVector = AppIcons.QrCodeScanner, tint = MaterialTheme.colorScheme.onSurface, - contentDescription = "scan_payment", + contentDescription = stringResource(R.string.wallet_scan_qr_code), ) } } diff --git a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt index 8e85e42668..eb401608e3 100644 --- a/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt +++ b/android/features/assets/viewmodels/src/main/kotlin/com/gemwallet/android/features/assets/viewmodels/AssetsViewModel.kt @@ -45,10 +45,10 @@ class AssetsViewModel @Inject constructor( getHideBalancesState: GetHideBalancesState, getShowWelcomeBanner: GetShowWelcomeBanner, getSession: GetSession, - userConfig: UserConfig, + private val userConfig: UserConfig, ) : ViewModel(), AssetToastEmitter by AssetToastEmitterImpl() { - val showScanner: Boolean = userConfig.developEnabled() + val showScanner: Boolean get() = userConfig.developEnabled() val currentWalletId = getSession() .map { it?.wallet?.id } 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 index cac80f7564..418207d4a0 100644 --- 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 @@ -43,23 +43,21 @@ private const val TAG = "PaymentDataCollection" @SuppressLint("SetJavaScriptEnabled") @Composable -fun PaymentDataCollectionScene( +internal fun PaymentDataCollectionScene( url: String, - onComplete: () -> Unit, - onError: (String?) -> Unit, - onCancel: () -> Unit, + onAction: (PaymentSceneAction) -> Unit, ) { var webView by remember { mutableStateOf(null) } val uriHandler = LocalUriHandler.current BackHandler { val view = webView - if (view != null && view.canGoBack()) view.goBack() else onCancel() + if (view != null && view.canGoBack()) view.goBack() else onAction(PaymentSceneAction.Cancel) } Scene( title = stringResource(R.string.transfer_payment_title), - onClose = onCancel, + onClose = { onAction(PaymentSceneAction.Cancel) }, ) { AndroidView( modifier = Modifier @@ -78,7 +76,7 @@ fun PaymentDataCollectionScene( CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) webViewClient = AllowedHostWebViewClient(context, uriHandler) webChromeClient = LoggingWebChromeClient() - addJavascriptInterface(CollectDataBridge(onComplete, onError), MESSAGE_HANDLER) + addJavascriptInterface(CollectDataBridge(onAction), MESSAGE_HANDLER) loadUrl(url) webView = this } @@ -88,15 +86,14 @@ fun PaymentDataCollectionScene( } private class CollectDataBridge( - private val onComplete: () -> Unit, - private val onError: (String?) -> Unit, + private val onAction: (PaymentSceneAction) -> 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() }) + COMPLETE -> onAction(PaymentSceneAction.DataCollected) + ERROR -> onAction(PaymentSceneAction.DataCollectionFailed(message.optString(MESSAGE_ERROR_KEY).takeIf { it.isNotEmpty() })) } } } 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 index ed4c4a0e99..6eaf9744c8 100644 --- 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 @@ -77,63 +77,68 @@ fun PaymentScene( LaunchedEffect(paymentId) { viewModel.onPayment(provider, paymentId) } + val onAction: (PaymentSceneAction) -> Unit = { action -> + when (action) { + is PaymentSceneAction.SelectQuote -> viewModel.onSelectQuote(action.quoteId) + PaymentSceneAction.ConfirmQuote -> viewModel.onConfirmQuote() + PaymentSceneAction.DataCollected -> viewModel.onDataCollected() + is PaymentSceneAction.DataCollectionFailed -> viewModel.onDataCollectionError(action.message) + PaymentSceneAction.Sign -> viewModel.onSign() + is PaymentSceneAction.ActionResult -> viewModel.onActionResult(action.result) + PaymentSceneAction.Cancel -> onCancel() + } + } + when (val sceneState = state) { PaymentSceneState.Loading -> LoadingScene( title = stringResource(R.string.transfer_payment_title), - onCancel = onCancel, + onCancel = { onAction(PaymentSceneAction.Cancel) }, ) is PaymentSceneState.Quotes -> PaymentQuotesScene( state = sceneState, - onSelect = viewModel::onSelectQuote, - onConfirm = viewModel::onConfirmQuote, - onCancel = onCancel, + onAction = onAction, ) is PaymentSceneState.CollectData -> PaymentDataCollectionScene( url = sceneState.url, - onComplete = viewModel::onDataCollected, - onError = viewModel::onDataCollectionError, - onCancel = onCancel, + onAction = onAction, ) is PaymentSceneState.Approve -> ConfirmScreen( params = sceneState.params, - finishAction = { hash -> viewModel.onActionResult(hash) }, - cancelAction = onCancel, + finishAction = { hash -> onAction(PaymentSceneAction.ActionResult(hash)) }, + cancelAction = { onAction(PaymentSceneAction.Cancel) }, onAcquireAsset = onAcquireAsset, ) is PaymentSceneState.Confirm -> ConfirmScreen( params = sceneState.params, - finishAction = { hash -> viewModel.onActionResult(hash) }, - cancelAction = onCancel, + finishAction = { hash -> onAction(PaymentSceneAction.ActionResult(hash)) }, + cancelAction = { onAction(PaymentSceneAction.Cancel) }, onAcquireAsset = onAcquireAsset, ) is PaymentSceneState.SignMessage -> PaymentSignMessageScene( state = sceneState, - onApprove = viewModel::onSign, - onCancel = onCancel, + onAction = onAction, ) - is PaymentSceneState.Outcome -> PaymentToastEffect(sceneState.outcome.messageRes(), onCancel) - is PaymentSceneState.Error -> PaymentToastEffect(sceneState.error.messageRes(), onCancel) + is PaymentSceneState.Outcome -> PaymentToastEffect(sceneState.outcome.messageRes()) { onAction(PaymentSceneAction.Cancel) } + is PaymentSceneState.Error -> PaymentToastEffect(sceneState.error.messageRes()) { onAction(PaymentSceneAction.Cancel) } } } @Composable private fun PaymentQuotesScene( state: PaymentSceneState.Quotes, - onSelect: (String) -> Unit, - onConfirm: () -> Unit, - onCancel: () -> Unit, + onAction: (PaymentSceneAction) -> Unit, ) { var isSelectingQuote by remember { mutableStateOf(false) } Scene( title = stringResource(R.string.transfer_payment_title), backHandle = true, - onClose = onCancel, + onClose = { onAction(PaymentSceneAction.Cancel) }, mainAction = { MainActionButton( title = stringResource(R.string.common_continue), state = if (state.expired || state.selected == null) ButtonState.Disabled else ButtonState.Enabled, - onClick = onConfirm, + onClick = { onAction(PaymentSceneAction.ConfirmQuote) }, ) }, ) { @@ -202,7 +207,7 @@ private fun PaymentQuotesScene( quotes = state.quotes, selected = state.selected, onSelect = { - onSelect(it) + onAction(PaymentSceneAction.SelectQuote(it)) isSelectingQuote = false }, onDismissRequest = { isSelectingQuote = false }, @@ -253,8 +258,7 @@ private fun PaymentQuotesSelectModal( @Composable private fun PaymentSignMessageScene( state: PaymentSceneState.SignMessage, - onApprove: () -> Unit, - onCancel: () -> Unit, + onAction: (PaymentSceneAction) -> Unit, ) { val context = LocalContext.current var sheetType by remember { mutableStateOf(null) } @@ -263,10 +267,10 @@ private fun PaymentSignMessageScene( title = stringResource(R.string.transfer_payment_title), backHandle = true, closeIcon = true, - onClose = onCancel, + onClose = { onAction(PaymentSceneAction.Cancel) }, mainAction = { MainActionButton(title = stringResource(R.string.transfer_confirm)) { - context.requestAuth(AuthRequest.Confirmation) { onApprove() } + context.requestAuth(AuthRequest.Confirmation) { onAction(PaymentSceneAction.Sign) } } }, ) { paddingValues -> diff --git a/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt new file mode 100644 index 0000000000..54fb31d492 --- /dev/null +++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentSceneAction.kt @@ -0,0 +1,11 @@ +package com.gemwallet.android.features.payment.presents + +internal sealed interface PaymentSceneAction { + data class SelectQuote(val quoteId: String) : PaymentSceneAction + data object ConfirmQuote : PaymentSceneAction + data object DataCollected : PaymentSceneAction + data class DataCollectionFailed(val message: String?) : PaymentSceneAction + data object Sign : PaymentSceneAction + data class ActionResult(val result: String) : PaymentSceneAction + data object Cancel : PaymentSceneAction +} From 4ee8ad99fad55b14d00f7ab3343d17ca7637757c Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:49:43 +0300 Subject: [PATCH 48/53] Android: simulate a payment's message before asking to sign it The payment sign screen showed a merchant's message with no simulation behind it, so a request to permit a suspicious spender arrived with no warning and a live confirm button. Every other signing surface in the app simulates: WalletConnect does it for the same kind of message, and a payment's transactions go through the confirm screen, which does too. Read the simulation the same way, show its warnings, and take the button away on a critical one or once the quote has expired, which the quotes screen already did and this one did not. The simulator parses typed data as text, so the message bytes go over as text like iOS sends them; hex would have parsed as nothing and warned about nothing. --- .../WalletConnectSimulationService.kt | 9 +++++ .../WalletConnectSimulationServiceTest.kt | 38 +++++++++++++++++++ .../features/payment/presents/PaymentScene.kt | 9 ++++- .../payment/viewmodels/PaymentSceneState.kt | 3 ++ .../payment/viewmodels/PaymentViewModel.kt | 27 ++++++++++--- 5 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt 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 09a4c35b40..5714170494 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationService.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationService.kt @@ -3,6 +3,7 @@ package com.gemwallet.android.blockchain.services import com.gemwallet.android.blockchain.gemstone.toPrimitives import com.wallet.core.primitives.SimulationResult import uniffi.gemstone.SignDigestType +import uniffi.gemstone.SignMessage import uniffi.gemstone.WalletConnectSimulationClientInterface import uniffi.gemstone.SignableTransactionType @@ -12,6 +13,14 @@ class WalletConnectSimulationService( suspend fun simulateSignMessage(chain: String, signType: SignDigestType, data: String, sessionDomain: String): SimulationResult = client.simulateSignMessage(chain = chain, signType = signType, data = data, sessionDomain = sessionDomain).toPrimitives() + suspend fun simulateSignMessage(message: SignMessage, sessionDomain: String): SimulationResult = + simulateSignMessage( + chain = message.chain, + signType = message.signType, + data = String(message.data, Charsets.UTF_8), + sessionDomain = sessionDomain, + ) + suspend fun simulateSendTransaction(chain: String, transactionType: SignableTransactionType, data: String): SimulationResult = client.simulateSendTransaction(chain = chain, transactionType = transactionType, data = data).toPrimitives() } diff --git a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt new file mode 100644 index 0000000000..d1ea814ea1 --- /dev/null +++ b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/WalletConnectSimulationServiceTest.kt @@ -0,0 +1,38 @@ +package com.gemwallet.android.blockchain.services + +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import uniffi.gemstone.SignDigestType +import uniffi.gemstone.SignMessage +import uniffi.gemstone.SimulationResult +import uniffi.gemstone.WalletConnectSimulationClientInterface + +private const val TYPED_DATA = """{"domain":{"name":"Permit2"},"message":{"spender":"0xspender"}}""" + +class WalletConnectSimulationServiceTest { + + private val client = mockk() + private val service = WalletConnectSimulationService(client) + + @Test + fun simulateSignMessage_sendsTypedDataAsTextTheSimulatorCanParse() = runBlocking { + val sent = slot() + coEvery { client.simulateSignMessage(any(), any(), capture(sent), any()) } returns SimulationResult( + warnings = emptyList(), + balanceChanges = emptyList(), + payload = emptyList(), + header = null, + ) + + service.simulateSignMessage( + SignMessage(chain = "ethereum", signType = SignDigestType.EIP712, data = TYPED_DATA.toByteArray()), + sessionDomain = "https://pay.walletconnect.com", + ) + + assertEquals(TYPED_DATA, sent.captured) + } +} diff --git a/android/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 index 6eaf9744c8..adf4c28fcf 100644 --- 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 @@ -58,7 +58,10 @@ import com.gemwallet.android.ui.components.screen.LoadingScene import com.gemwallet.android.ui.components.screen.ModalBottomSheet import com.gemwallet.android.ui.components.screen.Scene import com.gemwallet.android.ui.components.simulation.simulationPayloadFieldsContent +import com.gemwallet.android.ui.components.simulation.simulationWarningsContent import com.gemwallet.android.ui.models.ButtonState +import com.gemwallet.android.ui.models.buttonState +import com.gemwallet.android.ui.models.hasCriticalWarning import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.requestAuth import com.gemwallet.android.ui.theme.paddingDefault @@ -269,7 +272,10 @@ private fun PaymentSignMessageScene( closeIcon = true, onClose = { onAction(PaymentSceneAction.Cancel) }, mainAction = { - MainActionButton(title = stringResource(R.string.transfer_confirm)) { + MainActionButton( + title = stringResource(R.string.transfer_confirm), + state = buttonState(enabled = !state.expired && !state.warnings.hasCriticalWarning()), + ) { context.requestAuth(AuthRequest.Confirmation) { onAction(PaymentSceneAction.Sign) } } }, @@ -304,6 +310,7 @@ private fun PaymentSignMessageScene( ) } } + simulationWarningsContent(state.warnings) if (state.quote == null) { if (state.hasPayload) { simulationPayloadFieldsContent( 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 index 7ed1abbf69..bd5358f46b 100644 --- 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 @@ -5,6 +5,7 @@ import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIM import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIModel import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.ui.models.PayloadField +import com.wallet.core.primitives.SimulationWarning import com.wallet.core.primitives.Chain import com.wallet.core.primitives.WalletType import uniffi.gemstone.PaymentException @@ -47,6 +48,8 @@ sealed interface PaymentSceneState { val plainMessage: String, val primaryPayloadFields: List, val secondaryPayloadFields: List, + val warnings: List, + val expired: Boolean, ) : PaymentSceneState { val hasPayload: Boolean get() = primaryPayloadFields.isNotEmpty() || secondaryPayloadFields.isNotEmpty() 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 index 2e80d6d063..46c8008ff1 100644 --- 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 @@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.PasswordStore import com.gemwallet.android.application.assets.coordinators.GetAssetInfo +import com.gemwallet.android.blockchain.gemstone.toGem import com.gemwallet.android.blockchain.gemstone.toPrimitives import com.gemwallet.android.blockchain.services.GemSignMessageOperator import com.gemwallet.android.blockchain.services.PaymentService +import com.gemwallet.android.blockchain.services.WalletConnectSimulationService import com.gemwallet.android.cases.tokens.SearchTokensCase import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.ext.getAccount @@ -47,10 +49,12 @@ import uniffi.gemstone.MessageSigner import uniffi.gemstone.PaymentAction import uniffi.gemstone.PaymentException import uniffi.gemstone.SignableTransaction +import uniffi.gemstone.paymentWalletConnectUrl @HiltViewModel class PaymentViewModel @Inject constructor( private val paymentService: PaymentService, + private val simulationService: WalletConnectSimulationService, private val sessionRepository: SessionRepository, private val signMessageOperator: GemSignMessageOperator, private val passwordStore: PasswordStore, @@ -155,11 +159,13 @@ class PaymentViewModel @Inject constructor( private fun watchExpiry(quotes: PaymentQuotes) { val expiresAt = quotes.expiresAt ?: return + expiryJob?.cancel() expiryJob = viewModelScope.launch(Dispatchers.IO) { delay((expiresAt - System.currentTimeMillis()).coerceAtLeast(0)) - val current = state.value - if (current is PaymentSceneState.Quotes) { - state.value = current.copy(expired = true) + state.value = when (val current = state.value) { + is PaymentSceneState.Quotes -> current.copy(expired = true) + is PaymentSceneState.SignMessage -> current.copy(expired = true) + else -> current } } } @@ -201,21 +207,28 @@ class PaymentViewModel @Inject constructor( state.value = PaymentSceneState.Outcome(settled?.status?.toUIModel() ?: PaymentOutcomeUIModel.Pending) return } - state.value = when (val action = step.action) { + val next = when (val action = step.action) { is PaymentAction.SignMessage -> signMessageState(action, current) is PaymentAction.SendTransaction -> confirmState(action.chain, action.transaction, true, current) is PaymentAction.SignTransaction -> confirmState(action.chain, action.transaction, false, current) is PaymentAction.ApproveToken -> approvalState(action, current) } + state.value = next + if (next is PaymentSceneState.SignMessage) { + watchExpiry(current.quotes) + } } - private fun signMessageState( + private suspend fun signMessageState( action: PaymentAction.SignMessage, current: ActivePayment, ): PaymentSceneState { val chain = action.message.chain.toChain() ?: return PaymentSceneState.Error(PaymentLinkError.NoAccount) + val simulation = runCatchingCancellable { + simulationService.simulateSignMessage(action.message, paymentWalletConnectUrl()) + }.getOrNull() val signer = runCatching { MessageSigner(action.message) }.getOrNull() - val preview = signer?.let { runCatching { it.payloadPreview(emptyList()) }.getOrNull() } + val preview = signer?.let { runCatching { it.payloadPreview(simulation?.payload.orEmpty().map { field -> field.toGem() }) }.getOrNull() } return PaymentSceneState.SignMessage( merchant = current.quotes.merchant.toUIModel(), chain = chain, @@ -228,6 +241,8 @@ class PaymentViewModel @Inject constructor( .withExplorerLinks(chain, null), secondaryPayloadFields = preview?.secondary?.map { it.toPrimitives() }.orEmpty() .withExplorerLinks(chain, null), + warnings = simulation?.warnings.orEmpty(), + expired = false, ) } From bfc07f357ad451ca628e0b0bdf95b153fafe82c6 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:12:21 +0300 Subject: [PATCH 49/53] Android: reconcile a payment through the shared transaction update Checking a payment reached into the dao to swap the placeholder id for the settled hash, which is what storeTransactionUpdate already does for every other transaction, and more carefully: it moves swap metadata and merges into an existing row when the chain transaction is already recorded. It also skipped nextTransactionState. Report the outcome as changes, the way iOS does, and let the shared path apply them. The webview reads the allowed host from core now too. --- .../TransactionsRepositoryImpl.kt | 66 +++++++------------ .../presents/PaymentDataCollectionScene.kt | 6 +- 2 files changed, 26 insertions(+), 46 deletions(-) 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 ed721ff9a2..d75f398c4a 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt @@ -32,6 +32,8 @@ import com.wallet.core.primitives.PaymentStatus import com.wallet.core.primitives.Transaction import com.wallet.core.primitives.TransactionDirection import com.wallet.core.primitives.TransactionId +import com.gemwallet.android.model.HashChanges +import com.gemwallet.android.model.TransactionChanges import com.wallet.core.primitives.TransactionPaymentMetadata import com.wallet.core.primitives.TransactionState import com.wallet.core.primitives.TransactionStateRequest @@ -264,61 +266,35 @@ class TransactionsRepositoryImpl( return ((sourceTimeout + destinationTimeout) * 3).coerceAtLeast(DateUtils.DAY_IN_MILLIS) } - private suspend fun checkPayment( - transaction: DbTransactionExtended, - metadata: TransactionPaymentMetadata, - ): DbTransactionExtended? { + private suspend fun paymentStateChanges(record: DbTransaction, metadata: TransactionPaymentMetadata): TransactionChanges { val outcome = try { paymentService.getPaymentStatus(metadata.provider, metadata.paymentId) } catch (_: Throwable) { - return transaction.withUpdatedAt() + throw ServiceUnavailable } - val state = when (outcome.status) { - PaymentStatus.Succeeded -> TransactionState.Confirmed - PaymentStatus.Failed, PaymentStatus.Expired, PaymentStatus.Cancelled -> TransactionState.Failed - PaymentStatus.Processing, PaymentStatus.RequiresAction -> return transaction.withUpdatedAt() - } - val isAwaitingPaymentHash = transaction.transaction.hash == metadata.paymentId - if (state == TransactionState.Confirmed && isAwaitingPaymentHash) { - val settledHash = outcome.transactionId ?: return transaction.withUpdatedAt() - 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() + return when (outcome.status) { + PaymentStatus.Succeeded -> { + val settledHash = outcome.transactionId + if (settledHash == null || record.hash != metadata.paymentId) { + TransactionChanges(state = TransactionState.Confirmed) + } else { + TransactionChanges( + state = TransactionState.Confirmed, + hashChanges = HashChanges(old = record.hash, new = settledHash), + ) + } } - 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(), - ), - ) + PaymentStatus.Failed, PaymentStatus.Expired, PaymentStatus.Cancelled -> TransactionChanges(state = TransactionState.Failed) + PaymentStatus.Processing, PaymentStatus.RequiresAction -> TransactionChanges(state = record.state) } - return transaction.copy( - transaction = transaction.transaction.copy(state = state, updatedAt = System.currentTimeMillis()), - ) } - private fun DbTransactionExtended.withUpdatedAt(): DbTransactionExtended = - copy(transaction = transaction.copy(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) { - if (!paymentService.hasStatus(paymentMetadata.provider)) { - return null - } - return checkPayment(transaction, paymentMetadata) + if (paymentMetadata != null && !paymentService.hasStatus(paymentMetadata.provider)) { + return null } val swapMetadata = getTransactionSwapMetadata(transactionRecord.type, transactionRecord.metadata) val swapProvider = swapMetadata?.provider?.toSwapProvider() @@ -332,7 +308,9 @@ class TransactionsRepositoryImpl( blockNumber = transactionRecord.blockNumber.toLongOrNull() ?: 0L, ) val stateChanges = try { - if (swapMetadata != null && swapProvider != null) { + if (paymentMetadata != null) { + paymentStateChanges(transactionRecord, paymentMetadata) + } else if (swapMetadata != null && swapProvider != null) { transactionStatusService.getSwapStatus( chain, TransactionSwapStateRequest( diff --git a/android/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 index 418207d4a0..88f3254551 100644 --- 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 @@ -32,13 +32,13 @@ import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.screen.Scene import com.gemwallet.android.ui.open import org.json.JSONObject +import uniffi.gemstone.paymentWalletConnectHost private const val MESSAGE_HANDLER = "payDataCollectionComplete" private const val MESSAGE_TYPE_KEY = "type" private const val MESSAGE_ERROR_KEY = "error" private const val COMPLETE = "IC_COMPLETE" private const val ERROR = "IC_ERROR" -private const val ALLOWED_HOST = "walletconnect.com" private const val TAG = "PaymentDataCollection" @SuppressLint("SetJavaScriptEnabled") @@ -102,11 +102,13 @@ private class AllowedHostWebViewClient( private val context: Context, private val uriHandler: UriHandler, ) : WebViewClient() { + private val allowedHost = paymentWalletConnectHost() + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val uri = request?.url ?: return true if (uri.scheme != "https" && uri.scheme != "http") return true val host = uri.host?.lowercase() ?: return true - if (uri.scheme == "https" && (host == ALLOWED_HOST || host.endsWith(".$ALLOWED_HOST"))) return false + if (uri.scheme == "https" && (host == allowedHost || host.endsWith(".$allowedHost"))) return false uriHandler.open(context, uri.toString()) return true } From d0b000f16c4ed34e8a1551a494420b5bd08c2102 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:50:27 +0300 Subject: [PATCH 50/53] Android: name the payment entry a screen It collects the view model and owns the action dispatcher, which is what a screen does here; the scenes it renders were already stateless. --- .../com/gemwallet/android/ui/navigation/routes/Payment.kt | 4 ++-- .../payment/presents/{PaymentScene.kt => PaymentScreen.kt} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/{PaymentScene.kt => PaymentScreen.kt} (99%) 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 index 45fbbed5b3..c79bfb33c7 100644 --- 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 @@ -3,7 +3,7 @@ package com.gemwallet.android.ui.navigation.routes import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.gemwallet.android.features.confirm.presents.AcquireAssetAction -import com.gemwallet.android.features.payment.presents.PaymentScene +import com.gemwallet.android.features.payment.presents.PaymentScreen import com.gemwallet.android.ui.models.actions.CancelAction import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.PaymentProviderName @@ -17,7 +17,7 @@ fun EntryProviderScope.payment( cancelAction: CancelAction, ) { entry { key -> - PaymentScene( + PaymentScreen( provider = key.provider, paymentId = key.paymentId, onAcquireAsset = onAcquireAsset, 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/PaymentScreen.kt similarity index 99% rename from android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt rename to android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScreen.kt index adf4c28fcf..19c53e866c 100644 --- 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/PaymentScreen.kt @@ -69,7 +69,7 @@ import com.gemwallet.android.ui.theme.paddingSmall import uniffi.gemstone.PaymentException @Composable -fun PaymentScene( +fun PaymentScreen( provider: PaymentProviderName, paymentId: String, onAcquireAsset: (AcquireAssetAction, AssetId) -> Unit, From 45f2824a3f0952501b4f0d4d7666d38c89498528 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:35:23 +0300 Subject: [PATCH 51/53] Payments: dev routing guard, cancellable tasks, error handling Prevent enqueuing payment routes from URL actions unless developer mode is enabled by injecting UserConfig into PendingNavigationCoordinator. Switch MessageSigner creation and preview calls to runCatchingCancellable in PaymentViewModel so coroutine cancellation is respected. On iOS, log data-collection failures and surface a specific PaymentLinkError.dataCollection (added to PaymentLinkError) instead of throwing AnyError. Touched: PendingNavigationCoordinator.kt, PaymentViewModel.kt, PaymentDataCollectionScene.swift, PaymentLinkError.swift. --- .../gemwallet/android/PendingNavigationCoordinator.kt | 9 ++++++++- .../features/payment/viewmodels/PaymentViewModel.kt | 6 +++--- .../Payments/Scenes/PaymentDataCollectionScene.swift | 4 ++-- .../Sources/Payments/Types/PaymentLinkError.swift | 3 ++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt index bc602e6137..408bcd298a 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import com.gemwallet.android.data.repositories.config.UserConfig import com.gemwallet.android.ext.toPrimitives import com.gemwallet.android.ui.navigation.routes.PaymentRoute import uniffi.gemstone.UrlAction @@ -23,6 +24,7 @@ internal sealed interface PendingNavigation { class PendingNavigationCoordinator @Inject constructor( private val notificationNavigation: NotificationNavigation, + private val userConfig: UserConfig, ) { private val _pendingNavigation = MutableStateFlow(null) @@ -59,7 +61,12 @@ class PendingNavigationCoordinator @Inject constructor( } } is UrlAction.Payment -> { - replace(pendingIntent, PendingNavigation.Route(PaymentRoute(action.link.provider.toPrimitives(), action.link.id))) + val route = if (userConfig.developEnabled()) { + PendingNavigation.Route(PaymentRoute(action.link.provider.toPrimitives(), action.link.id)) + } else { + null + } + replace(pendingIntent, route) return } null -> Unit diff --git a/android/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 index 46c8008ff1..fb80e85c4b 100644 --- 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 @@ -227,8 +227,8 @@ class PaymentViewModel @Inject constructor( val simulation = runCatchingCancellable { simulationService.simulateSignMessage(action.message, paymentWalletConnectUrl()) }.getOrNull() - val signer = runCatching { MessageSigner(action.message) }.getOrNull() - val preview = signer?.let { runCatching { it.payloadPreview(simulation?.payload.orEmpty().map { field -> field.toGem() }) }.getOrNull() } + val signer = runCatchingCancellable { MessageSigner(action.message) }.getOrNull() + val preview = signer?.let { runCatchingCancellable { it.payloadPreview(simulation?.payload.orEmpty().map { field -> field.toGem() }) }.getOrNull() } return PaymentSceneState.SignMessage( merchant = current.quotes.merchant.toUIModel(), chain = chain, @@ -236,7 +236,7 @@ class PaymentViewModel @Inject constructor( quote = current.quote?.toUIModel(), price = current.quotes.price?.toPriceText(), expiresAt = current.quotes.expiresAt, - plainMessage = signer?.let { runCatching { it.plainPreview() }.getOrNull() }.orEmpty(), + plainMessage = signer?.let { runCatchingCancellable { it.plainPreview() }.getOrNull() }.orEmpty(), primaryPayloadFields = preview?.primary?.map { it.toPrimitives() }.orEmpty() .withExplorerLinks(chain, null), secondaryPayloadFields = preview?.secondary?.map { it.toPrimitives() }.orEmpty() diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift index 40fd6b94ac..7d2c00a6bc 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -2,7 +2,6 @@ import Components import Foundation -import Localization import PaymentService import Primitives import PrimitivesComponents @@ -44,7 +43,8 @@ extension PaymentDataCollectionScene { case Self.completeMessageType: finish(.success(.empty)) case Self.errorMessageType: - finish(.failure(AnyError(payload[Self.messageErrorKey] as? String ?? Localized.Errors.transferError))) + debugLog("payment data collection error: \(payload[Self.messageErrorKey] as? String ?? .empty)") + finish(.failure(PaymentLinkError.dataCollection)) default: break } diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift index c9dbc0fbb4..9ecdc9a15d 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift @@ -7,6 +7,7 @@ enum PaymentLinkError: Error, Equatable { case noQuotes case quoteUnavailable case invalidDataCollectionUrl + case dataCollection case unknownAsset } @@ -14,7 +15,7 @@ extension PaymentLinkError: LocalizedError { public var errorDescription: String? { switch self { case .noQuotes, .quoteUnavailable, .invalidDataCollectionUrl: Localized.Errors.notSupported - case .unknownAsset: Localized.Errors.errorOccurred + case .dataCollection, .unknownAsset: Localized.Errors.errorOccurred } } } From efe49d62bd0ebf3e9ffc2e78dc21be632bed619b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:24:03 +0300 Subject: [PATCH 52/53] Update PendingNavigationCoordinatorTest.kt --- .../PendingNavigationCoordinatorTest.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt b/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt index c66b28de55..bacf7e8e8a 100644 --- a/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt +++ b/android/app/src/test/kotlin/com/gemwallet/android/PendingNavigationCoordinatorTest.kt @@ -1,8 +1,11 @@ package com.gemwallet.android import android.content.Intent +import com.gemwallet.android.data.repositories.config.UserConfig import com.gemwallet.android.model.PushNotificationField +import com.gemwallet.android.ui.navigation.routes.PaymentRoute import com.gemwallet.android.ui.navigation.routes.ReferralRoute +import com.wallet.core.primitives.PaymentProviderName import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -16,6 +19,8 @@ import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import uniffi.gemstone.Deeplink +import uniffi.gemstone.GemPaymentLink +import uniffi.gemstone.GemPaymentProviderName import uniffi.gemstone.UrlAction import uniffi.gemstone.WalletConnectLink import uniffi.gemstone.urlAction @@ -23,7 +28,8 @@ import uniffi.gemstone.urlAction class PendingNavigationCoordinatorTest { private val notificationNavigation = mockk(relaxed = true) - private val coordinator = PendingNavigationCoordinator(notificationNavigation) + private val userConfig = mockk() + private val coordinator = PendingNavigationCoordinator(notificationNavigation, userConfig) @Before fun setUp() = mockkStatic("uniffi.gemstone.GemstoneKt") @@ -76,6 +82,26 @@ class PendingNavigationCoordinatorTest { assertEquals(listOf(ReferralRoute(code = "gemcoder")), routes) } + @Test + fun resolve_paymentLink_storesRouteOnlyInDeveloperMode() = runTest { + val uri = "https://pay.walletconnect.com/pay_1" + every { urlAction(uri) } returns UrlAction.Payment(GemPaymentLink(GemPaymentProviderName.WALLET_CONNECT_PAY, "pay_1")) + every { userConfig.developEnabled() } returns true + coordinator.setPendingIntentForTest(intent(uri = uri)) + + coordinator.resolve(NoOpWalletConnect) + + val routes = (coordinator.pendingNavigation.value as PendingNavigation.Route).routes + assertEquals(listOf(PaymentRoute(PaymentProviderName.WalletConnectPay, "pay_1")), routes) + + every { userConfig.developEnabled() } returns false + coordinator.setPendingIntentForTest(intent(uri = uri)) + + coordinator.resolve(NoOpWalletConnect) + + assertNull("payment links must not navigate outside developer mode", coordinator.pendingNavigation.value) + } + @Test fun resolve_unknownIntentWithoutNotificationPayload_clears() = runTest { val uri = "https://example.com/unknown" From a93e4d304b1a327c86d95359494fb6d3d8aa9d6a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:08:00 +0300 Subject: [PATCH 53/53] Add Payments docs and repo references Add docs/PAYMENTS.md with the payment URL decoding, hosted flow, action semantics, validation rules, platform differences, and code map. Update AGENTS.md to reference the Payments doc and core/skills/project-structure.md to list the new payment crate. Add a decision note in skills/decisions.md clarifying that payment protocol and providers live in Core (not the WalletConnect SDK) so apps share one implementation and validation surface. --- AGENTS.md | 1 + core/skills/project-structure.md | 1 + docs/PAYMENTS.md | 115 +++++++++++++++++++++++++++++++ skills/decisions.md | 4 ++ 4 files changed, 121 insertions(+) create mode 100644 docs/PAYMENTS.md diff --git a/AGENTS.md b/AGENTS.md index 234a84fdd7..05e28df138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ If a task spans multiple platforms, read every affected guide. Do not treat ever Cross-platform subsystem references live in [docs/](docs). Read the relevant one before changing that area: - [Device and subscriptions](docs/DEVICE_SUBSCRIPTIONS.md) — device registration, subscription sync, and the iOS/Android contract +- [Payments](docs/PAYMENTS.md) — payment link decoding, the action list both apps sign, and provider coverage - [Swapper](docs/SWAPPER.md) — quote flow, route preloading, and the shared route cache Core-owned subsystems (keystore, device and wallet authentication, WebSockets, provider coverage) are documented in [core/docs/](core/docs). diff --git a/core/skills/project-structure.md b/core/skills/project-structure.md index 2b1ff75b2c..da71235856 100644 --- a/core/skills/project-structure.md +++ b/core/skills/project-structure.md @@ -51,6 +51,7 @@ Individual `gem_*` crates for each blockchain with unified RPC client patterns: ### Cross-Chain Operations - `swapper/`: Standalone swap/exchange integration crate supporting DEX and CEX swaps across multiple chains +- `payment/`: Merchant payment providers; turns a payment link into the actions the wallet signs - `signer/`: Cryptographic signing operations for transactions across multiple blockchain types ### Data & Storage diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md new file mode 100644 index 0000000000..4b1cb246a8 --- /dev/null +++ b/docs/PAYMENTS.md @@ -0,0 +1,115 @@ +# Payments + +Scanned and pasted payment URLs decode to one of two shapes. Core owns the decoding and every rule; the apps own the screens and the signing. + +## Two shapes + +`PaymentURLDecoder::decode` returns a `Payment`, and the variant decides which flow runs. The split predates hosted payments and is where a provider with a different flow attaches. + +| URL | Shape | What runs | +|---|---|---| +| Bare address, BIP21, ERC-681 (`ethereum:`), TON (`ton://`) | `Request` | The ordinary send flow, recipient prefilled. No remote call. | +| Solana Pay transfer request | `Request` | As above. | +| Solana Pay transaction request | `Link(SolanaPay)` | Hosted flow below. | +| WalletConnect Pay | `Link(WalletConnectPay)` | Hosted flow below. | + +WalletConnect Pay is matched before the URL is split on `:`, so a payment link is never read as a WalletConnect pairing URI or a BIP21 address. + +Each shape reaches the wallet through its own scanner. The recipient scanner decodes to a `Request` and prefills the send form, and refuses a `Link` because a hosted payment is not a recipient. The wallet scanner starts the hosted flow. + +## Hosted flow + +Applies to `Payment::Link`. The four `PaymentService` calls are provider-agnostic; what a provider puts inside them is not. + +```mermaid +flowchart LR + Link["Payment::Link"] --> Options["get_options"] + Options --> Quote["Quote selection"] + Quote --> Prepare["get_prepared_payment"] + Prepare --> Sign["Sign actions in order"] + Sign --> Confirm["confirm"] +``` + +`get_options` returns `Quotes` or a settled `Outcome`, so a link that is already paid, expired, or cancelled never reaches quote selection. A quote may carry a `collect_data_url`; the app opens it in a web view and continues once the page reports completion. + +Only WalletConnect Pay implements this today, and parts of it are its shape rather than the contract. A quote list, a merchant price, an expiry, hosted data collection, and a multi-action list are things WalletConnect Pay happens to have. Solana Pay's transaction request is the counter-example already in the decoder: one GET for the label, one POST returning a single transaction, nothing to confirm afterwards. Such a provider fits by returning one quote and a one-element action list, with `confirm` reporting the outcome. + +`PaymentProviderName` is the whole provider surface, and `PaymentService::provider` returns `NotSupported` for anything unimplemented — Solana Pay transaction requests decode but are refused rather than half-handled. `PaymentProviderName::has_status` reports whether polling exists, for the same reason `is_relayed` is computed in core: capability belongs beside the provider, not at the call site. + +## Actions + +`get_prepared_payment` returns an ordered `Vec`. The apps execute them in order and collect one result string per action, positionally — result `n` belongs to action `n`. + +| Action | What the app does | Result | +|---|---|---| +| `SignMessage` | Signs an EIP-712 message after simulating it | Signature | +| `ApproveToken` | Broadcasts a token approval | Transaction hash | +| `SignTransaction` | Signs without broadcasting | Signature | +| `SendTransaction` | Broadcasts | Transaction hash | + +A payment is relayed when it contains no `SendTransaction`: the provider broadcasts on the wallet's behalf, so signing never yields a hash. + +## Validation + +`PreparedPayment::validate` runs in core before any action reaches a signing screen, so both apps get the same refusals: + +- A payment with no actions is rejected. +- An action on a chain the wallet has no address for is rejected. +- An `ApproveToken` on a chain other than the one the quote pays with is rejected, so a payment cannot use its approval step to reach another chain. + +Quote and price amounts arrive as decimal strings from the provider. They are machine strings, never human input — see [Decision Records](../skills/decisions.md#number-parsing-human-input-vs-machine-strings). + +## Failure and lifetime + +A relayed payment is written to the activity list as pending as soon as the last action is signed, keyed by the payment id, because there is no hash yet and the amount must not be invisible while the provider settles it. Reconciliation later swaps that id for the real hash. + +A failed `confirm` does not mean a failed payment. The actions are already signed and the provider may still settle it, so both apps report processing and let reconciliation decide. + +Quotes expire. Each app watches the expiry it was given and disables the action rather than sending a doomed request; an expired confirm is still rejected by the provider as the backstop. + +There is no execution journal. A payment interrupted between two actions is not resumable, and the user starts again from the link. + +## iOS and Android + +| | iOS | Android | +|---|---|---| +| Working entry point | wallet scanner | wallet scanner | +| Developer gate | `isDeveloperEnabled` | `userConfig.developEnabled()` | +| Flow driver | `PaymentManager.pay`, one async function awaiting each sheet | `PaymentViewModel`, a state machine over `ActivePayment` | +| Screen model | sheets via `PaymentSheetPresentable` | one `PaymentScreen` switching on `PaymentSceneState` | +| Action execution | `PaymentActionExecutor` → `SigningRequestInteractable` | `advance()` → `GemSignMessageOperator` / `ConfirmParams` | +| Pending record | `PaymentTransactionFactory` → `TransactionStateScheduler` | `RecordPayment` → `CreateTransaction` | +| Reconciliation | `TransactionStateService.paymentStateChanges` | `TransactionsRepositoryImpl.paymentStateChanges` | +| `Link` at recipient scanner | `notSupported` error | silently ignored | + +Both platforms can also route a payment link from an incoming URL, but neither claims the payment host: the iOS entitlement lists only `applinks:gemwallet.com`, and the Android manifest only `gemwallet.com` paths and the `wc`/`gem` schemes. Until the host is registered, that path is reachable only by an explicit intent. + +Deliberate difference today: iOS drives the flow as one linear `async` function awaiting each sheet, while Android drives it as a state machine whose single screen re-renders per step. Both consume the same ordered action list and produce the same positional results, so the difference is presentation only. + +Everything else is shared on purpose. Payments reuse the existing signing, simulation, confirmation, and transaction-update stacks rather than parallel ones — the same message-signing and confirm screens as WalletConnect, and the same update path, with both platforms implementing `paymentStateChanges` returning `TransactionChanges` carrying a hash change. Once recorded, a payment is an ordinary transaction. + +## Rules + +Changes on either platform must keep these true: + +- Provider capability is decided in core. No platform branches on `PaymentProviderName`. +- A `Request` goes to the send flow and a `Link` to the hosted flow. Neither scanner learns the other's shape. +- Action results stay positional and complete. Never reorder, skip, or substitute one. +- A relayed payment is recorded before the provider is confirmed, not after. +- A failed `confirm` is never reported to the user as a failed payment. +- Amounts from a provider are parsed as machine strings and validated before use. +- A new provider adds a `PaymentProviderName` arm and nothing on either platform. If its flow does not fit the four calls, widen them in core rather than adding a second path through the apps. + +Keep this document current in the same change when the decoded shapes, the action set, the validation rules, provider coverage, or the platform mechanisms above change. + +## Code map + +- [Link decoding](../core/crates/primitives/src/payment_decoder/decoder.rs) +- [Payment service](../core/crates/payment/src/service.rs) +- [Actions and validation](../core/crates/payment/src/action.rs) +- [WalletConnect Pay provider](../core/crates/payment/src/wallet_connect_pay/service.rs) +- [Gemstone bridge](../core/gemstone/src/payment/mod.rs) +- [iOS flow](../ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift) +- [iOS reconciliation](../ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift) +- [Android flow](../android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt) +- [Android reconciliation](../android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/transactions/TransactionsRepositoryImpl.kt) diff --git a/skills/decisions.md b/skills/decisions.md index 0fe30969d9..f94e98b4d3 100644 --- a/skills/decisions.md +++ b/skills/decisions.md @@ -26,6 +26,10 @@ Google, Huawei, Samsung, Solana, and Universal flavors exist to satisfy differen `core/` is tracked source in this repository, not a Git submodule. Changes to Core and the mobile apps should land together when shared behavior, generated models, or bindings need to stay aligned. +## Payment protocol lives in Core, not in the WalletConnect SDK + +Payment link decoding and the provider client are implemented in Core (`payment_decoder`, the `payment` crate) rather than delegated to Reown's WalletKit Pay APIs. Both apps therefore share one implementation, one set of validation rules, and one test suite, and neither depends on the iOS Reown fork gaining Pay support. `PaymentProviderName` is the only provider surface the apps see, so a second provider is a Core change alone. Do not move detection or the client back behind a platform SDK — it would split the rules across two codebases, which is what this design exists to avoid. See [Payments](../docs/PAYMENTS.md). + ## Number parsing: human input vs machine strings Amount strings come from two sources that must be parsed differently. Confusing them silently corrupts amounts on locales that group thousands with a dot (de, it, es, nl, pt-BR, da), where `"1.234"` means 1234, not 1.234. Pick the parser by the source of the string, never by convenience.