From 263620b66e46bd681019295f1739c3ac5bcf2713 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:28:48 +0300 Subject: [PATCH 01/27] core: move ApprovalData out of the swap module ApprovalData is an ERC-20 allowance, not a swap concept, and it was already used by yielder, Tron's signer and preload, contract_call_data and transaction_input_type. Move it to primitives::approval_data and give gemstone a models/approval module for its FFI record. --- .../wallet/core/primitives/ApprovalData.kt | 3 -- .../core/primitives/generated/ApprovalData.kt | 17 +++++++ .../primitives/generated/swap/Approval.kt | 8 --- .../core/primitives/swap/ApprovalData.kt | 3 ++ .../crates/gem_evm/src/signer/chain_signer.rs | 8 +-- core/crates/gem_tron/src/provider/preload.rs | 6 +-- .../gem_tron/src/signer/chain_signer.rs | 15 ++---- .../crates/gem_tron/src/signer/transaction.rs | 4 +- core/crates/primitives/src/approval_data.rs | 50 +++++++++++++++++++ .../primitives/src/contract_call_data.rs | 2 +- core/crates/primitives/src/lib.rs | 2 + core/crates/primitives/src/swap/approval.rs | 12 +---- .../primitives/src/testkit/swap_mock.rs | 4 +- .../primitives/src/transaction_input_type.rs | 7 +-- core/crates/primitives/src/transfer_amount.rs | 2 +- core/crates/swapper/src/across/provider.rs | 2 +- core/crates/swapper/src/approval/evm.rs | 2 +- core/crates/swapper/src/approval/mod.rs | 2 +- core/crates/swapper/src/approval/tron.rs | 2 +- .../swapper/src/mayan/tx_builder/evm.rs | 2 +- core/crates/swapper/src/models.rs | 3 +- core/crates/swapper/src/okx/quote_data.rs | 4 +- core/crates/swapper/src/relay/mapper.rs | 2 +- core/crates/swapper/src/relay/provider.rs | 2 +- core/crates/swapper/src/thorchain/provider.rs | 2 +- .../src/thorchain/quote_data_mapper.rs | 4 +- .../crates/swapper/src/uniswap/v3/provider.rs | 2 +- .../crates/swapper/src/uniswap/v4/provider.rs | 2 +- core/crates/yielder/src/yo/client.rs | 2 +- core/crates/yielder/src/yo/mapper.rs | 2 +- core/gemstone/src/models/approval.rs | 11 ++++ core/gemstone/src/models/mod.rs | 2 + core/gemstone/src/models/swap.rs | 12 +---- core/gemstone/src/models/transaction.rs | 31 ++++++++---- .../Sources/Generated/ApprovalData.swift | 19 +++++++ .../Sources/Generated/Swap/Approval.swift | 14 ------ 36 files changed, 168 insertions(+), 99 deletions(-) delete mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt create mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.kt create mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt create mode 100644 core/crates/primitives/src/approval_data.rs create mode 100644 core/gemstone/src/models/approval.rs create mode 100644 ios/Packages/Primitives/Sources/Generated/ApprovalData.swift diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt deleted file mode 100644 index de541d0a84..0000000000 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/ApprovalData.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.wallet.core.primitives - -typealias ApprovalData = com.wallet.core.primitives.swap.ApprovalData diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.kt new file mode 100644 index 0000000000..6dc574f7dc --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/ApprovalData.kt @@ -0,0 +1,17 @@ +/** + * Generated by typeshare 1.13.3 + */ + +package com.wallet.core.primitives + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +@Serializable +data class ApprovalData ( + val token: String, + val spender: String, + val value: String, + val isUnlimited: Boolean +) + diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt index 7b43f82806..faf8a64184 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/swap/Approval.kt @@ -7,14 +7,6 @@ package com.wallet.core.primitives.swap import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName -@Serializable -data class ApprovalData ( - val token: String, - val spender: String, - val value: String, - val isUnlimited: Boolean -) - @Serializable data class SwapProviderData ( val provider: SwapProvider, diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt new file mode 100644 index 0000000000..24c10ce3e7 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/swap/ApprovalData.kt @@ -0,0 +1,3 @@ +package com.wallet.core.primitives.swap + +typealias ApprovalData = com.wallet.core.primitives.ApprovalData diff --git a/core/crates/gem_evm/src/signer/chain_signer.rs b/core/crates/gem_evm/src/signer/chain_signer.rs index 24f2c86c01..b91b48bd70 100644 --- a/core/crates/gem_evm/src/signer/chain_signer.rs +++ b/core/crates/gem_evm/src/signer/chain_signer.rs @@ -172,7 +172,7 @@ fn sign_contract_call( call_data: Vec, gas_limit: u64, value: U256, - approval: Option<&primitives::swap::ApprovalData>, + approval: Option<&primitives::ApprovalData>, private_key: &[u8], ) -> Result, SignerError> { let params = TransactionParams::from_input(input)?; @@ -200,8 +200,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; use primitives::{ - Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, WalletConnectionSessionAppMetadata, - contract_call_data::ContractCallData, nft::NFTAsset, swap::*, + ApprovalData, Asset, Chain, ChainSigner, DelegationValidator, NFTType, SignerInput, TransactionAppMetadata, TransactionInputType, TransactionLoadMetadata, + TransferDataExtra, contract_call_data::ContractCallData, nft::NFTAsset, swap::*, }; #[test] @@ -338,7 +338,7 @@ mod tests { let signer = EvmChainSigner; let extra = TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]); let input = SignerInput::mock_evm( - TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), WalletConnectionSessionAppMetadata::mock(), extra), + TransactionInputType::Generic(Asset::from_chain(Chain::Ethereum), TransactionAppMetadata::mock(), extra), "0", 100000, ); diff --git a/core/crates/gem_tron/src/provider/preload.rs b/core/crates/gem_tron/src/provider/preload.rs index ff786e899a..f098f97539 100644 --- a/core/crates/gem_tron/src/provider/preload.rs +++ b/core/crates/gem_tron/src/provider/preload.rs @@ -8,9 +8,9 @@ use num_bigint::BigInt; use gem_client::Client; use number_formatter::BigNumberFormatter; use primitives::{ - Asset, AssetSubtype, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, - TransactionPreloadInput, TransferDataOutputAction, TronStakeData, decode_hex, - swap::{ApprovalData, SwapData, SwapQuoteData, SwapQuoteDataType}, + ApprovalData, Asset, AssetSubtype, FeePriority, FeeRate, GasPriceType, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, + TransactionLoadMetadata, TransactionPreloadInput, TransferDataOutputAction, TronStakeData, decode_hex, + swap::{SwapData, SwapQuoteData, SwapQuoteDataType}, }; use crate::{ diff --git a/core/crates/gem_tron/src/signer/chain_signer.rs b/core/crates/gem_tron/src/signer/chain_signer.rs index 02e653309c..657f575012 100644 --- a/core/crates/gem_tron/src/signer/chain_signer.rs +++ b/core/crates/gem_tron/src/signer/chain_signer.rs @@ -45,10 +45,10 @@ mod tests { use gem_hash::sha2::sha256; use num_bigint::BigInt; use primitives::{ - Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, SwapProvider, TransactionFee, - TransactionInputType, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, - WalletConnectionSessionAppMetadata, decode_hex, - swap::{ApprovalData, SwapData, SwapQuote, SwapQuoteData}, + ApprovalData, Asset, AssetId, AssetType, Chain, ChainSigner, Delegation, DelegationValidator, GasPriceType, Resource, SignerInput, StakeType, SwapProvider, + TransactionAppMetadata, TransactionFee, TransactionInputType, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, + TronUnfreeze, TronVote, decode_hex, + swap::{SwapData, SwapQuote, SwapQuoteData}, }; use serde_json::{Value, json}; @@ -655,12 +655,7 @@ mod tests { SignerInput::mock_tron( TransactionInputType::Generic( Asset::from_chain(Chain::Tron), - WalletConnectionSessionAppMetadata { - name: "Test".to_string(), - description: "Test".to_string(), - url: "https://example.com".to_string(), - icon: "https://example.com/icon.png".to_string(), - }, + TransactionAppMetadata::mock(), TransferDataExtra { data: Some(payload), output_type, diff --git a/core/crates/gem_tron/src/signer/transaction.rs b/core/crates/gem_tron/src/signer/transaction.rs index 0d7490263d..5bf03f6c0b 100644 --- a/core/crates/gem_tron/src/signer/transaction.rs +++ b/core/crates/gem_tron/src/signer/transaction.rs @@ -1,7 +1,7 @@ use gem_hash::sha2::sha256; use primitives::{ - SignerError, SignerInput, StakeType, TransactionLoadMetadata, TronStakeData, decode_hex, - swap::{ApprovalData, SwapQuoteData, SwapQuoteDataType}, + ApprovalData, SignerError, SignerInput, StakeType, TransactionLoadMetadata, TronStakeData, decode_hex, + swap::{SwapQuoteData, SwapQuoteDataType}, }; use signer::{SignatureScheme, Signer}; diff --git a/core/crates/primitives/src/approval_data.rs b/core/crates/primitives/src/approval_data.rs new file mode 100644 index 0000000000..9137312bbc --- /dev/null +++ b/core/crates/primitives/src/approval_data.rs @@ -0,0 +1,50 @@ +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +const UNLIMITED_APPROVE_BIT_WIDTHS: [u32; 2] = [160, 256]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[typeshare(swift = "Equatable, Hashable, Sendable")] +#[serde(rename_all = "camelCase")] +pub struct ApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub is_unlimited: bool, +} + +impl ApprovalData { + pub fn new(token: String, spender: String, value: String) -> Self { + Self { + is_unlimited: Self::is_unlimited_value(&value), + token, + spender, + value, + } + } + + fn is_unlimited_value(value: &str) -> bool { + let Ok(value) = value.parse::() else { + return false; + }; + UNLIMITED_APPROVE_BIT_WIDTHS.iter().any(|bits| value == (BigUint::from(1u8) << bits) - BigUint::from(1u8)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_approval_data_reads_an_unlimited_value() { + let unlimited = |value: &str| ApprovalData::new("0xtoken".to_string(), "0xspender".to_string(), value.to_string()).is_unlimited; + + assert!(unlimited("115792089237316195423570985008687907853269984665640564039457584007913129639935")); + assert!(unlimited("1461501637330902918203684832716283019655932542975")); + + assert!(!unlimited("1000000")); + assert!(!unlimited("0")); + assert!(!unlimited("not a number")); + } +} diff --git a/core/crates/primitives/src/contract_call_data.rs b/core/crates/primitives/src/contract_call_data.rs index 8da3af6b3b..4ebef09019 100644 --- a/core/crates/primitives/src/contract_call_data.rs +++ b/core/crates/primitives/src/contract_call_data.rs @@ -1,4 +1,4 @@ -use crate::swap::ApprovalData; +use crate::ApprovalData; use serde::{Deserialize, Serialize}; use typeshare::typeshare; diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index 0fa726bcfa..36b65e067d 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -249,6 +249,8 @@ pub mod priority; pub use self::priority::{PrioritizedProvider, sort_by_priority_then_amount}; pub mod swap_provider; pub use self::swap_provider::SwapProvider; +pub mod approval_data; +pub use self::approval_data::ApprovalData; pub mod swap; pub mod websocket; pub use self::websocket::{WebSocketPriceAction, WebSocketPriceActionType, WebSocketPricePayload}; diff --git a/core/crates/primitives/src/swap/approval.rs b/core/crates/primitives/src/swap/approval.rs index 60b04b4c30..3779dc85f5 100644 --- a/core/crates/primitives/src/swap/approval.rs +++ b/core/crates/primitives/src/swap/approval.rs @@ -1,17 +1,7 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use crate::{SwapProvider, TransactionState}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[typeshare(swift = "Equatable, Hashable, Sendable")] -#[serde(rename_all = "camelCase")] -pub struct ApprovalData { - pub token: String, - pub spender: String, - pub value: String, - pub is_unlimited: bool, -} +use crate::{ApprovalData, SwapProvider, TransactionState}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[typeshare(swift = "Equatable, Hashable, Sendable")] diff --git a/core/crates/primitives/src/testkit/swap_mock.rs b/core/crates/primitives/src/testkit/swap_mock.rs index 906aef4d10..25b7f06522 100644 --- a/core/crates/primitives/src/testkit/swap_mock.rs +++ b/core/crates/primitives/src/testkit/swap_mock.rs @@ -1,8 +1,8 @@ use super::signer_mock::TEST_EVM_RECIPIENT; use crate::{ - SwapProvider, + ApprovalData, SwapProvider, asset_constants::ETHEREUM_USDT_TOKEN_ID, - swap::{ApprovalData, Slippage, SlippageMode, SwapData, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteDataType}, + swap::{Slippage, SlippageMode, SwapData, SwapProviderData, SwapQuote, SwapQuoteData, SwapQuoteDataType}, }; impl Slippage { diff --git a/core/crates/primitives/src/transaction_input_type.rs b/core/crates/primitives/src/transaction_input_type.rs index 544efb20c2..ef5c7458ad 100644 --- a/core/crates/primitives/src/transaction_input_type.rs +++ b/core/crates/primitives/src/transaction_input_type.rs @@ -1,10 +1,11 @@ +use crate::ApprovalData; use crate::contract_call_data::ContractCallData; use crate::earn_type::EarnType; use crate::stake_type::StakeType; -use crate::swap::{ApprovalData, SwapData, SwapQuoteDataType}; +use crate::swap::{SwapData, SwapQuoteDataType}; use crate::transaction_fee::TransactionFee; use crate::transaction_load_metadata::TransactionLoadMetadata; -use crate::{Asset, GasPriceType, PerpetualType, SignerError, TransactionType, TransferDataExtra, WalletConnectionSessionAppMetadata, nft::NFTAsset, perpetual::AccountDataType}; +use crate::{Asset, GasPriceType, PerpetualType, SignerError, TransactionAppMetadata, TransactionType, TransferDataExtra, nft::NFTAsset, perpetual::AccountDataType}; use num_bigint::BigInt; use num_traits::ToPrimitive; use serde::{Deserialize, Serialize}; @@ -22,7 +23,7 @@ pub enum TransactionInputType { Swap(Asset, Asset, SwapData), Stake(Asset, StakeType), TokenApprove(Asset, ApprovalData), - Generic(Asset, WalletConnectionSessionAppMetadata, TransferDataExtra), + Generic(Asset, TransactionAppMetadata, TransferDataExtra), TransferNft(Asset, NFTAsset), Account(Asset, AccountDataType), Perpetual(Asset, PerpetualType), diff --git a/core/crates/primitives/src/transfer_amount.rs b/core/crates/primitives/src/transfer_amount.rs index 3ef5b22731..9a7fca58a1 100644 --- a/core/crates/primitives/src/transfer_amount.rs +++ b/core/crates/primitives/src/transfer_amount.rs @@ -152,7 +152,7 @@ impl TransferAmountInput { #[cfg(test)] mod tests { use super::*; - use crate::{AccountDataType, Delegation, DelegationValidator, PerpetualConfirmData, PerpetualDirection, Resource, nft::NFTAsset, swap::ApprovalData}; + use crate::{AccountDataType, ApprovalData, Delegation, DelegationValidator, PerpetualConfirmData, PerpetualDirection, Resource, nft::NFTAsset}; const SOLANA_MINIMUM_ACCOUNT_BALANCE: u64 = 890_880; const FEE: u64 = 5_000; diff --git a/core/crates/swapper/src/across/provider.rs b/core/crates/swapper/src/across/provider.rs index 2523c5df6d..646f8a93c0 100644 --- a/core/crates/swapper/src/across/provider.rs +++ b/core/crates/swapper/src/across/provider.rs @@ -35,7 +35,7 @@ use gem_evm::{ weth::WETH9, }; use num_bigint::{BigInt, Sign}; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, EVMChain}; use serde_serializers::biguint_from_hex_str; use std::{fmt::Debug, str::FromStr, sync::Arc}; diff --git a/core/crates/swapper/src/approval/evm.rs b/core/crates/swapper/src/approval/evm.rs index 127f7dbf00..45a1c72e16 100644 --- a/core/crates/swapper/src/approval/evm.rs +++ b/core/crates/swapper/src/approval/evm.rs @@ -17,7 +17,7 @@ use gem_evm::{ jsonrpc::{BlockParameter, EthereumRpc, TransactionObject}, permit2::IAllowanceTransfer, }; -use primitives::{Chain, swap::ApprovalData}; +use primitives::{ApprovalData, Chain}; use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, diff --git a/core/crates/swapper/src/approval/mod.rs b/core/crates/swapper/src/approval/mod.rs index 289ba954c1..101a8060aa 100644 --- a/core/crates/swapper/src/approval/mod.rs +++ b/core/crates/swapper/src/approval/mod.rs @@ -2,7 +2,7 @@ pub mod evm; pub mod tron; pub use evm::*; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; pub use tron::*; pub const DEFAULT_EVM_SWAP_GAS_LIMIT: u64 = 750_000; diff --git a/core/crates/swapper/src/approval/tron.rs b/core/crates/swapper/src/approval/tron.rs index 0133851371..601de18a1d 100644 --- a/core/crates/swapper/src/approval/tron.rs +++ b/core/crates/swapper/src/approval/tron.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use alloy_primitives::U256; use gem_tron::address::TronAddress; use num_bigint::BigUint; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use serde_serializers::biguint_from_hex_str; use crate::{SwapperError, alien::RpcProvider, client_factory::create_tron_client, models::ApprovalType}; diff --git a/core/crates/swapper/src/mayan/tx_builder/evm.rs b/core/crates/swapper/src/mayan/tx_builder/evm.rs index 8dd60d7324..2acd277790 100644 --- a/core/crates/swapper/src/mayan/tx_builder/evm.rs +++ b/core/crates/swapper/src/mayan/tx_builder/evm.rs @@ -6,7 +6,7 @@ use crate::{ use alloy_primitives::{Address, Bytes, U256}; use alloy_sol_types::{SolCall, sol}; use futures::try_join; -use primitives::{AssetId, ChainType, decode_hex, hex, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, ChainType, decode_hex, hex}; use std::{future::Future, str::FromStr, sync::Arc}; sol! { diff --git a/core/crates/swapper/src/models.rs b/core/crates/swapper/src/models.rs index f981011007..cb6a70ce52 100644 --- a/core/crates/swapper/src/models.rs +++ b/core/crates/swapper/src/models.rs @@ -1,9 +1,10 @@ use super::permit2_data::Permit2Data; use crate::{SwapperProvider, SwapperQuoteAsset, SwapperSlippage, config::DEFAULT_SLIPPAGE_BPS}; +pub use primitives::ApprovalData; pub use primitives::swap::SwapResult; use primitives::{ AssetId, Chain, - swap::{ApprovalData, SlippageMode, SwapProviderMode}, + swap::{SlippageMode, SwapProviderMode}, }; use serde::Serialize; use std::fmt::Debug; diff --git a/core/crates/swapper/src/okx/quote_data.rs b/core/crates/swapper/src/okx/quote_data.rs index 8bf847c9e2..26b3b152c2 100644 --- a/core/crates/swapper/src/okx/quote_data.rs +++ b/core/crates/swapper/src/okx/quote_data.rs @@ -14,8 +14,8 @@ use gem_encoding::encode_base64; use gem_evm::provider::preload_mapper::calculate_gas_limit_with_increase; use num_bigint::BigInt; use primitives::{ - Chain, ChainType, - swap::{ApprovalData, QuoteAsset, SwapQuoteData}, + ApprovalData, Chain, ChainType, + swap::{QuoteAsset, SwapQuoteData}, }; use std::{str::FromStr, sync::Arc}; diff --git a/core/crates/swapper/src/relay/mapper.rs b/core/crates/swapper/src/relay/mapper.rs index c8e54ff424..2c97c91dfb 100644 --- a/core/crates/swapper/src/relay/mapper.rs +++ b/core/crates/swapper/src/relay/mapper.rs @@ -1,4 +1,4 @@ -use primitives::{TransactionSwapMetadata, swap::ApprovalData}; +use primitives::{ApprovalData, TransactionSwapMetadata}; use super::{ asset::map_currency_to_asset_id, diff --git a/core/crates/swapper/src/relay/provider.rs b/core/crates/swapper/src/relay/provider.rs index 497ae2bbc2..f6a100eefb 100644 --- a/core/crates/swapper/src/relay/provider.rs +++ b/core/crates/swapper/src/relay/provider.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use alloy_primitives::U256; use async_trait::async_trait; use gem_client::Client; -use primitives::{AssetId, Chain, ChainType, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, ChainType, EVMChain}; use super::{ asset::{SUPPORTED_CHAINS, asset_to_currency}, diff --git a/core/crates/swapper/src/thorchain/provider.rs b/core/crates/swapper/src/thorchain/provider.rs index 227d0f4841..9ae713c69b 100644 --- a/core/crates/swapper/src/thorchain/provider.rs +++ b/core/crates/swapper/src/thorchain/provider.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, fmt::Debug, sync::Arc, time::Duration}; use alloy_primitives::U256; use async_trait::async_trait; use gem_client::Client; -use primitives::{AssetId, Chain, ChainType, MINUTE, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, ChainType, MINUTE}; use num_bigint::BigInt; diff --git a/core/crates/swapper/src/thorchain/quote_data_mapper.rs b/core/crates/swapper/src/thorchain/quote_data_mapper.rs index 301309919c..ba0a1bcb6f 100644 --- a/core/crates/swapper/src/thorchain/quote_data_mapper.rs +++ b/core/crates/swapper/src/thorchain/quote_data_mapper.rs @@ -7,7 +7,7 @@ use alloy_primitives::{Address, U256, hex::encode_prefixed as HexEncode}; use alloy_sol_types::SolCall; use gem_evm::thorchain::contracts::RouterInterface; use num_bigint::BigInt; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use super::{DEFAULT_DEPOSIT_GAS_LIMIT, asset::THORChainAsset, model::RouteData}; use crate::{SwapperQuoteData, approval::get_swap_gas_limit_with_approval}; @@ -51,7 +51,7 @@ pub fn map_quote_data( mod tests { use super::*; use crate::thorchain::{THORChainNetwork, chain::ChainName}; - use primitives::{Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID, swap::ApprovalData}; + use primitives::{ApprovalData, Chain, asset_constants::ETHEREUM_USDC_TOKEN_ID}; fn asset(chain: Chain, token_id: Option) -> THORChainAsset { THORChainAsset { diff --git a/core/crates/swapper/src/uniswap/v3/provider.rs b/core/crates/swapper/src/uniswap/v3/provider.rs index f33b5660f8..15ad8fd398 100644 --- a/core/crates/swapper/src/uniswap/v3/provider.rs +++ b/core/crates/swapper/src/uniswap/v3/provider.rs @@ -21,7 +21,7 @@ use gem_evm::{ uniswap::{command::encode_commands, path::get_base_pair}, }; use gem_jsonrpc::client::JsonRpcClient; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, EVMChain}; use std::{fmt, str::FromStr, sync::Arc}; use super::{DEFAULT_SWAP_GAS_LIMIT, UniversalRouterProvider, commands::build_commands, path::build_paths_with_routes}; diff --git a/core/crates/swapper/src/uniswap/v4/provider.rs b/core/crates/swapper/src/uniswap/v4/provider.rs index c2dfdd8308..e77882dfaf 100644 --- a/core/crates/swapper/src/uniswap/v4/provider.rs +++ b/core/crates/swapper/src/uniswap/v4/provider.rs @@ -27,7 +27,7 @@ use gem_evm::{ }; use gem_hash::keccak::keccak256; use gem_jsonrpc::client::JsonRpcClient; -use primitives::{AssetId, Chain, EVMChain, swap::ApprovalData}; +use primitives::{ApprovalData, AssetId, Chain, EVMChain}; use super::{ DEFAULT_SWAP_GAS_LIMIT, diff --git a/core/crates/yielder/src/yo/client.rs b/core/crates/yielder/src/yo/client.rs index ea413ce45e..555a58deb7 100644 --- a/core/crates/yielder/src/yo/client.rs +++ b/core/crates/yielder/src/yo/client.rs @@ -6,7 +6,7 @@ use gem_evm::jsonrpc::TransactionObject; use gem_evm::multicall3::{create_call3, decode_call3_return}; use gem_evm::rpc::EthereumClient; use gem_jsonrpc::alien::RpcClient; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use super::assets::YoAsset; use super::contract::IYoGateway; diff --git a/core/crates/yielder/src/yo/mapper.rs b/core/crates/yielder/src/yo/mapper.rs index d822bb358a..9cfad5cabb 100644 --- a/core/crates/yielder/src/yo/mapper.rs +++ b/core/crates/yielder/src/yo/mapper.rs @@ -2,7 +2,7 @@ use alloy_primitives::U256; use gem_evm::jsonrpc::TransactionObject; use gem_evm::u256::u256_to_biguint; use num_bigint::BigUint; -use primitives::swap::ApprovalData; +use primitives::ApprovalData; use primitives::{AssetBalance, AssetId, Chain, ContractCallData, DelegationBase, DelegationState, DelegationValidator, StakeProviderType, YieldProvider}; use super::assets::YoAsset; diff --git a/core/gemstone/src/models/approval.rs b/core/gemstone/src/models/approval.rs new file mode 100644 index 0000000000..3d9b4123a5 --- /dev/null +++ b/core/gemstone/src/models/approval.rs @@ -0,0 +1,11 @@ +pub use primitives::ApprovalData; + +pub type GemApprovalData = ApprovalData; + +#[uniffi::remote(Record)] +pub struct GemApprovalData { + pub token: String, + pub spender: String, + pub value: String, + pub is_unlimited: bool, +} diff --git a/core/gemstone/src/models/mod.rs b/core/gemstone/src/models/mod.rs index ab60e3c5db..c183778508 100644 --- a/core/gemstone/src/models/mod.rs +++ b/core/gemstone/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod approval; pub mod asset; pub mod balance; pub mod chain_address; @@ -15,6 +16,7 @@ pub mod swap; pub mod token; pub mod transaction; +pub use approval::*; pub use asset::*; pub use balance::*; pub use chain_address::*; diff --git a/core/gemstone/src/models/swap.rs b/core/gemstone/src/models/swap.rs index a9247f269f..5a3933570c 100644 --- a/core/gemstone/src/models/swap.rs +++ b/core/gemstone/src/models/swap.rs @@ -1,9 +1,9 @@ use crate::config::swap_config::get_swap_config; +use crate::models::approval::GemApprovalData; use primitives::swap::SwapQuoteDataType; -pub use primitives::swap::{ApprovalData, SwapData, SwapPriceImpact, SwapPriceImpactType, SwapProviderData, SwapQuote, SwapQuoteData}; +pub use primitives::swap::{SwapData, SwapPriceImpact, SwapPriceImpactType, SwapProviderData, SwapQuote, SwapQuoteData}; pub use swapper::SwapperProvider; -pub type GemApprovalData = ApprovalData; pub type GemSwapData = SwapData; pub type GemSwapPriceImpact = SwapPriceImpact; pub type GemSwapPriceImpactType = SwapPriceImpactType; @@ -12,14 +12,6 @@ pub type GemSwapQuote = SwapQuote; pub type GemSwapQuoteData = SwapQuoteData; pub type GemSwapQuoteDataType = SwapQuoteDataType; -#[uniffi::remote(Record)] -pub struct GemApprovalData { - pub token: String, - pub spender: String, - pub value: String, - pub is_unlimited: bool, -} - #[uniffi::remote(Enum)] pub enum GemSwapQuoteDataType { Contract, diff --git a/core/gemstone/src/models/transaction.rs b/core/gemstone/src/models/transaction.rs index 9960862bfe..83f0395292 100644 --- a/core/gemstone/src/models/transaction.rs +++ b/core/gemstone/src/models/transaction.rs @@ -1,17 +1,18 @@ use crate::address::checksum_address; use crate::models::*; +use approval::GemApprovalData; use chrono::{DateTime, Utc}; use num_bigint::BigInt; use primitives::contract_call_data::ContractCallData; use primitives::{ AccountDataType, Asset, Chain, EarnType, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualProvider, - PerpetualType, Resource, SignerInput, StakeType, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, - TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType, TransactionUpdate, TransferDataExtra, - TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata, + PerpetualType, Resource, SignerInput, StakeType, TransactionAppMetadata, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, + TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, TransactionStateRequest, TransactionSwapMetadata, TransactionType, + TransactionUpdate, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, UInt64, WalletConnectAppMetadata, perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData}, }; use std::collections::HashMap; -use swap::{GemApprovalData, GemSwapData}; +use swap::GemSwapData; use swapper::SwapperProvider; pub type GemPerpetualDirection = PerpetualDirection; @@ -202,16 +203,26 @@ pub enum GemEarnType { Withdraw(GemDelegation), } -pub type GemWalletConnectionSessionAppMetadata = WalletConnectionSessionAppMetadata; +pub type GemWalletConnectAppMetadata = WalletConnectAppMetadata; #[uniffi::remote(Record)] -pub struct GemWalletConnectionSessionAppMetadata { +pub struct GemWalletConnectAppMetadata { pub name: String, pub description: String, pub url: String, pub icon: String, } +pub type GemTransactionAppMetadata = TransactionAppMetadata; + +#[uniffi::remote(Record)] +pub struct GemTransactionAppMetadata { + pub name: String, + pub description: Option, + pub url: Option, + pub icon: Option, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct GemTransferDataExtra { pub to: String, @@ -310,7 +321,7 @@ pub enum GemTransactionInputType { }, Generic { asset: GemAsset, - metadata: GemWalletConnectionSessionAppMetadata, + app_metadata: GemTransactionAppMetadata, extra: GemTransferDataExtra, }, TransferNft { @@ -573,9 +584,9 @@ impl From for GemTransactionInputType { stake_type: stake_type.into(), }, TransactionInputType::TokenApprove(asset, approval_data) => GemTransactionInputType::TokenApprove { asset, approval_data }, - TransactionInputType::Generic(asset, metadata, extra) => GemTransactionInputType::Generic { + TransactionInputType::Generic(asset, app_metadata, extra) => GemTransactionInputType::Generic { asset, - metadata, + app_metadata, extra: extra.into(), }, TransactionInputType::TransferNft(asset, nft_asset) => GemTransactionInputType::TransferNft { asset, nft_asset }, @@ -727,7 +738,7 @@ impl From for TransactionInputType { is_unlimited: approval_data.is_unlimited, }, ), - GemTransactionInputType::Generic { asset, metadata, extra } => TransactionInputType::Generic(asset, metadata, extra.into()), + GemTransactionInputType::Generic { asset, app_metadata, extra } => TransactionInputType::Generic(asset, app_metadata, extra.into()), GemTransactionInputType::TransferNft { asset, nft_asset } => TransactionInputType::TransferNft(asset, nft_asset), GemTransactionInputType::Account { asset, account_type } => TransactionInputType::Account(asset, account_type), GemTransactionInputType::Perpetual { asset, perpetual_type } => TransactionInputType::Perpetual(asset, perpetual_type), diff --git a/ios/Packages/Primitives/Sources/Generated/ApprovalData.swift b/ios/Packages/Primitives/Sources/Generated/ApprovalData.swift new file mode 100644 index 0000000000..5944b7d2fe --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/ApprovalData.swift @@ -0,0 +1,19 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct ApprovalData: Codable, Equatable, Hashable, Sendable { + public let token: String + public let spender: String + public let value: String + public let isUnlimited: Bool + + public init(token: String, spender: String, value: String, isUnlimited: Bool) { + self.token = token + self.spender = spender + self.value = value + self.isUnlimited = isUnlimited + } +} diff --git a/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift b/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift index 42ce2c1120..915f39f6d1 100644 --- a/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift +++ b/ios/Packages/Primitives/Sources/Generated/Swap/Approval.swift @@ -4,20 +4,6 @@ import Foundation -public struct ApprovalData: Codable, Equatable, Hashable, Sendable { - public let token: String - public let spender: String - public let value: String - public let isUnlimited: Bool - - public init(token: String, spender: String, value: String, isUnlimited: Bool) { - self.token = token - self.spender = spender - self.value = value - self.isUnlimited = isUnlimited - } -} - public struct SwapProviderData: Codable, Equatable, Hashable, Sendable { public let provider: SwapProvider public let name: String From b99f36131602f0478f42c1ba05db1e83705d20bb Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:29:48 +0300 Subject: [PATCH 02/27] core: split WalletConnect request handling behind a feature The RPC decoders and the session half are two independent halves of the crate, and a payment provider needs only the first. Gate them as `request` and `session`, with base64 and url following their half, so a consumer that only decodes requests does not build the session code. --- core/crates/gem_wallet_connect/Cargo.toml | 9 ++++++-- core/crates/gem_wallet_connect/src/lib.rs | 25 ++++++++++++++++++++--- core/gemstone/Cargo.toml | 3 ++- 3 files changed, 31 insertions(+), 6 deletions(-) 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/lib.rs b/core/crates/gem_wallet_connect/src/lib.rs index c854ad5a0b..b6c49ac505 100644 --- a/core/crates/gem_wallet_connect/src/lib.rs +++ b/core/crates/gem_wallet_connect/src/lib.rs @@ -1,21 +1,40 @@ -pub mod accounts; +#[cfg(feature = "request")] pub mod actions; +#[cfg(feature = "request")] pub mod decode; +#[cfg(feature = "request")] pub mod request_handler; +#[cfg(feature = "request")] +pub mod sign_type; + +#[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 = "request")] +pub use sign_type::{SignDigestType, SignMessage}; + +#[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/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"] } From 84d8ca959c5780be62c9f9748a5ab15b59532ffe Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:30:04 +0300 Subject: [PATCH 03/27] core: give the signing payload types a neutral home SignMessage and the per-chain transaction payloads are what a wallet signs, and with payments a second producer speaks them. Move them to primitives::signing and drop the WalletConnect prefix, so the neutral payment action does not name a transport: WalletConnectTransaction becomes SignableTransaction, the name iOS already uses for it. --- .../WalletConnectSimulationService.kt | 4 +- .../bridge/viewmodels/model/WCRequest.kt | 38 +++--- .../core/primitives/generated/Signing.kt | 24 ++++ 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 | 4 - .../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/sign_type.rs | 19 --- .../gem_wallet_connect/src/validator.rs | 15 +- core/crates/primitives/src/lib.rs | 2 + core/crates/primitives/src/signing.rs | 110 +++++++++++++++ core/gemstone/src/signer/chain.rs | 16 +-- core/gemstone/src/wallet_connect/mod.rs | 128 ++++++++++-------- .../gemstone/src/wallet_connect/simulation.rs | 32 ++--- .../src/wallet_connect/simulation_client.rs | 40 +++--- .../WalletConnectorService.swift | 21 ++- .../Sources/Generated/Signing.swift | 33 +++++ 22 files changed, 381 insertions(+), 317 deletions(-) create mode 100644 android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/Signing.kt delete mode 100644 core/crates/gem_wallet_connect/src/sign_type.rs create mode 100644 core/crates/primitives/src/signing.rs create mode 100644 ios/Packages/Primitives/Sources/Generated/Signing.swift 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/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..9673c070a1 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 @@ -13,7 +13,7 @@ import com.gemwallet.android.ui.models.withExplorerLinks import com.gemwallet.android.data.repositories.bridge.WalletConnectSessionRequest import com.wallet.core.primitives.Account import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import uniffi.gemstone.MessageSigner import com.gemwallet.android.blockchain.services.GemSignMessageOperator import com.gemwallet.android.blockchain.gemstone.toGem @@ -23,14 +23,14 @@ import uniffi.gemstone.TransferDataOutputType import uniffi.gemstone.WalletConnect import uniffi.gemstone.WalletConnectAction import uniffi.gemstone.WalletConnectResponseType -import uniffi.gemstone.WalletConnectTransaction -import uniffi.gemstone.WalletConnectTransactionType +import uniffi.gemstone.SignableTransaction +import uniffi.gemstone.SignableTransactionType import java.math.BigInteger sealed class WCRequest( internal val sessionRequest: WalletConnectSessionRequest, internal val account: Account, - private val appMetadata: WalletConnectionSessionAppMetadata, + private val appMetadata: WalletConnectAppMetadata, ) { internal val walletConnect = WalletConnect() @@ -49,7 +49,7 @@ sealed class WCRequest( class SignMessage( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val action: WalletConnectAction.SignMessage, val simulation: SimulationResult, private val explorerName: String?, @@ -100,10 +100,10 @@ sealed class WCRequest( abstract class Transaction( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val isSendable: Boolean, val inputType: ConfirmParams.TransferParams.InputType, - val transactionType: WalletConnectTransactionType, + val transactionType: SignableTransactionType, val data: String, val simulation: SimulationResult, ) : WCRequest(sessionRequest, account, appMetadata) { @@ -116,8 +116,8 @@ sealed class WCRequest( abstract class Signing( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, - transactionType: WalletConnectTransactionType, + appMetadata: WalletConnectAppMetadata, + transactionType: SignableTransactionType, data: String, simulation: SimulationResult, ) : Transaction( @@ -134,7 +134,7 @@ sealed class WCRequest( class SignTransaction( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val action: WalletConnectAction.SignTransaction, simulation: SimulationResult, ) : Signing( @@ -153,8 +153,8 @@ sealed class WCRequest( class SignAllTransactions( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, - transactionType: WalletConnectTransactionType, + appMetadata: WalletConnectAppMetadata, + transactionType: SignableTransactionType, data: String, simulation: SimulationResult, ) : Signing( @@ -173,7 +173,7 @@ sealed class WCRequest( class SendTransaction( sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, val action: WalletConnectAction.SendTransaction, simulation: SimulationResult, ) : Transaction( @@ -198,13 +198,13 @@ 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( + is SignableTransaction.Ethereum -> Generic( requestId = request.requestId.toString(), asset = asset, from = request.account, @@ -220,7 +220,7 @@ private fun WalletConnectTransaction.map( isSendable = isSendable, decodedTransactionType = transactionType.toPrimitives(), ) - is WalletConnectTransaction.Solana -> Generic( + is SignableTransaction.Solana -> Generic( requestId = request.requestId.toString(), asset = asset, from = request.account, @@ -238,7 +238,7 @@ private fun WalletConnectTransaction.map( amount = BigInteger.ZERO, isSendable = isSendable, ) - is WalletConnectTransaction.Sui -> Generic( + is SignableTransaction.Sui -> Generic( requestId = request.requestId.toString(), asset = asset, from = request.account, @@ -256,7 +256,7 @@ private fun WalletConnectTransaction.map( amount = BigInteger.ZERO, isSendable = isSendable, ) - is WalletConnectTransaction.Ton -> Generic( + is SignableTransaction.Ton -> Generic( requestId = request.requestId.toString(), asset = asset, from = request.account, @@ -274,7 +274,7 @@ private fun WalletConnectTransaction.map( amount = BigInteger.ZERO, isSendable = isSendable, ) - is WalletConnectTransaction.Tron -> Generic( + is SignableTransaction.Tron -> Generic( requestId = request.requestId.toString(), asset = asset, memo = data, 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/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 b6c49ac505..2277fb4852 100644 --- a/core/crates/gem_wallet_connect/src/lib.rs +++ b/core/crates/gem_wallet_connect/src/lib.rs @@ -4,8 +4,6 @@ pub mod actions; pub mod decode; #[cfg(feature = "request")] pub mod request_handler; -#[cfg(feature = "request")] -pub mod sign_type; #[cfg(feature = "session")] pub mod accounts; @@ -27,8 +25,6 @@ pub use actions::*; pub use decode::decode_sign_message; #[cfg(feature = "request")] pub use request_handler::WalletConnectRequestHandler; -#[cfg(feature = "request")] -pub use sign_type::{SignDigestType, SignMessage}; #[cfg(feature = "session")] pub use response_handler::WalletConnectResponseHandler; diff --git a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs index 876fc70bde..dd28bac820 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/ethereum.rs @@ -1,6 +1,7 @@ -use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, ValueAccess, WCEthereumTransaction, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; pub struct EthereumRequestHandler; @@ -49,7 +50,7 @@ impl EthereumRequestHandler { Ok(WalletConnectAction::SignTransaction { chain, - transaction_type: WalletConnectTransactionType::Ethereum, + transaction_type: SignableTransactionType::Ethereum, data, }) } @@ -59,15 +60,15 @@ impl EthereumRequestHandler { Ok(WalletConnectAction::SendTransaction { chain, - transaction_type: WalletConnectTransactionType::Ethereum, + transaction_type: SignableTransactionType::Ethereum, data, }) } - pub fn decode_send_transaction(data: String) -> Result { + pub fn decode_send_transaction(data: String) -> Result { let transaction: WCEthereumTransaction = serde_json::from_str(&data).map_err(|e| e.to_string())?; let transaction_type = gem_evm::transaction::decode_transaction_type(transaction.data.as_deref()); - Ok(WalletConnectTransaction::Ethereum { + Ok(SignableTransaction::Ethereum { data: transaction.into(), transaction_type, }) @@ -199,7 +200,7 @@ mod tests { WalletConnectAction::SendTransaction { chain, transaction_type, data } => { let transaction: Value = serde_json::from_str(&data).unwrap(); assert_eq!(chain, Chain::Ethereum); - assert_eq!(transaction_type, WalletConnectTransactionType::Ethereum); + assert_eq!(transaction_type, SignableTransactionType::Ethereum); assert_eq!(transaction["from"], "0xsender"); assert_eq!(transaction["chainId"], "0x1"); } diff --git a/core/crates/gem_wallet_connect/src/request_handler/mod.rs b/core/crates/gem_wallet_connect/src/request_handler/mod.rs index 522adc410f..36e1988f11 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/mod.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/mod.rs @@ -4,9 +4,10 @@ mod sui; mod ton; mod tron; -use crate::actions::{WalletConnectAction, WalletConnectChainOperation, WalletConnectTransaction, WalletConnectTransactionType}; +use crate::actions::{WalletConnectAction, WalletConnectChainOperation}; use ethereum::EthereumRequestHandler; use primitives::{Chain, ChainType, ValueAccess, WalletConnectCAIP2, WalletConnectRequest, WalletConnectionMethods, hex}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; use solana::SolanaRequestHandler; use sui::SuiRequestHandler; @@ -20,6 +21,10 @@ impl WalletConnectRequestHandler { let WalletConnectRequest { method, params, chain_id, domain, .. } = request; + Self::parse_action(method, params, chain_id, domain) + } + + pub fn parse_action(method: String, params: String, chain_id: Option, domain: String) -> Result { let method_name = method; let method = match serde_json::from_value::(serde_json::Value::String(method_name.clone())) { Ok(m) => m, @@ -73,13 +78,13 @@ impl WalletConnectRequestHandler { } } - pub fn decode_send_transaction(transaction_type: WalletConnectTransactionType, data: String) -> Result { + pub fn decode_send_transaction(transaction_type: SignableTransactionType, data: String) -> Result { match transaction_type { - WalletConnectTransactionType::Ethereum => EthereumRequestHandler::decode_send_transaction(data), - WalletConnectTransactionType::Solana { output_type } => SolanaRequestHandler::decode_send_transaction(data, output_type), - WalletConnectTransactionType::Sui { output_type } => SuiRequestHandler::decode_send_transaction(data, output_type), - WalletConnectTransactionType::Ton { output_type } => TonRequestHandler::decode_send_transaction(data, output_type), - WalletConnectTransactionType::Tron { output_type } => TronRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Ethereum => EthereumRequestHandler::decode_send_transaction(data), + SignableTransactionType::Solana { output_type } => SolanaRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Sui { output_type } => SuiRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Ton { output_type } => TonRequestHandler::decode_send_transaction(data, output_type), + SignableTransactionType::Tron { output_type } => TronRequestHandler::decode_send_transaction(data, output_type), } } @@ -104,8 +109,8 @@ impl WalletConnectRequestHandler { #[cfg(test)] mod tests { use super::*; - use crate::sign_type::SignDigestType; use gem_evm::testkit::eip712_mock::mock_eip712_json; + use primitives::SignDigestType; use primitives::TransferDataOutputType; #[test] @@ -256,7 +261,7 @@ mod tests { assert_eq!(transactions.len(), 1); let decoded = WalletConnectRequestHandler::decode_send_transaction(transaction_type.clone(), transactions[0].clone()).unwrap(); match decoded { - WalletConnectTransaction::Solana { data, output_type } => { + SignableTransaction::Solana { data, output_type } => { assert!(data.transaction.starts_with("AQAAAAAAAAA")); assert_eq!(output_type, TransferDataOutputType::EncodedTransaction); } @@ -271,13 +276,13 @@ mod tests { fn test_decode_ethereum_transaction_accepts_numeric_and_hex_string_chain_id() { for (chain_id, expected) in [(r#"4663"#, 4663), (r#""0x1237""#, 4663)] { let decoded = WalletConnectRequestHandler::decode_send_transaction( - WalletConnectTransactionType::Ethereum, + SignableTransactionType::Ethereum, format!(r#"{{"chainId":{chain_id},"from":"0xsender","to":"0xrouter","data":"0x1234","value":"0x0"}}"#), ) .unwrap(); match decoded { - WalletConnectTransaction::Ethereum { data, transaction_type } => { + SignableTransaction::Ethereum { data, transaction_type } => { assert_eq!(data.chain_id, Some(expected)); assert_eq!(data.data, Some("0x1234".to_string())); assert_eq!(transaction_type, primitives::TransactionType::SmartContractCall); @@ -297,13 +302,13 @@ mod tests { ]; for (data, expected) in cases { let decoded = WalletConnectRequestHandler::decode_send_transaction( - WalletConnectTransactionType::Ethereum, + SignableTransactionType::Ethereum, format!(r#"{{"from":"0xsender","to":"0xrouter","data":{data},"value":"0x0"}}"#), ) .unwrap(); match decoded { - WalletConnectTransaction::Ethereum { transaction_type, .. } => assert_eq!(transaction_type, expected), + SignableTransaction::Ethereum { transaction_type, .. } => assert_eq!(transaction_type, expected), _ => panic!("Expected Ethereum transaction"), } } diff --git a/core/crates/gem_wallet_connect/src/request_handler/solana.rs b/core/crates/gem_wallet_connect/src/request_handler/solana.rs index 8cbe0b0385..bd56f7690c 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/solana.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/solana.rs @@ -1,6 +1,7 @@ -use crate::actions::{WCSolanaTransactionData, WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType, SolanaTransactionData}; use serde_json::Value; pub struct SolanaRequestHandler; @@ -31,7 +32,7 @@ impl SolanaRequestHandler { Ok(WalletConnectAction::SignTransaction { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::Signature, }, data: params.to_string(), @@ -43,22 +44,22 @@ impl SolanaRequestHandler { Ok(WalletConnectAction::SendTransaction { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { let json: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; let transaction = json .get("transaction") .and_then(|value| value.as_str()) .ok_or_else(|| "Missing transaction field".to_string())? .to_string(); - Ok(WalletConnectTransaction::Solana { - data: WCSolanaTransactionData { transaction }, + Ok(SignableTransaction::Solana { + data: SolanaTransactionData { transaction }, output_type, }) } @@ -79,7 +80,7 @@ impl SolanaRequestHandler { Ok(WalletConnectAction::SignAllTransactions { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::EncodedTransaction, }, transactions, @@ -112,7 +113,7 @@ mod tests { SolanaRequestHandler::parse_sign_transaction(Chain::Solana, params).unwrap(), WalletConnectAction::SignTransaction { chain: Chain::Solana, - transaction_type: WalletConnectTransactionType::Solana { + transaction_type: SignableTransactionType::Solana { output_type: TransferDataOutputType::Signature, }, data: expected_data, diff --git a/core/crates/gem_wallet_connect/src/request_handler/sui.rs b/core/crates/gem_wallet_connect/src/request_handler/sui.rs index eacba2f239..9c54f2362a 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/sui.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/sui.rs @@ -1,6 +1,7 @@ -use crate::actions::{WCSuiTransactionData, WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType, SuiTransactionData}; use serde_json::Value; pub struct SuiRequestHandler; @@ -31,7 +32,7 @@ impl SuiRequestHandler { Ok(WalletConnectAction::SignTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::Signature, }, data: params.to_string(), @@ -43,14 +44,14 @@ impl SuiRequestHandler { Ok(WalletConnectAction::SendTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { let json: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; let transaction = json .get("transaction") @@ -63,8 +64,8 @@ impl SuiRequestHandler { .and_then(|value| value.as_str()) .unwrap_or_default() .to_string(); - Ok(WalletConnectTransaction::Sui { - data: WCSuiTransactionData { transaction, wallet_address }, + Ok(SignableTransaction::Sui { + data: SuiTransactionData { transaction, wallet_address }, output_type, }) } @@ -95,7 +96,7 @@ mod tests { SuiRequestHandler::parse_sign_transaction(Chain::Sui, params).unwrap(), WalletConnectAction::SignTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::Signature, }, data: expected_data, @@ -111,7 +112,7 @@ mod tests { SuiRequestHandler::parse_send_transaction(Chain::Sui, params).unwrap(), WalletConnectAction::SendTransaction { chain: Chain::Sui, - transaction_type: WalletConnectTransactionType::Sui { + transaction_type: SignableTransactionType::Sui { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, diff --git a/core/crates/gem_wallet_connect/src/request_handler/ton.rs b/core/crates/gem_wallet_connect/src/request_handler/ton.rs index 00cb034a7a..17c1725205 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/ton.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/ton.rs @@ -1,7 +1,8 @@ -use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; use gem_ton::signer::TonSignMessageData; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; pub struct TonRequestHandler; @@ -36,15 +37,15 @@ impl TonRequestHandler { params.get_value("messages")?; Ok(WalletConnectAction::SendTransaction { chain: Chain::Ton, - transaction_type: WalletConnectTransactionType::Ton { + transaction_type: SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { - Ok(WalletConnectTransaction::Ton { data, output_type }) + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + Ok(SignableTransaction::Ton { data, output_type }) } } diff --git a/core/crates/gem_wallet_connect/src/request_handler/tron.rs b/core/crates/gem_wallet_connect/src/request_handler/tron.rs index 6254fa283c..988c2bbc9f 100644 --- a/core/crates/gem_wallet_connect/src/request_handler/tron.rs +++ b/core/crates/gem_wallet_connect/src/request_handler/tron.rs @@ -1,6 +1,7 @@ -use crate::actions::{WalletConnectAction, WalletConnectTransaction, WalletConnectTransactionType}; -use crate::sign_type::SignDigestType; +use crate::actions::WalletConnectAction; +use primitives::SignDigestType; use primitives::{Chain, TransferDataOutputType, ValueAccess, WalletConnectionMethods}; +use primitives::{SignableTransaction, SignableTransactionType}; use serde_json::Value; pub struct TronRequestHandler; @@ -30,7 +31,7 @@ impl TronRequestHandler { Ok(WalletConnectAction::SignTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), @@ -42,15 +43,15 @@ impl TronRequestHandler { Ok(WalletConnectAction::SendTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: params.to_string(), }) } - pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { - Ok(WalletConnectTransaction::Tron { data, output_type }) + pub fn decode_send_transaction(data: String, output_type: TransferDataOutputType) -> Result { + Ok(SignableTransaction::Tron { data, output_type }) } } @@ -79,7 +80,7 @@ mod tests { TronRequestHandler::parse_sign_transaction(Chain::Tron, params).unwrap(), WalletConnectAction::SignTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, @@ -95,7 +96,7 @@ mod tests { TronRequestHandler::parse_send_transaction(Chain::Tron, params).unwrap(), WalletConnectAction::SendTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, @@ -118,7 +119,7 @@ mod tests { action, WalletConnectAction::SendTransaction { chain: Chain::Tron, - transaction_type: WalletConnectTransactionType::Tron { + transaction_type: SignableTransactionType::Tron { output_type: TransferDataOutputType::EncodedTransaction, }, data: expected_data, diff --git a/core/crates/gem_wallet_connect/src/sign_type.rs b/core/crates/gem_wallet_connect/src/sign_type.rs deleted file mode 100644 index 1e599c6ea2..0000000000 --- a/core/crates/gem_wallet_connect/src/sign_type.rs +++ /dev/null @@ -1,19 +0,0 @@ -use primitives::Chain; - -#[derive(Debug, Clone, PartialEq)] -pub enum SignDigestType { - Eip191, - Eip712, - Base58, - SuiPersonal, - Siwe, - TonPersonal, - TronPersonal, -} - -#[derive(Debug)] -pub struct SignMessage { - pub chain: Chain, - pub sign_type: SignDigestType, - pub data: Vec, -} diff --git a/core/crates/gem_wallet_connect/src/validator.rs b/core/crates/gem_wallet_connect/src/validator.rs index f49c8ae956..8daa21a036 100644 --- a/core/crates/gem_wallet_connect/src/validator.rs +++ b/core/crates/gem_wallet_connect/src/validator.rs @@ -1,10 +1,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use crate::actions::WalletConnectTransactionType; -use crate::sign_type::SignDigestType; use gem_evm::domain::host_only; use gem_evm::siwe::SiweMessage; use primitives::Chain; +use primitives::{SignDigestType, SignableTransactionType}; pub struct SignMessageValidation<'a> { pub chain: Chain, @@ -65,8 +64,8 @@ fn validate_session_domain(message: &SiweMessage, session_domain: &str) -> Resul Ok(()) } -pub fn validate_send_transaction(transaction_type: &WalletConnectTransactionType, data: &str) -> Result<(), String> { - let WalletConnectTransactionType::Ton { .. } = transaction_type else { +pub fn validate_send_transaction(transaction_type: &SignableTransactionType, data: &str) -> Result<(), String> { + let SignableTransactionType::Ton { .. } = transaction_type else { return Ok(()); }; @@ -135,7 +134,7 @@ mod tests { #[test] fn test_validate_ton_send_transaction_expired() { - let ton_type = WalletConnectTransactionType::Ton { + let ton_type = SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }; assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 1234567890, "messages": []}"#).is_err()); @@ -143,7 +142,7 @@ mod tests { #[test] fn test_validate_ton_send_transaction_valid() { - let ton_type = WalletConnectTransactionType::Ton { + let ton_type = SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }; assert!(validate_send_transaction(&ton_type, r#"{"valid_until": 9999999999, "messages": []}"#).is_ok()); @@ -151,12 +150,12 @@ mod tests { #[test] fn test_validate_ethereum_send_transaction_always_ok() { - assert!(validate_send_transaction(&WalletConnectTransactionType::Ethereum, "{}").is_ok()); + assert!(validate_send_transaction(&SignableTransactionType::Ethereum, "{}").is_ok()); } #[test] fn test_validate_ton_send_transaction_no_expiry() { - let ton_type = WalletConnectTransactionType::Ton { + let ton_type = SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }; assert!(validate_send_transaction(&ton_type, r#"{"messages": []}"#).is_ok()); diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index 36b65e067d..554d6302a6 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -183,6 +183,8 @@ pub mod wallet_connect_namespace; pub use self::wallet_connect_namespace::WalletConnectCAIP2; pub mod wallet_connect; pub use self::wallet_connect::{WCEthereumTransaction, WCTonMessage, WalletConnectLink, WalletConnectRequest}; +pub mod signing; +pub use self::signing::{EthereumTransactionData, SignDigestType, SignMessage, SignableTransaction, SignableTransactionType, SolanaTransactionData, SuiTransactionData}; pub mod account; pub use self::account::Account; pub mod wallet; 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/gemstone/src/signer/chain.rs b/core/gemstone/src/signer/chain.rs index 1ea5e5bf93..ae61ec9c6a 100644 --- a/core/gemstone/src/signer/chain.rs +++ b/core/gemstone/src/signer/chain.rs @@ -208,8 +208,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::{TEST_EVM_RECIPIENT, TEST_PRIVATE_KEY}; use primitives::{ - DelegationValidator, StakeType, SwapProvider, TransactionFee, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputType, - WalletConnectionSessionAppMetadata, contract_call_data::ContractCallData, nft::NFTAsset, + DelegationValidator, StakeType, SwapProvider, TransactionAppMetadata, TransactionFee, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, + TransferDataOutputType, contract_call_data::ContractCallData, nft::NFTAsset, }; #[test] @@ -226,13 +226,13 @@ mod tests { fn test_sign_input_ton_wallet_connect() { let request = include_str!("../../../crates/gem_ton/testdata/wallet_connect_dedust_send_message.json"); let transaction = gem_wallet_connect::WalletConnectRequestHandler::decode_send_transaction( - gem_wallet_connect::WalletConnectTransactionType::Ton { + primitives::SignableTransactionType::Ton { output_type: TransferDataOutputType::EncodedTransaction, }, request.to_string(), ) .unwrap(); - let gem_wallet_connect::WalletConnectTransaction::Ton { data, .. } = transaction else { + let primitives::SignableTransaction::Ton { data, .. } = transaction else { panic!("expected TON transaction"); }; let private_key = hex::decode("1e9d38b5274152a78dff1a86fa464ceadc1f4238ca2c17060c3c507349424a34").unwrap(); @@ -265,18 +265,14 @@ mod tests { assert_eq!(sign_one(token.clone()), vec![signer.sign_token_transfer(token, key.clone()).unwrap()]); // TokenApprove must route to sign_token_approval, not sign_token_transfer (the resolved iOS divergence). - let approve: GemSignerInput = SignerInput::mock_evm(TransactionInputType::TokenApprove(Asset::mock(), primitives::swap::ApprovalData::mock()), "0", 65000).into(); + let approve: GemSignerInput = SignerInput::mock_evm(TransactionInputType::TokenApprove(Asset::mock(), primitives::ApprovalData::mock()), "0", 65000).into(); assert_eq!(sign_one(approve.clone()), vec![signer.sign_token_approval(approve, key.clone()).unwrap()]); let nft: GemSignerInput = SignerInput::mock_evm(TransactionInputType::TransferNft(Asset::mock(), NFTAsset::mock()), "0", 100000).into(); assert_eq!(sign_one(nft.clone()), vec![signer.sign_nft_transfer(nft, key.clone()).unwrap()]); let generic: GemSignerInput = SignerInput::mock_evm( - TransactionInputType::Generic( - Asset::mock(), - WalletConnectionSessionAppMetadata::mock(), - TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd]), - ), + TransactionInputType::Generic(Asset::mock(), TransactionAppMetadata::mock(), TransferDataExtra::mock_encoded_transaction(vec![0xab, 0xcd])), "0", 100000, ) diff --git a/core/gemstone/src/wallet_connect/mod.rs b/core/gemstone/src/wallet_connect/mod.rs index 31717480e9..fc313ee9ca 100644 --- a/core/gemstone/src/wallet_connect/mod.rs +++ b/core/gemstone/src/wallet_connect/mod.rs @@ -1,12 +1,14 @@ use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectAction as WcWalletConnectAction, - WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler, - WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectTransaction as WcWalletConnectTransaction, - WalletConnectTransactionType as WcWalletConnectTransactionType, WalletConnectVerifier, config_session_properties, + WalletConnectAction as WcWalletConnectAction, WalletConnectChainOperation as WcWalletConnectChainOperation, WalletConnectRequestHandler, WalletConnectResponseHandler, + WalletConnectResponseType as WcWalletConnectResponseType, WalletConnectVerifier, config_session_properties, }; use primitives::{ Account, Chain, ChainAddress, TransactionType, TransferDataOutputType, WCEthereumTransaction, WalletConnectCAIP2, WalletConnectLink, WalletConnectRequest, - WalletConnectionVerificationStatus, + WalletConnectionVerificationStatus, wallet_connector::short_name, +}; +use primitives::{ + EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignMessage as CoreSignMessage, SignableTransaction as CoreSignableTransaction, + SignableTransactionType as CoreSignableTransactionType, }; use std::collections::HashMap; use std::str::FromStr; @@ -37,7 +39,7 @@ pub enum WalletConnectLink { } #[derive(Debug, Clone, uniffi::Record)] -pub struct WCEthereumTransactionData { +pub struct EthereumTransactionData { pub chain_id: Option, pub from: String, pub to: String, @@ -60,12 +62,12 @@ pub struct Account { } #[derive(Debug, Clone, uniffi::Record)] -pub struct WCSolanaTransactionData { +pub struct SolanaTransactionData { pub transaction: String, } #[derive(Debug, Clone, uniffi::Record)] -pub struct WCSuiTransactionData { +pub struct SuiTransactionData { pub transaction: String, pub wallet_address: String, } @@ -79,17 +81,17 @@ pub enum WalletConnectAction { }, SignTransaction { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, data: String, }, SignAllTransactions { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, transactions: Vec, }, SendTransaction { chain: Chain, - transaction_type: WalletConnectTransactionType, + transaction_type: SignableTransactionType, data: String, }, ChainOperation { @@ -104,7 +106,7 @@ pub enum WalletConnectAction { } #[derive(Debug, Clone, PartialEq, uniffi::Enum)] -pub enum WalletConnectTransactionType { +pub enum SignableTransactionType { Ethereum, Solana { output_type: TransferDataOutputType }, Sui { output_type: TransferDataOutputType }, @@ -121,17 +123,17 @@ pub enum WalletConnectChainOperation { #[derive(Debug, Clone, uniffi::Enum)] #[allow(clippy::large_enum_variant)] -pub enum WalletConnectTransaction { +pub enum SignableTransaction { Ethereum { - data: WCEthereumTransactionData, + data: EthereumTransactionData, transaction_type: TransactionType, }, Solana { - data: WCSolanaTransactionData, + data: SolanaTransactionData, output_type: TransferDataOutputType, }, Sui { - data: WCSuiTransactionData, + data: SuiTransactionData, output_type: TransferDataOutputType, }, Ton { @@ -152,7 +154,7 @@ pub enum WalletConnectResponseType { // From conversions: primitives -> UniFFI -impl From for WCEthereumTransactionData { +impl From for EthereumTransactionData { fn from(transaction: WCEthereumTransaction) -> Self { Self { chain_id: transaction.chain_id, @@ -172,21 +174,31 @@ impl From for WCEthereumTransactionData { // From conversions: gem_wallet_connect -> UniFFI -impl From for SignDigestType { - fn from(t: WcSignDigestType) -> Self { +impl From for SignMessage { + fn from(message: CoreSignMessage) -> Self { + Self { + chain: message.chain, + sign_type: message.sign_type.into(), + data: message.data, + } + } +} + +impl From for SignDigestType { + fn from(t: CoreSignDigestType) -> Self { match t { - WcSignDigestType::Eip191 => Self::Eip191, - WcSignDigestType::Eip712 => Self::Eip712, - WcSignDigestType::Base58 => Self::Base58, - WcSignDigestType::SuiPersonal => Self::SuiPersonal, - WcSignDigestType::Siwe => Self::Siwe, - WcSignDigestType::TonPersonal => Self::TonPersonal, - WcSignDigestType::TronPersonal => Self::TronPersonal, + CoreSignDigestType::Eip191 => Self::Eip191, + CoreSignDigestType::Eip712 => Self::Eip712, + CoreSignDigestType::Base58 => Self::Base58, + CoreSignDigestType::SuiPersonal => Self::SuiPersonal, + CoreSignDigestType::Siwe => Self::Siwe, + CoreSignDigestType::TonPersonal => Self::TonPersonal, + CoreSignDigestType::TronPersonal => Self::TronPersonal, } } } -impl From for WcSignDigestType { +impl From for CoreSignDigestType { fn from(t: SignDigestType) -> Self { match t { SignDigestType::Eip191 => Self::Eip191, @@ -200,26 +212,26 @@ impl From for WcSignDigestType { } } -impl From for WalletConnectTransactionType { - fn from(t: WcWalletConnectTransactionType) -> Self { +impl From for SignableTransactionType { + fn from(t: CoreSignableTransactionType) -> Self { match t { - WcWalletConnectTransactionType::Ethereum => Self::Ethereum, - WcWalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, - WcWalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, - WcWalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, - WcWalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + CoreSignableTransactionType::Ethereum => Self::Ethereum, + CoreSignableTransactionType::Solana { output_type } => Self::Solana { output_type }, + CoreSignableTransactionType::Sui { output_type } => Self::Sui { output_type }, + CoreSignableTransactionType::Ton { output_type } => Self::Ton { output_type }, + CoreSignableTransactionType::Tron { output_type } => Self::Tron { output_type }, } } } -impl From for WcWalletConnectTransactionType { - fn from(t: WalletConnectTransactionType) -> Self { +impl From for CoreSignableTransactionType { + fn from(t: SignableTransactionType) -> Self { match t { - WalletConnectTransactionType::Ethereum => Self::Ethereum, - WalletConnectTransactionType::Solana { output_type } => Self::Solana { output_type }, - WalletConnectTransactionType::Sui { output_type } => Self::Sui { output_type }, - WalletConnectTransactionType::Ton { output_type } => Self::Ton { output_type }, - WalletConnectTransactionType::Tron { output_type } => Self::Tron { output_type }, + SignableTransactionType::Ethereum => Self::Ethereum, + SignableTransactionType::Solana { output_type } => Self::Solana { output_type }, + SignableTransactionType::Sui { output_type } => Self::Sui { output_type }, + SignableTransactionType::Ton { output_type } => Self::Ton { output_type }, + SignableTransactionType::Tron { output_type } => Self::Tron { output_type }, } } } @@ -268,8 +280,8 @@ impl From for WalletConnectAction { } } -impl From for WCEthereumTransactionData { - fn from(d: WcEthereumTransactionData) -> Self { +impl From for EthereumTransactionData { + fn from(d: CoreEthereumTransactionData) -> Self { Self { chain_id: d.chain_id, from: d.from, @@ -286,26 +298,26 @@ impl From for WCEthereumTransactionData { } } -impl From for WalletConnectTransaction { - fn from(t: WcWalletConnectTransaction) -> Self { +impl From for SignableTransaction { + fn from(t: CoreSignableTransaction) -> Self { match t { - WcWalletConnectTransaction::Ethereum { data, transaction_type } => Self::Ethereum { + CoreSignableTransaction::Ethereum { data, transaction_type } => Self::Ethereum { data: data.into(), transaction_type, }, - WcWalletConnectTransaction::Solana { data, output_type } => Self::Solana { - data: WCSolanaTransactionData { transaction: data.transaction }, + CoreSignableTransaction::Solana { data, output_type } => Self::Solana { + data: SolanaTransactionData { transaction: data.transaction }, output_type, }, - WcWalletConnectTransaction::Sui { data, output_type } => Self::Sui { - data: WCSuiTransactionData { + CoreSignableTransaction::Sui { data, output_type } => Self::Sui { + data: SuiTransactionData { transaction: data.transaction, wallet_address: data.wallet_address, }, output_type, }, - WcWalletConnectTransaction::Ton { data, output_type } => Self::Ton { data, output_type }, - WcWalletConnectTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, + CoreSignableTransaction::Ton { data, output_type } => Self::Ton { data, output_type }, + CoreSignableTransaction::Tron { data, output_type } => Self::Tron { data, output_type }, } } } @@ -403,16 +415,16 @@ impl WalletConnect { config_session_properties(properties, &chains, &accounts) } - pub fn decode_send_transaction(&self, transaction_type: WalletConnectTransactionType, data: String) -> Result { - let wc_type: WcWalletConnectTransactionType = transaction_type.into(); + pub fn decode_send_transaction(&self, transaction_type: SignableTransactionType, data: String) -> Result { + let wc_type: CoreSignableTransactionType = transaction_type.into(); let wc_result = WalletConnectRequestHandler::decode_send_transaction(wc_type, data).map_err(|e| GemstoneError::AnyError { msg: e })?; Ok(wc_result.into()) } } #[uniffi::export] -pub fn wallet_connect_app_short_name(metadata: primitives::WalletConnectionSessionAppMetadata) -> String { - metadata.short_name() +pub fn wallet_connect_app_short_name(name: String) -> String { + short_name(&name) } #[cfg(test)] @@ -421,8 +433,8 @@ mod tests { #[test] fn short_name_strips_separators() { - use primitives::WalletConnectionSessionAppMetadata; - let meta = |name: &str| WalletConnectionSessionAppMetadata { + use primitives::WalletConnectAppMetadata; + let meta = |name: &str| WalletConnectAppMetadata { name: name.to_string(), description: String::new(), url: String::new(), diff --git a/core/gemstone/src/wallet_connect/simulation.rs b/core/gemstone/src/wallet_connect/simulation.rs index 86e7939db2..cab6292b92 100644 --- a/core/gemstone/src/wallet_connect/simulation.rs +++ b/core/gemstone/src/wallet_connect/simulation.rs @@ -1,28 +1,22 @@ -use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, SignMessageValidation, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectRequestHandler, - WalletConnectTransaction as WcWalletConnectTransaction, WalletConnectTransactionType as WcWalletConnectTransactionType, decode_sign_message, validate_send_transaction, - validate_sign_message, -}; +use gem_wallet_connect::{SignMessageValidation, WalletConnectRequestHandler, decode_sign_message, validate_send_transaction, validate_sign_message}; use primitives::{Chain, SimulationWarning, hex}; +use primitives::{ + EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignableTransaction as CoreSignableTransaction, + SignableTransactionType as CoreSignableTransactionType, +}; use crate::message::sign_type::{SignDigestType, SignMessage}; pub fn decode_message(chain: Chain, sign_type: SignDigestType, data: String) -> SignMessage { - let sign_type: WcSignDigestType = sign_type.into(); - let result = decode_sign_message(chain, sign_type, data); - - SignMessage { - chain: result.chain, - sign_type: result.sign_type.into(), - data: result.data, - } + let sign_type: CoreSignDigestType = sign_type.into(); + decode_sign_message(chain, sign_type, data).into() } pub(super) fn parse_eip712_message(data: &str) -> Option { serde_json::from_str(data).ok().and_then(|value| gem_evm::eip712::parse_eip712_json(&value).ok()) } -pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignDigestType, data: &str, session_domain: &str) -> Vec { +pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &CoreSignDigestType, data: &str, session_domain: &str) -> Vec { let input = SignMessageValidation { chain, sign_type, @@ -33,7 +27,7 @@ pub(super) fn sign_message_validation_warnings(chain: Chain, sign_type: &WcSignD validate_sign_message(&input).err().into_iter().map(SimulationWarning::validation_error).collect() } -pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletConnectTransactionType, data: &str) -> Vec { +pub(super) fn send_transaction_validation_warnings(transaction_type: &CoreSignableTransactionType, data: &str) -> Vec { validate_send_transaction(transaction_type, data) .err() .into_iter() @@ -41,15 +35,15 @@ pub(super) fn send_transaction_validation_warnings(transaction_type: &WcWalletCo .collect() } -pub(super) fn decode_ethereum_transaction(data: &str) -> Result { - let transaction = WalletConnectRequestHandler::decode_send_transaction(WcWalletConnectTransactionType::Ethereum, data.to_string())?; +pub(super) fn decode_ethereum_transaction(data: &str) -> Result { + let transaction = WalletConnectRequestHandler::decode_send_transaction(CoreSignableTransactionType::Ethereum, data.to_string())?; match transaction { - WcWalletConnectTransaction::Ethereum { data, .. } => Ok(data), + CoreSignableTransaction::Ethereum { data, .. } => Ok(data), _ => Err("Invalid Ethereum transaction".to_string()), } } -pub(super) fn decode_ethereum_calldata(transaction: &WcEthereumTransactionData) -> Vec { +pub(super) fn decode_ethereum_calldata(transaction: &CoreEthereumTransactionData) -> Vec { transaction.data.as_deref().and_then(|calldata| hex::decode_hex(calldata).ok()).unwrap_or_default() } diff --git a/core/gemstone/src/wallet_connect/simulation_client.rs b/core/gemstone/src/wallet_connect/simulation_client.rs index e5a9bf3c11..f730e65f6b 100644 --- a/core/gemstone/src/wallet_connect/simulation_client.rs +++ b/core/gemstone/src/wallet_connect/simulation_client.rs @@ -9,10 +9,8 @@ use gem_solana::rpc::{SolanaClient, SolanaProvider}; use gem_sui::rpc::{SuiClient, SuiProvider}; use gem_ton::rpc::client::TonClient; use gem_tron::rpc::{TronProvider, client::TronClient}; -use gem_wallet_connect::{ - SignDigestType as WcSignDigestType, WCEthereumTransactionData as WcEthereumTransactionData, WalletConnectTransactionType as WcWalletConnectTransactionType, -}; use primitives::{Chain, EVMChain, SimulationInput, SimulationResult}; +use primitives::{EthereumTransactionData as CoreEthereumTransactionData, SignDigestType as CoreSignDigestType, SignableTransactionType as CoreSignableTransactionType}; use crate::{ GemstoneError, @@ -21,7 +19,7 @@ use crate::{ network::JsonRpcClient, }; -use super::{WalletConnectTransactionType, simulation}; +use super::{SignableTransactionType, simulation}; #[derive(uniffi::Object)] pub struct WalletConnectSimulationClient { @@ -38,11 +36,11 @@ impl WalletConnectSimulationClient { } pub async fn simulate_sign_message(&self, chain: Chain, sign_type: SignDigestType, data: String, session_domain: String) -> Result { - let sign_type: WcSignDigestType = sign_type.into(); + let sign_type: CoreSignDigestType = sign_type.into(); let validation_warnings = simulation::sign_message_validation_warnings(chain, &sign_type, &data, &session_domain); let simulation = match sign_type { - WcSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { + CoreSignDigestType::Eip712 => match simulation::parse_eip712_message(&data) { Some(message) => self.simulate_eip712_message(chain, &message).await?, None => SimulationResult::default(), }, @@ -52,15 +50,15 @@ impl WalletConnectSimulationClient { Ok(simulation.prepend_warnings(validation_warnings)) } - pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: WalletConnectTransactionType, data: String) -> Result { - let transaction_type: WcWalletConnectTransactionType = transaction_type.into(); + pub async fn simulate_send_transaction(&self, chain: Chain, transaction_type: SignableTransactionType, data: String) -> Result { + let transaction_type: CoreSignableTransactionType = transaction_type.into(); let validation_warnings = simulation::send_transaction_validation_warnings(&transaction_type, &data); let simulation = match &transaction_type { - WcWalletConnectTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await, - WcWalletConnectTransactionType::Solana { .. } | WcWalletConnectTransactionType::Sui { .. } => self.simulate_encoded_transaction(&transaction_type, &data).await, - WcWalletConnectTransactionType::Ton { .. } => self.simulate_chain_transaction(Chain::Ton, SimulationInput::new(&data)).await, - WcWalletConnectTransactionType::Tron { .. } => self.simulate_chain_transaction(Chain::Tron, SimulationInput::new(&data)).await, + CoreSignableTransactionType::Ethereum => self.simulate_ethereum_transaction(chain, &data).await, + CoreSignableTransactionType::Solana { .. } | CoreSignableTransactionType::Sui { .. } => self.simulate_encoded_transaction(&transaction_type, &data).await, + CoreSignableTransactionType::Ton { .. } => self.simulate_chain_transaction(Chain::Ton, SimulationInput::new(&data)).await, + CoreSignableTransactionType::Tron { .. } => self.simulate_chain_transaction(Chain::Tron, SimulationInput::new(&data)).await, } .unwrap_or_default(); @@ -99,7 +97,7 @@ impl WalletConnectSimulationClient { chain: Chain, calldata: &[u8], provider: &EthereumProvider, - transaction: &WcEthereumTransactionData, + transaction: &CoreEthereumTransactionData, ) -> (Result, Result) { let calldata_task = async { if calldata.is_empty() { @@ -117,17 +115,17 @@ impl WalletConnectSimulationClient { async fn simulate_ethereum_balance_changes( &self, provider: &EthereumProvider, - transaction: &WcEthereumTransactionData, + transaction: &CoreEthereumTransactionData, ) -> Result { let encoded_transaction = serde_json::to_string(&map_transaction_object(transaction)).map_err(|error| error.to_string())?; Ok(provider.simulate_transaction(SimulationInput::new(encoded_transaction)).await?) } - async fn simulate_encoded_transaction(&self, transaction_type: &WcWalletConnectTransactionType, data: &str) -> Result { + async fn simulate_encoded_transaction(&self, transaction_type: &CoreSignableTransactionType, data: &str) -> Result { let chain = match transaction_type { - WcWalletConnectTransactionType::Solana { .. } => Chain::Solana, - WcWalletConnectTransactionType::Sui { .. } => Chain::Sui, + CoreSignableTransactionType::Solana { .. } => Chain::Solana, + CoreSignableTransactionType::Sui { .. } => Chain::Sui, _ => return Err("Chain does not use encoded transaction simulation".into()), }; let input: SimulationInput = serde_json::from_str(data).map_err(|error| error.to_string())?; @@ -162,7 +160,7 @@ impl WalletConnectSimulationClient { } /// Keeps the gas limit so out-of-gas failures surface, but omits fee prices - they make the trace charge gas and leak fee accounting into the signer's balance diff. -fn map_transaction_object(transaction: &WcEthereumTransactionData) -> TransactionObject { +fn map_transaction_object(transaction: &CoreEthereumTransactionData) -> TransactionObject { TransactionObject { from: Some(transaction.from.clone()), to: transaction.to.clone(), @@ -180,8 +178,8 @@ mod tests { use super::*; use primitives::testkit::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER}; - fn mock_wc_transaction() -> WcEthereumTransactionData { - WcEthereumTransactionData { + fn mock_wc_transaction() -> CoreEthereumTransactionData { + CoreEthereumTransactionData { chain_id: None, from: TEST_EVM_SENDER.to_string(), to: TEST_EVM_RECIPIENT.to_string(), @@ -205,7 +203,7 @@ mod tests { #[test] fn test_map_transaction_object_passes_gas_limit_and_omits_fee_prices() { - let transaction = WcEthereumTransactionData { + let transaction = CoreEthereumTransactionData { gas_limit: Some("0x5208".to_string()), gas_price: Some("0x9502f900".to_string()), max_fee_per_gas: Some("0x59682f10".to_string()), diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift index e4aa0bb237..df197ab504 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorService.swift @@ -9,11 +9,12 @@ import enum Gemstone.WalletConnectAction import enum Gemstone.WalletConnectChainOperation import enum Gemstone.WalletConnectResponseType import class Gemstone.WalletConnectSimulationClient -import enum Gemstone.WalletConnectTransaction -import enum Gemstone.WalletConnectTransactionType +import enum Gemstone.SignableTransaction +import enum Gemstone.SignableTransactionType import GemstonePrimitives import NativeProviderService import Primitives +import SigningRequestService @preconcurrency import ReownWalletKit @preconcurrency import WalletConnectPairing @@ -22,7 +23,7 @@ public final class WalletConnectorService { private let signer: WalletConnectorSignable private let messageTracker = MessageTracker() private let walletConnect = WalletConnect() - private let simulationClient: WalletConnectSimulationClient + private let simulator: SigningSimulator public init( signer: WalletConnectorSignable, @@ -30,7 +31,7 @@ public final class WalletConnectorService { requestInterceptor: any RequestInterceptable = EmptyRequestInterceptor(), ) { self.signer = signer - simulationClient = WalletConnectSimulationClient(provider: NativeProvider(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor)) + simulator = SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: requestInterceptor) } } @@ -98,19 +99,15 @@ extension WalletConnectorService: WalletConnectorServiceable { extension WalletConnectorService { private func simulateSignMessage(chain: Gemstone.Chain, signType: SignDigestType, data: String, sessionDomain: String) async throws -> Primitives.SimulationResult { - try await simulationClient - .simulateSignMessage(chain: chain, signType: signType, data: data, sessionDomain: sessionDomain) - .map() + try await simulator.simulateSignMessage(chain: chain, signType: signType, data: data, sessionDomain: sessionDomain) } private func simulateSendTransaction( chain: Gemstone.Chain, - transactionType: WalletConnectTransactionType, + transactionType: SignableTransactionType, data: String, ) async throws -> Primitives.SimulationResult { - try await simulationClient - .simulateSendTransaction(chain: chain, transactionType: transactionType, data: data) - .map() + try await simulator.simulateSendTransaction(chain: chain, transactionType: transactionType, data: data) } private func handleSessions() async { @@ -320,7 +317,7 @@ extension WalletConnectorService { throw WalletConnectorServiceError.invalidOrigin } - let payload = WalletConnectionSessionProposal( + let payload = WalletConnectSessionProposal( defaultWallet: preselectedWallet, wallets: wallets, metadata: metadata, diff --git a/ios/Packages/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 + } +} From 6a3025412e5c027c7549bd90700710664ee5f92b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:30:31 +0300 Subject: [PATCH 04/27] core: add the shared payment models A scanned or opened payment link decodes to Payment::Link(PaymentLink), carrying the provider and the id only that provider can read. The quote, outcome and merchant models are typeshared so both apps read one definition, and a paid transaction records which provider settled it. --- .../core/primitives/generated/Payment.kt | 112 ++++++++++ .../generated/TransactionAppMetadata.kt | 17 ++ .../generated/TransactionMetadataTypes.kt | 7 + .../primitives/generated/WalletConnector.kt | 14 +- core/crates/primitives/src/lib.rs | 23 +- 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 ++++++++++ .../src/transaction_app_metadata.rs | 20 ++ .../src/transaction_metadata_types.rs | 11 +- core/crates/primitives/src/url_action.rs | 20 +- .../src/wallet_connect_namespace.rs | 43 +++- .../crates/primitives/src/wallet_connector.rs | 24 +- .../Sources/Generated/Payment.swift | 211 ++++++++++++++++++ .../Generated/TransactionAppMetadata.swift | 19 ++ .../Generated/TransactionMetadataTypes.swift | 12 + .../Sources/Generated/WalletConnector.swift | 22 +- .../Primitives/Sources/PaymentData.swift | 27 +++ .../Primitives/Sources/URLAction.swift | 1 + .../TestKit/Payment+PrimitivesTestKit.swift | 92 ++++++++ 21 files changed, 956 insertions(+), 152 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/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/transaction_app_metadata.rs create mode 100644 ios/Packages/Primitives/Sources/Generated/Payment.swift create mode 100644 ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift create mode 100644 ios/Packages/Primitives/Sources/PaymentData.swift create mode 100644 ios/Packages/Primitives/TestKit/Payment+PrimitivesTestKit.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/TransactionAppMetadata.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionAppMetadata.kt new file mode 100644 index 0000000000..798123850e --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionAppMetadata.kt @@ -0,0 +1,17 @@ +/** + * Generated by typeshare 1.13.3 + */ + +package com.wallet.core.primitives + +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName + +@Serializable +data class TransactionAppMetadata ( + val name: String, + val description: String? = null, + val url: String? = null, + val icon: String? = null +) + diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt index 99ce863141..b6a6b2df49 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/TransactionMetadataTypes.kt @@ -13,6 +13,13 @@ data class TransactionNFTTransferMetadata ( val name: String? = null ) +@Serializable +data class TransactionPaymentMetadata ( + val paymentId: String, + val merchant: PaymentMerchant, + val provider: PaymentProviderName +) + @Serializable data class TransactionPerpetualMetadata ( val pnl: Double, diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt index 2def50e3a9..ed29f52630 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/WalletConnector.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName @Serializable -data class WalletConnectionSessionAppMetadata ( +data class WalletConnectAppMetadata ( val name: String, val description: String, val url: String, @@ -16,10 +16,10 @@ data class WalletConnectionSessionAppMetadata ( ) @Serializable -data class WalletConnectionSessionProposal ( +data class WalletConnectSessionProposal ( val defaultWallet: Wallet, val wallets: List, - val metadata: WalletConnectionSessionAppMetadata + val metadata: WalletConnectAppMetadata ) @Serializable @@ -37,7 +37,7 @@ enum class WalletConnectionVerificationStatus(val string: String) { @Serializable data class WCPairingProposal ( val pairingId: String, - val proposal: WalletConnectionSessionProposal, + val proposal: WalletConnectSessionProposal, val verificationStatus: WalletConnectionVerificationStatus ) @@ -52,19 +52,19 @@ enum class WalletConnectionState(val string: String) { } @Serializable -data class WalletConnectionSession ( +data class WalletConnectSession ( val id: String, val sessionId: String, val state: WalletConnectionState, val chains: List, val createdAt: SerializedDate, val expireAt: SerializedDate, - val metadata: WalletConnectionSessionAppMetadata + val metadata: WalletConnectAppMetadata ) @Serializable data class WalletConnection ( - val session: WalletConnectionSession, + val session: WalletConnectSession, val wallet: Wallet ) diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index 554d6302a6..88cc088056 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -119,6 +119,12 @@ pub mod platform; pub use self::platform::Platform; pub mod platform_store; pub use self::platform_store::PlatformStore; +pub mod payment; +pub use self::payment::{ + Payment, PaymentAmount, PaymentLink, PaymentMerchant, PaymentOptions, PaymentOutcome, PaymentPrice, PaymentProviderName, PaymentQuote, PaymentQuotes, PaymentRequest, + PaymentStatus, +}; + pub mod payment_type; pub use self::payment_type::PaymentType; pub mod contact; @@ -177,14 +183,17 @@ 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 mod wallet_connect; -pub use self::wallet_connect::{WCEthereumTransaction, WCTonMessage, WalletConnectLink, WalletConnectRequest}; +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; pub use self::account::Account; pub mod wallet; @@ -201,8 +210,8 @@ pub mod wallet_import; pub use self::wallet_import::WalletImport; pub mod wallet_connector; pub use self::wallet_connector::{ - WCPairingProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, WalletConnectionSession, WalletConnectionSessionAppMetadata, - WalletConnectionSessionProposal, WalletConnectionState, WalletConnectionVerificationStatus, + WCPairingProposal, WalletConnectAppMetadata, WalletConnectSession, WalletConnectSessionProposal, WalletConnection, WalletConnectionEvents, WalletConnectionMethods, + WalletConnectionState, WalletConnectionVerificationStatus, }; pub mod nft; pub use self::nft::{NFTAsset, NFTAssetId, NFTAttribute, NFTAttributeType, NFTCollection, NFTCollectionId, NFTData, NFTImages, NFTResource, NFTType, ReportNft}; @@ -215,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/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_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/url_action.rs b/core/crates/primitives/src/url_action.rs index e85baf413d..becf112123 100644 --- a/core/crates/primitives/src/url_action.rs +++ b/core/crates/primitives/src/url_action.rs @@ -1,13 +1,17 @@ -use crate::{Deeplink, WalletConnectLink}; +use crate::{Deeplink, Payment, PaymentLink, PaymentURLDecoder, WalletConnectLink}; #[derive(Debug, Clone, PartialEq)] pub enum UrlAction { Deeplink { deeplink: Deeplink }, + Payment { link: PaymentLink }, WalletConnect { link: WalletConnectLink }, } impl UrlAction { pub fn from_url(url: &str) -> Option { + if let Ok(Payment::Link(link)) = PaymentURLDecoder::decode(url) { + return Some(Self::Payment { link }); + } if let Some(link) = WalletConnectLink::from_url(url) { return Some(Self::WalletConnect { link }); } @@ -18,7 +22,7 @@ impl UrlAction { #[cfg(test)] mod tests { use super::*; - use crate::{AssetId, Chain}; + use crate::{AssetId, Chain, PaymentProviderName}; #[test] fn test_from_url() { @@ -44,6 +48,18 @@ mod tests { }, }) ); + assert_eq!( + UrlAction::from_url("https://pay.walletconnect.com/?pid=pay_123"), + Some(UrlAction::Payment { + link: PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()), + }) + ); + assert_eq!( + UrlAction::from_url("wc:abc@2?pay=https%3A%2F%2Fpay.walletconnect.com%2F%3Fpid%3Dpay_123"), + Some(UrlAction::Payment { + link: PaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string()), + }) + ); assert_eq!(UrlAction::from_url("https://example.com/tokens/bitcoin"), None); assert_eq!(UrlAction::from_url("not a url"), None); } diff --git a/core/crates/primitives/src/wallet_connect_namespace.rs b/core/crates/primitives/src/wallet_connect_namespace.rs index 44a7e33dd3..f739ac3378 100644 --- a/core/crates/primitives/src/wallet_connect_namespace.rs +++ b/core/crates/primitives/src/wallet_connect_namespace.rs @@ -1,4 +1,4 @@ -use crate::{Chain, ChainAddress, ChainType}; +use crate::{AssetId, Chain, ChainAddress, ChainType}; use serde::Serialize; use std::str::FromStr; use strum::{AsRefStr, EnumString}; @@ -170,3 +170,44 @@ mod tests { assert_eq!(WalletConnectCAIP2::parse_account("eip155:99999:0x1".to_string()), None); } } + +const SLIP44_NAMESPACE: &str = "slip44"; + +pub struct WalletConnectCAIP19; + +impl WalletConnectCAIP19 { + pub fn get_asset_id(asset: &str) -> Option { + let (chain_id, asset) = match asset.split_once('/') { + Some((chain_id, asset)) => (chain_id, Some(asset)), + None => (asset, None), + }; + let chain = WalletConnectCAIP2::get_chain_from_id(Some(chain_id.to_string())).ok()?; + let Some(asset) = asset else { + return Some(AssetId::from(chain, None)); + }; + match asset.split_once(':')? { + (SLIP44_NAMESPACE, _) => Some(AssetId::from(chain, None)), + (_, token_id) => Some(AssetId::from_token(chain, token_id)), + } + } +} + +#[cfg(test)] +mod caip19_tests { + use super::*; + + #[test] + fn test_get_asset_id() { + assert_eq!(WalletConnectCAIP19::get_asset_id("eip155:1/slip44:60"), Some(AssetId::from(Chain::Ethereum, None))); + assert_eq!(WalletConnectCAIP19::get_asset_id("eip155:1"), Some(AssetId::from(Chain::Ethereum, None))); + assert_eq!( + WalletConnectCAIP19::get_asset_id("eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + Some(AssetId::from_token(Chain::Base, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")) + ); + assert_eq!( + WalletConnectCAIP19::get_asset_id("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + Some(AssetId::from_token(Chain::Solana, "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")) + ); + assert_eq!(WalletConnectCAIP19::get_asset_id("bitcoin:000000000019d6689c085ae165831e93"), None); + } +} diff --git a/core/crates/primitives/src/wallet_connector.rs b/core/crates/primitives/src/wallet_connector.rs index 2b28f0d243..398b10ba59 100644 --- a/core/crates/primitives/src/wallet_connector.rs +++ b/core/crates/primitives/src/wallet_connector.rs @@ -6,7 +6,7 @@ use typeshare::typeshare; #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] pub struct WalletConnection { - pub session: WalletConnectionSession, + pub session: WalletConnectSession, pub wallet: Wallet, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -83,20 +83,20 @@ pub enum WalletConnectionEvents { #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] -pub struct WalletConnectionSession { +pub struct WalletConnectSession { pub id: String, pub session_id: String, pub state: WalletConnectionState, pub chains: Vec, pub created_at: DateTime, pub expire_at: DateTime, - pub metadata: WalletConnectionSessionAppMetadata, + pub metadata: WalletConnectAppMetadata, } #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] -pub struct WalletConnectionSessionAppMetadata { +pub struct WalletConnectAppMetadata { pub name: String, pub description: String, pub url: String, @@ -106,9 +106,15 @@ pub struct WalletConnectionSessionAppMetadata { const SHORT_NAME_SEPARATORS: [char; 3] = ['-', ':', '|']; const SHORT_NAME_MAX_LENGTH: usize = 80; -impl WalletConnectionSessionAppMetadata { +impl WalletConnectAppMetadata { pub fn short_name(&self) -> String { - let name = self.name.trim(); + short_name(&self.name) + } +} + +pub fn short_name(name: &str) -> String { + { + let name = name.trim(); for sep in SHORT_NAME_SEPARATORS { if let Some(idx) = name.find(sep) { return name[..idx].trim().to_string(); @@ -124,10 +130,10 @@ impl WalletConnectionSessionAppMetadata { #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "camelCase")] -pub struct WalletConnectionSessionProposal { +pub struct WalletConnectSessionProposal { pub default_wallet: Wallet, pub wallets: Vec, - pub metadata: WalletConnectionSessionAppMetadata, + pub metadata: WalletConnectAppMetadata, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -145,6 +151,6 @@ pub enum WalletConnectionVerificationStatus { #[serde(rename_all = "camelCase")] pub struct WCPairingProposal { pub pairing_id: String, - pub proposal: WalletConnectionSessionProposal, + pub proposal: WalletConnectSessionProposal, pub verification_status: WalletConnectionVerificationStatus, } diff --git a/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/TransactionAppMetadata.swift b/ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift new file mode 100644 index 0000000000..2f5e54c977 --- /dev/null +++ b/ios/Packages/Primitives/Sources/Generated/TransactionAppMetadata.swift @@ -0,0 +1,19 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct TransactionAppMetadata: Codable, Equatable, Hashable, Sendable { + public let name: String + public let description: String? + public let url: String? + public let icon: String? + + public init(name: String, description: String?, url: String?, icon: String?) { + self.name = name + self.description = description + self.url = url + self.icon = icon + } +} diff --git a/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift b/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift index 7670286bd6..b7918b6f5f 100644 --- a/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift +++ b/ios/Packages/Primitives/Sources/Generated/TransactionMetadataTypes.swift @@ -14,6 +14,18 @@ public struct TransactionNFTTransferMetadata: Codable, Sendable { } } +public struct TransactionPaymentMetadata: Codable, Equatable, Hashable, Sendable { + public let paymentId: String + public let merchant: PaymentMerchant + public let provider: PaymentProviderName + + public init(paymentId: String, merchant: PaymentMerchant, provider: PaymentProviderName) { + self.paymentId = paymentId + self.merchant = merchant + self.provider = provider + } +} + public struct TransactionPerpetualMetadata: Codable, Sendable { public let pnl: Double public let price: Double diff --git a/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift b/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift index dc5a721efc..0abc01036a 100644 --- a/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift +++ b/ios/Packages/Primitives/Sources/Generated/WalletConnector.swift @@ -4,7 +4,7 @@ import Foundation -public struct WalletConnectionSessionAppMetadata: Codable, Equatable, Hashable, Sendable { +public struct WalletConnectAppMetadata: Codable, Equatable, Hashable, Sendable { public let name: String public let description: String public let url: String @@ -18,12 +18,12 @@ public struct WalletConnectionSessionAppMetadata: Codable, Equatable, Hashable, } } -public struct WalletConnectionSessionProposal: Codable, Equatable, Hashable, Sendable { +public struct WalletConnectSessionProposal: Codable, Equatable, Hashable, Sendable { public let defaultWallet: Wallet public let wallets: [Wallet] - public let metadata: WalletConnectionSessionAppMetadata + public let metadata: WalletConnectAppMetadata - public init(defaultWallet: Wallet, wallets: [Wallet], metadata: WalletConnectionSessionAppMetadata) { + public init(defaultWallet: Wallet, wallets: [Wallet], metadata: WalletConnectAppMetadata) { self.defaultWallet = defaultWallet self.wallets = wallets self.metadata = metadata @@ -39,10 +39,10 @@ public enum WalletConnectionVerificationStatus: String, Codable, Hashable, Senda public struct WCPairingProposal: Codable, Sendable { public let pairingId: String - public let proposal: WalletConnectionSessionProposal + public let proposal: WalletConnectSessionProposal public let verificationStatus: WalletConnectionVerificationStatus - public init(pairingId: String, proposal: WalletConnectionSessionProposal, verificationStatus: WalletConnectionVerificationStatus) { + public init(pairingId: String, proposal: WalletConnectSessionProposal, verificationStatus: WalletConnectionVerificationStatus) { self.pairingId = pairingId self.proposal = proposal self.verificationStatus = verificationStatus @@ -55,16 +55,16 @@ public enum WalletConnectionState: String, Codable, Hashable, Sendable { case expired } -public struct WalletConnectionSession: Codable, Equatable, Hashable, Sendable { +public struct WalletConnectSession: Codable, Equatable, Hashable, Sendable { public let id: String public let sessionId: String public let state: WalletConnectionState public let chains: [Chain] public let createdAt: Date public let expireAt: Date - public let metadata: WalletConnectionSessionAppMetadata + public let metadata: WalletConnectAppMetadata - public init(id: String, sessionId: String, state: WalletConnectionState, chains: [Chain], createdAt: Date, expireAt: Date, metadata: WalletConnectionSessionAppMetadata) { + public init(id: String, sessionId: String, state: WalletConnectionState, chains: [Chain], createdAt: Date, expireAt: Date, metadata: WalletConnectAppMetadata) { self.id = id self.sessionId = sessionId self.state = state @@ -76,10 +76,10 @@ public struct WalletConnectionSession: Codable, Equatable, Hashable, Sendable { } public struct WalletConnection: Codable, Equatable, Hashable, Sendable { - public let session: WalletConnectionSession + public let session: WalletConnectSession public let wallet: Wallet - public init(session: WalletConnectionSession, wallet: Wallet) { + public init(session: WalletConnectSession, wallet: Wallet) { self.session = session self.wallet = wallet } diff --git a/ios/Packages/Primitives/Sources/PaymentData.swift b/ios/Packages/Primitives/Sources/PaymentData.swift new file mode 100644 index 0000000000..3dd1fd4b82 --- /dev/null +++ b/ios/Packages/Primitives/Sources/PaymentData.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PaymentData: Hashable, Equatable, Sendable { + public let provider: PaymentProviderName + public let quotes: PaymentQuotes + public let quote: PaymentQuote + + public init(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote) { + self.provider = provider + self.quotes = quotes + self.quote = quote + } + + public var merchant: PaymentMerchant { + quotes.merchant + } + + public var price: PaymentPrice? { + quotes.price + } + + public var expiresAt: Date? { + quotes.expiresAt + } +} diff --git a/ios/Packages/Primitives/Sources/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) + } +} From dbd3c28dc2903e9494ea7428f824a1198d307588 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:30:46 +0300 Subject: [PATCH 05/27] core: add the payment crate with the WalletConnect Pay provider A PaymentProvider trait and a registry that routes a link to the one provider that can settle it, so every layer above stays provider agnostic. WalletConnect Pay is the first provider, living inside the crate the way swapper and yielder hold theirs: it turns the gateway's JSON-RPC into the four actions a wallet can be asked to perform, and reads results back by position. --- core/Cargo.lock | 18 + core/Cargo.toml | 1 + core/crates/payment/Cargo.toml | 21 ++ core/crates/payment/src/action.rs | 68 ++++ core/crates/payment/src/config.rs | 12 + core/crates/payment/src/error.rs | 33 ++ core/crates/payment/src/lib.rs | 13 + core/crates/payment/src/provider.rs | 16 + core/crates/payment/src/service.rs | 61 ++++ .../payment/src/wallet_connect_pay/account.rs | 46 +++ .../src/wallet_connect_pay/action_mapper.rs | 212 +++++++++++ .../payment/src/wallet_connect_pay/client.rs | 235 ++++++++++++ .../payment/src/wallet_connect_pay/error.rs | 128 +++++++ .../payment/src/wallet_connect_pay/mod.rs | 18 + .../payment/src/wallet_connect_pay/model.rs | 151 ++++++++ .../payment/src/wallet_connect_pay/params.rs | 154 ++++++++ .../src/wallet_connect_pay/payment_mapper.rs | 160 +++++++++ .../src/wallet_connect_pay/provider.rs | 51 +++ .../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 | 102 ++++++ .../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 + 28 files changed, 2028 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/provider.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/provider.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 41d8bdce76..01f43594da 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4121,6 +4121,7 @@ dependencies = [ "hex", "num-bigint 0.5.1", "number_formatter", + "payment", "primitives", "serde_json", "signer", @@ -5985,6 +5986,23 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payment" +version = "2.114.0" +dependencies = [ + "async-trait", + "chrono", + "gem_client", + "gem_evm", + "gem_jsonrpc", + "gem_wallet_connect", + "primitives", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/core/Cargo.toml b/core/Cargo.toml index ed23e13fac..cdd991d937 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/lists", "crates/settings", "crates/settings_chain", + "crates/payment", "crates/pricer", "crates/chain_primitives", "crates/chain_traits", diff --git a/core/crates/payment/Cargo.toml b/core/crates/payment/Cargo.toml new file mode 100644 index 0000000000..8fd5e0aba5 --- /dev/null +++ b/core/crates/payment/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "payment" +edition = { workspace = true } +version = { workspace = true } + +[dependencies] +primitives = { path = "../primitives" } +gem_client = { path = "../gem_client" } +gem_jsonrpc = { path = "../gem_jsonrpc", features = ["client"] } +gem_evm = { path = "../gem_evm" } +gem_wallet_connect = { path = "../gem_wallet_connect", features = ["request"] } + +async-trait = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +gem_client = { path = "../gem_client", features = ["testkit"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/payment/src/action.rs b/core/crates/payment/src/action.rs new file mode 100644 index 0000000000..35ad1af949 --- /dev/null +++ b/core/crates/payment/src/action.rs @@ -0,0 +1,68 @@ +use primitives::{ApprovalData, Chain, ChainAddress, PaymentQuote, PaymentQuotes, SignMessage, SignableTransaction}; + +use crate::error::PaymentError; + +#[derive(Debug)] +pub enum PaymentAction { + SignMessage { message: SignMessage }, + SignTransaction { chain: Chain, transaction: SignableTransaction }, + SendTransaction { chain: Chain, transaction: SignableTransaction }, + ApproveToken { chain: Chain, approval: ApprovalData }, +} + +impl PaymentAction { + pub fn chain(&self) -> Chain { + match self { + Self::SignMessage { message } => message.chain, + Self::SignTransaction { chain, .. } | Self::SendTransaction { chain, .. } | Self::ApproveToken { chain, .. } => *chain, + } + } +} + +#[derive(Debug)] +pub struct PreparedPayment { + pub quotes: PaymentQuotes, + pub quote: PaymentQuote, + pub actions: Vec, +} + +impl PreparedPayment { + pub fn validate(&self, addresses: &[ChainAddress]) -> Result<(), PaymentError> { + validate_actions(&self.actions, addresses) + } +} + +fn validate_actions(actions: &[PaymentAction], addresses: &[ChainAddress]) -> Result<(), PaymentError> { + if actions.is_empty() { + return Err(PaymentError::InvalidRequest("Payment has no actions".to_string())); + } + match actions.iter().find(|action| !addresses.iter().any(|address| address.chain == action.chain())) { + Some(action) => Err(PaymentError::InvalidRequest(format!("Payment asks to sign on {}", action.chain().as_ref()))), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::TransferDataOutputType; + + fn send(chain: Chain) -> PaymentAction { + PaymentAction::SendTransaction { + chain, + transaction: SignableTransaction::Ton { + data: String::new(), + output_type: TransferDataOutputType::EncodedTransaction, + }, + } + } + + #[test] + fn test_validate_actions() { + let addresses = vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string())]; + + assert!(validate_actions(&[send(Chain::Ethereum)], &addresses).is_ok()); + assert!(validate_actions(&[send(Chain::Ethereum), send(Chain::Solana)], &addresses).is_err()); + assert!(validate_actions(&[], &addresses).is_err()); + } +} diff --git a/core/crates/payment/src/config.rs b/core/crates/payment/src/config.rs new file mode 100644 index 0000000000..860cced875 --- /dev/null +++ b/core/crates/payment/src/config.rs @@ -0,0 +1,12 @@ +use crate::wallet_connect_pay::WalletConnectPayAuth; + +#[derive(Debug, Clone)] +pub struct PaymentConfig { + pub wallet_connect_pay: WalletConnectPayAuth, +} + +impl PaymentConfig { + pub fn new(wallet_connect_pay: WalletConnectPayAuth) -> Self { + Self { wallet_connect_pay } + } +} diff --git a/core/crates/payment/src/error.rs b/core/crates/payment/src/error.rs new file mode 100644 index 0000000000..42655597b4 --- /dev/null +++ b/core/crates/payment/src/error.rs @@ -0,0 +1,33 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub enum PaymentError { + NotSupported, + PaymentNotFound, + PaymentExpired, + QuoteExpired, + NoPaymentOptions, + UnsupportedAccounts, + Rejected, + RateLimited, + InvalidRequest(String), + Network(String), +} + +impl fmt::Display for PaymentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSupported => write!(f, "Payment is not supported"), + Self::PaymentNotFound => write!(f, "Payment not found"), + Self::PaymentExpired => write!(f, "Payment expired"), + Self::QuoteExpired => write!(f, "Quote expired"), + Self::NoPaymentOptions => write!(f, "No payment options"), + Self::UnsupportedAccounts => write!(f, "No supported accounts"), + Self::Rejected => write!(f, "Payment rejected"), + Self::RateLimited => write!(f, "Too many requests"), + Self::InvalidRequest(message) | Self::Network(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for PaymentError {} diff --git a/core/crates/payment/src/lib.rs b/core/crates/payment/src/lib.rs new file mode 100644 index 0000000000..884c53b6f4 --- /dev/null +++ b/core/crates/payment/src/lib.rs @@ -0,0 +1,13 @@ +mod action; +mod config; +mod error; +mod provider; +mod service; +mod wallet_connect_pay; + +pub use action::{PaymentAction, PreparedPayment}; +pub use config::PaymentConfig; +pub use error::PaymentError; +pub use provider::PaymentProvider; +pub use service::PaymentService; +pub use wallet_connect_pay::WalletConnectPayAuth; diff --git a/core/crates/payment/src/provider.rs b/core/crates/payment/src/provider.rs new file mode 100644 index 0000000000..81abc4b2d5 --- /dev/null +++ b/core/crates/payment/src/provider.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; +use primitives::{ChainAddress, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::action::PreparedPayment; +use crate::error::PaymentError; + +#[async_trait] +pub trait PaymentProvider: Send + Sync { + fn name(&self) -> PaymentProviderName; + + async fn get_options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result; + async fn get_prepared_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result; + async fn confirm(&self, quote: &PaymentQuote, action_results: Vec) -> Result; + async fn get_status(&self, payment_id: &str) -> Result; + async fn cancel(&self, payment_id: &str) -> Result<(), PaymentError>; +} diff --git a/core/crates/payment/src/service.rs b/core/crates/payment/src/service.rs new file mode 100644 index 0000000000..50f62a564b --- /dev/null +++ b/core/crates/payment/src/service.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use gem_jsonrpc::alien::{RpcClient, RpcProvider}; +use primitives::{ChainAddress, PaymentLink, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::action::PreparedPayment; +use crate::config::PaymentConfig; +use crate::error::PaymentError; +use crate::provider::PaymentProvider; +use crate::wallet_connect_pay::{WALLET_CONNECT_PAY_API_URL, WalletConnectPayService}; + +pub struct PaymentService { + providers: Vec>, +} + +impl PaymentService { + pub fn new(provider: Arc, config: PaymentConfig) -> Self { + Self { + providers: vec![Box::new(WalletConnectPayService::new( + RpcClient::new(WALLET_CONNECT_PAY_API_URL.to_string(), provider), + config.wallet_connect_pay, + ))], + } + } + + pub async fn get_options(&self, link: &PaymentLink, addresses: &[ChainAddress]) -> Result { + self.provider(link.provider)?.get_options(&link.id, addresses).await + } + + pub async fn get_prepared_payment( + &self, + provider: PaymentProviderName, + quotes: &PaymentQuotes, + quote: &PaymentQuote, + addresses: &[ChainAddress], + ) -> Result { + let payment = self.provider(provider)?.get_prepared_payment(quotes, quote, addresses).await?; + payment.validate(addresses)?; + Ok(payment) + } + + pub async fn confirm(&self, provider: PaymentProviderName, quote: &PaymentQuote, action_results: Vec) -> Result { + self.provider(provider)?.confirm(quote, action_results).await + } + + pub async fn get_status(&self, provider: PaymentProviderName, payment_id: &str) -> Result { + self.provider(provider)?.get_status(payment_id).await + } + + pub async fn cancel(&self, provider: PaymentProviderName, payment_id: &str) -> Result<(), PaymentError> { + self.provider(provider)?.cancel(payment_id).await + } + + fn provider(&self, name: PaymentProviderName) -> Result<&dyn PaymentProvider, PaymentError> { + self.providers + .iter() + .find(|provider| provider.name() == name) + .map(|provider| provider.as_ref()) + .ok_or(PaymentError::NotSupported) + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/account.rs b/core/crates/payment/src/wallet_connect_pay/account.rs new file mode 100644 index 0000000000..236a1b8660 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/account.rs @@ -0,0 +1,46 @@ +use primitives::{Chain, ChainType, WalletConnectCAIP2}; + +fn is_supported(chain: Chain) -> bool { + matches!(chain.chain_type(), ChainType::Ethereum | ChainType::Solana) +} + +pub fn account_identifier(chain: Chain, address: &str) -> Option { + if !is_supported(chain) { + return None; + } + let namespace = WalletConnectCAIP2::get_namespace(chain)?; + let reference = WalletConnectCAIP2::get_reference(chain)?; + Some(format!("{namespace}:{reference}:{address}")) +} + +pub fn account_chain(account: &str) -> Option { + let chain = WalletConnectCAIP2::parse_account(account.to_string())?.chain; + is_supported(chain).then_some(chain) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_account_identifier() { + assert_eq!(account_identifier(Chain::Ethereum, "0x1"), Some("eip155:1:0x1".to_string())); + assert_eq!(account_identifier(Chain::Base, "0x1"), Some("eip155:8453:0x1".to_string())); + assert_eq!(account_identifier(Chain::Solana, "abc"), Some("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:abc".to_string())); + + assert_eq!(account_identifier(Chain::Bitcoin, "bc1"), None); + assert_eq!(account_identifier(Chain::Cosmos, "cosmos1"), None); + assert_eq!(account_identifier(Chain::Ton, "UQA"), None); + assert_eq!(account_identifier(Chain::Tron, "TX"), None); + } + + #[test] + fn test_account_chain() { + assert_eq!(account_chain("eip155:1:0x1"), Some(Chain::Ethereum)); + assert_eq!(account_chain("eip155:8453:0x1"), Some(Chain::Base)); + assert_eq!(account_chain("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:abc"), Some(Chain::Solana)); + + assert_eq!(account_chain("cosmos:cosmoshub-4:cosmos1"), None); + assert_eq!(account_chain("not-an-account"), None); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/action_mapper.rs b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs new file mode 100644 index 0000000000..b2dd988439 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/action_mapper.rs @@ -0,0 +1,212 @@ +use gem_evm::call_decoder::decode_call; +use gem_wallet_connect::{WalletConnectAction, WalletConnectRequestHandler, decode_sign_message}; +use primitives::payment_decoder::wallet_connect_pay::WALLET_CONNECT_PAY_HOST; +use primitives::{ApprovalData, SignableTransaction, SignableTransactionType, TransferDataOutputType}; + +use crate::PaymentAction; +use crate::wallet_connect_pay::error::WalletConnectPayError; +use crate::wallet_connect_pay::model::WalletRpcAction; +use crate::wallet_connect_pay::{params, validator}; + +const APPROVE_CALL: &str = "approve"; +const APPROVE_SPENDER: &str = "spender"; +const APPROVE_VALUE: &str = "value"; + +pub fn map_wallet_rpc(account: &str, wallet_rpc: &WalletRpcAction) -> Result { + let params = params::normalize(&wallet_rpc.method, &wallet_rpc.params)?; + let action = WalletConnectRequestHandler::parse_action( + wallet_rpc.method.clone(), + params.to_string(), + Some(wallet_rpc.chain_id.clone()), + WALLET_CONNECT_PAY_HOST.to_string(), + ) + .map_err(WalletConnectPayError::InvalidRequest)?; + if let WalletConnectAction::Unsupported { method } = action { + return Err(WalletConnectPayError::Unsupported(method)); + } + validator::validate_signer(account, ¶ms, &action)?; + map_action(with_encoded_solana_output(action)) +} + +fn map_action(action: WalletConnectAction) -> Result { + match action { + WalletConnectAction::SignMessage { chain, sign_type, data } => Ok(PaymentAction::SignMessage { + message: decode_sign_message(chain, sign_type, data), + }), + WalletConnectAction::SignTransaction { chain, transaction_type, data } => Ok(PaymentAction::SignTransaction { + chain, + transaction: WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(WalletConnectPayError::InvalidRequest)?, + }), + WalletConnectAction::SendTransaction { chain, transaction_type, data } => { + let transaction = WalletConnectRequestHandler::decode_send_transaction(transaction_type, data).map_err(WalletConnectPayError::InvalidRequest)?; + Ok(match map_approval(&transaction) { + Some(approval) => PaymentAction::ApproveToken { chain, approval }, + None => PaymentAction::SendTransaction { chain, transaction }, + }) + } + WalletConnectAction::SignAllTransactions { .. } => Err(WalletConnectPayError::Unsupported("signAllTransactions".to_string())), + WalletConnectAction::ChainOperation { .. } => Err(WalletConnectPayError::Unsupported("chainOperation".to_string())), + WalletConnectAction::GetAccounts { .. } => Err(WalletConnectPayError::Unsupported("getAccounts".to_string())), + WalletConnectAction::Unsupported { method } => Err(WalletConnectPayError::Unsupported(method)), + } +} + +fn map_approval(transaction: &SignableTransaction) -> Option { + let SignableTransaction::Ethereum { data, .. } = transaction else { + return None; + }; + let call = decode_call(data.data.as_ref()?, None).ok()?; + if call.function != APPROVE_CALL { + return None; + } + let param = |name: &str| call.params.iter().find(|param| param.name == name).map(|param| param.value.clone()); + Some(ApprovalData::new(data.to.clone(), param(APPROVE_SPENDER)?, param(APPROVE_VALUE)?)) +} + +fn with_encoded_solana_output(action: WalletConnectAction) -> WalletConnectAction { + match action { + WalletConnectAction::SignTransaction { + chain, + transaction_type: SignableTransactionType::Solana { .. }, + data, + } => WalletConnectAction::SignTransaction { + chain, + transaction_type: SignableTransactionType::Solana { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data, + }, + action => action, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_connect_pay::model::{FetchActionsResponse, WalletConnectPayAction}; + use crate::wallet_connect_pay::testkit::{TEST_ACCOUNT_ETHEREUM, TEST_ACCOUNT_POLYGON, TEST_ACCOUNT_SOLANA}; + use primitives::Chain; + use primitives::{SignDigestType, SignMessage}; + use serde_json::Value; + + fn get_actions(json: &str) -> Vec { + let response: FetchActionsResponse = serde_json::from_str(json).unwrap(); + response + .actions + .into_iter() + .map(|action| match action { + WalletConnectPayAction::WalletRpc(wallet_rpc) => wallet_rpc, + WalletConnectPayAction::Build(_) => panic!("Expected walletRpc action"), + }) + .collect() + } + + fn sign_message_text(account: &str, action: &WalletRpcAction) -> String { + let PaymentAction::SignMessage { message } = map_wallet_rpc(account, action).unwrap() else { + panic!("Expected a SignMessage action"); + }; + String::from_utf8(message.data).unwrap() + } + + #[test] + fn test_map_wallet_rpc_action() { + let actions = get_actions(include_str!("../../testdata/fetch_response_transfer_authorization.json")); + assert!(matches!( + map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &actions[0]).unwrap(), + PaymentAction::SignMessage { + message: SignMessage { + chain: Chain::Ethereum, + sign_type: SignDigestType::Eip712, + .. + } + } + )); + + let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json")); + match map_wallet_rpc(TEST_ACCOUNT_POLYGON, &actions[0]).unwrap() { + PaymentAction::ApproveToken { chain, approval } => { + assert_eq!(chain, Chain::Polygon); + assert_eq!(approval.token, "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"); + assert_eq!(approval.spender, "0x000000000022D473030F116dDEE9F6B43aC78BA3"); + assert!(approval.is_unlimited); + } + action => panic!("Expected an approval, got {action:?}"), + } + assert!(matches!( + map_wallet_rpc(TEST_ACCOUNT_POLYGON, &actions[1]).unwrap(), + PaymentAction::SignMessage { + message: SignMessage { + chain: Chain::Polygon, + sign_type: SignDigestType::Eip712, + .. + } + } + )); + + let actions = get_actions(include_str!("../../testdata/fetch_response_solana.json")); + match map_wallet_rpc(TEST_ACCOUNT_SOLANA, &actions[0]).unwrap() { + PaymentAction::SignTransaction { chain, transaction } => { + assert_eq!(chain, Chain::Solana); + let SignableTransaction::Solana { data, output_type } = transaction else { + panic!("Expected a Solana transaction"); + }; + assert_eq!(output_type, TransferDataOutputType::EncodedTransaction); + assert!(!data.transaction.is_empty()); + } + action => panic!("Expected Solana SignTransaction, got {action:?}"), + } + } + + #[test] + fn test_map_wallet_rpc_action_normalizes_typed_data() { + let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json")); + let typed_data: Value = serde_json::from_str(&sign_message_text(TEST_ACCOUNT_POLYGON, &actions[1])).unwrap(); + assert_eq!( + typed_data["types"]["EIP712Domain"], + serde_json::json!([ + { "name": "name", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ]) + ); + + let actions = get_actions(include_str!("../../testdata/fetch_response_transfer_authorization.json")); + assert_eq!(Value::String(sign_message_text(TEST_ACCOUNT_ETHEREUM, &actions[0])), actions[0].params[1]); + } + + #[test] + fn test_map_wallet_rpc_action_rejects() { + let actions = get_actions(include_str!("../../testdata/fetch_response_permit2.json")); + + let mismatched_account = "eip155:137:0x9999999999999999999999999999999999999999"; + assert!(matches!( + map_wallet_rpc(mismatched_account, &actions[0]), + Err(WalletConnectPayError::InvalidRequest(message)) if message.contains("mismatch") + )); + assert!(matches!( + map_wallet_rpc(mismatched_account, &actions[1]), + Err(WalletConnectPayError::InvalidRequest(message)) if message.contains("mismatch") + )); + + let wrong_chain = WalletRpcAction { + chain_id: "eip155:1".to_string(), + ..actions[1].clone() + }; + assert!(map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &wrong_chain).is_err()); + + let unsupported = WalletRpcAction { + chain_id: "eip155:1".to_string(), + method: "eth_sign".to_string(), + params: serde_json::json!([]), + }; + assert!(matches!( + map_wallet_rpc(TEST_ACCOUNT_ETHEREUM, &unsupported), + Err(WalletConnectPayError::Unsupported(method)) if method == "eth_sign" + )); + + assert!(matches!( + map_wallet_rpc("invalid-account", &actions[0]), + Err(WalletConnectPayError::InvalidRequest(message)) if message.contains("Invalid option account") + )); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/client.rs b/core/crates/payment/src/wallet_connect_pay/client.rs new file mode 100644 index 0000000000..b874322893 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/client.rs @@ -0,0 +1,235 @@ +use gem_client::{Client, build_path_with_query}; +use std::collections::HashMap; + +use crate::wallet_connect_pay::error::WalletConnectPayError; +use crate::wallet_connect_pay::model::{ + ConfirmPaymentRequest, FetchActionsRequest, FetchActionsResponse, PaymentOptionsRequest, PaymentOptionsResponse, PaymentStatusResponse, WalletConnectPayAction, + WalletConnectPayActionResult, +}; +use primitives::payment_decoder::wallet_connect_pay::is_payment_id; + +pub const WALLET_CONNECT_PAY_API_URL: &str = "https://api.pay.walletconnect.com"; +const WALLET_CONNECT_PAY_VERSION: &str = "2026-02-18"; + +const HEADER_WALLET_CONNECT_PAY_VERSION: &str = "WCP-Version"; +const HEADER_APP_ID: &str = "App-Id"; +const HEADER_CLIENT_ID: &str = "Client-Id"; +const HEADER_SDK_NAME: &str = "Sdk-Name"; +const HEADER_SDK_VERSION: &str = "Sdk-Version"; + +const SDK_NAME: &str = "gem-wallet"; +const SDK_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const QUERY_INCLUDE_PAYMENT_INFO: &str = "includePaymentInfo"; +const QUERY_MAX_POLL_MS: &str = "maxPollMs"; +const MAX_POLL_MS: i64 = 60_000; + +#[derive(Debug, Clone)] +pub struct WalletConnectPayAuth { + pub app_id: String, + pub client_id: String, +} + +impl WalletConnectPayAuth { + pub fn new(app_id: String, client_id: String) -> Self { + Self { app_id, client_id } + } +} + +#[derive(Debug)] +pub struct WalletConnectPayClient { + client: C, + auth: WalletConnectPayAuth, +} + +impl WalletConnectPayClient { + pub fn new(client: C, auth: WalletConnectPayAuth) -> Self { + Self { client, auth } + } + + pub async fn get_options(&self, payment_id: &str, accounts: Vec) -> Result { + let path = Self::path(payment_id, "/options", &[(QUERY_INCLUDE_PAYMENT_INFO, "true".to_string())])?; + let request = PaymentOptionsRequest { accounts }; + Ok(self.client.post_with(&path, &request, self.headers()).await?) + } + + pub async fn get_actions(&self, payment_id: &str, option_id: &str, data: String) -> Result, WalletConnectPayError> { + let path = Self::path(payment_id, "/fetch", &[])?; + let request = FetchActionsRequest { + option_id: option_id.to_string(), + data, + }; + let response: FetchActionsResponse = self.client.post_with(&path, &request, self.headers()).await?; + Ok(response.actions) + } + + pub async fn confirm(&self, payment_id: &str, option_id: &str, action_results: Vec) -> Result { + let path = Self::path(payment_id, "/confirm", &[(QUERY_MAX_POLL_MS, MAX_POLL_MS.to_string())])?; + let request = ConfirmPaymentRequest { + option_id: option_id.to_string(), + results: action_results.into_iter().map(WalletConnectPayActionResult::wallet_rpc).collect(), + }; + Ok(self.client.post_with(&path, &request, self.headers()).await?) + } + + pub async fn cancel(&self, payment_id: &str) -> Result<(), WalletConnectPayError> { + let path = Self::path(payment_id, "/cancel", &[])?; + let _: serde_json::Value = self.client.post_with(&path, &serde_json::Value::Null, self.headers()).await?; + Ok(()) + } + + pub async fn get_status(&self, payment_id: &str) -> Result { + let path = Self::path(payment_id, "/status", &[(QUERY_MAX_POLL_MS, MAX_POLL_MS.to_string())])?; + Ok(self.client.get_with(&path, &[], self.headers()).await?) + } + + fn path(payment_id: &str, suffix: &str, query: &[(&str, String)]) -> Result { + if !is_payment_id(payment_id) { + return Err(WalletConnectPayError::InvalidRequest(format!("Invalid payment id: {payment_id}"))); + } + let path = format!("/v1/gateway/payment/{payment_id}{suffix}"); + if query.is_empty() { + return Ok(path); + } + build_path_with_query(&path, &query).map_err(|error| WalletConnectPayError::InvalidRequest(error.to_string())) + } + + fn headers(&self) -> HashMap { + [ + (HEADER_WALLET_CONNECT_PAY_VERSION.to_string(), WALLET_CONNECT_PAY_VERSION.to_string()), + (HEADER_SDK_NAME.to_string(), SDK_NAME.to_string()), + (HEADER_SDK_VERSION.to_string(), SDK_VERSION.to_string()), + (HEADER_APP_ID.to_string(), self.auth.app_id.clone()), + (HEADER_CLIENT_ID.to_string(), self.auth.client_id.clone()), + ] + .into_iter() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_connect_pay::model::WalletRpcAction; + use crate::wallet_connect_pay::testkit::{TEST_APP_ID, TEST_CLIENT_ID}; + use gem_client::{ClientError, testkit::MockClient}; + use primitives::PaymentStatus; + + fn client(mock: MockClient) -> WalletConnectPayClient { + WalletConnectPayClient::new(mock, WalletConnectPayAuth::mock()) + } + + #[tokio::test] + async fn test_get_options() { + let mock = MockClient::new().with_post_with_headers(|path, body, headers| { + assert_eq!(headers.get(HEADER_WALLET_CONNECT_PAY_VERSION).unwrap(), WALLET_CONNECT_PAY_VERSION); + assert_eq!(headers.get(HEADER_SDK_NAME).unwrap(), SDK_NAME); + assert_eq!(headers.get(HEADER_APP_ID).unwrap(), TEST_APP_ID); + assert_eq!(headers.get(HEADER_CLIENT_ID).unwrap(), TEST_CLIENT_ID); + assert!(path.ends_with("/v1/gateway/payment/pay_123/options?includePaymentInfo=true")); + assert_eq!(serde_json::from_slice::(body).unwrap()["accounts"], serde_json::json!(["eip155:1:0x1"])); + Ok(include_str!("../../testdata/options_response.json").as_bytes().to_vec()) + }); + + let response = client(mock).get_options("pay_123", vec!["eip155:1:0x1".to_string()]).await.unwrap(); + let info = response.info.unwrap(); + assert_eq!(info.status, PaymentStatus::RequiresAction); + assert_eq!(info.merchant.name, "Gem Wallet Test Merchant"); + assert_eq!(info.amount.value, "5000"); + assert_eq!(info.amount.display.decimals, 2); + + let option = response.options.unwrap().remove(0); + assert_eq!(option.id, "opt_1"); + assert_eq!(option.account, "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD"); + assert_eq!(option.amount.display.asset_symbol, "USDC"); + assert!(option.actions.is_empty()); + assert!(option.collect_data.is_none()); + } + + #[tokio::test] + async fn test_fetch_actions() { + let mock = MockClient::new().with_post(|path, body| { + assert!(path.ends_with("/v1/gateway/payment/pay_123/fetch")); + assert_eq!( + serde_json::from_slice::(body).unwrap(), + serde_json::json!({"optionId": "opt_1", "data": ""}) + ); + Ok(include_str!("../../testdata/fetch_response_transfer_authorization.json").as_bytes().to_vec()) + }); + + let actions = client(mock).get_actions("pay_123", "opt_1", String::new()).await.unwrap(); + assert!(matches!(actions.as_slice(), [WalletConnectPayAction::WalletRpc(WalletRpcAction { method, .. })] if method == "eth_signTypedData_v4")); + } + + #[tokio::test] + async fn test_confirm() { + let mock = MockClient::new().with_post(|path, body| { + assert!(path.ends_with("/v1/gateway/payment/pay_123/confirm?maxPollMs=60000")); + assert_eq!( + serde_json::from_slice::(body).unwrap(), + serde_json::json!({ + "optionId": "opt_1", + "results": [{ "type": "walletRpc", "data": ["0xsignature"] }] + }) + ); + Ok(br#"{"status":"processing","isFinal":false,"pollInMs":1000}"#.to_vec()) + }); + + let response = client(mock).confirm("pay_123", "opt_1", vec!["0xsignature".to_string()]).await.unwrap(); + assert_eq!(response.status, PaymentStatus::Processing); + assert!(!response.is_final); + assert_eq!(response.poll_in_ms, Some(1000)); + } + + #[tokio::test] + async fn test_get_status() { + let mock = MockClient::new().with_get(|path| { + assert!(path.ends_with("/v1/gateway/payment/pay_123/status?maxPollMs=60000")); + Ok(include_str!("../../testdata/status_response_succeeded.json").as_bytes().to_vec()) + }); + + let response = client(mock).get_status("pay_123").await.unwrap(); + assert_eq!(response.status, PaymentStatus::Succeeded); + assert!(response.is_final); + assert_eq!(response.info.unwrap().tx_id, "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4"); + } + + #[test] + fn test_path_rejects_unsafe_payment_id() { + assert!(WalletConnectPayClient::::path("pay_123", "/status", &[]).is_ok()); + assert!(WalletConnectPayClient::::path("pay_123/cancel", "/status", &[]).is_err()); + assert!(WalletConnectPayClient::::path("pay_123?x=1", "", &[]).is_err()); + assert!(WalletConnectPayClient::::path("pay 123", "", &[]).is_err()); + assert!(WalletConnectPayClient::::path("", "/status", &[]).is_err()); + } + + #[tokio::test] + async fn test_error_mapping() { + let mock = MockClient::new().with_get(|_| Err(ClientError::Http { status: 410, body: vec![] })); + assert_eq!(client(mock).get_status("pay_123").await, Err(WalletConnectPayError::PaymentExpired)); + + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 404, body: vec![] }), + WalletConnectPayError::PaymentNotFound + ); + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 409, body: vec![] }), + WalletConnectPayError::QuoteExpired + ); + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 429, body: vec![] }), + WalletConnectPayError::RateLimited + ); + assert!(matches!( + WalletConnectPayError::from(ClientError::Http { + status: 400, + body: b"bad".to_vec() + }), + WalletConnectPayError::InvalidRequest(_) + )); + assert!(matches!( + WalletConnectPayError::from(ClientError::Http { status: 500, body: vec![] }), + WalletConnectPayError::Network(_) + )); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/error.rs b/core/crates/payment/src/wallet_connect_pay/error.rs new file mode 100644 index 0000000000..7d2496bf69 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/error.rs @@ -0,0 +1,128 @@ +use gem_client::ClientError; +use std::fmt; + +const CODE_PAYMENT_NOT_FOUND: &str = "payment_not_found"; +const CODE_PAYMENT_EXPIRED: &str = "payment_expired"; +const CODE_QUOTE_EXPIRED: &str = "quote_expired"; +const CODE_RATE_LIMITED: &str = "rate_limited"; +const CODE_SANCTIONED_USER: &str = "sanctioned_user"; +const STALE_OPTION_MESSAGE: &str = "Option not found"; + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletConnectPayError { + PaymentNotFound, + PaymentExpired, + NoPaymentOptions, + UnsupportedAccounts, + QuoteExpired, + RateLimited, + SanctionedUser, + InvalidRequest(String), + Unsupported(String), + Network(String), +} + +impl fmt::Display for WalletConnectPayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PaymentNotFound => write!(f, "Payment not found"), + Self::PaymentExpired => write!(f, "Payment expired"), + Self::NoPaymentOptions => write!(f, "No payment options available"), + Self::UnsupportedAccounts => write!(f, "Wallet has no account the payment accepts"), + Self::QuoteExpired => write!(f, "Payment quote expired"), + Self::RateLimited => write!(f, "Rate limited"), + Self::SanctionedUser => write!(f, "Payment not allowed"), + Self::InvalidRequest(msg) => write!(f, "Invalid payment request: {msg}"), + Self::Unsupported(msg) => write!(f, "Unsupported payment action: {msg}"), + Self::Network(msg) => write!(f, "Network error: {msg}"), + } + } +} + +impl std::error::Error for WalletConnectPayError {} + +impl From for WalletConnectPayError { + fn from(error: ClientError) -> Self { + match error { + ClientError::Http { status, body } => Self::from_response(status, &body), + ClientError::Network(msg) | ClientError::Serialization(msg) => Self::Network(msg), + ClientError::Timeout => Self::Network("Timeout".to_string()), + } + } +} + +impl WalletConnectPayError { + fn from_response(status: u16, body: &[u8]) -> Self { + let body = String::from_utf8_lossy(&body[..body.len().min(512)]).to_string(); + match Self::error_code(&body).as_deref() { + Some(CODE_PAYMENT_NOT_FOUND) => Self::PaymentNotFound, + Some(CODE_PAYMENT_EXPIRED) => Self::PaymentExpired, + Some(CODE_QUOTE_EXPIRED) => Self::QuoteExpired, + Some(CODE_RATE_LIMITED) => Self::RateLimited, + Some(CODE_SANCTIONED_USER) => Self::SanctionedUser, + _ if body.contains(STALE_OPTION_MESSAGE) => Self::QuoteExpired, + _ => Self::from_status(status, body), + } + } + + fn from_status(status: u16, body: String) -> Self { + match status { + 404 => Self::PaymentNotFound, + 409 => Self::QuoteExpired, + 410 => Self::PaymentExpired, + 429 => Self::RateLimited, + 400 | 422 => Self::InvalidRequest(format!("{status}: {body}")), + _ => Self::Network(format!("{status}: {body}")), + } + } + + fn error_code(body: &str) -> Option { + serde_json::from_str::(body).ok()?.get("code")?.as_str().map(str::to_string) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_from_client_error() { + let stale = ClientError::Http { + status: 400, + body: br#"{"code":"params_validation","message":"Validation error: Option not found"}"#.to_vec(), + }; + assert_eq!(WalletConnectPayError::from(stale), WalletConnectPayError::QuoteExpired); + + let invalid = ClientError::Http { + status: 400, + body: br#"{"message":"Invalid URL"}"#.to_vec(), + }; + assert!(matches!(WalletConnectPayError::from(invalid), WalletConnectPayError::InvalidRequest(_))); + + assert_eq!( + WalletConnectPayError::from(ClientError::Http { status: 410, body: vec![] }), + WalletConnectPayError::PaymentExpired + ); + } + + #[test] + fn test_error_reads_the_gateway_code_over_the_status() { + let sanctioned = ClientError::Http { + status: 400, + body: br#"{"code":"sanctioned_user","message":"User is sanctioned"}"#.to_vec(), + }; + assert_eq!(WalletConnectPayError::from(sanctioned), WalletConnectPayError::SanctionedUser); + + let not_found = ClientError::Http { + status: 400, + body: br#"{"code":"payment_not_found","message":"No such payment"}"#.to_vec(), + }; + assert_eq!(WalletConnectPayError::from(not_found), WalletConnectPayError::PaymentNotFound); + + let expired = ClientError::Http { + status: 500, + body: br#"{"code":"payment_expired"}"#.to_vec(), + }; + assert_eq!(WalletConnectPayError::from(expired), WalletConnectPayError::PaymentExpired); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/mod.rs b/core/crates/payment/src/wallet_connect_pay/mod.rs new file mode 100644 index 0000000000..c157bf7573 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/mod.rs @@ -0,0 +1,18 @@ +mod account; +mod action_mapper; +mod client; +mod error; +mod model; +mod params; +mod payment_mapper; +mod provider; +mod quote; +mod service; +mod validator; + +#[cfg(test)] +mod testkit; + +pub(crate) use client::WALLET_CONNECT_PAY_API_URL; +pub use client::WalletConnectPayAuth; +pub(crate) use service::WalletConnectPayService; diff --git a/core/crates/payment/src/wallet_connect_pay/model.rs b/core/crates/payment/src/wallet_connect_pay/model.rs new file mode 100644 index 0000000000..f4b5d0afb9 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/model.rs @@ -0,0 +1,151 @@ +use primitives::{PaymentMerchant, PaymentStatus}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::wallet_connect_pay::error::WalletConnectPayError; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAmount { + pub unit: String, + pub value: String, + pub display: PaymentAmountDisplay, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAmountDisplay { + pub asset_symbol: String, + pub asset_name: String, + pub decimals: i32, + #[serde(default)] + pub icon_url: Option, + #[serde(default)] + pub network_name: Option, + #[serde(default)] + pub network_icon_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentInfo { + pub status: PaymentStatus, + pub amount: PaymentAmount, + pub expires_at: i64, + pub merchant: PaymentMerchant, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOption { + pub id: String, + pub account: String, + pub amount: PaymentAmount, + #[serde(default)] + pub expires_at: Option, + #[serde(default)] + pub actions: Vec, + #[serde(default)] + pub collect_data: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentCollectData { + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +pub enum WalletConnectPayAction { + WalletRpc(WalletRpcAction), + Build(BuildAction), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalletRpcAction { + pub chain_id: String, + pub method: String, + pub params: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuildAction { + pub data: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +pub enum WalletConnectPayActionResult { + WalletRpc(Vec), +} + +impl WalletConnectPayActionResult { + pub fn wallet_rpc(result: String) -> Self { + Self::WalletRpc(vec![Value::String(result)]) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOptionsRequest { + pub accounts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOptionsResponse { + #[serde(default)] + pub info: Option, + #[serde(default)] + pub options: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchActionsRequest { + pub option_id: String, + pub data: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FetchActionsResponse { + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmPaymentRequest { + pub option_id: String, + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentStatusResponse { + pub status: PaymentStatus, + pub is_final: bool, + #[serde(default)] + pub poll_in_ms: Option, + #[serde(default)] + pub info: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentResultInfo { + pub tx_id: String, + #[serde(default)] + pub option_amount: Option, +} + +impl TryFrom for WalletRpcAction { + type Error = WalletConnectPayError; + + fn try_from(action: WalletConnectPayAction) -> Result { + match action { + WalletConnectPayAction::WalletRpc(wallet_rpc) => Ok(wallet_rpc), + WalletConnectPayAction::Build(_) => Err(WalletConnectPayError::InvalidRequest("Payment action is not a wallet RPC call".to_string())), + } + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/params.rs b/core/crates/payment/src/wallet_connect_pay/params.rs new file mode 100644 index 0000000000..2d5c0b91c1 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/params.rs @@ -0,0 +1,154 @@ +use gem_evm::eip712::{EIP712Type, eip712_domain_types}; +use primitives::ValueAccess; +use serde_json::{Map, Value}; +use std::fmt::Display; + +use crate::wallet_connect_pay::error::WalletConnectPayError; + +const SOLANA_METHOD_PREFIX: &str = "solana_"; +const METHOD_ETH_SIGN_TYPED_DATA_V4: &str = "eth_signTypedData_v4"; +const TYPE_EIP712_DOMAIN: &str = "EIP712Domain"; + +pub fn normalize(method: &str, params: &Value) -> Result { + if method.starts_with(SOLANA_METHOD_PREFIX) { + return Ok(unwrapped_solana_transaction(params)); + } + if method == METHOD_ETH_SIGN_TYPED_DATA_V4 { + return typed_data_with_schema(params); + } + Ok(params.clone()) +} + +fn unwrapped_solana_transaction(params: &Value) -> Value { + match params.as_array().map(Vec::as_slice) { + Some([transaction]) => transaction.clone(), + _ => params.clone(), + } +} + +fn typed_data_with_schema(params: &Value) -> Result { + let signer = params.at(0).map_err(invalid)?; + let typed_data = params.at(1).map_err(invalid)?; + Ok(Value::Array(vec![signer.clone(), with_domain_schema(typed_data)?])) +} + +fn with_domain_schema(typed_data: &Value) -> Result { + let Value::String(json) = typed_data else { + return insert_domain_schema(typed_data); + }; + let decoded = serde_json::from_str(json).map_err(|error| invalid(format!("Invalid typed data: {error}")))?; + let encoded = serde_json::to_string(&insert_domain_schema(&decoded)?).map_err(invalid)?; + Ok(Value::String(encoded)) +} + +fn insert_domain_schema(typed_data: &Value) -> Result { + if typed_data.get_value("types").and_then(|types| types.get_value(TYPE_EIP712_DOMAIN)).is_ok() { + return Ok(typed_data.clone()); + } + + let domain = typed_data.get_value("domain").map_err(invalid)?; + let schema = serde_json::to_value(domain_schema(domain)?).map_err(invalid)?; + let types = object(typed_data.get_value("types").map_err(invalid)?, "types")? + .clone() + .into_iter() + .chain([(TYPE_EIP712_DOMAIN.to_string(), schema)]) + .collect(); + + Ok(Value::Object( + object(typed_data, "typed data")? + .clone() + .into_iter() + .chain([("types".to_string(), Value::Object(types))]) + .collect(), + )) +} + +fn domain_schema(domain: &Value) -> Result, WalletConnectPayError> { + let domain = object(domain, "EIP712 domain")?; + let fields = eip712_domain_types(); + + for (name, value) in domain { + if !fields.iter().any(|field| field.name == *name) { + return Err(invalid(format!("Unsupported EIP712 domain field: {name}"))); + } + if value.is_null() { + return Err(invalid(format!("Missing EIP712 domain field value: {name}"))); + } + } + + Ok(fields.into_iter().filter(|field| domain.contains_key(&field.name)).collect()) +} + +fn object<'a>(value: &'a Value, name: &str) -> Result<&'a Map, WalletConnectPayError> { + value.as_object().ok_or_else(|| invalid(format!("Expected {name} object"))) +} + +fn invalid(message: impl Display) -> WalletConnectPayError { + WalletConnectPayError::InvalidRequest(message.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signer_params(typed_data: Value) -> Value { + Value::Array(vec![Value::String("0x1".to_string()), typed_data]) + } + + fn domain_schema_of(params: &Value) -> Value { + let typed_data = match ¶ms[1] { + Value::String(json) => serde_json::from_str(json).unwrap(), + value => value.clone(), + }; + typed_data["types"][TYPE_EIP712_DOMAIN].clone() + } + + fn permit2_typed_data() -> Value { + serde_json::json!({ + "domain": {"name": "Permit2", "chainId": 1, "verifyingContract": "0x00"}, + "types": {"PermitSingle": []}, + "primaryType": "PermitSingle", + "message": {}, + }) + } + + #[test] + fn test_normalize() { + let transaction = Value::String("base64".to_string()); + let solana_params = Value::Array(vec![transaction.clone()]); + assert_eq!(normalize("solana_signTransaction", &solana_params).unwrap(), transaction); + assert_eq!(normalize("eth_sendTransaction", &solana_params).unwrap(), solana_params); + + let params = normalize(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(permit2_typed_data())).unwrap(); + assert_eq!( + domain_schema_of(¶ms), + serde_json::json!([ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ]) + ); + + let as_string = Value::String(serde_json::to_string(&permit2_typed_data()).unwrap()); + let params = normalize(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(as_string)).unwrap(); + assert!(params[1].is_string()); + assert_eq!(domain_schema_of(¶ms).as_array().unwrap().len(), 3); + + let with_schema = signer_params(serde_json::json!({ + "domain": {"name": "Permit2"}, + "types": {TYPE_EIP712_DOMAIN: [{"name": "verifyingContract", "type": "address"}]}, + })); + assert_eq!(normalize(METHOD_ETH_SIGN_TYPED_DATA_V4, &with_schema).unwrap(), with_schema); + } + + #[test] + fn test_wallet_connect_params_rejects_unusable_typed_data() { + let rejected = |typed_data: Value| normalize(METHOD_ETH_SIGN_TYPED_DATA_V4, &signer_params(typed_data)).is_err(); + + assert!(rejected(serde_json::json!({"domain": {"chainId": 1, "unexpected": "1"}, "types": {}}))); + assert!(rejected(serde_json::json!({"domain": {"chainId": 1, "salt": "0x00"}, "types": {}}))); + assert!(rejected(serde_json::json!({"domain": {"chainId": null}, "types": {}}))); + assert!(rejected(serde_json::json!({"domain": {"chainId": 1}}))); + assert!(rejected(Value::String("{".to_string()))); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs b/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs new file mode 100644 index 0000000000..42116c226e --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/payment_mapper.rs @@ -0,0 +1,160 @@ +use chrono::{DateTime, Utc}; +use primitives::{AssetId, PaymentAmount, PaymentOutcome, PaymentPrice, WalletConnectCAIP19}; +use url::Url; + +use crate::wallet_connect_pay::account::account_chain; +use crate::wallet_connect_pay::error::WalletConnectPayError; +use crate::wallet_connect_pay::model::{PaymentInfo, PaymentOption, PaymentOptionsResponse, PaymentStatusResponse}; +use crate::wallet_connect_pay::quote::{QuotedOption, QuotedPayment}; + +const CAIP19_PREFIX: &str = "caip19"; +const COLLECT_DATA_HOST: &str = "walletconnect.com"; + +pub fn map_quoted_payment(response: PaymentOptionsResponse) -> Result { + let payment = response.info.ok_or(WalletConnectPayError::PaymentNotFound)?; + Ok(QuotedPayment { + status: payment.status, + expires_at: map_expiry(&payment)?, + price: PaymentPrice { + symbol: payment.amount.display.asset_symbol.clone(), + value: payment.amount.value.clone(), + decimals: payment.amount.display.decimals, + }, + merchant: payment.merchant, + options: response.options.unwrap_or_default().into_iter().filter_map(map_option).collect(), + }) +} + +pub fn map_payment_outcome(response: PaymentStatusResponse) -> PaymentOutcome { + PaymentOutcome { + status: response.status, + transaction_id: response.info.map(|info| info.tx_id), + } +} + +fn map_expiry(payment: &PaymentInfo) -> Result, WalletConnectPayError> { + DateTime::from_timestamp(payment.expires_at, 0).ok_or_else(|| WalletConnectPayError::InvalidRequest("Invalid payment expiry".to_string())) +} + +fn map_option(option: PaymentOption) -> Option { + let provider_data = serde_json::to_string(&option).ok()?; + let collect_data_url = match &option.collect_data { + Some(collect_data) => Some(collect_data_url(&collect_data.url)?), + None => None, + }; + Some(QuotedOption { + id: option.id.clone(), + expires_at: option.expires_at.and_then(|expires_at| DateTime::from_timestamp(expires_at, 0)), + chain: account_chain(&option.account)?, + provider_data, + amount: PaymentAmount { + asset_id: asset_id(&option.amount.unit)?, + value: option.amount.value, + symbol: option.amount.display.asset_symbol, + decimals: option.amount.display.decimals, + }, + collect_data_url, + }) +} + +fn collect_data_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" { + return None; + } + let host = parsed.host_str()?.to_lowercase(); + if host == COLLECT_DATA_HOST || host.ends_with(&format!(".{COLLECT_DATA_HOST}")) { + Some(url.to_string()) + } else { + None + } +} + +fn asset_id(unit: &str) -> Option { + match unit.split_once('/')? { + (CAIP19_PREFIX, asset) => WalletConnectCAIP19::get_asset_id(asset), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Chain, PaymentStatus}; + + fn options_response() -> PaymentOptionsResponse { + serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap() + } + + #[test] + fn test_map_quoted_payment() { + let quoted = map_quoted_payment(options_response()).unwrap(); + + assert_eq!(quoted.status, PaymentStatus::RequiresAction); + assert_eq!(quoted.merchant.name, "Gem Wallet Test Merchant"); + assert_eq!(quoted.expires_at.timestamp(), 1785175272); + + let option = "ed.options[0]; + assert_eq!(option.id, "opt_1"); + assert_eq!(option.chain, Chain::Ethereum); + assert_eq!(option.collect_data_url, None); + } + + #[test] + fn test_asset_id_reads_the_chain_coin_and_its_tokens() { + assert_eq!(asset_id("caip19/eip155:1/slip44:60"), Some(AssetId::from(Chain::Ethereum, None))); + assert_eq!( + asset_id("caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + Some(AssetId::from_token(Chain::Base, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")) + ); + assert_eq!(asset_id("caip19/eip155:1").unwrap(), AssetId::from(Chain::Ethereum, None)); + assert_eq!(asset_id("caip19/eip155:99999/erc20:0x1"), None); + assert_eq!(asset_id("eip155:1/erc20:0x1"), None); + assert_eq!(asset_id("iso4217/USD"), None); + } + + #[test] + fn test_map_option_with_collect_data() { + let option: PaymentOption = serde_json::from_str(include_str!("../../testdata/option_collect_data.json")).unwrap(); + let mapped = map_option(option.clone()).unwrap(); + + assert_eq!(mapped.collect_data_url.unwrap(), "https://data-collection.walletconnect.com/ic/pay_123"); + + for url in [ + "https://evil.com/ic/pay_123", + "https://evil-walletconnect.com/ic/pay_123", + "http://walletconnect.com/ic/pay_123", + "not a url", + ] { + let mut off_domain = option.clone(); + off_domain.collect_data.as_mut().unwrap().url = url.to_string(); + + assert!(map_option(off_domain).is_none(), "{url} must not reach the collection web view"); + } + } + + #[test] + fn test_map_payment_options_drops_unpayable_option() { + let mut response = options_response(); + let options = response.options.as_mut().unwrap(); + options[0].account = "cosmos:cosmoshub-4:cosmos1".to_string(); + + assert!(map_quoted_payment(response).unwrap().options.is_empty()); + } + + #[test] + fn test_map_payment_options_without_info() { + let response = PaymentOptionsResponse { info: None, options: None }; + + assert_eq!(map_quoted_payment(response), Err(WalletConnectPayError::PaymentNotFound)); + } + + #[test] + fn test_map_payment_outcome() { + let response: PaymentStatusResponse = serde_json::from_str(include_str!("../../testdata/status_response_succeeded.json")).unwrap(); + let outcome = map_payment_outcome(response); + + assert_eq!(outcome.status, PaymentStatus::Succeeded); + assert_eq!(outcome.transaction_id.unwrap(), "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4"); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/provider.rs b/core/crates/payment/src/wallet_connect_pay/provider.rs new file mode 100644 index 0000000000..a22f8ecc1f --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/provider.rs @@ -0,0 +1,51 @@ +use crate::{PaymentError, PaymentProvider, PreparedPayment}; +use async_trait::async_trait; +use gem_client::Client; +use primitives::{ChainAddress, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::wallet_connect_pay::error::WalletConnectPayError; +use crate::wallet_connect_pay::service::WalletConnectPayService; + +#[async_trait] +impl PaymentProvider for WalletConnectPayService { + fn name(&self) -> PaymentProviderName { + PaymentProviderName::WalletConnectPay + } + + async fn get_options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result { + Ok(self.payment_options(payment_id, addresses).await?) + } + + async fn get_prepared_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result { + Ok(self.prepare_payment(quotes, quote, addresses).await?) + } + + async fn confirm(&self, quote: &PaymentQuote, results: Vec) -> Result { + Ok(self.confirm_payment(quote, results).await?) + } + + async fn get_status(&self, payment_id: &str) -> Result { + Ok(self.get_payment_status(payment_id).await?) + } + + async fn cancel(&self, payment_id: &str) -> Result<(), PaymentError> { + Ok(self.cancel_payment(payment_id).await?) + } +} + +impl From for PaymentError { + fn from(error: WalletConnectPayError) -> Self { + match error { + WalletConnectPayError::PaymentNotFound => Self::PaymentNotFound, + WalletConnectPayError::PaymentExpired => Self::PaymentExpired, + WalletConnectPayError::QuoteExpired => Self::QuoteExpired, + WalletConnectPayError::NoPaymentOptions => Self::NoPaymentOptions, + WalletConnectPayError::UnsupportedAccounts => Self::UnsupportedAccounts, + WalletConnectPayError::SanctionedUser => Self::Rejected, + WalletConnectPayError::RateLimited => Self::RateLimited, + WalletConnectPayError::Unsupported(_) => Self::NotSupported, + WalletConnectPayError::InvalidRequest(message) => Self::InvalidRequest(message), + WalletConnectPayError::Network(message) => Self::Network(message), + } + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/quote.rs b/core/crates/payment/src/wallet_connect_pay/quote.rs new file mode 100644 index 0000000000..be0debd41f --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/quote.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use primitives::{Chain, PaymentAmount, PaymentMerchant, PaymentPrice, PaymentStatus}; + +#[derive(Debug, Clone, PartialEq)] +pub struct QuotedPayment { + pub status: PaymentStatus, + pub expires_at: DateTime, + pub merchant: PaymentMerchant, + pub price: PaymentPrice, + pub options: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct QuotedOption { + pub id: String, + pub expires_at: Option>, + pub chain: Chain, + pub amount: PaymentAmount, + pub collect_data_url: Option, + pub provider_data: String, +} diff --git a/core/crates/payment/src/wallet_connect_pay/service.rs b/core/crates/payment/src/wallet_connect_pay/service.rs new file mode 100644 index 0000000000..695af07538 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/service.rs @@ -0,0 +1,339 @@ +use crate::wallet_connect_pay::account::account_identifier; +use crate::wallet_connect_pay::action_mapper::map_wallet_rpc; +use chrono::Utc; +use gem_client::Client; +use primitives::{AssetId, ChainAddress, PaymentOptions, PaymentOutcome, PaymentQuote, PaymentQuotes, PaymentStatus}; + +use crate::wallet_connect_pay::client::{WalletConnectPayAuth, WalletConnectPayClient}; +use crate::wallet_connect_pay::error::WalletConnectPayError; +use crate::wallet_connect_pay::model::{PaymentOption, WalletConnectPayAction, WalletRpcAction}; +use crate::wallet_connect_pay::payment_mapper; +use crate::wallet_connect_pay::quote::QuotedOption; +use crate::{PaymentAction, PreparedPayment}; + +#[derive(Debug)] +pub struct WalletConnectPayService { + client: WalletConnectPayClient, +} + +impl WalletConnectPayService { + pub fn new(client: C, auth: WalletConnectPayAuth) -> Self { + Self { + client: WalletConnectPayClient::new(client, auth), + } + } + + pub(super) async fn prepare_payment(&self, quotes: &PaymentQuotes, quote: &PaymentQuote, addresses: &[ChainAddress]) -> Result { + if Self::is_expired(quote) { + return self.get_requoted_actions(quote, addresses).await; + } + match self.payment_actions(quote).await { + Ok(actions) => Ok(PreparedPayment { + quotes: quotes.clone(), + quote: quote.clone(), + actions, + }), + Err(WalletConnectPayError::QuoteExpired) => self.get_requoted_actions(quote, addresses).await, + Err(error) => Err(error), + } + } + + async fn get_requoted_actions(&self, expired: &PaymentQuote, addresses: &[ChainAddress]) -> Result { + let quotes = match self.payment_options(&expired.payment_id, addresses).await? { + PaymentOptions::Quotes(quotes) => quotes, + PaymentOptions::Outcome(_) => return Err(WalletConnectPayError::PaymentExpired), + }; + let quote = Self::get_quote("es, &expired.amount.asset_id)?; + let actions = self.payment_actions("e).await?; + Ok(PreparedPayment { quotes, quote, actions }) + } + + pub(super) async fn confirm_payment(&self, quote: &PaymentQuote, action_results: Vec) -> Result { + let response = self.client.confirm("e.payment_id, "e.id, action_results).await?; + Ok(payment_mapper::map_payment_outcome(response)) + } + + pub(super) async fn payment_options(&self, payment_id: &str, addresses: &[ChainAddress]) -> Result { + let identifiers: Vec = addresses.iter().filter_map(|address| account_identifier(address.chain, &address.address)).collect(); + if identifiers.is_empty() { + return Err(WalletConnectPayError::UnsupportedAccounts); + } + let response = self.client.get_options(payment_id, identifiers).await?; + let quoted = payment_mapper::map_quoted_payment(response)?; + + match quoted.status { + PaymentStatus::RequiresAction => {} + PaymentStatus::Succeeded | PaymentStatus::Processing => { + return Ok(PaymentOptions::Outcome(PaymentOutcome { + status: quoted.status, + transaction_id: None, + })); + } + PaymentStatus::Failed | PaymentStatus::Expired | PaymentStatus::Cancelled => return Err(WalletConnectPayError::PaymentExpired), + } + if quoted.expires_at <= Utc::now() { + return Err(WalletConnectPayError::PaymentExpired); + } + + let quotes = Self::payment_quotes(payment_id, quoted.options); + if quotes.is_empty() { + return Err(WalletConnectPayError::NoPaymentOptions); + } + + Ok(PaymentOptions::Quotes(PaymentQuotes { + merchant: quoted.merchant, + price: Some(quoted.price), + expires_at: Some(quoted.expires_at), + quotes, + })) + } + + fn is_expired(quote: &PaymentQuote) -> bool { + quote.expires_at.is_some_and(|expires_at| expires_at <= Utc::now()) + } + + fn get_quote(quotes: &PaymentQuotes, asset_id: &AssetId) -> Result { + quotes + .quotes + .iter() + .find(|quote| "e.amount.asset_id == asset_id) + .or(quotes.quotes.first()) + .cloned() + .ok_or(WalletConnectPayError::NoPaymentOptions) + } + + async fn payment_actions(&self, quote: &PaymentQuote) -> Result, WalletConnectPayError> { + let option = Self::option(quote)?; + let actions = self.wallet_rpc_actions("e.payment_id, &option).await?; + if actions.is_empty() { + return Err(WalletConnectPayError::InvalidRequest("Payment option has no executable actions".to_string())); + } + + actions.iter().map(|action| map_wallet_rpc(&option.account, action)).collect() + } + + pub(super) async fn cancel_payment(&self, payment_id: &str) -> Result<(), WalletConnectPayError> { + self.client.cancel(payment_id).await + } + + pub(super) async fn get_payment_status(&self, payment_id: &str) -> Result { + let response = self.client.get_status(payment_id).await?; + Ok(payment_mapper::map_payment_outcome(response)) + } + + fn payment_quotes(payment_id: &str, options: Vec) -> Vec { + let (open, collecting): (Vec, Vec) = options.into_iter().partition(|option| option.collect_data_url.is_none()); + open.into_iter() + .chain(collecting) + .map(|option| PaymentQuote { + id: option.id, + payment_id: payment_id.to_string(), + amount: option.amount, + expires_at: option.expires_at, + collect_data_url: option.collect_data_url, + provider_data: option.provider_data, + }) + .collect() + } + + async fn wallet_rpc_actions(&self, payment_id: &str, option: &PaymentOption) -> Result, WalletConnectPayError> { + let actions = match option.actions.first() { + None => self.client.get_actions(payment_id, &option.id, String::new()).await?, + Some(WalletConnectPayAction::Build(build)) => self.client.get_actions(payment_id, &option.id, build.data.clone()).await?, + Some(WalletConnectPayAction::WalletRpc(_)) => option.actions.clone(), + }; + + actions.into_iter().map(WalletRpcAction::try_from).collect() + } + + fn option(quote: &PaymentQuote) -> Result { + serde_json::from_str("e.provider_data).map_err(|error| WalletConnectPayError::InvalidRequest(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use chrono::Duration; + use gem_client::ClientError; + use gem_client::testkit::MockClient; + use primitives::{AssetId, Chain, PaymentAmount}; + use std::sync::{Arc, Mutex}; + + fn service(mock: MockClient) -> WalletConnectPayService { + WalletConnectPayService::new(mock, WalletConnectPayAuth::mock()) + } + + fn service_with_response(transform: impl Fn(&mut serde_json::Value) + Send + Sync + 'static) -> WalletConnectPayService { + service(MockClient::new().with_post(move |_, _| { + let mut response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap(); + transform(&mut response); + Ok(response.to_string().into_bytes()) + })) + } + + #[tokio::test] + async fn test_prepared_payment_requotes_a_stale_quote() { + let requested = Arc::new(Mutex::new(Vec::::new())); + let seen = requested.clone(); + let client = MockClient::new().with_post(move |path: &str, _| { + seen.lock().unwrap().push(path.to_string()); + if path.contains("/fetch") { + return Err(ClientError::Http { + status: 409, + body: br#"{"code":"quote_expired"}"#.to_vec(), + }); + } + let mut response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap(); + far_future(&mut response); + Ok(response.to_string().into_bytes()) + }); + let service = service(client); + let PaymentOptions::Quotes(quotes) = service.payment_options("pay_123", &addresses()).await.unwrap() else { + panic!("Expected quotes"); + }; + let selected = quotes.quotes.first().unwrap().clone(); + + let result = service.prepare_payment("es, &selected, &addresses()).await; + + let paths = requested.lock().unwrap().clone(); + assert!(paths.iter().filter(|path| path.contains("/options")).count() == 2, "expected a requote, got {paths:?}"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_prepared_payment_refetches_before_a_dead_quote_is_used() { + let requested = Arc::new(Mutex::new(Vec::::new())); + let seen = requested.clone(); + let client = MockClient::new().with_post(move |path: &str, _| { + seen.lock().unwrap().push(path.to_string()); + let mut response: serde_json::Value = serde_json::from_str(include_str!("../../testdata/options_response.json")).unwrap(); + far_future(&mut response); + Ok(response.to_string().into_bytes()) + }); + let service = service(client); + let PaymentOptions::Quotes(quotes) = service.payment_options("pay_123", &addresses()).await.unwrap() else { + panic!("Expected quotes"); + }; + let expired = PaymentQuote { + expires_at: Some(Utc::now() - Duration::seconds(1)), + ..quotes.quotes.first().unwrap().clone() + }; + + let _ = service.prepare_payment("es, &expired, &addresses()).await; + + let paths = requested.lock().unwrap().clone(); + assert_eq!(paths.iter().filter(|path| path.contains("/options")).count(), 2); + assert_eq!(paths.iter().filter(|path| path.contains("/fetch")).count(), 1); + } + + fn addresses() -> Vec { + vec![ChainAddress::new(Chain::Ethereum, "0x1".to_string()), ChainAddress::new(Chain::Bitcoin, "bc1".to_string())] + } + + fn far_future(response: &mut serde_json::Value) { + response["info"]["expiresAt"] = serde_json::json!(4102444800i64); + } + + #[tokio::test] + async fn test_get_payment_options() { + let service = service_with_response(far_future); + let prepared = service.payment_options("pay_123", &addresses()).await.unwrap(); + + let PaymentOptions::Quotes(quotes) = prepared else { + panic!("Expected Ready, got {prepared:?}"); + }; + assert_eq!(quotes.merchant.name, "Gem Wallet Test Merchant"); + let quote = quotes.quotes.first().unwrap(); + assert_eq!(quote.payment_id, "pay_123"); + assert_eq!(quote.amount.symbol, "USDC"); + assert_eq!(quote.amount.value, "50000000"); + assert_eq!(WalletConnectPayService::::option(quote).unwrap().id, "opt_1"); + } + + #[tokio::test] + async fn test_get_payment_options_collect_data() { + let service = service_with_response(|response| { + far_future(response); + response["options"][0]["collectData"] = serde_json::json!({"url": "https://data-collection.walletconnect.com/ic/pay_123"}); + }); + + let prepared = service.payment_options("pay_123", &addresses()).await.unwrap(); + let PaymentOptions::Quotes(quotes) = prepared else { + panic!("Expected Ready, got {prepared:?}"); + }; + assert_eq!( + quotes.quotes.first().unwrap().collect_data_url.as_deref(), + Some("https://data-collection.walletconnect.com/ic/pay_123") + ); + } + + #[tokio::test] + async fn test_get_payment_options_settled() { + let service = service_with_response(|response| { + far_future(response); + response["info"]["status"] = serde_json::json!("succeeded"); + }); + + let options = service.payment_options("pay_123", &addresses()).await.unwrap(); + assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Succeeded)); + + let processing = service_with_response(|response| { + far_future(response); + response["info"]["status"] = serde_json::json!("processing"); + }); + let options = processing.payment_options("pay_123", &addresses()).await.unwrap(); + assert!(matches!(options, PaymentOptions::Outcome(outcome) if outcome.status == PaymentStatus::Processing)); + } + + #[tokio::test] + async fn test_get_payment_options_rejects_unpayable() { + let failed = service_with_response(|response| { + far_future(response); + response["info"]["status"] = serde_json::json!("failed"); + }); + assert_eq!(failed.payment_options("pay_123", &addresses()).await, Err(WalletConnectPayError::PaymentExpired)); + + let expired = service_with_response(|response| { + response["info"]["expiresAt"] = serde_json::json!(1); + }); + assert_eq!(expired.payment_options("pay_123", &addresses()).await, Err(WalletConnectPayError::PaymentExpired)); + + let no_options = service_with_response(|response| { + far_future(response); + response["options"] = serde_json::json!([]); + }); + assert_eq!(no_options.payment_options("pay_123", &addresses()).await, Err(WalletConnectPayError::NoPaymentOptions)); + + let unsupported_addresses = vec![ChainAddress::new(Chain::Bitcoin, "bc1".to_string())]; + assert_eq!( + service_with_response(far_future).payment_options("pay_123", &unsupported_addresses).await, + Err(WalletConnectPayError::UnsupportedAccounts) + ); + } + + #[test] + fn test_payment_quotes_offer_options_asking_for_no_personal_data_first() { + let option = |id: &str, collect_data_url: Option<&str>| QuotedOption { + id: id.to_string(), + expires_at: None, + chain: Chain::Ethereum, + amount: PaymentAmount { + asset_id: AssetId::from(Chain::Ethereum, None), + value: "1".to_string(), + symbol: "ETH".to_string(), + decimals: 18, + }, + collect_data_url: collect_data_url.map(str::to_string), + provider_data: format!("{{\"id\":\"{id}\"}}"), + }; + + let quotes = WalletConnectPayService::::payment_quotes("pay_123", vec![option("opt_form", Some("https://form")), option("opt_plain", None)]); + + assert_eq!(quotes.len(), 2); + assert!(quotes[0].collect_data_url.is_none()); + assert!(quotes[1].collect_data_url.is_some()); + assert!(quotes.iter().all(|quote| quote.payment_id == "pay_123")); + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/testkit.rs b/core/crates/payment/src/wallet_connect_pay/testkit.rs new file mode 100644 index 0000000000..fbf3e2ee4f --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/testkit.rs @@ -0,0 +1,16 @@ +use crate::wallet_connect_pay::client::WalletConnectPayAuth; + +pub const TEST_ACCOUNT_ETHEREUM: &str = "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD"; +pub const TEST_ACCOUNT_POLYGON: &str = "eip155:137:0x1085c5f70F7F7591D97da281A64688385455c2bD"; +pub const TEST_ACCOUNT_SOLANA: &str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5"; +pub const TEST_APP_ID: &str = "app_1"; +pub const TEST_CLIENT_ID: &str = "client_1"; + +impl WalletConnectPayAuth { + pub fn mock() -> Self { + Self { + app_id: TEST_APP_ID.to_string(), + client_id: TEST_CLIENT_ID.to_string(), + } + } +} diff --git a/core/crates/payment/src/wallet_connect_pay/validator.rs b/core/crates/payment/src/wallet_connect_pay/validator.rs new file mode 100644 index 0000000000..c6be02d709 --- /dev/null +++ b/core/crates/payment/src/wallet_connect_pay/validator.rs @@ -0,0 +1,102 @@ +use gem_wallet_connect::WalletConnectAction; +use primitives::{ChainType, SignDigestType, ValueAccess, WCEthereumTransaction, WalletConnectCAIP2}; +use serde_json::Value; + +use crate::wallet_connect_pay::error::WalletConnectPayError; + +pub fn validate_signer(account: &str, params: &Value, action: &WalletConnectAction) -> Result<(), WalletConnectPayError> { + let chain_address = + WalletConnectCAIP2::parse_account(account.to_string()).ok_or_else(|| WalletConnectPayError::InvalidRequest(format!("Invalid option account: {account}")))?; + if chain_address.chain.chain_type() != ChainType::Ethereum { + return Ok(()); + } + + match action { + WalletConnectAction::SendTransaction { data, .. } | WalletConnectAction::SignTransaction { data, .. } => { + let transaction: WCEthereumTransaction = + serde_json::from_str(data).map_err(|error| WalletConnectPayError::InvalidRequest(format!("Invalid transaction payload: {error}")))?; + validate_address_match(&chain_address.address, &transaction.from) + } + WalletConnectAction::SignMessage { + sign_type: SignDigestType::Eip712, + .. + } => { + let signer = params.at(0).and_then(Value::string).map_err(WalletConnectPayError::InvalidRequest)?; + validate_address_match(&chain_address.address, signer) + } + WalletConnectAction::SignMessage { + sign_type: + SignDigestType::Eip191 | SignDigestType::Base58 | SignDigestType::SuiPersonal | SignDigestType::Siwe | SignDigestType::TonPersonal | SignDigestType::TronPersonal, + .. + } + | WalletConnectAction::SignAllTransactions { .. } + | WalletConnectAction::ChainOperation { .. } + | WalletConnectAction::GetAccounts { .. } + | WalletConnectAction::Unsupported { .. } => Ok(()), + } +} + +fn validate_address_match(expected: &str, actual: &str) -> Result<(), WalletConnectPayError> { + if actual.to_lowercase() == expected.to_lowercase() { + return Ok(()); + } + Err(WalletConnectPayError::InvalidRequest(format!("Signer address mismatch: expected {expected}, got {actual}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_connect_pay::testkit::{TEST_ACCOUNT_ETHEREUM, TEST_ACCOUNT_SOLANA}; + use primitives::SignableTransactionType; + use primitives::{Chain, TransferDataOutputType}; + + fn send_transaction(from: &str) -> WalletConnectAction { + WalletConnectAction::SendTransaction { + chain: Chain::Ethereum, + transaction_type: SignableTransactionType::Ethereum, + data: serde_json::json!({"from": from, "to": "0x00"}).to_string(), + } + } + + fn sign_message(sign_type: SignDigestType) -> WalletConnectAction { + WalletConnectAction::SignMessage { + chain: Chain::Ethereum, + sign_type, + data: "{}".to_string(), + } + } + + #[test] + fn test_validate_signer_transaction() { + let address = "0x1085c5f70F7F7591D97da281A64688385455c2bD"; + + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction(address)).is_ok()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction(&address.to_lowercase())).is_ok()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &send_transaction("0xdead")).is_err()); + } + + #[test] + fn test_validate_signer_typed_data() { + let signer = serde_json::json!(["0x1085c5f70f7f7591d97da281a64688385455c2bd", {}]); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &signer, &sign_message(SignDigestType::Eip712)).is_ok()); + + let other_signer = serde_json::json!(["0xdead", {}]); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &other_signer, &sign_message(SignDigestType::Eip712)).is_err()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &Value::Null, &sign_message(SignDigestType::Eip712)).is_err()); + assert!(validate_signer(TEST_ACCOUNT_ETHEREUM, &other_signer, &sign_message(SignDigestType::Eip191)).is_ok()); + } + + #[test] + fn test_validate_signer_skips_non_ethereum() { + let action = WalletConnectAction::SignTransaction { + chain: Chain::Solana, + transaction_type: SignableTransactionType::Solana { + output_type: TransferDataOutputType::EncodedTransaction, + }, + data: "base64".to_string(), + }; + + assert!(validate_signer(TEST_ACCOUNT_SOLANA, &Value::Null, &action).is_ok()); + assert!(validate_signer("not-an-account", &Value::Null, &action).is_err()); + } +} diff --git a/core/crates/payment/testdata/fetch_response_permit2.json b/core/crates/payment/testdata/fetch_response_permit2.json new file mode 100644 index 0000000000..65a20e92ec --- /dev/null +++ b/core/crates/payment/testdata/fetch_response_permit2.json @@ -0,0 +1,30 @@ +{ + "actions": [ + { + "type": "walletRpc", + "data": { + "chain_id": "eip155:137", + "method": "eth_sendTransaction", + "params": [ + { + "from": "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "to": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "value": "0x0", + "data": "0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + } + }, + { + "type": "walletRpc", + "data": { + "chain_id": "eip155:137", + "method": "eth_signTypedData_v4", + "params": [ + "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "{\"domain\":{\"name\":\"Permit2\",\"chainId\":\"0x89\",\"verifyingContract\":\"0x000000000022d473030f116ddee9f6b43ac78ba3\"},\"types\":{\"PermitTransferFrom\":[{\"name\":\"permitted\",\"type\":\"TokenPermissions\"},{\"name\":\"spender\",\"type\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\"}],\"TokenPermissions\":[{\"name\":\"token\",\"type\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\"}]},\"primaryType\":\"PermitTransferFrom\",\"message\":{\"permitted\":{\"token\":\"0xc2132d05d31c914a87c6611c10748aeb04b58e8f\",\"amount\":\"1000000\"},\"spender\":\"0x0000000000a84d1a9b0063a910315c7ffa9cd248\",\"nonce\":\"0x08fda2bbc9b3fcd08a09d38f61b4c56a80a3a5a7e0a050e3a5f9c012b3c60a11\",\"deadline\":\"1785175272\"}}" + ] + } + } + ] +} diff --git a/core/crates/payment/testdata/fetch_response_solana.json b/core/crates/payment/testdata/fetch_response_solana.json new file mode 100644 index 0000000000..c6920791bb --- /dev/null +++ b/core/crates/payment/testdata/fetch_response_solana.json @@ -0,0 +1,16 @@ +{ + "actions": [ + { + "type": "walletRpc", + "data": { + "chain_id": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "method": "solana_signTransaction", + "params": [ + { + "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ] + } + } + ] +} diff --git a/core/crates/payment/testdata/fetch_response_transfer_authorization.json b/core/crates/payment/testdata/fetch_response_transfer_authorization.json new file mode 100644 index 0000000000..4e13de757c --- /dev/null +++ b/core/crates/payment/testdata/fetch_response_transfer_authorization.json @@ -0,0 +1,15 @@ +{ + "actions": [ + { + "type": "walletRpc", + "data": { + "chain_id": "eip155:1", + "method": "eth_signTypedData_v4", + "params": [ + "0x1085c5f70F7F7591D97da281A64688385455c2bD", + "{\"domain\":{\"name\":\"USD Coin\",\"version\":\"2\",\"chainId\":\"0x1\",\"verifyingContract\":\"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48\"},\"types\":{\"EIP712Domain\":[{\"type\":\"string\",\"name\":\"name\"},{\"type\":\"string\",\"name\":\"version\"},{\"type\":\"uint256\",\"name\":\"chainId\"},{\"type\":\"address\",\"name\":\"verifyingContract\"}],\"ReceiveWithAuthorization\":[{\"type\":\"address\",\"name\":\"from\"},{\"type\":\"address\",\"name\":\"to\"},{\"type\":\"uint256\",\"name\":\"value\"},{\"type\":\"uint256\",\"name\":\"validAfter\"},{\"type\":\"uint256\",\"name\":\"validBefore\"},{\"type\":\"bytes32\",\"name\":\"nonce\"}]},\"primaryType\":\"ReceiveWithAuthorization\",\"message\":{\"from\":\"0x1085c5f70F7F7591D97da281A64688385455c2bD\",\"nonce\":\"0x1111111111111111111111111111111111111111111111111111111111111111\",\"to\":\"0x2222222222222222222222222222222222222222\",\"validAfter\":\"0x0\",\"validBefore\":\"0x699cb700\",\"value\":\"0x2faf080\"}}" + ] + } + } + ] +} diff --git a/core/crates/payment/testdata/option_collect_data.json b/core/crates/payment/testdata/option_collect_data.json new file mode 100644 index 0000000000..4073ee0bb1 --- /dev/null +++ b/core/crates/payment/testdata/option_collect_data.json @@ -0,0 +1,32 @@ +{ + "id": "opt_ic", + "account": "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD", + "amount": { + "unit": "caip19/eip155:1/slip44:60", + "value": "1064973576179385", + "display": { "assetSymbol": "ETH", "assetName": "Ethereum", "decimals": 18 } + }, + "etaS": 5, + "collectData": { + "url": "https://data-collection.walletconnect.com/ic/pay_123", + "fields": [], + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "fullName": { "type": "string", "title": "Full Name", "minLength": 1, "description": "User's full legal name" }, + "dob": { "type": "string", "format": "date", "title": "Date of Birth", "description": "Date of birth in YYYY-MM-DD format" }, + "pobAddress": { "type": "string", "title": "Place of Birth Address", "maxLength": 200 }, + "pobCountry": { "type": "string", "pattern": "^[A-Z]{2}$", "title": "Place of Birth Country" }, + "porAddress": { "type": "string", "title": "Place of Residence Address", "maxLength": 200 }, + "porCountry": { "type": "string", "pattern": "^[A-Z]{2}$", "title": "Place of Residence Country" }, + "tosConfirmed": { "type": "boolean", "const": true, "title": "Terms of Service Confirmation" } + }, + "required": ["fullName", "dob", "tosConfirmed"], + "anyOf": [ + { "required": ["pobCountry", "pobAddress"] }, + { "required": ["porCountry", "porAddress"] } + ] + } + } +} diff --git a/core/crates/payment/testdata/options_response.json b/core/crates/payment/testdata/options_response.json new file mode 100644 index 0000000000..7916a68335 --- /dev/null +++ b/core/crates/payment/testdata/options_response.json @@ -0,0 +1,43 @@ +{ + "paymentId": "pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4", + "info": { + "status": "requires_action", + "amount": { + "unit": "iso4217/USD", + "value": "5000", + "display": { + "assetSymbol": "USD", + "assetName": "US Dollar", + "decimals": 2 + } + }, + "expiresAt": 1785175272, + "merchant": { + "name": "Gem Wallet Test Merchant", + "iconUrl": "https://imagedelivery.net/example/md" + }, + "buyer": null + }, + "options": [ + { + "id": "opt_1", + "account": "eip155:1:0x1085c5f70F7F7591D97da281A64688385455c2bD", + "amount": { + "unit": "caip19/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "value": "50000000", + "display": { + "assetSymbol": "USDC", + "assetName": "USD Coin", + "decimals": 6, + "networkName": "Ethereum", + "iconUrl": "https://assets.walletconnect.com/usdc.png", + "networkIconUrl": "https://assets.walletconnect.com/ethereum.png" + } + }, + "etaS": 5, + "collectData": null + } + ], + "collectData": null, + "resultInfo": null +} diff --git a/core/crates/payment/testdata/status_response_succeeded.json b/core/crates/payment/testdata/status_response_succeeded.json new file mode 100644 index 0000000000..c6e812a61b --- /dev/null +++ b/core/crates/payment/testdata/status_response_succeeded.json @@ -0,0 +1,16 @@ +{ + "status": "succeeded", + "isFinal": true, + "info": { + "txId": "test:pay_b9a2ecc101KYJAYCGQZ9E0K6NY7SR7YVV4", + "optionAmount": { + "unit": "caip19/eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "value": "50000000", + "display": { + "assetSymbol": "USDC", + "assetName": "USD Coin", + "decimals": 6 + } + } + } +} From 4874c4c8af16279ff6cb509639184973ec0ab3ca Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:31:04 +0300 Subject: [PATCH 06/27] core: expose payments through gemstone One GemPaymentService object over the neutral registry, and PaymentError crossing the FFI as itself through a remote enum, the way SwapperError already does, so the apps localize one closed set of failures. --- core/gemstone/src/payment/error.rs | 15 +++ core/gemstone/src/payment/mod.rs | 128 ++++++++++++------ core/gemstone/src/payment/remote_types.rs | 152 ++++++++++++++++++++++ core/gemstone/src/url_action.rs | 4 +- 4 files changed, 261 insertions(+), 38 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/src/payment/error.rs b/core/gemstone/src/payment/error.rs new file mode 100644 index 0000000000..61d1549542 --- /dev/null +++ b/core/gemstone/src/payment/error.rs @@ -0,0 +1,15 @@ +pub type PaymentError = payment::PaymentError; + +#[uniffi::remote(Enum)] +pub enum PaymentError { + NotSupported, + PaymentNotFound, + PaymentExpired, + QuoteExpired, + NoPaymentOptions, + UnsupportedAccounts, + Rejected, + RateLimited, + InvalidRequest(String), + Network(String), +} diff --git a/core/gemstone/src/payment/mod.rs b/core/gemstone/src/payment/mod.rs index feb4124d31..d061a52559 100644 --- a/core/gemstone/src/payment/mod.rs +++ b/core/gemstone/src/payment/mod.rs @@ -1,43 +1,97 @@ -use crate::GemstoneError; -use primitives::PaymentURLDecoder; - -#[derive(Debug, Clone, PartialEq, uniffi::Record)] -pub struct PaymentWrapper { - pub address: String, - pub amount: Option, - pub memo: Option, - pub asset_id: Option, - pub payment_link: Option, +pub mod error; +pub mod remote_types; + +use std::sync::Arc; + +use payment::PaymentConfig; +use payment::WalletConnectPayAuth; +use payment::{PaymentAction as CorePaymentAction, PaymentService}; +use primitives::{Chain, ChainAddress, PaymentLink, PaymentOptions, PaymentOutcome, PaymentProviderName, PaymentQuote, PaymentQuotes}; + +use crate::alien::{AlienProvider, AlienProviderWrapper}; +use crate::message::sign_type::SignMessage; +use crate::models::approval::GemApprovalData; +use crate::payment::error::PaymentError; +use crate::wallet_connect::SignableTransaction; + +#[derive(Debug, uniffi::Record)] +pub struct GemPreparedPayment { + pub quotes: PaymentQuotes, + pub quote: PaymentQuote, + pub actions: Vec, } -/// Exports functions -#[uniffi::export] -pub fn payment_decode_url(string: &str) -> Result { - let payment = PaymentURLDecoder::decode(string)?; - Ok(PaymentWrapper { - address: payment.address, - amount: payment.amount, - memo: payment.memo, - asset_id: payment.asset_id.map(|c| c.to_string()), - payment_link: payment.link.map(|c| c.to_string()), - }) +#[derive(Debug, uniffi::Enum)] +#[allow(clippy::large_enum_variant)] +pub enum PaymentAction { + SignMessage { message: SignMessage }, + SignTransaction { chain: Chain, transaction: SignableTransaction }, + SendTransaction { chain: Chain, transaction: SignableTransaction }, + ApproveToken { chain: Chain, approval: GemApprovalData }, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_address() { - assert_eq!( - payment_decode_url("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(), - PaymentWrapper { - address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(), - amount: Some("0.42301".to_string()), - memo: None, - asset_id: Some("solana".to_string()), - payment_link: None, - } - ); +impl From for PaymentAction { + fn from(action: CorePaymentAction) -> Self { + match action { + CorePaymentAction::SignMessage { message } => Self::SignMessage { message: message.into() }, + CorePaymentAction::SignTransaction { chain, transaction } => Self::SignTransaction { + chain, + transaction: transaction.into(), + }, + CorePaymentAction::SendTransaction { chain, transaction } => Self::SendTransaction { + chain, + transaction: transaction.into(), + }, + CorePaymentAction::ApproveToken { chain, approval } => Self::ApproveToken { chain, approval }, + } + } +} + +#[derive(uniffi::Object)] +pub struct GemPaymentService { + service: PaymentService, +} + +#[uniffi::export] +impl GemPaymentService { + #[uniffi::constructor] + pub fn new(provider: Arc, app_id: String, client_id: String) -> Self { + Self { + service: PaymentService::new( + Arc::new(AlienProviderWrapper::new(provider)), + PaymentConfig::new(WalletConnectPayAuth::new(app_id, client_id)), + ), + } + } + + pub async fn get_payment_options(&self, link: PaymentLink, addresses: Vec) -> Result { + self.service.get_options(&link, &addresses).await + } + + pub async fn get_prepared_payment( + &self, + provider: PaymentProviderName, + quotes: PaymentQuotes, + quote: PaymentQuote, + addresses: Vec, + ) -> Result { + let payment = self.service.get_prepared_payment(provider, "es, "e, &addresses).await?; + Ok(GemPreparedPayment { + quotes: payment.quotes, + quote: payment.quote, + actions: payment.actions.into_iter().map(Into::into).collect(), + }) + } + + pub async fn confirm_payment(&self, provider: PaymentProviderName, quote: PaymentQuote, action_results: Vec) -> Result { + self.service.confirm(provider, "e, action_results).await + } + + pub async fn cancel_payment(&self, provider: PaymentProviderName, payment_id: String) -> Result<(), PaymentError> { + self.service.cancel(provider, &payment_id).await + } + + pub async fn get_payment_status(&self, provider: PaymentProviderName, payment_id: String) -> Result { + self.service.get_status(provider, &payment_id).await } } diff --git a/core/gemstone/src/payment/remote_types.rs b/core/gemstone/src/payment/remote_types.rs new file mode 100644 index 0000000000..49dd45768c --- /dev/null +++ b/core/gemstone/src/payment/remote_types.rs @@ -0,0 +1,152 @@ +use chrono::{DateTime, Utc}; + +use crate::GemstoneError; +use primitives::payment_decoder::wallet_connect_pay::WALLET_CONNECT_PAY_HOST; +use primitives::{ + AssetId, Payment, PaymentAmount, PaymentLink, PaymentMerchant, PaymentOptions, PaymentOutcome, PaymentPrice, PaymentProviderName, PaymentQuote, PaymentQuotes, PaymentRequest, + PaymentStatus, PaymentURLDecoder, +}; + +pub type GemPayment = Payment; +pub type GemPaymentRequest = PaymentRequest; +pub type GemPaymentLink = PaymentLink; +pub type GemPaymentProviderName = PaymentProviderName; +pub type GemPaymentMerchant = PaymentMerchant; +pub type GemPaymentOutcome = PaymentOutcome; +pub type GemPaymentStatus = PaymentStatus; +pub type GemPaymentOptions = PaymentOptions; +pub type GemPaymentQuotes = PaymentQuotes; +pub type GemPaymentPrice = PaymentPrice; +pub type GemPaymentQuote = PaymentQuote; +pub type GemPaymentAmount = PaymentAmount; + +#[uniffi::remote(Enum)] +pub enum GemPaymentOptions { + Quotes(GemPaymentQuotes), + Outcome(GemPaymentOutcome), +} + +#[uniffi::remote(Record)] +pub struct GemPaymentQuotes { + pub merchant: GemPaymentMerchant, + pub price: Option, + pub expires_at: Option>, + pub quotes: Vec, +} + +#[uniffi::remote(Record)] +pub struct GemPaymentQuote { + pub id: String, + pub payment_id: String, + pub amount: GemPaymentAmount, + pub expires_at: Option>, + pub collect_data_url: Option, + pub provider_data: String, +} + +#[uniffi::remote(Record)] +pub struct GemPaymentAmount { + pub asset_id: AssetId, + pub value: String, + pub symbol: String, + pub decimals: i32, +} + +#[uniffi::remote(Record)] +pub struct GemPaymentMerchant { + pub name: String, + pub icon_url: Option, +} + +#[uniffi::remote(Enum)] +pub enum GemPaymentStatus { + RequiresAction, + Processing, + Succeeded, + Failed, + Expired, + Cancelled, +} + +#[uniffi::remote(Record)] +pub struct GemPaymentOutcome { + pub status: GemPaymentStatus, + pub transaction_id: Option, +} + +#[uniffi::remote(Enum)] +pub enum GemPayment { + Request(GemPaymentRequest), + Link(GemPaymentLink), +} + +#[uniffi::remote(Record)] +pub struct GemPaymentRequest { + pub address: String, + pub amount: Option, + pub memo: Option, + pub asset_id: Option, +} + +#[uniffi::remote(Record)] +pub struct GemPaymentLink { + pub provider: PaymentProviderName, + pub id: String, +} + +#[uniffi::remote(Enum)] +pub enum GemPaymentProviderName { + SolanaPay, + WalletConnectPay, +} + +#[uniffi::export] +pub fn payment_wallet_connect_url() -> String { + format!("https://{WALLET_CONNECT_PAY_HOST}") +} + +#[uniffi::export] +pub fn payment_decode_url(string: &str) -> Result { + Ok(PaymentURLDecoder::decode(string)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Chain; + + #[test] + fn test_request() { + assert_eq!( + payment_decode_url("solana:3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw?amount=0.42301").unwrap(), + GemPayment::Request(GemPaymentRequest { + address: "3u3ta6yXYgpheLGc2GVF3QkLHAUwBrvX71Eg8XXjJHGw".to_string(), + amount: Some("0.42301".to_string()), + memo: None, + asset_id: Some(AssetId::from_chain(Chain::Solana)), + }) + ); + } + + #[test] + fn test_link() { + assert_eq!( + payment_decode_url("solana:https%3A%2F%2Fapi.spherepay.co%2Fv1%2Fpublic%2FpaymentLink%2Fpay%2FpaymentLink_1").unwrap(), + GemPayment::Link(GemPaymentLink::new( + PaymentProviderName::SolanaPay, + "https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1".to_string() + )) + ); + assert_eq!( + payment_decode_url("https://pay.walletconnect.com/?pid=pay_123").unwrap(), + GemPayment::Link(GemPaymentLink::new(PaymentProviderName::WalletConnectPay, "pay_123".to_string())) + ); + } +} + +#[uniffi::remote(Record)] +pub struct GemPaymentPrice { + pub symbol: String, + pub value: String, + pub decimals: i32, +} diff --git a/core/gemstone/src/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); } } From b1256b1392fafc61ec1cabe662171137ecec5571 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:32:25 +0300 Subject: [PATCH 07/27] iOS: add the signing request kernel The sheets a signing request can ask for, and nothing more: sign a message, sign a transaction, send one. WalletConnect and payments both drive the same three, so the payload types, the transfer-data factory and the simulator live in one service rather than in either feature. --- core/Cargo.lock | 2 +- ios/Packages/ChainServices/Package.swift | 25 ++++ .../SignMessagePayload.swift | 36 ++++++ .../SignableTransaction+Mapping.swift | 38 ++++++ .../SignableTransaction.swift | 12 ++ .../SigningRequestCallback.swift | 30 +++++ .../SigningRequestError.swift | 14 +++ .../SigningRequestInteractable.swift | 10 ++ .../SigningSimulator.swift | 43 +++++++ .../SigningTransferData.swift | 20 +++ .../SigningTransferDataFactory.swift | 116 ++++++++++++++++++ .../TestKit/SignMessagePayload+TestKit.swift | 44 +++++++ .../SigningRequestInteractableMock.swift | 33 +++++ .../TestKit/SigningSimulatableMock.swift | 24 ++++ ios/Packages/Primitives/Sources/Action.swift | 1 + 15 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestError.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 diff --git a/core/Cargo.lock b/core/Cargo.lock index 01f43594da..376e1ce69c 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -5988,7 +5988,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payment" -version = "2.114.0" +version = "2.114.5" dependencies = [ "async-trait", "chrono", diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 66a228f535..b4375d8323 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -15,6 +15,8 @@ let package = Package( .library(name: "StakeServiceTestKit", targets: ["StakeServiceTestKit"]), .library(name: "NodeService", targets: ["NodeService"]), .library(name: "NodeServiceTestKit", targets: ["NodeServiceTestKit"]), + .library(name: "SigningRequestService", targets: ["SigningRequestService"]), + .library(name: "SigningRequestServiceTestKit", targets: ["SigningRequestServiceTestKit"]), .library(name: "WalletConnectorService", targets: ["WalletConnectorService"]), .library(name: "WalletConnectorServiceTestKit", targets: ["WalletConnectorServiceTestKit"]), .library(name: "ScanService", targets: ["ScanService"]), @@ -25,6 +27,7 @@ let package = Package( ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), + .package(name: "Localization", path: "../Localization"), .package(name: "GemAPI", path: "../GemAPI"), .package(name: "Store", path: "../Store"), .package(name: "Blockchain", path: "../Blockchain"), @@ -102,10 +105,31 @@ let package = Package( ], path: "NodeService/Tests", ), + .target( + name: "SigningRequestService", + dependencies: [ + "Primitives", + "Gemstone", + "GemstonePrimitives", + "NativeProviderService", + .product(name: "Localization", package: "Localization"), + ], + path: "SigningRequestService", + exclude: ["TestKit"], + ), + .target( + name: "SigningRequestServiceTestKit", + dependencies: [ + "SigningRequestService", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "SigningRequestService/TestKit", + ), .target( name: "WalletConnectorService", dependencies: [ "Primitives", + "SigningRequestService", "Gemstone", "GemstonePrimitives", "NativeProviderService", @@ -120,6 +144,7 @@ let package = Package( name: "WalletConnectorServiceTestKit", dependencies: [ "WalletConnectorService", + "SigningRequestService", .product(name: "PrimitivesTestKit", package: "Primitives"), ], path: "WalletConnectorService/TestKit", diff --git a/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift b/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift new file mode 100644 index 0000000000..11b7235ac3 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SignMessagePayload.swift @@ -0,0 +1,36 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import Primitives + +public struct SignMessagePayload: Sendable, Identifiable { + public let id: String + public let chain: Chain + public let appMetadata: TransactionAppMetadata + public let wallet: Wallet + public let message: SignMessage + public let simulation: SimulationResult + public let payment: PaymentData? + public let networkFee: AssetValuePrice? + + public init( + id: String, + chain: Chain, + appMetadata: TransactionAppMetadata, + wallet: Wallet, + message: SignMessage, + simulation: SimulationResult, + payment: PaymentData? = .none, + networkFee: AssetValuePrice? = .none, + ) { + self.id = id + self.chain = chain + self.appMetadata = appMetadata + self.wallet = wallet + self.message = message + self.simulation = simulation + self.payment = payment + self.networkFee = networkFee + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift new file mode 100644 index 0000000000..639b3bcbe6 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift @@ -0,0 +1,38 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +typealias GemSignableTransaction = Gemstone.SignableTransaction +typealias GemEthereumTransactionData = Gemstone.EthereumTransactionData + +public extension GemSignableTransaction { + 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()) + case let .sui(data, outputType): .sui(data.transaction, outputType.map()) + case let .ton(data, outputType): .ton(data, outputType.map()) + case let .tron(data, outputType): .tron(data, outputType.map()) + } + } +} + +extension GemEthereumTransactionData { + func map() -> Primitives.EthereumTransactionData { + Primitives.EthereumTransactionData( + chainId: chainId, + from: from, + to: to, + value: value, + gas: gas, + gasLimit: gasLimit, + gasPrice: gasPrice, + maxFeePerGas: maxFeePerGas, + maxPriorityFeePerGas: maxPriorityFeePerGas, + nonce: nonce, + data: data, + ) + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift new file mode 100644 index 0000000000..7a7bccfbaa --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public enum SignableTransaction: Sendable { + case ethereum(EthereumTransactionData, TransactionType) + case solana(String, TransferDataOutputType) + case sui(String, TransferDataOutputType) + case ton(String, TransferDataOutputType) + case tron(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/SigningRequestError.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestError.swift new file mode 100644 index 0000000000..56a3da44da --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestError.swift @@ -0,0 +1,14 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization + +public enum SigningRequestError: LocalizedError { + case userCancelled + + public var errorDescription: String? { + switch self { + case .userCancelled: Localized.Errors.Connections.userCancelled + } + } +} 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..4b12bf071f --- /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 GemstonePrimitives +import Primitives + +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..a621ad0bf4 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SignMessagePayload+TestKit.swift @@ -0,0 +1,44 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import SigningRequestService +import Primitives +import PrimitivesTestKit + +public extension SignMessagePayload { + static func mock( + id: String = .empty, + chain: Chain = .ethereum, + appMetadata: TransactionAppMetadata = .mock(), + wallet: Wallet = .mock(), + message: SignMessage = SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), + simulation: SimulationResult = .mock(), + payment: PaymentData? = .none, + ) -> SignMessagePayload { + SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message, + simulation: simulation, + payment: payment, + ) + } +} + +public extension PaymentData { + static func mock( + provider: PaymentProviderName = .walletConnectPay, + quote: PaymentQuote = .mock(), + quotes: [PaymentQuote]? = .none, + expiresAt: Date = Date(timeIntervalSinceNow: 900), + ) -> PaymentData { + PaymentData( + provider: provider, + quotes: .mock(expiresAt: expiresAt, quotes: quotes ?? [quote]), + quote: quote, + ) + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift new file mode 100644 index 0000000000..b096cc44a6 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningRequestInteractableMock.swift @@ -0,0 +1,33 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import SigningRequestService + +public final class SigningRequestInteractableMock: SigningRequestInteractable, @unchecked Sendable { + public init() {} + + public var transactionHash = "1" + public var signature = "signature" + public var signMessageError: Error? + private var failedSignOnce = false + + public private(set) var signMessagePayloads: [SignMessagePayload] = [] + + public func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + if let signMessageError, !failedSignOnce { + failedSignOnce = true + throw signMessageError + } + return signature + } + + public func signTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + public func sendTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift new file mode 100644 index 0000000000..6737fea789 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/TestKit/SigningSimulatableMock.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import enum Gemstone.SignDigestType +import struct Gemstone.Chain +import enum Gemstone.SignableTransactionType +import SigningRequestService +import Primitives + +public struct SigningSimulatableMock: SigningSimulatable { + private let result: SimulationResult + + public init(result: SimulationResult = .empty) { + self.result = result + } + + public func simulateSignMessage(chain _: Gemstone.Chain, signType _: SignDigestType, data _: String, sessionDomain _: String) async throws -> SimulationResult { + result + } + + public func simulateSendTransaction(chain _: Gemstone.Chain, transactionType _: SignableTransactionType, data _: String) async throws -> SimulationResult { + result + } +} diff --git a/ios/Packages/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)? From b8bc8dd73e0e9735a0f6b1f27de31f8bdfac7922 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:32:36 +0300 Subject: [PATCH 08/27] iOS: add PaymentService The app-side view of a payment provider: the gateway client over the gemstone object, the four actions a payment can ask for, and the two screens only a payment has. Everything above it speaks PaymentAction, never the gateway. --- ios/Packages/ChainServices/Package.swift | 25 ++++++++ .../PaymentAction+Mapping.swift | 24 +++++++ .../PaymentService/PaymentAction.swift | 13 ++++ .../PaymentApprovalExecutable.swift | 11 ++++ .../PaymentAssetsProvidable.swift | 8 +++ .../PaymentAssetsProvider.swift | 22 +++++++ .../PaymentDataCollectionRequest.swift | 13 ++++ .../PaymentService/PaymentQuotesRequest.swift | 22 +++++++ .../PaymentService/PaymentService.swift | 52 +++++++++++++++ .../PaymentService/PaymentServiceable.swift | 11 ++++ .../PaymentSheetPresentable.swift | 9 +++ .../PaymentStatusServiceable.swift | 9 +++ .../PaymentService/PreparedPayment.swift | 16 +++++ .../TestKit/PaymentAction+TestKit.swift | 12 ++++ .../PaymentApprovalExecutableMock.swift | 36 +++++++++++ .../TestKit/PaymentAssetsProvidableMock.swift | 17 +++++ .../TestKit/PaymentServiceableMock.swift | 64 +++++++++++++++++++ .../TestKit/PaymentSheetPresentableMock.swift | 31 +++++++++ .../PaymentStatusServiceableMock.swift | 30 +++++++++ 19 files changed, 425 insertions(+) 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/PaymentServiceable.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift create mode 100644 ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.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/PaymentSheetPresentableMock.swift create mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentStatusServiceableMock.swift diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index b4375d8323..8140e76b25 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -17,6 +17,8 @@ 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,6 +127,29 @@ let package = Package( ], path: "SigningRequestService/TestKit", ), + .target( + name: "PaymentService", + dependencies: [ + "SigningRequestService", + "Primitives", + "Store", + .product(name: "Localization", package: "Localization"), + "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/ChainServices/PaymentService/PaymentAction+Mapping.swift b/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift new file mode 100644 index 0000000000..7ea9a30eb0 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import enum Gemstone.PaymentAction +import GemstonePrimitives +import Primitives +import SigningRequestService + +typealias GemPaymentAction = Gemstone.PaymentAction + +public extension GemPaymentAction { + func map() throws -> PaymentAction { + switch self { + case let .signMessage(message): + try .signMessage(chain: message.chain.map(), message: message) + case let .signTransaction(chain, transaction): + try .signTransaction(chain: chain.map(), transaction: transaction.map()) + case let .sendTransaction(chain, transaction): + try .sendTransaction(chain: chain.map(), transaction: transaction.map()) + case let .approveToken(chain, approval): + try .approveToken(chain: chain.map(), approval: approval.map()) + } + } +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAction.swift b/ios/Packages/ChainServices/PaymentService/PaymentAction.swift new file mode 100644 index 0000000000..85f599029e --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAction.swift @@ -0,0 +1,13 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import Primitives +import SigningRequestService + +public enum PaymentAction: Sendable { + case signMessage(chain: Chain, message: SignMessage) + case signTransaction(chain: Chain, transaction: SignableTransaction) + case sendTransaction(chain: Chain, transaction: SignableTransaction) + case approveToken(chain: Chain, approval: ApprovalData) +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift b/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift new file mode 100644 index 0000000000..175314763a --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentApprovalExecutable.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import Primitives + +public protocol PaymentApprovalExecutable: Sendable { + func getApprovalFee(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> BigInt + func approve(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> String + func waitForApproval(hash: String, assetId: AssetId, wallet: Wallet) async throws +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift new file mode 100644 index 0000000000..05a2e32c4f --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvidable.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentAssetsProvidable: Sendable { + func assetsData(walletId: WalletId, assetIds: [AssetId]) -> [AssetData] +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift new file mode 100644 index 0000000000..b4707c4499 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentAssetsProvider.swift @@ -0,0 +1,22 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import Store + +public struct PaymentAssetsProvider: PaymentAssetsProvidable { + private let assetStore: AssetStore + + public init(assetStore: AssetStore) { + self.assetStore = assetStore + } + + public func assetsData(walletId: WalletId, assetIds: [AssetId]) -> [AssetData] { + do { + return try assetStore.getAssetsData(walletId: walletId, filters: [.chainsOrAssets([], assetIds.map(\.identifier))]) + } catch { + debugLog("PaymentAssetsProvider assets data error: \(error)") + return [] + } + } +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift b/ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift new file mode 100644 index 0000000000..558fc88085 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentDataCollectionRequest.swift @@ -0,0 +1,13 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public struct PaymentDataCollectionRequest: Identifiable, Sendable { + public let id: String + public let url: URL + + public init(id: String, url: URL) { + self.id = id + self.url = url + } +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift b/ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift new file mode 100644 index 0000000000..486bf9b343 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentQuotesRequest.swift @@ -0,0 +1,22 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct PaymentQuotesRequest: Identifiable, Sendable { + public let id: String + public let quotes: PaymentQuotes + public let wallet: Wallet + public let assetsData: [AssetData] + + public init(id: String, quotes: PaymentQuotes, wallet: Wallet, assetsData: [AssetData]) { + self.id = id + self.quotes = quotes + self.wallet = wallet + self.assetsData = assetsData + } + + public func assetData(for quote: PaymentQuote) -> AssetData? { + assetsData.first { $0.asset.id == quote.amount.assetId } + } +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentService.swift b/ios/Packages/ChainServices/PaymentService/PaymentService.swift new file mode 100644 index 0000000000..591da54f42 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentService.swift @@ -0,0 +1,52 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import NativeProviderService +import Primitives +import SigningRequestService + +public final class PaymentService: PaymentServiceable { + private let service: Gemstone.GemPaymentService + + public init(provider: NativeProvider, appId: String, clientId: String) { + service = Gemstone.GemPaymentService(provider: provider, appId: appId, clientId: clientId) + } + + public func getPaymentOptions(link: PaymentLink, wallet: Wallet) async throws -> PaymentOptions { + try await service.getPaymentOptions(link: link.map(), addresses: wallet.chainAddresses.map { $0.map() }).map() + } + + public func getPreparedPayment(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PreparedPayment { + let payment = try await service.getPreparedPayment( + provider: provider.map(), + quotes: quotes.map(), + quote: quote.map(), + addresses: wallet.chainAddresses.map { $0.map() }, + ) + return try PreparedPayment( + quotes: payment.quotes.map(), + quote: payment.quote.map(), + actions: payment.actions.map { try $0.map() }, + ) + } + + public func confirmPayment(provider: PaymentProviderName, quote: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome { + try await service.confirmPayment(provider: provider.map(), quote: quote.map(), actionResults: actionResults).map() + } + + public func cancelPayment(provider: PaymentProviderName, paymentId: String) async throws { + try await service.cancelPayment(provider: provider.map(), paymentId: paymentId) + } + + public func hasStatus(provider: PaymentProviderName) -> Bool { + switch provider { + case .walletConnectPay: true + case .solanaPay: false + } + } + + public func getPaymentStatus(provider: PaymentProviderName, paymentId: String) async throws -> PaymentOutcome { + try await service.getPaymentStatus(provider: provider.map(), paymentId: paymentId).map() + } +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift b/ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift new file mode 100644 index 0000000000..bcb6f213fb --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentServiceable: PaymentStatusServiceable { + func getPaymentOptions(link: PaymentLink, wallet: Wallet) async throws -> PaymentOptions + func getPreparedPayment(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PreparedPayment + func confirmPayment(provider: PaymentProviderName, quote: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome + func cancelPayment(provider: PaymentProviderName, paymentId: String) async throws +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift b/ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift new file mode 100644 index 0000000000..bca5eebaf0 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public protocol PaymentSheetPresentable: Sendable { + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String +} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.swift b/ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.swift new file mode 100644 index 0000000000..d5b516c01f --- /dev/null +++ b/ios/Packages/ChainServices/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/ChainServices/PaymentService/PreparedPayment.swift b/ios/Packages/ChainServices/PaymentService/PreparedPayment.swift new file mode 100644 index 0000000000..c0f9bdbdc3 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/PreparedPayment.swift @@ -0,0 +1,16 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct PreparedPayment: Sendable { + public let quotes: PaymentQuotes + public let quote: PaymentQuote + public let actions: [PaymentAction] + + public init(quotes: PaymentQuotes, quote: PaymentQuote, actions: [PaymentAction]) { + self.quotes = quotes + self.quote = quote + self.actions = actions + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift new file mode 100644 index 0000000000..2c002a1e1c --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAction+TestKit.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SignMessage +import PaymentService +import Primitives + +public extension PaymentAction { + static func mockSignMessage(chain: Chain = .ethereum, data: Data = Data("pay".utf8)) -> PaymentAction { + .signMessage(chain: chain, message: SignMessage(chain: chain.rawValue, signType: .eip712, data: data)) + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift new file mode 100644 index 0000000000..abc3f7e8fc --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentApprovalExecutableMock.swift @@ -0,0 +1,36 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import PaymentService +import Primitives + +public final class PaymentApprovalExecutableMock: PaymentApprovalExecutable, @unchecked Sendable { + public private(set) var approvals: [ApprovalData] = [] + public private(set) var confirmedHashes: [String] = [] + private let hash: String + private let fee: BigInt + private let validationError: (any Error)? + + public init(hash: String = "0xapproval", fee: BigInt = .zero, validationError: (any Error)? = .none) { + self.hash = hash + self.fee = fee + self.validationError = validationError + } + + public func getApprovalFee(assetId _: AssetId, approval _: ApprovalData, wallet _: Wallet) async throws -> BigInt { + if let validationError { + throw validationError + } + return fee + } + + public func approve(assetId _: AssetId, approval: ApprovalData, wallet _: Wallet) async throws -> String { + approvals.append(approval) + return hash + } + + public func waitForApproval(hash: String, assetId _: AssetId, wallet _: Wallet) async throws { + confirmedHashes.append(hash) + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift new file mode 100644 index 0000000000..81ae6f2231 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentAssetsProvidableMock.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives + +public struct PaymentAssetsProvidableMock: PaymentAssetsProvidable { + private let assetsData: [AssetData] + + public init(assetsData: [AssetData] = []) { + self.assetsData = assetsData + } + + public func assetsData(walletId _: WalletId, assetIds: [AssetId]) -> [AssetData] { + assetsData.filter { assetIds.contains($0.asset.id) } + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift new file mode 100644 index 0000000000..72a32fab63 --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentServiceableMock.swift @@ -0,0 +1,64 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import PrimitivesTestKit + +public actor PaymentServiceableMock: PaymentServiceable { + private var options: [PaymentOptions] + private let actions: [PaymentAction] + private let confirmOutcome: PaymentOutcome + private let confirmError: (any Error)? + private let statusOutcome: PaymentOutcome + + public private(set) var cancelledPaymentIds: [String] = [] + public private(set) var confirmedResults: [[String]] = [] + public private(set) var requestedQuotes: [PaymentQuote] = [] + + public init( + options: [PaymentOptions], + actions: [PaymentAction] = [], + confirmOutcome: PaymentOutcome = .mock(), + confirmError: (any Error)? = .none, + statusOutcome: PaymentOutcome = .mock(), + ) { + self.options = options + self.actions = actions + self.confirmOutcome = confirmOutcome + self.confirmError = confirmError + self.statusOutcome = statusOutcome + } + + public func getPaymentOptions(link _: PaymentLink, wallet _: Wallet) async throws -> PaymentOptions { + guard !options.isEmpty else { + throw AnyError("Unexpected payment options request") + } + return options.removeFirst() + } + + public func getPreparedPayment(provider _: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet _: Wallet) async throws -> PreparedPayment { + requestedQuotes.append(quote) + return PreparedPayment(quotes: quotes, quote: quote, actions: actions) + } + + public func confirmPayment(provider _: PaymentProviderName, quote _: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome { + confirmedResults.append(actionResults) + if let confirmError { + throw confirmError + } + return confirmOutcome + } + + public func cancelPayment(provider _: PaymentProviderName, paymentId: String) async throws { + cancelledPaymentIds.append(paymentId) + } + + public nonisolated func hasStatus(provider _: PaymentProviderName) -> Bool { + true + } + + public func getPaymentStatus(provider _: PaymentProviderName, paymentId _: String) async throws -> PaymentOutcome { + statusOutcome + } +} diff --git a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift new file mode 100644 index 0000000000..73c3faadbf --- /dev/null +++ b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift @@ -0,0 +1,31 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives + +public final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sendable { + public init() {} + + public var collectDataError: Error? + public var selectedQuoteId: String? + + public private(set) var collectDataRequests: [PaymentDataCollectionRequest] = [] + public private(set) var quotesRequests: [PaymentQuotesRequest] = [] + + public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + quotesRequests.append(request) + guard let selectedQuoteId else { + throw AnyError("no quote selected") + } + return selectedQuoteId + } + + public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + collectDataRequests.append(request) + if let collectDataError { + throw collectDataError + } + return .empty + } +} 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 + } +} From ee76c4f633f1c720e633fce5010a437c0e0df1e6 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:32:46 +0300 Subject: [PATCH 09/27] iOS: extract TransferService out of the Transfer feature TransferExecutor, TransferTransactionProvider and the types they need are UI-free services, but they lived in a feature package, so Payments would have to depend on Features/Transfer, the app's only feature-to-feature dependency. Move them to FeatureServices with their TestKit and tests, alongside SwapService and EarnService. --- ios/Features/Transfer/Package.swift | 7 +- .../Extensions/TransferData+Available.swift | 1 + .../Sources/Scenes/ConfirmTransferScene.swift | 3 + .../Sources/Services/ConfirmService.swift | 1 + .../Services/ConfirmServiceFactory.swift | 1 + .../ConfirmTransferInputProvider.swift | 1 + .../Types/ConfirmInfoSheetBuilder.swift | 1 + .../Sources/Types/ConfirmTransferError.swift | 1 + .../Types/ConfirmTransferRequest.swift | 6 +- .../Types/ConfirmTransferSection.swift | 3 + .../Sources/Types/ScanRecipientResult.swift | 9 --- .../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 ++- .../ConfirmTransferInput+TestKit.swift | 1 + .../Tests/RecipientSceneViewModelTests.swift | 17 +++- .../Tests/Services/ConfirmServiceTests.swift | 6 +- .../ConfirmTransferInputProviderTests.swift | 2 + .../ViewModels/ConfirmAppViewModelTests.swift | 4 +- .../ConfirmRecipientViewModelTests.swift | 4 +- .../ConfirmTransferSceneViewModelTests.swift | 26 ++++-- .../TransferDataViewModelTests.swift | 4 +- ios/GemTests/unit_frameworks.xctestplan | 7 ++ .../ConnectionsService.swift | 2 +- ios/Packages/FeatureServices/Package.swift | 42 ++++++++++ .../TransactionStateScheduler+TestKit.swift | 4 + .../Tests/TransactionStateServiceTests.swift | 80 ++++++++++++++++++- .../TransactionStateService.swift | 24 ++++++ .../TransferService}/FeeRateService.swift | 8 +- .../ScanTransactionError.swift | 4 +- .../ScanTransactionValidator.swift | 4 +- .../TestKit/TransactionData+TestKit.swift | 0 .../TestKit/TransferExecutor+TestKit.swift | 10 ++- .../TransferTransactionProvider+TestKit.swift | 2 +- .../ScanTransactionValidatorTests.swift | 4 +- .../Tests}/TransferExecutorTests.swift | 3 +- .../TransferTransactionProviderTests.swift | 3 +- .../TransactionConfirmationWaiter.swift | 54 +++++++++++++ .../TransferService}/TransactionFactory.swift | 4 +- .../TransferConfirmationInput.swift | 6 +- .../TransferService}/TransferExecutor.swift | 20 +++-- .../TransferTransactionData.swift | 0 .../TransferTransactionProvider.swift | 0 .../Primitives/Sources/TransferDataType.swift | 8 ++ .../Sources/ViewModels/PaymentExpiry.swift | 24 ++++++ 50 files changed, 418 insertions(+), 101 deletions(-) delete mode 100644 ios/Features/Transfer/Sources/Types/ScanRecipientResult.swift rename ios/{Features/Transfer/Sources/Services => Packages/FeatureServices/TransferService}/FeeRateService.swift (66%) rename ios/{Features/Transfer/Sources/Errors => Packages/FeatureServices/TransferService}/ScanTransactionError.swift (82%) rename ios/{Features/Transfer/Sources/Validators => Packages/FeatureServices/TransferService}/ScanTransactionValidator.swift (86%) rename ios/{Features/Transfer => Packages/FeatureServices/TransferService}/TestKit/TransactionData+TestKit.swift (100%) rename ios/{Features/Transfer => Packages/FeatureServices/TransferService}/TestKit/TransferExecutor+TestKit.swift (59%) rename ios/{Features/Transfer => Packages/FeatureServices/TransferService}/TestKit/TransferTransactionProvider+TestKit.swift (96%) rename ios/{Features/Transfer/Tests/Validators => Packages/FeatureServices/TransferService/Tests}/ScanTransactionValidatorTests.swift (97%) rename ios/{Features/Transfer/Tests/Services => Packages/FeatureServices/TransferService/Tests}/TransferExecutorTests.swift (99%) rename ios/{Features/Transfer/Tests/Services => Packages/FeatureServices/TransferService/Tests}/TransferTransactionProviderTests.swift (95%) create mode 100644 ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift rename ios/{Features/Transfer/Sources/Services => Packages/FeatureServices/TransferService}/TransactionFactory.swift (97%) rename ios/{Features/Transfer/Sources/Types => Packages/FeatureServices/TransferService}/TransferConfirmationInput.swift (82%) rename ios/{Features/Transfer/Sources/Services => Packages/FeatureServices/TransferService}/TransferExecutor.swift (91%) rename ios/{Features/Transfer/Sources/Types => Packages/FeatureServices/TransferService}/TransferTransactionData.swift (100%) rename ios/{Features/Transfer/Sources/Services => Packages/FeatureServices/TransferService}/TransferTransactionProvider.swift (100%) create mode 100644 ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentExpiry.swift diff --git a/ios/Features/Transfer/Package.swift b/ios/Features/Transfer/Package.swift index b56d92a0f2..f034bac407 100644 --- a/ios/Features/Transfer/Package.swift +++ b/ios/Features/Transfer/Package.swift @@ -34,7 +34,6 @@ let package = Package( .package(name: "Store", path: "../../Packages/Store"), .package(name: "Stake", path: "../Stake"), - .package(name: "WalletConnector", path: "../WalletConnector"), .package(name: "InfoSheet", path: "../InfoSheet"), .package(name: "Swap", path: "../Swap"), .package(name: "Perpetuals", path: "../Perpetuals"), @@ -63,16 +62,17 @@ let package = Package( "Validators", "Stake", - "WalletConnector", "InfoSheet", "Swap", "Perpetuals", "EventPresenterService", .product(name: "ChainService", package: "ChainServices"), + .product(name: "PaymentService", package: "ChainServices"), .product(name: "WalletService", package: "FeatureServices"), .product(name: "NodeService", package: "ChainServices"), .product(name: "TransactionStateService", package: "FeatureServices"), + .product(name: "TransferService", package: "FeatureServices"), .product(name: "ScanService", package: "ChainServices"), .product(name: "BalanceService", package: "FeatureServices"), .product(name: "AssetsService", package: "FeatureServices"), @@ -91,6 +91,7 @@ let package = Package( name: "TransferTestKit", dependencies: [ "Transfer", + .product(name: "TransferServiceTestKit", package: "FeatureServices"), "Primitives", .product(name: "PrimitivesTestKit", package: "Primitives"), ], @@ -101,10 +102,12 @@ let package = Package( dependencies: [ "Transfer", "TransferTestKit", + .product(name: "TransferServiceTestKit", package: "FeatureServices"), "GemstonePrimitives", .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "BlockchainTestKit", package: "Blockchain"), .product(name: "ScanServiceTestKit", package: "ChainServices"), + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), .product(name: "SwapServiceTestKit", package: "FeatureServices"), .product(name: "KeystoreTestKit", package: "Keystore"), .product(name: "WalletServiceTestKit", package: "FeatureServices"), diff --git a/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift b/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift index 94c4a42461..2029990665 100644 --- a/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift +++ b/ios/Features/Transfer/Sources/Extensions/TransferData+Available.swift @@ -13,6 +13,7 @@ extension TransferData { .swap, .tokenApprove, .generic, + .payment, .transferNft, .perpetual, .account(_, .activate), diff --git a/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift b/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift index 118043c0eb..5124367742 100644 --- a/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift +++ b/ios/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift @@ -30,6 +30,7 @@ public struct ConfirmTransferScene: View { .task(id: model.feeModel.selection) { await model.fetch() } + .task { await model.paymentExpiry.start() } .navigationTitle(model.title) .navigationBarTitleDisplayMode(.inline) .activityIndicator(isLoading: model.isConfirming, message: model.progressMessage) @@ -49,6 +50,8 @@ extension ConfirmTransferScene { headerType: model.headerType, showClearHeader: model.showClearHeader, ) + case let .paymentExpiry(title, expiresAt): + ListItemExpiryView(title: title, expiresAt: expiresAt) case let .app(model): ListItemImageView(model: model) .contextMenu( diff --git a/ios/Features/Transfer/Sources/Services/ConfirmService.swift b/ios/Features/Transfer/Sources/Services/ConfirmService.swift index a83f987829..dfef7be166 100644 --- a/ios/Features/Transfer/Sources/Services/ConfirmService.swift +++ b/ios/Features/Transfer/Sources/Services/ConfirmService.swift @@ -8,6 +8,7 @@ import EventPresenterService import ExplorerService import Keystore import Primitives +import TransferService public struct ConfirmService: Sendable { private let metadataProvider: any TransferMetadataProvidable diff --git a/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift b/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift index 323b4ce106..49aef640f9 100644 --- a/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift +++ b/ios/Features/Transfer/Sources/Services/ConfirmServiceFactory.swift @@ -11,6 +11,7 @@ import Foundation import Keystore import PriceService import Primitives +import TransferService import ScanService import Signer import TransactionStateService diff --git a/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift b/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift index 53e1de694d..e5f97f51ba 100644 --- a/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift +++ b/ios/Features/Transfer/Sources/Services/ConfirmTransferInputProvider.swift @@ -2,6 +2,7 @@ import Foundation import Primitives +import TransferService import PrimitivesComponents import Validators diff --git a/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift b/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift index 47936ef83c..f602ae0465 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmInfoSheetBuilder.swift @@ -5,6 +5,7 @@ import Foundation import GemstonePrimitives import InfoSheet import Primitives +import TransferService import PrimitivesComponents import Validators diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift index 51d51a2ccd..7800fb463e 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferError.swift @@ -3,6 +3,7 @@ import Blockchain import Foundation import Primitives +import TransferService import Validators enum ConfirmTransferError { diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift index ec02edaaef..899bd986c5 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferRequest.swift @@ -1,20 +1,20 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import PaymentService import Primitives -import WalletConnector public struct ConfirmTransferRequest: Sendable { public let wallet: Wallet public let data: TransferData public let simulation: SimulationResult? - public let delegate: TransferDataCallback.ConfirmTransferDelegate? + public let delegate: StringResultAction? public init( wallet: Wallet, data: TransferData, simulation: SimulationResult? = nil, - delegate: TransferDataCallback.ConfirmTransferDelegate? = nil, + delegate: StringResultAction? = nil, ) { self.wallet = wallet self.data = data diff --git a/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift b/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift index b425dd2760..4c668986a8 100644 --- a/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift +++ b/ios/Features/Transfer/Sources/Types/ConfirmTransferSection.swift @@ -11,6 +11,7 @@ enum ConfirmTransferSectionType: String, Identifiable, Equatable { case header case warnings case details + case paymentExpiry case balanceChanges case payload case fee @@ -30,6 +31,7 @@ public enum ConfirmTransferItem: Identifiable, Hashable, Sendable { case recipient case memo case details + case paymentExpiry case balanceChange(Int) case payload case networkFee @@ -47,6 +49,7 @@ public enum ConfirmTransferItemModel { case recipient(AddressListItemViewModel) case network(ListItemModel) case memo(ListItemModel) + case paymentExpiry(title: String, expiresAt: Date) case swapDetails(SwapDetailsViewModel) case networkFee(ListItemModel, selectable: Bool) case perpetualDetails(PerpetualDetailsViewModel) diff --git a/ios/Features/Transfer/Sources/Types/ScanRecipientResult.swift b/ios/Features/Transfer/Sources/Types/ScanRecipientResult.swift deleted file mode 100644 index d533da4f67..0000000000 --- a/ios/Features/Transfer/Sources/Types/ScanRecipientResult.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -struct PaymentScanResult { - let address: String - let amount: String? - let memo: String? -} diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift index 097898718d..30a663fb97 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmAppViewModel.swift @@ -23,7 +23,7 @@ public extension ConfirmAppViewModel { return .app( ListItemModel( - title: Localized.WalletConnect.app, + title: title, subtitle: name, imageStyle: .list(assetImage: assetImage), ), @@ -34,6 +34,13 @@ public extension ConfirmAppViewModel { // MARK: - Private extension ConfirmAppViewModel { + private var title: String { + switch type { + case .payment: Localized.Transfer.merchant + default: Localized.WalletConnect.app + } + } + private var appValue: String? { switch type { case .transfer, @@ -46,8 +53,10 @@ extension ConfirmAppViewModel { .account, .perpetual, .earn: .none - case let .generic(_, metadata, _): - metadata.shortName + case let .generic(_, app, _): + app.shortName + case let .payment(_, payment, _): + payment.merchant.name } } @@ -64,8 +73,10 @@ extension ConfirmAppViewModel { .perpetual, .earn: .none - case let .generic(_, session, _): - AssetImage(imageURL: session.icon.asURL) + case let .generic(_, app, _): + AssetImage(imageURL: app.icon?.asURL) + case let .payment(_, payment, _): + AssetImage(imageURL: payment.merchant.iconUrl?.asURL) } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift index 2b7bc05cf4..f80f39207d 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmDetailsViewModel.swift @@ -44,6 +44,7 @@ extension ConfirmDetailsViewModel: ItemModelProvidable { .stake, .account, .generic, + .payment, .earn: .empty } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift index 2faa2b901a..4a63973784 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmMemoViewModel.swift @@ -29,7 +29,7 @@ extension ConfirmMemoViewModel { private var showMemo: Bool { switch type { case .transfer, .deposit, .withdrawal: type.chain.isMemoSupported - case .transferNft, .swap, .tokenApprove, .generic, .account, .stake, .perpetual, .earn: false + case .transferNft, .swap, .tokenApprove, .generic, .payment, .account, .stake, .perpetual, .earn: false } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift index fba7679db9..845b9018fb 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmNetworkViewModel.swift @@ -35,7 +35,7 @@ extension ConfirmNetworkViewModel { switch type { case .transfer, .deposit, .withdrawal: return model.networkFullName - case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .perpetual, .earn: + case .transferNft, .swap, .tokenApprove, .stake, .account, .generic, .payment, .perpetual, .earn: return model.networkName } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift index 7d8e351a9d..a468b04e8f 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmRecipientViewModel.swift @@ -67,7 +67,7 @@ extension ConfirmRecipientViewModel { case .stake, .unstake, .redelegate, .rewards, .withdraw: Localized.Stake.validator case .freeze, .unfreeze: Localized.Stake.resource } - case .generic: + case .generic, .payment: switch model.type.outputAction { case .sign: Localized.Asset.contract case .send: Localized.Transfer.Recipient.title @@ -90,7 +90,7 @@ extension ConfirmRecipientViewModel { .swap, .perpetual: false case .earn: true - case .generic: + case .generic, .payment: switch model.type.outputAction { case .sign: false case .send: true diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift index 2d8226dbe2..862e240047 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift @@ -14,7 +14,6 @@ import Store import Swap import SwiftUI import Validators -import WalletConnector @Observable @MainActor @@ -26,6 +25,8 @@ public final class ConfirmTransferSceneViewModel { public var isPresentingSheet: ConfirmTransferSheetType? + public let paymentExpiry: PaymentExpiry + public var isPresentingAlertMessage: AlertMessage? { get { switch state.confirmation { @@ -55,6 +56,7 @@ public final class ConfirmTransferSceneViewModel { self.request = request self.confirmService = confirmService self.onComplete = onComplete + paymentExpiry = PaymentExpiry(payment: request.data.type.payment) let currency = Currency(rawValue: Preferences.standard.currency) ?? .usd self.currency = currency @@ -123,7 +125,7 @@ public final class ConfirmTransferSceneViewModel { } var isButtonDisabled: Bool { - simulationWarnings.contains(where: { $0.severity == .critical }) + paymentExpiry.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } var confirmButtonModel: ConfirmButtonViewModel { @@ -146,11 +148,21 @@ public final class ConfirmTransferSceneViewModel { // MARK: - ListSectionProvideable +extension ConfirmTransferSceneViewModel { + private var paymentExpiryItemModel: ConfirmTransferItemModel { + guard let expiresAt = request.data.type.payment?.expiresAt else { + return .empty + } + return .paymentExpiry(title: Localized.Transfer.paymentExpiresIn, expiresAt: expiresAt) + } +} + extension ConfirmTransferSceneViewModel: ListSectionProvideable { public var sections: [ListSection] { [ ListSection(type: .header, [.header]), ListSection(type: .details, detailItems), + paymentItems.isEmpty ? nil : ListSection(type: .paymentExpiry, paymentItems), simulationWarnings.isEmpty ? nil : ListSection(type: .warnings, [.warnings]), primaryPayloadFields.isEmpty ? nil : ListSection(type: .payload, [.payload]), balanceChangeModels.isEmpty ? nil : ListSection(type: .balanceChanges, balanceChangeModels.indices.map(ConfirmTransferItem.balanceChange)), @@ -163,15 +175,27 @@ extension ConfirmTransferSceneViewModel: ListSectionProvideable { if case .generic = request.data.type { return [.app, .sender, .network] } + if case .payment = request.data.type { + return [.app, .sender, .recipient, .network] + } return [.app, .sender, .recipient, .network, .memo, .details] } + private var paymentItems: [ConfirmTransferItem] { + guard request.data.type.payment != nil else { + return [] + } + return [.paymentExpiry] + } + public func itemModel(for item: ConfirmTransferItem) -> any ItemModelProvidable { switch item { case .header: ConfirmHeaderViewModel(request: request, state: state) case .warnings: ConfirmTransferItemModel.warnings(simulationWarnings) + case .paymentExpiry: + paymentExpiryItemModel case .app: ConfirmAppViewModel(type: request.data.type) case .sender: diff --git a/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift index ca59389674..51c0f1cc44 100644 --- a/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/RecipientSceneViewModel.swift @@ -182,18 +182,7 @@ extension RecipientSceneViewModel { // MARK: - Private extension RecipientSceneViewModel { - // TODO: Add unit tests, will be added once moved to package - private func paymentScan(string: String) throws -> PaymentScanResult { - let payment = try PaymentURLDecoder.decode(string) - - return PaymentScanResult( - address: payment.address, - amount: payment.amount, - memo: payment.memo, - ) - } - - func getRecipientScanResult(payment: PaymentScanResult) throws -> RecipientScanResult { + func getRecipientScanResult(payment: PaymentRequest) throws -> RecipientScanResult { let address = asset.chain.checksumAddress(payment.address) if let amount = payment.amount, showMemo ? ((payment.memo?.isEmpty) == nil) : true, asset.chain.isValidAddress(address) @@ -252,17 +241,21 @@ extension RecipientSceneViewModel { } private func handleAddressScan(_ string: String) throws { - let payment = try paymentScan(string: string) - let scanResult = try getRecipientScanResult(payment: payment) - switch scanResult { - case let .transferData(data): - handle(transferData: data) - case let .recipient(address, memo, amount): - // TODO: - open if all fields filled - addressInputModel.update(text: address) - - if let memo { self.memo = memo } - if let amount { self.amount = amount } + let type = try PaymentURLDecoder.decode(string) + switch type { + case let .request(payment): + let scanResult = try getRecipientScanResult(payment: payment) + switch scanResult { + case let .transferData(data): + handle(transferData: data) + case let .recipient(address, memo, amount): + // TODO: - open if all fields filled + addressInputModel.update(text: address) + + if let memo { self.memo = memo } + if let amount { self.amount = amount } + } + case .link: throw AnyError(Localized.Errors.notSupported) } } diff --git a/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift index 23aa1635ce..4ae99e7b00 100644 --- a/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/TransferDataViewModel.swift @@ -46,8 +46,10 @@ struct TransferDataViewModel { case .deposit: Localized.Wallet.deposit case .withdrawal: Localized.Wallet.withdraw case .transferNft: Localized.Transfer.Send.title - case .swap, .tokenApprove: Localized.Wallet.swap + case .swap: Localized.Wallet.swap + case .tokenApprove: Localized.Transfer.Approve.title case .generic: Localized.Transfer.reviewRequest + case .payment: Localized.Transfer.paymentTitle case let .stake(_, type): switch type { case .stake: Localized.Transfer.Stake.title @@ -89,9 +91,10 @@ struct TransferDataViewModel { .stake, .account, .perpetual, - .earn: .none - case let .generic(_, metadata, _): - URL(string: metadata.url) + .earn, + .payment: .none + case let .generic(_, app, _): + app.url?.asURL } } diff --git a/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift b/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift index e2ed01d540..fdfb3de014 100644 --- a/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift +++ b/ios/Features/Transfer/TestKit/ConfirmTransferInput+TestKit.swift @@ -3,6 +3,7 @@ import BigInt import Foundation import Primitives +import TransferServiceTestKit import PrimitivesComponents import Transfer import Validators diff --git a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift index 422621ae4d..9e435bd3f7 100644 --- a/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/RecipientSceneViewModelTests.swift @@ -92,6 +92,17 @@ struct RecipientSceneViewModelTests { #expect(recipientData?.recipient.address == checksummed) } + @Test + func onHandleScan_paymentLink_keepsAddress() { + let model = RecipientSceneViewModel.mock() + let address = "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326" + model.addressInputModel.update(text: address) + + model.onHandleScan("https://pay.walletconnect.com/?pid=pay_123", for: .address) + + #expect(model.addressInputModel.text == address) + } + @Test func getRecipientScanResult_transferData() throws { let asset = Asset.mockEthereum() @@ -99,10 +110,9 @@ struct RecipientSceneViewModelTests { let address = "0x5615e8ab93b9d695b6d4d6545f7792aa59e1069a" let checksummed = "0x5615E8AB93b9d695b6d4d6545f7792aA59e1069a" - let payment = PaymentScanResult( + let payment = PaymentRequest.mock( address: " \n\(address)\r ", amount: "1.234", - memo: nil, ) let result = try model.getRecipientScanResult(payment: payment) @@ -120,9 +130,8 @@ struct RecipientSceneViewModelTests { func getRecipientScanResult_recipient() throws { let model = RecipientSceneViewModel.mock() - let payment = PaymentScanResult( + let payment = PaymentRequest.mock( address: "0x123", - amount: nil, memo: "test memo", ) diff --git a/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift b/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift index 50588ce12b..50c89c173e 100644 --- a/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift +++ b/ios/Features/Transfer/Tests/Services/ConfirmServiceTests.swift @@ -53,7 +53,7 @@ struct ConfirmServiceTests { ) let state = await service.updateState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock(header: SimulationHeader(assetId: Asset.mockEthereumUSDT().id, value: "0", isUnlimited: true)), ) @@ -71,7 +71,7 @@ struct ConfirmServiceTests { ) let state = service.makeState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock( payload: [ SimulationPayloadField.standard(kind: .contract, value: "0x123", fieldType: .address, display: .primary), @@ -153,7 +153,7 @@ struct ConfirmServiceTests { ) let state = await service.updateState( - data: TransferData.mock(type: .generic(asset: .mockBNB(), metadata: .mock(), extra: .mock())), + data: TransferData.mock(type: .generic(asset: .mockBNB(), appMetadata: .mock(), extra: .mock())), simulation: SimulationResult.mock(payload: [ SimulationPayloadField.standard(kind: .contract, value: "0x123", fieldType: .address, display: .primary), ]), diff --git a/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift b/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift index 7b4089669f..b8198f9bbc 100644 --- a/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift +++ b/ios/Features/Transfer/Tests/Services/ConfirmTransferInputProviderTests.swift @@ -5,6 +5,8 @@ import Foundation import PrimitivesTestKit import Testing @testable import Transfer +import TransferService +import TransferServiceTestKit import TransferTestKit import Validators diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift index ec59fa3c75..779e3a48ef 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmAppViewModelTests.swift @@ -11,11 +11,11 @@ import TransferTestKit struct ConfirmAppViewModelTests { @Test func generic() { - let metadata = WalletConnectionSessionAppMetadata.mock( + let appMetadata = TransactionAppMetadata.mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", ) - let model = ConfirmAppViewModel(type: .generic(asset: .mock(), metadata: metadata, extra: .mock())) + let model = ConfirmAppViewModel(type: .generic(asset: .mock(), appMetadata: appMetadata, extra: .mock())) guard case let .app(item) = model.itemModel else { return } #expect(item.title == Localized.WalletConnect.app) diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift index 064c2de4e4..cdc9f65524 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmRecipientViewModelTests.swift @@ -52,7 +52,7 @@ struct ConfirmRecipientViewModelTests { @Test func genericSend() { let model = ConfirmRecipientViewModel( - model: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .send))), + model: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .send))), addressName: nil, addressLink: .mock(), ) @@ -64,7 +64,7 @@ struct ConfirmRecipientViewModelTests { @Test func genericSign() { let model = ConfirmRecipientViewModel( - model: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .sign))), + model: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .sign))), addressName: nil, addressLink: .mock(), ) diff --git a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift index 4c9c6aedea..e90d9ece9d 100644 --- a/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift @@ -25,6 +25,7 @@ import Primitives import PrimitivesComponents import PrimitivesTestKit import ScanServiceTestKit +import SigningRequestServiceTestKit import Store import Testing import TransactionStateServiceTestKit @@ -71,7 +72,7 @@ struct ConfirmTransferSceneViewModelTests { } let modelWithWebsite = ConfirmTransferSceneViewModel.mock( - data: .mock(type: .generic(asset: .mock(), metadata: .mock(name: "Gem Wallet", url: "https://example.com"), extra: .mock())), + data: .mock(type: .generic(asset: .mock(), appMetadata: .mock(name: "Gem Wallet", url: "https://example.com"), extra: .mock())), ) let appItemWithWebsite = modelWithWebsite.itemModel(for: .app) as? ConfirmAppViewModel @@ -87,8 +88,8 @@ struct ConfirmTransferSceneViewModelTests { #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .transfer(.mock()))).title == Localized.Transfer.Send.title) // #expect(ConfirmTransferViewModel.mock(data: .mock(type: .transferNft(.mock()))).title == Localized.Transfer.Send.title) #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .swap(.mock(), .mock(), .mock()))).title == Localized.Wallet.swap) - #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .tokenApprove(.mock(), .mock()))).title == Localized.Wallet.swap) - #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mock(), metadata: .mock(), extra: .mock()))).title == Localized.Transfer.reviewRequest) + #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .tokenApprove(.mock(), .mock()))).title == Localized.Transfer.Approve.title) + #expect(ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mock(), appMetadata: .mock(), extra: .mock()))).title == Localized.Transfer.reviewRequest) } @Test @@ -190,7 +191,7 @@ struct ConfirmTransferSceneViewModelTests { Issue.record("Expected network item model for USDT") } - let genericEthModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereum(), metadata: .mock(), extra: .mock()))) + let genericEthModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereum(), appMetadata: .mock(), extra: .mock()))) let genericEthNetworkItem = genericEthModel.itemModel(for: .network) as? ConfirmNetworkViewModel if case let .network(listItem) = genericEthNetworkItem?.itemModel { @@ -199,7 +200,7 @@ struct ConfirmTransferSceneViewModelTests { Issue.record("Expected network item model for generic ETH") } - let genericUsdtModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereumUSDT(), metadata: .mock(), extra: .mock()))) + let genericUsdtModel = ConfirmTransferSceneViewModel.mock(data: .mock(type: .generic(asset: .mockEthereumUSDT(), appMetadata: .mock(), extra: .mock()))) let genericUsdtNetworkItem = genericUsdtModel.itemModel(for: .network) as? ConfirmNetworkViewModel if case let .network(listItem) = genericUsdtNetworkItem?.itemModel { @@ -326,7 +327,7 @@ struct ConfirmTransferSceneViewModelTests { @Test func walletConnectSectionsStructure() { let model = ConfirmTransferSceneViewModel.mock( - data: .mock(type: .generic(asset: .mockEthereum(), metadata: .mock(), extra: .mock(to: "0x1111111111111111111111111111111111111111"))), + data: .mock(type: .generic(asset: .mockEthereum(), appMetadata: .mock(), extra: .mock(to: "0x1111111111111111111111111111111111111111"))), simulation: .mock( warnings: [SimulationWarning( severity: .warning, @@ -606,6 +607,19 @@ struct ConfirmTransferSceneViewModelTests { Issue.record("Expected non-empty model") } } + + @Test + func confirmIsDisabledOnceThePaymentQuoteExpires() async { + let model = ConfirmTransferSceneViewModel.mock( + data: .mock(type: .payment(asset: .mock(), payment: .mock(expiresAt: .now), extra: .mock())), + ) + + #expect(model.isButtonDisabled == false) + + await model.paymentExpiry.start() + + #expect(model.isButtonDisabled) + } } private extension ConfirmTransferSceneViewModel { diff --git a/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift b/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift index c0f0972846..d0d6e8b266 100644 --- a/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift +++ b/ios/Features/Transfer/Tests/ViewModels/TransferDataViewModelTests.swift @@ -15,13 +15,13 @@ struct TransferDataViewModelTests { @Test func genericSendTitle() { - let type = TransferDataType.generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .send)) + let type = TransferDataType.generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .send)) #expect(TransferDataViewModel.mock(type: type).title == Localized.Transfer.reviewRequest) } @Test func genericSignTitle() { - let type = TransferDataType.generic(asset: .mock(), metadata: .mock(), extra: .mock(outputAction: .sign)) + let type = TransferDataType.generic(asset: .mock(), appMetadata: .mock(), extra: .mock(outputAction: .sign)) #expect(TransferDataViewModel.mock(type: type).title == Localized.Transfer.reviewRequest) } diff --git a/ios/GemTests/unit_frameworks.xctestplan b/ios/GemTests/unit_frameworks.xctestplan index 078ffca49e..2fb124c82f 100644 --- a/ios/GemTests/unit_frameworks.xctestplan +++ b/ios/GemTests/unit_frameworks.xctestplan @@ -175,6 +175,13 @@ "name" : "TransferTests" } }, + { + "target" : { + "containerPath" : "container:Packages\/FeatureServices", + "identifier" : "TransferServiceTests", + "name" : "TransferServiceTests" + } + }, { "target" : { "containerPath" : "container:Packages\/GemAPI", diff --git a/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift b/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift index f8ff85e516..b446131065 100644 --- a/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift +++ b/ios/Packages/FeatureServices/ConnectionsService/ConnectionsService.swift @@ -63,7 +63,7 @@ public extension ConnectionsService { try await connector.pair(uri: uri) } - func disconnect(session: WalletConnectionSession) async throws { + func disconnect(session: WalletConnectSession) async throws { try await disconnect(sessionId: session.sessionId) } diff --git a/ios/Packages/FeatureServices/Package.swift b/ios/Packages/FeatureServices/Package.swift index dd0fc31177..da7308ffcc 100644 --- a/ios/Packages/FeatureServices/Package.swift +++ b/ios/Packages/FeatureServices/Package.swift @@ -25,6 +25,8 @@ let package = Package( .library(name: "PriceAlertService", targets: ["PriceAlertService"]), .library(name: "PriceAlertServiceTestKit", targets: ["PriceAlertServiceTestKit"]), .library(name: "TransactionStateService", targets: ["TransactionStateService"]), + .library(name: "TransferService", targets: ["TransferService"]), + .library(name: "TransferServiceTestKit", targets: ["TransferServiceTestKit"]), .library(name: "TransactionStateServiceTestKit", targets: ["TransactionStateServiceTestKit"]), .library(name: "TransactionsService", targets: ["TransactionsService"]), .library(name: "TransactionsServiceTestKit", targets: ["TransactionsServiceTestKit"]), @@ -73,6 +75,7 @@ let package = Package( .package(name: "GemstonePrimitives", path: "../GemstonePrimitives"), .package(name: "Gemstone", path: "../Gemstone"), .package(name: "Signer", path: "../Signer"), + .package(name: "Validators", path: "../Validators"), .package(name: "Keystore", path: "../Keystore"), .package(name: "Formatters", path: "../Formatters"), .package(name: "SwiftHTTPClient", path: "../SwiftHTTPClient"), @@ -265,9 +268,31 @@ let package = Package( ], path: "PriceAlertService/TestKit", ), + .target( + name: "TransferService", + dependencies: [ + .product(name: "PaymentService", package: "ChainServices"), + .product(name: "ScanService", package: "ChainServices"), + .product(name: "Validators", package: "Validators"), + "Primitives", + "Blockchain", + "Signer", + "BalanceService", + "TransactionStateService", + "GemstonePrimitives", + ], + path: "TransferService", + exclude: ["TestKit", "Tests"], + ), + .target( + name: "TransferServiceTestKit", + dependencies: ["TransferService"], + path: "TransferService/TestKit", + ), .target( name: "TransactionStateService", dependencies: [ + .product(name: "PaymentService", package: "ChainServices"), "Primitives", "Store", "Blockchain", @@ -283,6 +308,8 @@ let package = Package( .target( name: "TransactionStateServiceTestKit", dependencies: [ + .product(name: "PaymentServiceTestKit", package: "ChainServices"), + .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "StoreTestKit", package: "Store"), .product(name: "StakeServiceTestKit", package: "ChainServices"), .product(name: "PreferencesTestKit", package: "Preferences"), @@ -722,9 +749,24 @@ let package = Package( ], path: "AssetsService/Tests", ), + .testTarget( + name: "TransferServiceTests", + dependencies: [ + "TransferService", + "TransferServiceTestKit", + .product(name: "PrimitivesTestKit", package: "Primitives"), + .product(name: "BlockchainTestKit", package: "Blockchain"), + .product(name: "ChainServiceTestKit", package: "ChainServices"), + .product(name: "SignerTestKit", package: "Signer"), + "TransactionStateServiceTestKit", + "GemstonePrimitives", + ], + path: "TransferService/Tests", + ), .testTarget( name: "TransactionStateServiceTests", dependencies: [ + .product(name: "PaymentServiceTestKit", package: "ChainServices"), "TransactionStateService", "TransactionStateServiceTestKit", "BalanceServiceTestKit", diff --git a/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift b/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift index faff54a3a1..f4dc50739d 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/TestKit/TransactionStateScheduler+TestKit.swift @@ -8,6 +8,8 @@ import Foundation import NativeProviderService import NFTService import NFTServiceTestKit +import PaymentService +import PaymentServiceTestKit import Primitives import StakeService import StakeServiceTestKit @@ -22,6 +24,7 @@ public extension TransactionStateScheduler { stakeService: StakeService = .mock(), earnService: EarnService = .mock(), nftService: NFTService = .mock(), + paymentStatusService: any PaymentStatusServiceable = PaymentStatusServiceableMock(), ) -> TransactionStateScheduler { let postProcessingService = TransactionPostProcessingService( transactionStore: transactionStore, @@ -34,6 +37,7 @@ public extension TransactionStateScheduler { transactionStore: transactionStore, gatewayService: gatewayService, postProcessingService: postProcessingService, + paymentStatusService: paymentStatusService, ) return TransactionStateScheduler( transactionStore: transactionStore, diff --git a/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift b/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift index 9226e06eb0..0f9dceab73 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/Tests/TransactionStateServiceTests.swift @@ -5,11 +5,13 @@ import EarnService import Foundation import GemAPITestKit import NFTServiceTestKit +import PaymentService import Primitives import PrimitivesTestKit import StakeServiceTestKit import Store import StoreTestKit +import PaymentServiceTestKit import Testing @testable import TransactionStateService import TransactionStateServiceTestKit @@ -215,6 +217,54 @@ struct TransactionStateServiceTests { #expect(swapRequests.map(\.state) == [TransactionState.pending, .inTransit]) } + @Test + func paymentSettlesWithGatewayTransactionHash() async throws { + let statusService = TransactionStatusServiceMock(stateChanges: TransactionChanges(state: .confirmed)) + let paymentStatusService = PaymentStatusServiceableMock(result: .mock(status: .succeeded, transactionId: "0xsettled")) + let fixture = try makeFixture( + statusService: statusService, + paymentStatusService: paymentStatusService, + transaction: makePaymentTransaction(paymentId: "pay_1"), + ) + + let status = await fixture.service.update(for: fixture.transaction).status + + expectComplete(status) + #expect(await paymentStatusService.paymentIds() == ["pay_1"]) + #expect(await statusService.regularRequestCount() == 0) + let saved = try #require(fixture.store.getTransactions(states: [.confirmed]).first) + #expect(saved.id.hash == "0xsettled") + } + + @Test + func paymentKeepsPendingWhileGatewayProcesses() async throws { + let fixture = try makeFixture( + statusService: TransactionStatusServiceMock(stateChanges: TransactionChanges(state: .confirmed)), + paymentStatusService: PaymentStatusServiceableMock(result: .mock(status: .processing)), + transaction: makePaymentTransaction(paymentId: "pay_1"), + ) + + let status = await fixture.service.update(for: fixture.transaction).status + + expectRetry(status) + let saved = try #require(fixture.store.getTransactions(states: [.pending]).first) + #expect(saved.id.hash == "pay_1") + } + + @Test + func paymentFailsWhenGatewayExpiresIt() async throws { + let fixture = try makeFixture( + statusService: TransactionStatusServiceMock(stateChanges: TransactionChanges(state: .confirmed)), + paymentStatusService: PaymentStatusServiceableMock(result: .mock(status: .expired)), + transaction: makePaymentTransaction(paymentId: "pay_1"), + ) + + let status = await fixture.service.update(for: fixture.transaction).status + + expectComplete(status) + #expect(try fixture.store.getTransactions(states: [.failed]).count == 1) + } + @Test func postProcessingRefreshesSwapBalances() async throws { let fromAsset = AssetId.mock(.bitcoin) @@ -297,6 +347,8 @@ private extension TransactionStateServiceTests { state: TransactionState = .pending, provider: SwapProvider? = .thorchain, statusService: any TransactionStatusServiceable, + paymentStatusService: any PaymentStatusServiceable = PaymentStatusServiceableMock(), + transaction: Transaction? = .none, ) throws -> Fixture { let fromAsset = AssetId.mock(.bitcoin) let toAsset = AssetId.mock(.ethereum) @@ -307,7 +359,7 @@ private extension TransactionStateServiceTests { let store = TransactionStore.mock(db: db) let wallet = Wallet.mock() let walletId = wallet.id - let transaction = try makeSwapTransaction( + let transaction = try transaction ?? makeSwapTransaction( fromAsset: fromAsset, toAsset: toAsset, state: state, @@ -326,6 +378,7 @@ private extension TransactionStateServiceTests { transactionStore: store, postProcessingService: postProcessingService, statusService: statusService, + paymentStatusService: paymentStatusService, ) return Fixture(store: store, walletId: walletId, wallet: wallet, transaction: transaction, service: service) } @@ -366,6 +419,31 @@ private extension TransactionStateServiceTests { ) } + func makePaymentTransaction(paymentId: String) -> Transaction { + let assetId = AssetId.mock(.ethereum) + let metadata = AnyCodableValue.encode(TransactionPaymentMetadata(paymentId: paymentId, merchant: .mock(), provider: .walletConnectPay)) + return Transaction( + id: TransactionId(chain: assetId.chain, hash: paymentId), + assetId: assetId, + from: "sender", + to: .empty, + contract: nil, + type: .transfer, + state: .pending, + blockNumber: nil, + sequence: nil, + fee: .zero, + feeAssetId: assetId.chain.assetId, + value: "10000", + memo: nil, + direction: .outgoing, + utxoInputs: [], + utxoOutputs: [], + metadata: metadata, + createdAt: Date(timeIntervalSince1970: 1234), + ) + } + func fetchNFTs(db: DB, walletId: WalletId) throws -> [NFTData] { try db.dbQueue.read { database in try NFTRequest(walletId: walletId, filter: .all).fetch(database) diff --git a/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift b/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift index 979b1ea9f3..beb38471c0 100644 --- a/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift +++ b/ios/Packages/FeatureServices/TransactionStateService/TransactionStateService.swift @@ -2,6 +2,7 @@ import Blockchain import Foundation +import PaymentService import Primitives import Store @@ -26,16 +27,19 @@ public struct TransactionStateService: Sendable { private let transactionStore: TransactionStore private let postProcessingService: TransactionPostProcessingService private let statusService: any TransactionStatusServiceable + private let paymentStatusService: any PaymentStatusServiceable public init( transactionStore: TransactionStore, gatewayService: GatewayService, postProcessingService: TransactionPostProcessingService, + paymentStatusService: any PaymentStatusServiceable, ) { self.init( transactionStore: transactionStore, postProcessingService: postProcessingService, statusService: gatewayService, + paymentStatusService: paymentStatusService, ) } @@ -43,10 +47,12 @@ public struct TransactionStateService: Sendable { transactionStore: TransactionStore, postProcessingService: TransactionPostProcessingService, statusService: any TransactionStatusServiceable, + paymentStatusService: any PaymentStatusServiceable, ) { self.transactionStore = transactionStore self.postProcessingService = postProcessingService self.statusService = statusService + self.paymentStatusService = paymentStatusService } func update(for transaction: Transaction) async -> TransactionStateUpdateResult { @@ -77,6 +83,9 @@ public struct TransactionStateService: Sendable { extension TransactionStateService { private func fetchStateChanges(for transaction: Transaction) async throws -> TransactionChanges { + if let payment = transaction.paymentMetadata, paymentStatusService.hasStatus(provider: payment.provider) { + return try await paymentStateChanges(payment: payment, transaction: transaction) + } let request = transactionStateRequest(for: transaction) if let swapRequest = transactionSwapStateRequest(for: transaction, transactionRequest: request) { return try await statusService.transactionSwapStatus( @@ -93,6 +102,21 @@ extension TransactionStateService { ) } + private func paymentStateChanges(payment: TransactionPaymentMetadata, transaction: Transaction) async throws -> TransactionChanges { + let result = try await paymentStatusService.getPaymentStatus(provider: payment.provider, paymentId: payment.paymentId) + switch result.status { + case .succeeded: + guard let hash = result.transactionId, transaction.isAwaitingPaymentHash else { + return TransactionChanges(state: .confirmed) + } + return TransactionChanges(state: .confirmed, changes: [.hashChange(old: transaction.id.hash, new: hash)]) + case .failed, .expired, .cancelled: + return TransactionChanges(state: .failed) + case .processing, .requiresAction: + return TransactionChanges(state: transaction.state) + } + } + private func transactionStateRequest(for transaction: Transaction) -> TransactionStateRequest { TransactionStateRequest( id: transaction.id.hash, diff --git a/ios/Features/Transfer/Sources/Services/FeeRateService.swift b/ios/Packages/FeatureServices/TransferService/FeeRateService.swift similarity index 66% rename from ios/Features/Transfer/Sources/Services/FeeRateService.swift rename to ios/Packages/FeatureServices/TransferService/FeeRateService.swift index 2317b86fe4..ba4bc88652 100644 --- a/ios/Features/Transfer/Sources/Services/FeeRateService.swift +++ b/ios/Packages/FeatureServices/TransferService/FeeRateService.swift @@ -4,20 +4,20 @@ import Blockchain import Foundation import Primitives -protocol FeeRateProviding: Sendable { +public protocol FeeRateProviding: Sendable { func rates(for type: TransferDataType) async throws -> [FeeRate] } -struct FeeRateService: FeeRateProviding { +public struct FeeRateService: FeeRateProviding { private let service: any ChainFeeRateFetchable - init( + public init( service: any ChainFeeRateFetchable, ) { self.service = service } - func rates(for type: TransferDataType) async throws -> [FeeRate] { + public func rates(for type: TransferDataType) async throws -> [FeeRate] { try await service.feeRates(type: type) } } diff --git a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift b/ios/Packages/FeatureServices/TransferService/ScanTransactionError.swift similarity index 82% rename from ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift rename to ios/Packages/FeatureServices/TransferService/ScanTransactionError.swift index 0164b65982..eba55b2009 100644 --- a/ios/Features/Transfer/Sources/Errors/ScanTransactionError.swift +++ b/ios/Packages/FeatureServices/TransferService/ScanTransactionError.swift @@ -4,13 +4,13 @@ import Foundation import Localization import Primitives -enum ScanTransactionError: Error, Equatable { +public enum ScanTransactionError: Error, Equatable { case malicious case memoRequired(symbol: String) } extension ScanTransactionError: LocalizedError { - var errorDescription: String? { + public var errorDescription: String? { switch self { case .malicious: Localized.Errors.ScanTransaction.Malicious.description case let .memoRequired(symbol): Localized.Errors.ScanTransaction.memoRequired(symbol.boldMarkdown()) diff --git a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift b/ios/Packages/FeatureServices/TransferService/ScanTransactionValidator.swift similarity index 86% rename from ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift rename to ios/Packages/FeatureServices/TransferService/ScanTransactionValidator.swift index 55268f9441..62ff5d0f2c 100644 --- a/ios/Features/Transfer/Sources/Validators/ScanTransactionValidator.swift +++ b/ios/Packages/FeatureServices/TransferService/ScanTransactionValidator.swift @@ -4,8 +4,8 @@ import Foundation import GemstonePrimitives import Primitives -enum ScanTransactionValidator { - static func validate( +public enum ScanTransactionValidator { + public static func validate( transaction: ScanTransaction, asset: Asset, memo: String?, diff --git a/ios/Features/Transfer/TestKit/TransactionData+TestKit.swift b/ios/Packages/FeatureServices/TransferService/TestKit/TransactionData+TestKit.swift similarity index 100% rename from ios/Features/Transfer/TestKit/TransactionData+TestKit.swift rename to ios/Packages/FeatureServices/TransferService/TestKit/TransactionData+TestKit.swift diff --git a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift b/ios/Packages/FeatureServices/TransferService/TestKit/TransferExecutor+TestKit.swift similarity index 59% rename from ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift rename to ios/Packages/FeatureServices/TransferService/TestKit/TransferExecutor+TestKit.swift index 011e2d913b..dd3492fc65 100644 --- a/ios/Features/Transfer/TestKit/TransferExecutor+TestKit.swift +++ b/ios/Packages/FeatureServices/TransferService/TestKit/TransferExecutor+TestKit.swift @@ -1,18 +1,22 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Transfer +import TransferService public struct TransferExecutorMock: TransferExecutable { public var error: Error? + public var results: [String] - public init(error: Error? = nil) { + public init(error: Error? = nil, results: [String] = ["1"]) { self.error = error + self.results = results } - public func execute(input _: TransferConfirmationInput) async throws { + @discardableResult + public func execute(input _: TransferConfirmationInput) async throws -> [String] { if let error { throw error } + return results } } diff --git a/ios/Features/Transfer/TestKit/TransferTransactionProvider+TestKit.swift b/ios/Packages/FeatureServices/TransferService/TestKit/TransferTransactionProvider+TestKit.swift similarity index 96% rename from ios/Features/Transfer/TestKit/TransferTransactionProvider+TestKit.swift rename to ios/Packages/FeatureServices/TransferService/TestKit/TransferTransactionProvider+TestKit.swift index cf82b4183a..20c3ec7362 100644 --- a/ios/Features/Transfer/TestKit/TransferTransactionProvider+TestKit.swift +++ b/ios/Packages/FeatureServices/TransferService/TestKit/TransferTransactionProvider+TestKit.swift @@ -3,7 +3,7 @@ import BigInt import Foundation import Primitives -import Transfer +import TransferService public struct TransferTransactionProviderMock: TransferTransactionProvidable { public var result: Result diff --git a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift b/ios/Packages/FeatureServices/TransferService/Tests/ScanTransactionValidatorTests.swift similarity index 97% rename from ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift rename to ios/Packages/FeatureServices/TransferService/Tests/ScanTransactionValidatorTests.swift index cedcefec04..eff551261b 100644 --- a/ios/Features/Transfer/Tests/Validators/ScanTransactionValidatorTests.swift +++ b/ios/Packages/FeatureServices/TransferService/Tests/ScanTransactionValidatorTests.swift @@ -4,8 +4,8 @@ import Foundation import Primitives import PrimitivesTestKit import Testing -@testable import Transfer -import TransferTestKit +import TransferServiceTestKit +@testable import TransferService struct ScanTransactionValidatorTests { @Test diff --git a/ios/Features/Transfer/Tests/Services/TransferExecutorTests.swift b/ios/Packages/FeatureServices/TransferService/Tests/TransferExecutorTests.swift similarity index 99% rename from ios/Features/Transfer/Tests/Services/TransferExecutorTests.swift rename to ios/Packages/FeatureServices/TransferService/Tests/TransferExecutorTests.swift index 43e04f51d3..cc44615194 100644 --- a/ios/Features/Transfer/Tests/Services/TransferExecutorTests.swift +++ b/ios/Packages/FeatureServices/TransferService/Tests/TransferExecutorTests.swift @@ -11,7 +11,8 @@ import StoreTestKit import Testing import TransactionStateService import TransactionStateServiceTestKit -@testable import Transfer +import TransferServiceTestKit +@testable import TransferService struct TransferExecutorTests { @Test diff --git a/ios/Features/Transfer/Tests/Services/TransferTransactionProviderTests.swift b/ios/Packages/FeatureServices/TransferService/Tests/TransferTransactionProviderTests.swift similarity index 95% rename from ios/Features/Transfer/Tests/Services/TransferTransactionProviderTests.swift rename to ios/Packages/FeatureServices/TransferService/Tests/TransferTransactionProviderTests.swift index 80912e7205..a1bc69baeb 100644 --- a/ios/Features/Transfer/Tests/Services/TransferTransactionProviderTests.swift +++ b/ios/Packages/FeatureServices/TransferService/Tests/TransferTransactionProviderTests.swift @@ -3,7 +3,8 @@ import Blockchain import Primitives import Testing -@testable import Transfer +import TransferServiceTestKit +@testable import TransferService struct TransferTransactionProviderTests { @Test diff --git a/ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift b/ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift new file mode 100644 index 0000000000..0f5e7aa88f --- /dev/null +++ b/ios/Packages/FeatureServices/TransferService/TransactionConfirmationWaiter.swift @@ -0,0 +1,54 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Blockchain +import Foundation +import GemstonePrimitives +import Localization +import Primitives + +public enum TransactionConfirmationError: Error, Equatable { + case reverted + case timedOut +} + +extension TransactionConfirmationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .reverted: Localized.Transaction.Status.failed + case .timedOut: Localized.Errors.errorOccurred + } + } +} + +public struct TransactionConfirmationWaiter: Sendable { + private let chainService: any ChainServiceable + private let timeout: Duration + + public init( + chainService: any ChainServiceable, + timeout: Duration = .seconds(120), + ) { + self.chainService = chainService + self.timeout = timeout + } + + public func wait(hash: String, chain: Chain, senderAddress: String) async throws { + let configuration = chain.transactionStateConfig + let request = TransactionStateRequest(id: hash, senderAddress: senderAddress, createdAt: Date(), blockNumber: 0) + let deadline = ContinuousClock.now.advanced(by: timeout) + var intervalMs = configuration.initialIntervalMs + + while ContinuousClock.now < deadline { + switch try await chainService.transactionState(for: request).state { + case .confirmed: + return + case .failed, .reverted: + throw TransactionConfirmationError.reverted + case .pending, .inTransit: + try await Task.sleep(for: .milliseconds(Int(intervalMs))) + intervalMs = configuration.nextInterval(after: intervalMs) + } + } + throw TransactionConfirmationError.timedOut + } +} diff --git a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift b/ios/Packages/FeatureServices/TransferService/TransactionFactory.swift similarity index 97% rename from ios/Features/Transfer/Sources/Services/TransactionFactory.swift rename to ios/Packages/FeatureServices/TransferService/TransactionFactory.swift index 70cfd3c74c..d83b0d9dc0 100644 --- a/ios/Features/Transfer/Sources/Services/TransactionFactory.swift +++ b/ios/Packages/FeatureServices/TransferService/TransactionFactory.swift @@ -3,8 +3,8 @@ import Foundation import Primitives -enum TransactionFactory { - static func makePendingTransaction( +public enum TransactionFactory { + public static func makePendingTransaction( wallet: Wallet, transferData: TransferData, transactionData: TransactionData, diff --git a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift b/ios/Packages/FeatureServices/TransferService/TransferConfirmationInput.swift similarity index 82% rename from ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift rename to ios/Packages/FeatureServices/TransferService/TransferConfirmationInput.swift index 5568a0edf3..a69251024b 100644 --- a/ios/Features/Transfer/Sources/Types/TransferConfirmationInput.swift +++ b/ios/Packages/FeatureServices/TransferService/TransferConfirmationInput.swift @@ -1,8 +1,8 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import PaymentService import Primitives -import WalletConnector public struct TransferConfirmationInput: Sendable { public let data: TransferData @@ -10,7 +10,7 @@ public struct TransferConfirmationInput: Sendable { public let transactionData: TransactionData public let amount: TransferAmount public let simulation: SimulationResult? - public let delegate: TransferDataCallback.ConfirmTransferDelegate? + public let delegate: StringResultAction? public init( data: TransferData, @@ -18,7 +18,7 @@ public struct TransferConfirmationInput: Sendable { transactionData: TransactionData, amount: TransferAmount, simulation: SimulationResult? = nil, - delegate: TransferDataCallback.ConfirmTransferDelegate?, + delegate: StringResultAction?, ) { self.data = data self.wallet = wallet diff --git a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift b/ios/Packages/FeatureServices/TransferService/TransferExecutor.swift similarity index 91% rename from ios/Features/Transfer/Sources/Services/TransferExecutor.swift rename to ios/Packages/FeatureServices/TransferService/TransferExecutor.swift index 1f9b822708..560944b7e3 100644 --- a/ios/Features/Transfer/Sources/Services/TransferExecutor.swift +++ b/ios/Packages/FeatureServices/TransferService/TransferExecutor.swift @@ -8,7 +8,8 @@ import Signer import TransactionStateService public protocol TransferExecutable: Sendable { - func execute(input: TransferConfirmationInput) async throws + @discardableResult + func execute(input: TransferConfirmationInput) async throws -> [String] } public struct TransferExecutor: TransferExecutable { @@ -33,8 +34,10 @@ public struct TransferExecutor: TransferExecutable { self.transactionStateScheduler = transactionStateScheduler } - public func execute(input: TransferConfirmationInput) async throws { + @discardableResult + public func execute(input: TransferConfirmationInput) async throws -> [String] { let signedData = try await sign(input: input) + var results: [String] = [] for (index, transactionData) in signedData.enumerated() { debugLog("TransferExecutor data \(transactionData)") @@ -42,15 +45,17 @@ public struct TransferExecutor: TransferExecutable { switch input.data.type.outputAction { case .sign: input.delegate?(.success(transactionData)) + results.append(transactionData) case .send: - try await send( + try await results.append(send( input: input, transactionData: transactionData, transactionIndex: index, totalTransactions: signedData.count, - ) + )) } } + return results } } @@ -62,7 +67,7 @@ extension TransferExecutor { transactionData: String, transactionIndex: Int, totalTransactions: Int, - ) async throws { + ) async throws -> String { let hash = try await chainService.broadcast(data: transactionData, options: broadcastOptions(data: input.data)) debugLog("TransferExecutor broadcast response hash \(hash)") @@ -98,6 +103,7 @@ extension TransferExecutor { if totalTransactions > 1, transactionIndex < totalTransactions - 1 { try await Task.sleep(for: transactionDelay(for: input.data.chain.type)) } + return hash } private func sign(input: TransferConfirmationInput) async throws -> [String] { @@ -131,7 +137,7 @@ extension TransferExecutor { && data.quote.providerData.provider == .hyperliquid && transactionIndex < totalTransactions - 1: return [] - case .stake, .perpetual, .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, .generic, .account, .earn: + case .stake, .perpetual, .transfer, .deposit, .withdrawal, .transferNft, .swap, .tokenApprove, .generic, .payment, .account, .earn: break } default: @@ -152,7 +158,7 @@ extension TransferExecutor { case .transfer, .deposit, .withdrawal, .transferNft, .stake, .account, .tokenApprove, .perpetual, .earn: BroadcastOptions( skipPreflight: false, ) - case .swap, .generic: BroadcastOptions(skipPreflight: true) + case .swap, .generic, .payment: BroadcastOptions(skipPreflight: true) } default: BroadcastOptions(skipPreflight: false) } diff --git a/ios/Features/Transfer/Sources/Types/TransferTransactionData.swift b/ios/Packages/FeatureServices/TransferService/TransferTransactionData.swift similarity index 100% rename from ios/Features/Transfer/Sources/Types/TransferTransactionData.swift rename to ios/Packages/FeatureServices/TransferService/TransferTransactionData.swift diff --git a/ios/Features/Transfer/Sources/Services/TransferTransactionProvider.swift b/ios/Packages/FeatureServices/TransferService/TransferTransactionProvider.swift similarity index 100% rename from ios/Features/Transfer/Sources/Services/TransferTransactionProvider.swift rename to ios/Packages/FeatureServices/TransferService/TransferTransactionProvider.swift diff --git a/ios/Packages/Primitives/Sources/TransferDataType.swift b/ios/Packages/Primitives/Sources/TransferDataType.swift index 9b7dc18ada..0c7dd9e496 100644 --- a/ios/Packages/Primitives/Sources/TransferDataType.swift +++ b/ios/Packages/Primitives/Sources/TransferDataType.swift @@ -15,6 +15,14 @@ public enum TransferDataType: Hashable, Equatable, Sendable { case earn(Asset, EarnType, ContractCallData) case generic(asset: Asset, metadata: WalletConnectionSessionAppMetadata, 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 diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentExpiry.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentExpiry.swift new file mode 100644 index 0000000000..59bdf354c5 --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentExpiry.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +@Observable +@MainActor +public final class PaymentExpiry { + public private(set) var isExpired: Bool = false + + private let expiresAt: Date? + + public init(payment: PaymentData?) { + expiresAt = payment?.expiresAt + } + + public func start() async { + guard let expiresAt else { + return + } + await expiresAt.sleepUntil() + isExpired = true + } +} From 3141a14fd60cc74e5ac56a1cc91ea5511427f19e Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:32:57 +0300 Subject: [PATCH 10/27] iOS: pay payment links Resolve a link to its quotes, let the buyer pick one, then run the actions the provider returned: signatures first, so a rejection never costs gas, and approvals broadcast silently and wait to be mined the way swap does. Results go back to the provider by position, and a confirm that fails leaves the payment pending for its status to settle. --- ios/Features/Payments/Package.swift | 70 +++++++ .../Scenes/PaymentDataCollectionScene.swift | 53 +++++ .../Scenes/PaymentNavigationStack.swift | 48 +++++ .../Payments/Scenes/PaymentQuotesScene.swift | 83 ++++++++ .../Services/PaymentActionExecutor.swift | 152 ++++++++++++++ .../Services/PaymentApprovalExecutor.swift | 111 ++++++++++ .../Services/PaymentLinkManager.swift | 70 +++++++ .../Payments/Services/PaymentManager.swift | 136 +++++++++++++ .../Payments/Services/PaymentQueue.swift | 30 +++ .../Services/PaymentSheetPresenter.swift | 109 ++++++++++ .../Payments/Types/PaymentLinkError.swift | 20 ++ .../Payments/Types/PaymentSheetType.swift | 25 +++ .../Types/PaymentTransactionFactory.swift | 35 ++++ .../PaymentQuotesSceneViewModel.swift | 156 ++++++++++++++ .../ViewModels/PaymentQuotesViewModel.swift | 44 ++++ .../PaymentActionExecutorTests.swift | 190 ++++++++++++++++++ .../PaymentsTests/PaymentManagerTests.swift | 174 ++++++++++++++++ .../PaymentsTests/PaymentQueueTests.swift | 74 +++++++ .../PaymentQuotesSceneViewModelTests.swift | 61 ++++++ .../PaymentSheetPresenterTests.swift | 67 ++++++ 20 files changed, 1708 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/PaymentNavigationStack.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/Services/PaymentQueue.swift create mode 100644 ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift create mode 100644 ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.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/PaymentQueueTests.swift create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift diff --git a/ios/Features/Payments/Package.swift b/ios/Features/Payments/Package.swift new file mode 100644 index 0000000000..86f0aef0fc --- /dev/null +++ b/ios/Features/Payments/Package.swift @@ -0,0 +1,70 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Payments", + platforms: [.iOS(.v17), + .macOS(.v15)], + products: [ + .library( + name: "Payments", + targets: ["Payments"], + ), + ], + dependencies: [ + .package(name: "Primitives", path: "../../Packages/Primitives"), + .package(name: "Store", path: "../../Packages/Store"), + .package(name: "Blockchain", path: "../../Packages/Blockchain"), + .package(name: "ChainServices", path: "../../Packages/ChainServices"), + .package(name: "Components", path: "../../Packages/Components"), + .package(name: "Localization", path: "../../Packages/Localization"), + .package(name: "Style", path: "../../Packages/Style"), + .package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"), + .package(name: "FeatureServices", path: "../../Packages/FeatureServices"), + .package(name: "GemstonePrimitives", path: "../../Packages/GemstonePrimitives"), + .package(name: "Keystore", path: "../../Packages/Keystore"), + .package(name: "Signer", path: "../../Packages/Signer"), + .package(name: "EventPresenterService", path: "../EventPresenterService"), + .package(name: "Formatters", path: "../../Packages/Formatters"), + ], + targets: [ + .target( + name: "Payments", + dependencies: [ + "Primitives", + "Components", + "Localization", + "Style", + "PrimitivesComponents", + "GemstonePrimitives", + "Formatters", + "Keystore", + "Signer", + .product(name: "TransferService", package: "FeatureServices"), + "EventPresenterService", + "Blockchain", + .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "ChainService", package: "ChainServices"), + .product(name: "ScanService", package: "ChainServices"), + .product(name: "AssetsService", package: "FeatureServices"), + .product(name: "BalanceService", package: "FeatureServices"), + .product(name: "PaymentService", package: "ChainServices"), + .product(name: "TransactionStateService", package: "FeatureServices"), + ], + path: "Sources/Payments", + ), + .testTarget( + name: "PaymentsTests", + dependencies: [ + "Payments", + .product(name: "PrimitivesTestKit", package: "Primitives"), + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), + .product(name: "PaymentServiceTestKit", package: "ChainServices"), + .product(name: "StoreTestKit", package: "Store"), + .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), + ], + path: "Tests/PaymentsTests", + ), + ], +) diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift new file mode 100644 index 0000000000..fea8d5df21 --- /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 SigningRequestService +import SwiftUI + +public struct PaymentDataCollectionScene: View { + private static let messageHandlerName = "payDataCollectionComplete" + private static let completeMessageType = "IC_COMPLETE" + private static let errorMessageType = "IC_ERROR" + private static let messageTypeKey = "type" + private static let messageErrorKey = "error" + + private let callback: SigningRequestCallback + private let onComplete: () -> Void + + public init( + callback: SigningRequestCallback, + onComplete: @escaping () -> Void, + ) { + self.callback = callback + self.onComplete = onComplete + } + + public var body: some View { + WebView( + url: callback.payload.url, + allowedHost: callback.payload.url.host() ?? .empty, + messageHandler: WebViewMessageHandler(name: Self.messageHandlerName, onMessage: onMessage), + ) + .ignoresSafeArea(edges: .bottom) + } + + private func onMessage(_ payload: [String: Any]) { + switch payload[Self.messageTypeKey] as? String { + case Self.completeMessageType: + finish(.success(.empty)) + case Self.errorMessageType: + finish(.failure(AnyError(payload[Self.messageErrorKey] as? String ?? Localized.Errors.transferError))) + default: + break + } + } + + private func finish(_ result: Result) { + callback.delegate(result) + onComplete() + } +} diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift new file mode 100644 index 0000000000..fb6935b225 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift @@ -0,0 +1,48 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Style +import SwiftUI + +public struct PaymentNavigationStack: View { + private let type: PaymentSheetType + private let presenter: PaymentSheetPresenter + + public init( + type: PaymentSheetType, + presenter: PaymentSheetPresenter, + ) { + self.type = type + self.presenter = presenter + } + + public var body: some View { + NavigationStack { + Group { + switch type { + case let .quotes(data): + PaymentQuotesScene( + model: PaymentQuotesSceneViewModel( + request: data.payload, + confirmTransferDelegate: data.delegate, + ), + onComplete: { presenter.complete(type: type) }, + ) + case let .dataCollection(data): + PaymentDataCollectionScene( + callback: data, + onComplete: { presenter.complete(type: type) }, + ) + } + } + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark) { + presenter.cancelSheet(type: type) + } + } + } + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift new file mode 100644 index 0000000000..30768866d0 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentQuotesScene.swift @@ -0,0 +1,83 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Localization +import PrimitivesComponents +import Style +import SwiftUI + +public struct PaymentQuotesScene: View { + @State private var model: PaymentQuotesSceneViewModel + private let onComplete: () -> Void + + public init( + model: PaymentQuotesSceneViewModel, + onComplete: @escaping () -> Void, + ) { + _model = State(wrappedValue: model) + self.onComplete = onComplete + } + + public var body: some View { + List { + ListAssetHeaderView(model: model.preview, subtitleLayout: .vertical) + + Section { + ListItemImageView( + title: model.merchantTitle, + subtitle: model.merchantText, + assetImage: model.merchantAssetImage, + ) + ListItemImageView( + title: Localized.Common.wallet, + subtitle: model.walletText, + assetImage: model.walletAssetImage, + ) + if let expiresAt = model.expiresAt { + ListItemExpiryView( + title: model.expiresTitle, + expiresAt: expiresAt, + ) + } + } + + if let selected = model.selectedItem { + Section { + NavigationCustomLink( + with: ListItemImageView( + title: model.quotesTitle, + subtitle: selected.amountText, + assetImage: selected.assetImage, + ), + action: model.onSelectQuotes, + ) + } + } + } + .contentMargins(.top, .scene.top, for: .scrollContent) + .listSectionSpacing(.compact) + .taskOnce { model.onAppear() } + .task { await model.awaitExpiry() } + .safeAreaButton { + StateButton( + text: model.buttonTitle, + type: model.buttonType, + action: confirm, + ) + } + .sheet(isPresented: $model.isPresentingQuotes) { + SelectableListNavigationStack( + model: model.quotesModel, + onFinishSelection: model.onFinishQuotesSelection, + listContent: { ListAssetItemView(model: $0) }, + ) + } + .navigationTitle(model.title) + .navigationBarTitleDisplayMode(.inline) + } + + private func confirm() { + model.onConfirm() + onComplete() + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift new file mode 100644 index 0000000000..1281f4e30e --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -0,0 +1,152 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import GemstonePrimitives +import PaymentService +import Primitives +import SigningRequestService + +public struct PaymentActionExecutor: Sendable { + private let interactor: any SigningRequestInteractable + private let approvalExecutor: any PaymentApprovalExecutable + private let simulator: any SigningSimulatable + private let assetsProvider: any PaymentAssetsProvidable + + public init( + interactor: any SigningRequestInteractable, + simulator: any SigningSimulatable, + approvalExecutor: any PaymentApprovalExecutable, + assetsProvider: any PaymentAssetsProvidable, + ) { + self.interactor = interactor + self.approvalExecutor = approvalExecutor + self.simulator = simulator + self.assetsProvider = assetsProvider + } + + @MainActor + public func perform( + actions: [PaymentAction], + paymentId: String, + appMetadata: TransactionAppMetadata, + payment: PaymentData, + wallet: Wallet, + onSubmitted: @MainActor () -> Void = {}, + ) async throws -> PaymentActionResults { + let networkFee = try await getApprovalsFee(actions: actions, payment: payment, wallet: wallet) + + var signatures: [(index: Int, action: PaymentAction)] = [] + var spends: [(index: Int, action: PaymentAction)] = [] + for (index, action) in actions.enumerated() { + switch action { + case .signMessage, .signTransaction: + signatures.append((index, action)) + case .approveToken, .sendTransaction: + spends.append((index, action)) + } + } + + var results = [String](repeating: "", count: actions.count) + var transactionHash: String? + var submitted = false + for (index, action) in signatures + spends { + let value = try await perform( + action: action, + id: "\(paymentId).\(index)", + appMetadata: appMetadata, + payment: payment, + networkFee: networkFee, + wallet: wallet, + ) + results[index] = value + + switch action { + case .sendTransaction: + transactionHash = value + case .approveToken: + if !submitted { + submitted = true + onSubmitted() + } + try await approvalExecutor.waitForApproval(hash: value, assetId: payment.quote.amount.assetId, wallet: wallet) + case .signMessage, .signTransaction: + break + } + } + if !submitted { + onSubmitted() + } + return PaymentActionResults(results: results, transactionHash: transactionHash) + } +} + +public struct PaymentActionResults: Sendable { + public let results: [String] + public let transactionHash: String? +} + +// MARK: - Private + +extension PaymentActionExecutor { + @MainActor + private func perform(action: PaymentAction, id: String, appMetadata: TransactionAppMetadata, payment: PaymentData, networkFee: AssetValuePrice?, wallet: Wallet) async throws -> String { + switch action { + case let .signMessage(chain, message): + let payload = try await SignMessagePayload( + id: id, + chain: chain, + appMetadata: appMetadata, + wallet: wallet, + message: message, + simulation: simulator.simulateSignMessage(message: message, sessionDomain: appMetadata.url ?? .empty), + payment: payment, + networkFee: networkFee, + ) + return try await interactor.signMessage(payload: payload) + case let .signTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + chain: chain, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .sign, + payment: payment, + ) + return try await interactor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + case let .approveToken(_, approval): + return try await approvalExecutor.approve(assetId: payment.quote.amount.assetId, approval: approval, wallet: wallet) + case let .sendTransaction(chain, transaction): + let transferData = try SigningTransferDataFactory.transferData( + chain: chain, + appMetadata: appMetadata, + transaction: transaction, + outputAction: .send, + payment: payment, + ) + return try await interactor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: .empty)) + } + } +} + +// MARK: - Private + +extension PaymentActionExecutor { + private func getApprovalsFee(actions: [PaymentAction], payment: PaymentData, wallet: Wallet) async throws -> AssetValuePrice { + let approvals = actions.compactMap { action -> (chain: Chain, approval: ApprovalData)? in + guard case let .approveToken(chain, approval) = action else { + return .none + } + return (chain, approval) + } + let chain = approvals.first?.chain ?? payment.quote.amount.assetId.chain + var value = BigInt.zero + for approval in approvals { + value += try await approvalExecutor.getApprovalFee(assetId: payment.quote.amount.assetId, approval: approval.approval, wallet: wallet) + } + return AssetValuePrice( + asset: chain.asset, + value: value, + price: assetsProvider.assetsData(walletId: wallet.id, assetIds: [chain.assetId]).first?.price, + ) + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift new file mode 100644 index 0000000000..c5caac0840 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift @@ -0,0 +1,111 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import AssetsService +import BigInt +import Blockchain +import ChainService +import BalanceService +import Foundation +import GemstonePrimitives +import Keystore +import PaymentService +import Primitives +import ScanService +import Signer +import TransactionStateService +import TransferService + +public struct PaymentApprovalExecutor: PaymentApprovalExecutable { + private static let confirmationTimeout: Duration = .seconds(120) + + private let keystore: any Keystore + private let assetsService: AssetsService + private let chainServiceFactory: any ChainServiceFactorable + private let scanService: ScanService + private let assetsEnabler: any AssetsEnabler + private let transactionStateScheduler: TransactionStateScheduler + + public init( + keystore: any Keystore, + assetsService: AssetsService, + chainServiceFactory: any ChainServiceFactorable, + scanService: ScanService, + assetsEnabler: any AssetsEnabler, + transactionStateScheduler: TransactionStateScheduler, + ) { + self.keystore = keystore + self.assetsService = assetsService + self.chainServiceFactory = chainServiceFactory + self.scanService = scanService + self.assetsEnabler = assetsEnabler + self.transactionStateScheduler = transactionStateScheduler + } + + public func getApprovalFee(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> BigInt { + try await load(assetId: assetId, approval: approval, wallet: wallet).transaction.transactionData.fee.fee + } + + public func approve(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> String { + let loaded = try await load(assetId: assetId, approval: approval, wallet: wallet) + return try await broadcast(data: loaded.data, transaction: loaded.transaction, wallet: wallet, chainService: loaded.chainService) + } + + public func waitForApproval(hash: String, assetId: AssetId, wallet: Wallet) async throws { + let chain = assetId.chain + try await TransactionConfirmationWaiter(chainService: chainServiceFactory.service(for: chain)) + .wait(hash: hash, chain: chain, senderAddress: try wallet.account(for: chain).address) + } +} + +private struct LoadedApproval { + let data: TransferData + let transaction: TransferTransactionData + let chainService: any ChainServiceable +} + +// MARK: - Private + +extension PaymentApprovalExecutor { + private func load(assetId: AssetId, approval: ApprovalData, wallet: Wallet) async throws -> LoadedApproval { + let asset = try await assetsService.getOrFetchAsset(for: assetId) + let chainService = chainServiceFactory.service(for: assetId.chain) + let account = try wallet.account(for: assetId.chain) + let data = TransferData( + type: .tokenApprove(asset, approval), + recipientData: RecipientData(recipient: Recipient(name: .none, address: approval.spender, memo: .none), amount: .none), + amount: .exact(.zero), + ) + let available = try await chainService.coinBalance(for: account.address).balance.available + let transaction = try await TransferTransactionProvider(chainService: chainService, scanService: scanService) + .loadTransferTransactionData(wallet: wallet, data: data, selection: .preset(.normal), available: available) + + return LoadedApproval(data: data, transaction: transaction, chainService: chainService) + } + + private func broadcast( + data: TransferData, + transaction: TransferTransactionData, + wallet: Wallet, + chainService: any ChainServiceable, + ) async throws -> String { + let input = TransferConfirmationInput( + data: data, + wallet: wallet, + transactionData: transaction.transactionData, + amount: TransferAmount(value: .zero, networkFee: transaction.transactionData.fee.fee, useMaxAmount: false), + simulation: .none, + delegate: .none, + ) + let hashes = try await TransferExecutor( + signer: TransactionSigner(keystore: keystore), + chainService: chainService, + assetsEnabler: assetsEnabler, + transactionStateScheduler: transactionStateScheduler, + ).execute(input: input) + + guard let hash = hashes.first else { + throw PaymentLinkError.approvalNotBroadcast + } + return hash + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift new file mode 100644 index 0000000000..a5bf4b2a42 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentLinkManager.swift @@ -0,0 +1,70 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import EventPresenterService +import Components +import PrimitivesComponents +import Foundation +import Localization +import Primitives +import Style + +public final class PaymentLinkManager: PaymentLinkPayable, Sendable { + private let paymentManager: PaymentManager + private let eventPresenterService: EventPresenterService + + public init( + paymentManager: PaymentManager, + eventPresenterService: EventPresenterService, + ) { + self.paymentManager = paymentManager + self.eventPresenterService = eventPresenterService + } + + @MainActor + public func pay(link: PaymentLink, wallet: Wallet) async { + guard !wallet.isViewOnly else { + return present(error: Localized.Wallet.Watch.Tooltip.title) + } + eventPresenterService.toastPresenter.toastMessage = ToastMessage( + title: Localized.Common.loading, + image: SystemImage.network, + ) + do { + present(outcome: try await paymentManager.pay(link: link, wallet: wallet)) + } catch { + present(error: error.localizedDescription) + } + } +} + +// MARK: - Private + +extension PaymentLinkManager { + @MainActor + private func present(outcome: PaymentOutcome) { + switch outcome.status { + case .succeeded: + eventPresenterService.toastPresenter.toastMessage = ToastMessage( + title: Localized.Transaction.Status.confirmed, + image: SystemImage.checkmark, + ) + case .processing: + eventPresenterService.toastPresenter.toastMessage = ToastMessage( + title: Localized.Transaction.Status.pending, + image: SystemImage.refresh, + ) + case .cancelled: + return + case .expired: + present(error: Localized.Errors.paymentExpired) + case .failed, .requiresAction: + present(error: Localized.Transaction.Status.failed) + } + } + + @MainActor + private func present(error: String) { + debugLog("PaymentLinkManager payment error: \(error)") + eventPresenterService.toastPresenter.toastMessage = .error(error) + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift new file mode 100644 index 0000000000..83ace1fd1f --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -0,0 +1,136 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import SigningRequestService +import TransactionStateService + +public final class PaymentManager: Sendable { + private let service: any PaymentServiceable + private let executor: PaymentActionExecutor + private let presenter: any PaymentSheetPresentable + private let assetsProvider: any PaymentAssetsProvidable + private let transactionStateScheduler: TransactionStateScheduler + private let queue = PaymentQueue() + + public init( + service: any PaymentServiceable, + executor: PaymentActionExecutor, + presenter: any PaymentSheetPresentable, + assetsProvider: any PaymentAssetsProvidable, + transactionStateScheduler: TransactionStateScheduler, + ) { + self.service = service + self.executor = executor + self.presenter = presenter + self.assetsProvider = assetsProvider + self.transactionStateScheduler = transactionStateScheduler + } + + public func pay(link: PaymentLink, wallet: Wallet) async throws -> PaymentOutcome { + try await queue.enqueue(paymentId: link.id) { [self] in + try await perform(link: link, wallet: wallet) + }.value + } +} + +// MARK: - Private + +extension PaymentManager { + @MainActor + private func perform(link: PaymentLink, wallet: Wallet) async throws -> PaymentOutcome { + do { + let quotes: PaymentQuotes + switch try await service.getPaymentOptions(link: link, wallet: wallet) { + case let .outcome(outcome): + return outcome + case let .quotes(value): + quotes = value + } + let quote = try await select(quotes: quotes, wallet: wallet) + return try await submit(provider: link.provider, quotes: quotes, quote: quote, wallet: wallet) + } catch SigningRequestError.userCancelled { + return PaymentOutcome(status: .cancelled, transactionId: .none) + } + } + + @MainActor + private func select(quotes: PaymentQuotes, wallet: Wallet) async throws -> PaymentQuote { + guard let first = quotes.quotes.first else { + throw PaymentLinkError.noQuotes + } + guard quotes.quotes.count > 1 else { + return first + } + let assetsData = assetsProvider.assetsData(walletId: wallet.id, assetIds: quotes.quotes.map(\.amount.assetId)) + let selected = try await presenter.selectPaymentQuote( + request: PaymentQuotesRequest( + id: first.paymentId, + quotes: quotes, + wallet: wallet, + assetsData: assetsData, + ), + ) + guard let quote = quotes.quotes.first(where: { $0.id == selected }) else { + throw PaymentLinkError.quoteUnavailable + } + return quote + } + + @MainActor + private func submit(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PaymentOutcome { + if let url = quote.collectDataUrl { + try await collectData(paymentId: quote.paymentId, url: url) + } + let payment = try await service.getPreparedPayment(provider: provider, quotes: quotes, quote: quote, wallet: wallet) + let isRelayed = payment.actions.allSatisfy { action in + switch action { + case .signMessage, .signTransaction, .approveToken: true + case .sendTransaction: false + } + } + let results = try await executor.perform( + actions: payment.actions, + paymentId: payment.quote.paymentId, + appMetadata: TransactionAppMetadata(merchant: payment.quotes.merchant), + payment: PaymentData(provider: provider, quotes: payment.quotes, quote: payment.quote), + wallet: wallet, + onSubmitted: { [self] in + guard isRelayed else { + return + } + save(provider: provider, payment: payment, wallet: wallet) + }, + ) + do { + return try await service.confirmPayment(provider: provider, quote: payment.quote, actionResults: results.results) + } catch { + debugLog("confirm payment error: \(error)") + return PaymentOutcome(status: .processing, transactionId: results.transactionHash) + } + } + + @MainActor + private func collectData(paymentId: String, url: String) async throws { + guard let url = url.asURL else { + throw PaymentLinkError.invalidDataCollectionUrl + } + _ = try await presenter.collectPaymentData(request: PaymentDataCollectionRequest(id: paymentId, url: url)) + } + + @MainActor + private func save(provider: PaymentProviderName, payment: PreparedPayment, wallet: Wallet) { + do { + let transaction = try PaymentTransactionFactory.makePendingPayment( + provider: provider, + quote: payment.quote, + merchant: payment.quotes.merchant, + wallet: wallet, + ) + try transactionStateScheduler.addTransactions(wallet: wallet, transactions: [transaction]) + } catch { + debugLog("PaymentManager record payment error: \(error)") + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentQueue.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentQueue.swift new file mode 100644 index 0000000000..39afebe1e2 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentQueue.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +actor PaymentQueue { + private var last: Task? + private var payments: [String: Task] = [:] + + func enqueue(paymentId: String, work: @Sendable @escaping () async throws -> PaymentOutcome) -> Task { + if let payment = payments[paymentId] { + return payment + } + let previous = last + let payment = Task { + await previous?.value + return try await work() + } + payments[paymentId] = payment + last = Task { [weak self] in + _ = try? await payment.value + await self?.finish(paymentId: paymentId) + } + return payment + } + + private func finish(paymentId: String) { + payments[paymentId] = .none + } +} diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift new file mode 100644 index 0000000000..b111a332bc --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift @@ -0,0 +1,109 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import Primitives +import SigningRequestService +import SwiftUI + +@Observable +public final class PaymentSheetPresenter: PaymentSheetPresentable, Sendable { + @MainActor + public var isPresentingSheet: PaymentSheetType? + @MainActor + private var isDismissingSheet: Bool = false + @MainActor + private var dismissals: [CheckedContinuation] = [] + + public init() {} + + public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + try await present(payload: request, sheet: { .quotes($0) }) + } + + public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + try await present(payload: request, sheet: { .dataCollection($0) }) + } + + @MainActor + public func complete(type: PaymentSheetType) { + guard isPresentingSheet?.id == type.id else { + return + } + dismiss() + } + + @MainActor + public func cancelSheet(type: PaymentSheetType) { + 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 PaymentSheetPresenter { + private func present( + payload: T, + sheet: @Sendable @escaping (SigningRequestCallback) -> PaymentSheetType, + ) async throws -> String where T.ID == String { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) + let callback = SigningRequestCallback(payload: payload) { + continuation.yield(with: $0) + continuation.finish() + } + await present(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 + private func present(sheet: PaymentSheetType) 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/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift new file mode 100644 index 0000000000..42af742ed4 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentLinkError.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization + +public enum PaymentLinkError: Error, Equatable { + case noQuotes + case quoteUnavailable + case invalidDataCollectionUrl + case approvalNotBroadcast +} + +extension PaymentLinkError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noQuotes, .quoteUnavailable, .invalidDataCollectionUrl: Localized.Errors.notSupported + case .approvalNotBroadcast: Localized.Errors.errorOccurred + } + } +} diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift new file mode 100644 index 0000000000..69b5d36af3 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -0,0 +1,25 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import SigningRequestService + +public enum PaymentSheetType: Sendable, Identifiable { + case quotes(SigningRequestCallback) + case dataCollection(SigningRequestCallback) + + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) + } + + private var callback: any SigningRequestRejectable { + switch self { + case let .quotes(callback): callback + case let .dataCollection(callback): callback + } + } +} 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..fe44636972 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift @@ -0,0 +1,156 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Components +import Formatters +import Foundation +import Style +import Localization +import PaymentService +import Primitives +import SigningRequestService +import PrimitivesComponents + +@Observable +@MainActor +public final class PaymentQuotesSceneViewModel { + private static let priceFormatter = ValueFormatter(style: .full) + private static let amountFormatter = ValueFormatter(style: .short) + + private let request: PaymentQuotesRequest + private let confirmTransferDelegate: StringResultAction + + public init( + request: PaymentQuotesRequest, + confirmTransferDelegate: @escaping StringResultAction, + ) { + self.request = request + self.confirmTransferDelegate = confirmTransferDelegate + } + + public var title: String { + Localized.Transfer.paymentTitle + } + + public var quotesTitle: String { + Localized.Transfer.payWith + } + + public var expiresTitle: String { + Localized.Transfer.paymentExpiresIn + } + + public var expiresAt: Date? { + request.quotes.expiresAt + } + + public var walletText: String { + request.wallet.name + } + + public var walletAssetImage: AssetImage { + WalletViewModel(wallet: request.wallet).avatarImage + } + + public var selected: PaymentQuote? + public var isExpired: Bool = false + public var isPresentingQuotes: Bool = false + + var quotesModel: PaymentQuotesViewModel { + PaymentQuotesViewModel( + state: .data(.plain(items)), + selectedItems: items.filter { $0.id == selected?.id }, + selectionType: .checkmark, + ) + } + + public var merchantTitle: String { + Localized.Transfer.merchant + } + + public var merchantText: String { + request.quotes.merchant.name + } + + public var merchantAssetImage: AssetImage { + AssetImage(imageURL: request.quotes.merchant.iconUrl?.asURL) + } + + public var buttonTitle: String { + Localized.Common.continue + } + + public var selectedItem: PaymentQuoteItem? { + selected.map { item(for: $0) } + } + + public var buttonType: ButtonType { + .primary(isButtonDisabled ? .disabled : .normal) + } + + public var isButtonDisabled: Bool { + isExpired || selected == nil + } + + public var preview: AppPreviewModel { + AppPreviewModel( + assetImage: AssetImage(imageURL: request.quotes.merchant.iconUrl?.asURL), + name: priceText ?? selectedItem?.amountText ?? request.quotes.merchant.name, + subtitleSymbol: .none, + ) + } + + public func onSelectQuotes() { + isPresentingQuotes = true + } + + func onFinishQuotesSelection(items: [PaymentQuoteItem]) { + selected = items.first?.quote + isPresentingQuotes = false + } + + public func awaitExpiry() async { + guard let expiresAt else { + return + } + await expiresAt.sleepUntil() + isExpired = true + } + + public func onConfirm() { + guard let selected else { + return + } + confirmTransferDelegate(.success(selected.id)) + } + + public func onAppear() { + guard selected == nil else { + return + } + selected = request.quotes.quotes.first + } +} + +// MARK: - Private + +extension PaymentQuotesSceneViewModel { + private var items: [PaymentQuoteItem] { + request.quotes.quotes.map { item(for: $0) } + } + + private func item(for quote: PaymentQuote) -> PaymentQuoteItem { + PaymentQuoteItem( + quote: quote, + assetData: request.assetData(for: quote), + formatter: Self.amountFormatter, + ) + } + + private var priceText: String? { + guard let price = request.quotes.price, let value = BigInt(price.value) else { + return .none + } + return Self.priceFormatter.string(value, decimals: price.decimals.asInt, currency: price.symbol) + } +} diff --git a/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift new file mode 100644 index 0000000000..b725129db6 --- /dev/null +++ b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesViewModel.swift @@ -0,0 +1,44 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Foundation +import PrimitivesComponents +import Localization + +struct PaymentQuotesViewModel: SelectableListAdoptable { + typealias Item = PaymentQuoteItem + + var state: StateViewType> + var selectedItems: Set + var selectionType: SelectionType + + init( + state: StateViewType>, + selectedItems: [PaymentQuoteItem], + selectionType: SelectionType, + ) { + self.state = state + self.selectedItems = Set(selectedItems) + self.selectionType = selectionType + } + + var emptyStateTitle: String? { + Localized.Common.notAvailable + } + + var errorTitle: String? { + Localized.Errors.errorOccurred + } +} + +// MARK: - SelectableListNavigationAdoptable + +extension PaymentQuotesViewModel: SelectableListNavigationAdoptable { + var title: String { + Localized.Transfer.payWith + } + + var doneTitle: String { + Localized.Common.done + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift new file mode 100644 index 0000000000..e28d0fa1f2 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentActionExecutorTests.swift @@ -0,0 +1,190 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Foundation +import PaymentService +import Primitives +import SigningRequestService +import PrimitivesTestKit +import PaymentServiceTestKit +import SigningRequestServiceTestKit +import Testing +@testable import Payments + +@MainActor +struct PaymentActionExecutorTests { + @Test + func sendTransactionReturnsHash() async throws { + let interactor = SigningRequestInteractableMock() + interactor.transactionHash = "transaction-hash" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [PaymentAction.sendTransaction(chain: .ethereum, transaction: .sui("transaction", .encodedTransaction))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(results.results == ["transaction-hash"]) + #expect(results.transactionHash == "transaction-hash") + } + + @Test + func relayedPaymentHasNoTransactionOfItsOwn() async throws { + let interactor = SigningRequestInteractableMock() + interactor.signature = "permit-signature" + let executor = PaymentApprovalExecutableMock(hash: "approval-hash") + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [ + .approveToken(chain: .ethereum, approval: ApprovalData(token: "0xtoken", spender: "0xspender", value: "1", isUnlimited: true)), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(results.results == ["approval-hash", "permit-signature"]) + #expect(results.transactionHash == nil) + #expect(executor.approvals.count == 1) + } + + @Test + func performsEveryActionInOrder() async throws { + let interactor = SigningRequestInteractableMock() + interactor.signature = "permit-signature" + interactor.transactionHash = "approval-hash" + + let results = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [ + .sendTransaction(chain: .ethereum, transaction: .sui("approval", .encodedTransaction)), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(results.results == ["approval-hash", "permit-signature"]) + #expect(interactor.signMessagePayloads.count == 1) + } + + @Test + func signMessageCarriesPaymentAmount() async throws { + let interactor = SigningRequestInteractableMock() + let payment = PaymentData.mock(quote: .mock(amount: .mock(value: "25000", symbol: "USDT"))) + + _ = try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [PaymentAction.mockSignMessage(data: Data("pay".utf8))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: payment, + wallet: .mock(), + ) + + #expect(interactor.signMessagePayloads.first?.payment == payment) + } + + @Test + func nothingIsSignedWhenAnApprovalCannotBeAfforded() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock(validationError: AnyError("insufficient balance")) + + await #expect(throws: (any Error).self) { + try await PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: executor, assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [ + PaymentAction.approveToken(chain: .ethereum, approval: .mock()), + PaymentAction.mockSignMessage(data: Data("{}".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + } + + #expect(interactor.signMessagePayloads.isEmpty) + } + @Test + func paymentIsRecordedAfterTheApprovalIsBroadcastAndBeforeItIsMined() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock(hash: "approval-hash") + var approvalsWhenRecorded: Int? + var confirmationsWhenRecorded: Int? + + _ = try await PaymentActionExecutor( + interactor: interactor, + simulator: SigningSimulatableMock(), + approvalExecutor: executor, + assetsProvider: PaymentAssetsProvidableMock(), + ).perform( + actions: [ + .approveToken(chain: .ethereum, approval: .mock()), + .mockSignMessage(data: Data("permit".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + onSubmitted: { + approvalsWhenRecorded = executor.approvals.count + confirmationsWhenRecorded = executor.confirmedHashes.count + }, + ) + + #expect(approvalsWhenRecorded == 1) + #expect(confirmationsWhenRecorded == 0) + #expect(executor.confirmedHashes == ["approval-hash"]) + } + + @Test + func signMessageShowsWhatTheApprovalWillCost() async throws { + let interactor = SigningRequestInteractableMock() + let executor = PaymentApprovalExecutableMock(fee: BigInt(7295000000000)) + let price = Price.mock(price: 2000) + + _ = try await PaymentActionExecutor( + interactor: interactor, + simulator: SigningSimulatableMock(), + approvalExecutor: executor, + assetsProvider: PaymentAssetsProvidableMock(assetsData: [.mock(asset: .mock(id: AssetId(chain: .ethereum)), price: price)]), + ).perform( + actions: [ + .approveToken(chain: .ethereum, approval: .mock()), + .mockSignMessage(data: Data("{}".utf8)), + ], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + let fee = try #require(interactor.signMessagePayloads.first?.networkFee) + #expect(fee.value == BigInt(7295000000000)) + #expect(fee.asset == Chain.ethereum.asset) + #expect(fee.price == price) + } + + @Test + func signMessageIsSimulatedBeforeItIsShown() async throws { + let interactor = SigningRequestInteractableMock() + let warning = SimulationWarning(severity: .critical, warning: .suspiciousSpender, message: "suspicious spender") + let simulator = SigningSimulatableMock( + result: SimulationResult(warnings: [warning], balanceChanges: [], payload: [], header: .none), + ) + + _ = try await PaymentActionExecutor(interactor: interactor, simulator: simulator, approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()).perform( + actions: [PaymentAction.mockSignMessage(data: Data("{}".utf8))], + paymentId: "pay_1", + appMetadata: .mock(), + payment: .mock(), + wallet: .mock(), + ) + + #expect(interactor.signMessagePayloads.first?.simulation.warnings == [warning]) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift new file mode 100644 index 0000000000..f543cac915 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -0,0 +1,174 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Localization +import PaymentService +import Primitives +import SigningRequestService +import PrimitivesTestKit +import Store +import StoreTestKit +import PaymentServiceTestKit +import SigningRequestServiceTestKit +import Testing +import TransactionStateServiceTestKit +@testable import Payments + +@MainActor +struct PaymentManagerTests { + private let interactor = SigningRequestInteractableMock() + private let presenter = PaymentSheetPresentableMock() + + private func makeManager( + service: PaymentServiceableMock, + transactionStore: TransactionStore = .mock(), + ) -> PaymentManager { + PaymentManager( + service: service, + executor: PaymentActionExecutor(interactor: interactor, simulator: SigningSimulatableMock(), approvalExecutor: PaymentApprovalExecutableMock(), assetsProvider: PaymentAssetsProvidableMock()), + presenter: presenter, + assetsProvider: PaymentAssetsProvidableMock(), + transactionStateScheduler: .mock( + transactionStore: transactionStore, + paymentStatusService: PaymentStatusServiceableMock(result: .mock(status: .processing)), + ), + ) + } + + @Test + func payReturnsSettledPaymentWithoutSigning() async throws { + let service = PaymentServiceableMock(options: [.outcome(.mock())]) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .succeeded) + #expect(interactor.signMessagePayloads.isEmpty) + #expect(await service.confirmedResults.isEmpty) + } + + @Test + func paySignsActionsAndConfirms() async throws { + let service = PaymentServiceableMock( + options: [.quotes(.mock(merchant: .mock(name: "Coffee Shop")))], + actions: [.mockSignMessage(data: Data("pay".utf8))], + ) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(interactor.signMessagePayloads.first?.id == "pay_1.0") + #expect(interactor.signMessagePayloads.first?.appMetadata.name == "Coffee Shop") + #expect(await service.confirmedResults == [["signature"]]) + #expect(outcome.status == .succeeded) + } + @Test + func payStaysPendingWhenConfirmFails() async throws { + let service = PaymentServiceableMock( + options: [.quotes(.mock())], + actions: [.mockSignMessage(data: Data("pay".utf8))], + confirmError: AnyError("gateway timeout"), + ) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .processing) + } + + @Test + func payCollectsDataThenSigns() async throws { + let quote = PaymentQuote.mock(collectDataUrl: "https://data-collection.walletconnect.com/ic/pay_1") + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [quote]))]) + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.collectDataRequests.first?.url.absoluteString == "https://data-collection.walletconnect.com/ic/pay_1") + #expect(await service.confirmedResults == [[]]) + } + + @Test + func paySignsWithoutCollectingDataWhenQuoteDoesNotAskForIt() async throws { + let service = PaymentServiceableMock(options: [.quotes(.mock())]) + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.collectDataRequests.isEmpty) + #expect(await service.confirmedResults == [[]]) + } + @Test + func payKeepsThePaymentAliveWhenUserClosesDataCollection() async throws { + presenter.collectDataError = SigningRequestError.userCancelled + let quote = PaymentQuote.mock(collectDataUrl: "https://data-collection.walletconnect.com/ic/pay_1") + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [quote]))]) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == .cancelled) + #expect(await service.cancelledPaymentIds.isEmpty) + #expect(await service.confirmedResults.isEmpty) + } + + @Test + func payRecordsPendingPaymentBeforeConfirming() async throws { + let assetId = AssetId.mock(.ethereum) + let store = TransactionStore.mock(db: .mockAssets(assets: [.mock(asset: .mock(id: assetId))])) + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [.mock(amount: .mock(assetId: assetId))]))]) + + _ = try await makeManager(service: service, transactionStore: store) + .pay(link: .mock(), wallet: .mock(accounts: [.mock(chain: assetId.chain)])) + + let saved = try #require(store.getTransactions(states: [.pending]).first) + #expect(saved.id.hash == "pay_1") + #expect(saved.value == "10000") + #expect(saved.metadata?.decode(TransactionPaymentMetadata.self)?.merchant.name == "Test Merchant") + #expect(await service.confirmedResults == [[]]) + } + + @Test + func payUsesTheQuoteTheBuyerPicked() async throws { + let other = PaymentQuote.mock(amount: .mock(symbol: "USDT"), id: "option_2") + let service = PaymentServiceableMock( + options: [.quotes(.mock(quotes: [.mock(), other]))], + actions: [.mockSignMessage(data: Data("pay".utf8))], + ) + presenter.selectedQuoteId = other.id + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.quotesRequests.count == 1) + #expect(interactor.signMessagePayloads.count == 1) + #expect(interactor.signMessagePayloads.first?.payment?.quote == other) + } + + @Test + func payDoesNotAskWhichQuoteWhenThereIsOnlyOne() async throws { + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [.mock()]))]) + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.quotesRequests.isEmpty) + #expect(await service.confirmedResults == [[]]) + } + + @Test + func payCollectsDataOnlyForTheQuoteTheBuyerPicked() async throws { + let url = "https://data-collection.walletconnect.com/ic/pay_1" + let other = PaymentQuote.mock(amount: .mock(symbol: "USDT"), id: "option_2") + let service = PaymentServiceableMock(options: [.quotes(.mock(quotes: [.mock(collectDataUrl: url), other]))]) + presenter.selectedQuoteId = other.id + + _ = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(presenter.collectDataRequests.isEmpty) + } + + @Test + func payReportsUnpayableStatuses() async throws { + for status in [PaymentStatus.expired, .failed] { + let service = PaymentServiceableMock(options: [.outcome(.mock(status: status))]) + + let outcome = try await makeManager(service: service).pay(link: .mock(), wallet: .mock()) + + #expect(outcome.status == status) + #expect(await service.confirmedResults.isEmpty) + } + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentQueueTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentQueueTests.swift new file mode 100644 index 0000000000..933e435712 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentQueueTests.swift @@ -0,0 +1,74 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import PrimitivesTestKit +import Testing +@testable import Payments + +struct PaymentQueueTests { + @Test + func joinsThePaymentAlreadyInFlight() async throws { + let queue = PaymentQueue() + let gate = Gate() + + let first = await queue.enqueue(paymentId: "pay_1") { + await gate.wait() + return .mock(status: .succeeded) + } + let second = await queue.enqueue(paymentId: "pay_1") { + Issue.record("Expected the payment in flight to be reused") + return .mock(status: .failed) + } + + await gate.open() + #expect(try await first.value.status == .succeeded) + #expect(try await second.value.status == .succeeded) + } + + @Test + func runsPaymentsOneAfterAnother() async throws { + let queue = PaymentQueue() + let gate = Gate() + let order = Order() + + let first = await queue.enqueue(paymentId: "pay_1") { + await gate.wait() + await order.append("first") + return .mock() + } + let second = await queue.enqueue(paymentId: "pay_2") { + await order.append("second") + return .mock() + } + + #expect(await order.values.isEmpty) + await gate.open() + _ = try await first.value + _ = try await second.value + #expect(await order.values == ["first", "second"]) + } +} + +private actor Gate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation = $0 } + } + + func open() { + isOpen = true + continuation?.resume() + continuation = nil + } +} + +private actor Order { + private(set) var values: [String] = [] + + func append(_ value: String) { + values.append(value) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift new file mode 100644 index 0000000000..c3511ce12c --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentQuotesSceneViewModelTests.swift @@ -0,0 +1,61 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Formatters +import Foundation +import PaymentService +import Primitives +import PrimitivesComponents +import PrimitivesTestKit +import Testing +@testable import Payments + +@MainActor +struct PaymentQuotesSceneViewModelTests { + @Test + func preselectsTheFirstQuote() { + let first = PaymentQuote.mock(id: "option_1") + let model = Self.model(quotes: [first, .mock(id: "option_2")]) + + model.onAppear() + + #expect(model.selectedItem?.id == first.id) + #expect(!model.isButtonDisabled) + } + + @Test + func selectingAQuoteClosesThePicker() { + let second = PaymentQuote.mock(id: "option_2") + let model = Self.model(quotes: [.mock(id: "option_1"), second]) + model.onAppear() + model.onSelectQuotes() + + model.onFinishQuotesSelection(items: [PaymentQuoteItem(quote: second, formatter: ValueFormatter(style: .short))]) + + #expect(model.selectedItem?.id == second.id) + #expect(!model.isPresentingQuotes) + } + + @Test + func confirmIsBlockedOnceThePaymentExpires() async { + let model = Self.model(quotes: [.mock(id: "option_1")], expiresAt: Date(timeIntervalSinceNow: 0.2)) + model.onAppear() + + #expect(!model.isButtonDisabled) + + await model.awaitExpiry() + + #expect(model.isButtonDisabled) + } + + private static func model(quotes: [PaymentQuote], expiresAt: Date = Date(timeIntervalSinceNow: 900)) -> PaymentQuotesSceneViewModel { + PaymentQuotesSceneViewModel( + request: PaymentQuotesRequest( + id: "pay_1", + quotes: .mock(expiresAt: expiresAt, quotes: quotes), + wallet: .mock(), + assetsData: [], + ), + confirmTransferDelegate: { _ in }, + ) + } +} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift new file mode 100644 index 0000000000..6c946e13f8 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift @@ -0,0 +1,67 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +import PaymentServiceTestKit +@testable import Payments +import Primitives +import PrimitivesTestKit +import SigningRequestService +import Testing + +struct PaymentSheetPresenterTests { + @Test + @MainActor + func selectPaymentQuoteReturnsTheSelectionOnceTheSheetHasClosed() async throws { + let presenter = PaymentSheetPresenter() + let selection = Task { @MainActor in + try await presenter.selectPaymentQuote(request: Self.request()) + } + try await Self.wait { presenter.isPresentingSheet != nil } + + guard case let .quotes(callback) = presenter.isPresentingSheet else { + Issue.record("quotes sheet is not presented") + return + } + callback.delegate(.success("quote_2")) + try await Self.wait { presenter.isPresentingSheet == nil } + presenter.onSheetDismiss() + + #expect(try await selection.value == "quote_2") + } + + @Test + @MainActor + func cancelSheetFailsTheRequestWithUserCancelled() async throws { + let presenter = PaymentSheetPresenter() + let selection = Task { @MainActor in + try await presenter.selectPaymentQuote(request: Self.request()) + } + try await Self.wait { presenter.isPresentingSheet != nil } + + guard let sheet = presenter.isPresentingSheet else { + Issue.record("quotes sheet is not presented") + return + } + presenter.cancelSheet(type: sheet) + presenter.onSheetDismiss() + + await #expect(throws: SigningRequestError.userCancelled) { + try await selection.value + } + } + + private static func request() -> PaymentQuotesRequest { + PaymentQuotesRequest(id: "pay_1", quotes: .mock(), wallet: .mock(), assetsData: []) + } + + 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") + } +} From b5612b0e38a5cf511593d902d055cc7ab798fa0f Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:16 +0300 Subject: [PATCH 11/27] iOS: show a payment through its own sheets --- .../TransactionExplorerViewModel.swift | 6 +- .../TransactionParticipantViewModel.swift | 9 +- .../TransactionExplorerViewModelTests.swift | 53 +++++++ ios/Features/WalletConnector/Package.swift | 6 + .../WalletConnectorInteractable.swift | 10 +- .../Scenes/SignMessageScene.swift | 25 +++- .../Services/WalletConnectorManager.swift | 30 ++-- .../Services/WalletConnectorPresenter.swift | 52 ++++++- .../Services/WalletConnectorSigner.swift | 132 +++--------------- .../Types/ConnectionsError.swift | 14 -- .../Types/TransferDataCallback.swift | 22 --- .../Types/WCTransferData.swift | 20 --- .../Types/WalletConnectorSheetType.swift | 31 ++-- .../ConnectionProposalViewModel.swift | 8 +- .../SignMessageSceneViewModel.swift | 94 +++++++++++-- .../WalletConnectionViewModel.swift | 8 +- .../ConnectionProposalViewModelTests.swift | 2 +- .../SignMessageSceneViewModelTests.swift | 106 +++++++------- .../WalletConnectorInteractableMock.swift | 43 ++++++ .../WalletConnectorPresenterTests.swift | 76 ++++++++-- .../AppMetadata+ChainServices.swift | 4 +- .../Extensions/Session+ChainServices.swift | 4 +- ...lletConnectTransaction+ChainServices.swift | 38 ----- .../SignMessagePayload.swift | 33 ----- .../TestKit/SignMessagePayload+TestKit.swift | 25 ---- .../TestKit/WalletConnectorSignableMock.swift | 7 +- .../WalletConnectorSignable.swift | 7 +- .../WalletConnectorTransaction.swift | 12 -- .../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 +++++++++++++ .../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 + ...ConnectionSessionProposal+Primitives.swift | 2 +- .../Protocols/PaymentLinkPayable.swift | 7 + .../Sources/TransactionExtendedMetadata.swift | 4 + .../Primitives/Sources/TransferDataType.swift | 17 ++- .../TestKit/PaymentLinkPayableMock.swift | 16 +++ .../Transaction+PrimitivesTestKit.swift | 3 +- ...sactionAppMetadata+PrimitivesTestKit.swift | 15 ++ .../TestKit/WCPairingProposal+TestKit.swift | 2 +- ...ift => WalletConnectSession+TestKit.swift} | 14 +- ...alletConnectSessionProposal+TestKit.swift} | 8 +- .../TestKit/WalletConnection+TestKit.swift | 2 +- .../Types/TransactionHeaderBuilder.swift | 3 +- .../Sources/ViewModels}/AppPreviewModel.swift | 7 +- .../Sources/ViewModels/PaymentQuoteItem.swift | 92 ++++++++++++ .../ViewModels/TransactionViewModel.swift | 7 + 55 files changed, 849 insertions(+), 436 deletions(-) create mode 100644 ios/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionExplorerViewModelTests.swift delete mode 100644 ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift delete mode 100644 ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift delete mode 100644 ios/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift create mode 100644 ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift delete mode 100644 ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift delete mode 100644 ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift delete mode 100644 ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift delete mode 100644 ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift 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/Primitives/Sources/Extensions/SimulationResult+Primitives.swift create mode 100644 ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift create mode 100644 ios/Packages/Primitives/Sources/Protocols/PaymentLinkPayable.swift create mode 100644 ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift create mode 100644 ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift rename ios/Packages/Primitives/TestKit/{WalletConnectionSession+TestKit.swift => WalletConnectSession+TestKit.swift} (72%) rename ios/Packages/Primitives/TestKit/{WalletConnectionSessionProposal+TestKit.swift => WalletConnectSessionProposal+TestKit.swift} (59%) rename ios/{Features/WalletConnector/Sources/WalletConnector/Types => Packages/PrimitivesComponents/Sources/ViewModels}/AppPreviewModel.swift (53%) create mode 100644 ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.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..ead575d5f8 100644 --- a/ios/Features/WalletConnector/Package.swift +++ b/ios/Features/WalletConnector/Package.swift @@ -32,6 +32,8 @@ let package = Package( name: "WalletConnector", dependencies: [ "Primitives", + .product(name: "SigningRequestService", package: "ChainServices"), + .product(name: "PaymentService", package: "ChainServices"), .product(name: "WalletConnectorService", package: "ChainServices"), .product(name: "ExplorerService", package: "ChainServices"), "Components", @@ -43,6 +45,7 @@ let package = Package( "QRScanner", .product(name: "AddressNameService", package: "FeatureServices"), .product(name: "WalletSessionService", package: "FeatureServices"), + .product(name: "TransactionStateService", package: "FeatureServices"), .product(name: "ConnectionsService", package: "FeatureServices"), "Keystore", "Gemstone", @@ -59,7 +62,10 @@ let package = Package( .product(name: "WalletSessionServiceTestKit", package: "FeatureServices"), .product(name: "ConnectionsServiceTestKit", package: "FeatureServices"), .product(name: "AddressNameServiceTestKit", package: "FeatureServices"), + .product(name: "SigningRequestServiceTestKit", package: "ChainServices"), + .product(name: "PaymentServiceTestKit", package: "ChainServices"), .product(name: "WalletConnectorServiceTestKit", package: "ChainServices"), + .product(name: "TransactionStateServiceTestKit", package: "FeatureServices"), .product(name: "KeystoreTestKit", package: "Keystore"), "WalletConnector", "Gemstone", diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift index 9f25ad6d86..f229f56802 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 { +public protocol WalletConnectorInteractable: SigningRequestInteractable { 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/SignMessageScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift index 19fee17e51..82d0a1c992 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/SignMessageScene.swift @@ -24,6 +24,13 @@ public struct SignMessageScene: View { ListAssetHeaderView(model: model.appPreview, subtitleLayout: .vertical) Section { + if let merchant = model.merchantText { + ListItemImageView( + title: model.merchantTitle, + subtitle: merchant, + assetImage: model.appAssetImage, + ) + } ListItemImageView( title: Localized.Common.wallet, subtitle: model.walletText, @@ -36,13 +43,28 @@ public struct SignMessageScene: View { ) } + if let expiresAt = model.expiresAt { + Section { + ListItemExpiryView( + title: model.expiresTitle, + expiresAt: expiresAt, + ) + } + } + + if let fee = model.networkFeeText { + Section { + ListItemView(title: model.networkFeeTitle, subtitle: fee) + } + } + if model.hasWarnings { Section { SimulationWarningsContent(warnings: model.simulationWarnings) } } - if model.hasPayload { + if model.showsPayload { Section { SimulationPayloadFieldsContent( fields: model.primaryPayloadFields, @@ -63,6 +85,7 @@ public struct SignMessageScene: View { .contentMargins(.top, .scene.top, for: .scrollContent) .listSectionSpacing(.compact) .taskOnce { model.fetch() } + .task { await model.paymentExpiry.start() } .safeAreaButton { StateButton( text: model.buttonTitle, diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index 5357d6213f..6c3f7eae54 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 SigningRequestService import SwiftUI 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? ConnectionsError, case .userCancelled = error { + if let error = error as? SigningRequestError, case .userCancelled = error { return } await MainActor.run { [weak self] in @@ -34,15 +35,15 @@ extension WalletConnectorManager: WalletConnectorInteractable { try await presentSheet(payload: payload, sheetType: { .signMessage($0) }) } - public func sendTransaction(transferData: WCTransferData) async throws -> String { + public func sendTransaction(transferData: SigningTransferData) async throws -> String { try await presentSheet(payload: transferData, sheetType: { .transferData($0) }) } - public func signTransaction(transferData: WCTransferData) async throws -> String { + public func signTransaction(transferData: SigningTransferData) 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 } @@ -50,22 +51,27 @@ extension WalletConnectorManager: WalletConnectorInteractable { private func presentSheet( payload: T, - sheetType: @Sendable @escaping (TransferDataCallback) -> WalletConnectorSheetType, + sheetType: @Sendable @escaping (SigningRequestCallback) -> WalletConnectorSheetType, ) async throws -> String { let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) - let callback = TransferDataCallback(payload: payload) { + let callback = SigningRequestCallback(payload: payload) { continuation.yield(with: $0) continuation.finish() } - await MainActor.run { [weak self] in - self?.presenter.isPresentingSheet = sheetType(callback) - } + await presenter.present(sheet: sheetType(callback)) - for try await value in stream { - return value + do { + for try await value in stream { + await presenter.dismissPresentedSheet() + return value + } + } catch { + await presenter.dismissPresentedSheet() + throw error } - throw ConnectionsError.userCancelled + await presenter.dismissPresentedSheet() + throw SigningRequestError.userCancelled } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift index 00529d1242..d1b69a494f 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,5 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. +import PaymentService +import SigningRequestService import SwiftUI @Observable @@ -10,15 +12,31 @@ public final class WalletConnectorPresenter: Sendable { public var isPresentingConnectionBar: Bool = false @MainActor public var isPresentingSheet: WalletConnectorSheetType? + @MainActor + private var isDismissingSheet: Bool = false + @MainActor + private var dismissals: [CheckedContinuation] = [] public init() {} + @MainActor + public func present(sheet: WalletConnectorSheetType) async { + await waitForDismiss() + isPresentingSheet = sheet + } + + @MainActor + public func dismissPresentedSheet() async { + dismiss() + await waitForDismiss() + } + @MainActor public func complete(type: WalletConnectorSheetType) { guard isPresentingSheet?.id == type.id else { return } - isPresentingSheet = nil + dismiss() } @MainActor @@ -26,8 +44,36 @@ public final class WalletConnectorPresenter: Sendable { 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 WalletConnectorPresenter { + @MainActor + private func dismiss() { + guard isPresentingSheet != nil else { + return + } + isDismissingSheet = true + isPresentingSheet = .none + } - type.reject(ConnectionsError.userCancelled) - isPresentingSheet = nil + @MainActor + private func waitForDismiss() async { + guard isPresentingSheet != nil || isDismissingSheet else { + return + } + await withCheckedContinuation { dismissals.append($0) } } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index f54a37769d..0ada27397c 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift @@ -1,13 +1,14 @@ // 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 PaymentService import Primitives +import SigningRequestService import Store import WalletConnectorService import WalletConnectSign @@ -89,8 +90,9 @@ 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, @@ -98,7 +100,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { return try await walletConnectorInteractor.signMessage(payload: payload) } - public func updateSessions(sessions: [WalletConnectionSession]) throws { + public func updateSessions(sessions: [WalletConnectSession]) throws { if sessions.isEmpty { _ = try? connectionsStore.deleteAll() } else { @@ -119,33 +121,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { await walletConnectorInteractor.sessionReject(error: error) } - private func buildTransferData( - chain: Chain, - metadata: WalletConnectionSessionAppMetadata, - transaction: String, - outputType: TransferDataOutputType, - outputAction: TransferDataOutputAction, - ) -> TransferData { - TransferData( - type: .generic( - asset: chain.asset, - metadata: metadata, - extra: TransferDataExtra( - to: "", - data: transaction.data(using: .utf8), - outputType: outputType, - outputAction: outputAction, - ), - ), - recipientData: RecipientData( - recipient: Recipient(name: .none, address: "", memo: .none), - amount: .none, - ), - amount: .exact(.zero), - ) - } - - public func signTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String { + public func signTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let wallet = try getWallet(id: session.wallet.id) @@ -153,114 +129,52 @@ public final class WalletConnectorSigner: WalletConnectorSignable { switch transaction { case .ethereum: throw AnyError("Not supported") - case let .solana(transaction, outputType), - let .sui(transaction, outputType), - let .ton(transaction, outputType), - let .tron(transaction, outputType): - let transferData = buildTransferData( + case .solana, .sui, .ton, .tron: + let transferData = try SigningTransferDataFactory.transferData( chain: chain, - metadata: session.session.metadata, + appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, - outputType: outputType, outputAction: .sign, ) - return try await walletConnectorInteractor.signTransaction(transferData: WCTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) + return try await walletConnectorInteractor.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 walletConnectorInteractor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) } public func sendRawTransaction(sessionId: String, chain: Chain, transaction: String) async throws -> String { let session = try connectionsStore.getConnection(id: sessionId) try validate(chain: chain, session: session.session) let wallet = try getWallet(id: session.wallet.id) - let transferData = buildTransferData( + let transferData = SigningTransferDataFactory.encodedTransferData( chain: chain, - metadata: session.session.metadata, + appMetadata: session.session.metadata.transactionAppMetadata, transaction: transaction, outputType: .encodedTransaction, outputAction: .send, ) - let simulation = SimulationResult( - warnings: [], - balanceChanges: [], - payload: [], - header: nil, - ) return try await walletConnectorInteractor.sendRawTransaction( - transferData: WCTransferData( + transferData: SigningTransferData( transferData: transferData, wallet: wallet, - simulation: simulation, + simulation: .empty, ), ) } - private func validate(chain: Chain, session: WalletConnectionSession) throws { + private func validate(chain: Chain, session: WalletConnectSession) throws { if !session.chains.contains(chain) { throw WalletConnectorServiceError.unresolvedChainId(chain.rawValue) } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift deleted file mode 100644 index bda4b397dd..0000000000 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/ConnectionsError.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Localization - -enum ConnectionsError: LocalizedError { - case userCancelled - - var errorDescription: String? { - switch self { - case .userCancelled: Localized.Errors.Connections.userCancelled - } - } -} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift deleted file mode 100644 index 9b5fbd276d..0000000000 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/TransferDataCallback.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public final class TransferDataCallback: Sendable, Identifiable { - public typealias ConfirmTransferDelegate = @Sendable (Result) -> Void - - public let payload: T - public let delegate: ConfirmTransferDelegate - - public init( - payload: T, - delegate: @escaping ConfirmTransferDelegate, - ) { - self.payload = payload - self.delegate = delegate - } - - public var id: any Hashable { - payload.id - } -} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift deleted file mode 100644 index 2e93991bb0..0000000000 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WCTransferData.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public struct WCTransferData: 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/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 1132555b94..eb496dd8d5 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -1,29 +1,28 @@ // 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 transferData(SigningRequestCallback) + case signMessage(SigningRequestCallback) + case connectionProposal(SigningRequestCallback) - public var id: Int { - switch self { - case let .transferData(callback): callback.id.hashValue - case let .signMessage(callback): callback.id.hashValue - case let .connectionProposal(callback): callback.id.hashValue - } + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) } - public func reject(_ error: Error) { + private var callback: any SigningRequestRejectable { switch self { - case let .transferData(callback): - callback.delegate(.failure(error)) - case let .signMessage(callback): - callback.delegate(.failure(error)) - case let .connectionProposal(callback): - callback.delegate(.failure(error)) + case let .transferData(callback): callback + case let .signMessage(callback): callback + case let .connectionProposal(callback): callback } } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift index c27e33be0d..17dc3e154b 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift @@ -3,20 +3,22 @@ import Components import Foundation import Localization +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import Style import SwiftUI import WalletConnectorService public struct ConnectionProposalViewModel { - private let confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate + private let confirmTransferDelegate: StringResultAction private let pairingProposal: WCPairingProposal var walletSelectorModel: SelectWalletViewModel public init( - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, pairingProposal: WCPairingProposal, ) { self.confirmTransferDelegate = confirmTransferDelegate @@ -127,7 +129,7 @@ public struct ConnectionProposalViewModel { ) } - private var payload: WalletConnectionSessionProposal { + private var payload: WalletConnectSessionProposal { pairingProposal.proposal } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift index dbd28ab7ae..2133dc0203 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift @@ -4,11 +4,16 @@ import AddressNameService import Components import ExplorerService import Foundation +import Formatters +import BigInt import class Gemstone.MessageSigner import GemstonePrimitives import Keystore import Localization +import Preferences +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import Style import WalletConnectorService @@ -16,28 +21,34 @@ import WalletConnectorService @Observable @MainActor public final class SignMessageSceneViewModel { + private static let priceFormatter = ValueFormatter(style: .full) + private static let feeFormatter = ValueFormatter(style: .auto) + private static let amountFormatter = ValueFormatter(style: .auto) + private let explorerService: ExplorerService = .standard private let keystore: any Keystore private let addressNameService: AddressNameService private let payload: SignMessagePayload - private let confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate + private let confirmTransferDelegate: StringResultAction private let signer: MessageSigner private let plainMessage: String public let messageDisplayType: SignMessageDisplayType public var isPresentingUrl: URL? public var isPresentingPayloadDetails: Bool = false + public let paymentExpiry: PaymentExpiry private var payloadAddressNames: [ChainAddress: AddressName] = [:] public init( keystore: any Keystore, addressNameService: AddressNameService, payload: SignMessagePayload, - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, ) { self.keystore = keystore self.addressNameService = addressNameService self.payload = payload + paymentExpiry = PaymentExpiry(payment: payload.payment) let signer = MessageSigner(message: payload.message) self.signer = signer let plainMessage = signer.plainPreview() @@ -56,12 +67,46 @@ public final class SignMessageSceneViewModel { self.confirmTransferDelegate = confirmTransferDelegate } + public var priceText: String? { + guard let price = payload.payment?.price, let value = BigInt(price.value) else { + return .none + } + return Self.priceFormatter.string(value, decimals: price.decimals.asInt, currency: price.symbol) + } + + public var expiresAt: Date? { + payload.payment?.expiresAt + } + + public var expiresTitle: String { + Localized.Transfer.paymentExpiresIn + } + + public var networkFeeTitle: String { + Localized.Transfer.networkFee + } + + public var networkFeeText: String? { + guard let networkFee = payload.networkFee else { + return .none + } + let display = NumericViewModel( + data: networkFee, + style: AmountDisplayStyle(formatter: .auto, currencyCode: Preferences.standard.currency), + ) + return display.fiat?.text ?? display.amount.text + } + + public var selectedQuoteItem: PaymentQuoteItem? { + payload.payment.map { PaymentQuoteItem(quote: $0.quote, formatter: Self.amountFormatter) } + } + public var networkText: String { payload.chain.networkName } public var title: String { - Localized.Transfer.reviewRequest + isPayment ? Localized.Transfer.paymentTitle : Localized.Transfer.reviewRequest } public var walletText: String { @@ -72,20 +117,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 +150,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 +194,20 @@ public final class SignMessageSceneViewModel { !payloadFields.isEmpty } + public var showsPayload: Bool { + hasPayload && !isPayment + } + + public var isPayment: Bool { + payload.payment != nil + } + public var hasWarnings: Bool { !simulationWarnings.isEmpty } public var isButtonDisabled: Bool { - simulationWarnings.contains(where: { $0.severity == .critical }) + paymentExpiry.isExpired || simulationWarnings.contains(where: { $0.severity == .critical }) } public var buttonType: ButtonType { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift index b8a0caaaa5..5c020155d3 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/WalletConnectionViewModel.swift @@ -12,13 +12,7 @@ public struct WalletConnectionViewModel: Sendable { } var imageUrl: URL? { - if let url = URL(string: connection.session.metadata.icon) { - if url.host() == nil { - return URL(string: connection.session.metadata.url + connection.session.metadata.icon) - } - return url - } - return .none + connection.session.metadata.transactionAppMetadata.iconURL } var hostText: String? { diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift index 22a38799cf..e3f01441e5 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/ConnectionProposalViewModelTests.swift @@ -8,7 +8,7 @@ import Testing struct ConnectionProposalViewModelTests { @Test func appTextKeepsNameAndDomain() { - let metadata = WalletConnectionSessionAppMetadata.mock( + let metadata = WalletConnectAppMetadata.mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", ) diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift index a72975f853..5fbde1d5d3 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/SignMessageSceneViewModelTests.swift @@ -4,9 +4,13 @@ import AddressNameServiceTestKit import Foundation import struct Gemstone.SignMessage import KeystoreTestKit +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import PrimitivesTestKit +import PaymentServiceTestKit +import SigningRequestServiceTestKit import Testing @testable import WalletConnector import WalletConnectorService @@ -17,9 +21,9 @@ struct SignMessageSceneViewModelTests { @MainActor func walletTextDisplaysPayloadWallet() throws { let wallet = Wallet.mock(name: "My Secure Wallet") - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: wallet, message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -35,38 +39,14 @@ struct SignMessageSceneViewModelTests { #expect(viewModel.walletText == "My Secure Wallet") } - @Test - @MainActor - func connectionViewModelUsesPayloadWallet() throws { - let wallet = Wallet.mock(id: .multicoin(address: "0xspecific"), name: "Test Wallet") - let session = WalletConnectionSession.mock(sessionId: "test-session") - let payload = try SignMessagePayload( - chain: .ethereum, - session: session, - wallet: wallet, - message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), - simulation: .mock(), - ) - - let viewModel = SignMessageSceneViewModel( - keystore: KeystoreMock(), - addressNameService: .mock(), - payload: payload, - confirmTransferDelegate: { _ in }, - ) - - #expect(viewModel.connectionViewModel.connection.wallet.id == .multicoin(address: "0xspecific")) - #expect(viewModel.connectionViewModel.connection.wallet.name == "Test Wallet") - } - @Test @MainActor func appTextUsesShortNameWithoutDomain() { let payload = SignMessagePayload.mock( - session: .mock(metadata: .mock( + appMetadata: .mock( name: "PancakeSwap - Trade", url: "https://pancakeswap.finance/swap", - )), + ), ) let viewModel = SignMessageSceneViewModel( @@ -82,9 +62,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func titleUsesReviewRequest() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -103,9 +83,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func payloadStoresValidatedChainNotMessageChain() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "bitcoin", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -118,9 +98,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func networkTextUsesPayloadChain() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "bitcoin", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -139,9 +119,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func contextRowsProvideWalletAndNetworkImages() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -158,12 +138,34 @@ struct SignMessageSceneViewModelTests { #expect(viewModel.networkAssetImage == AssetIdViewModel(assetId: payload.chain.asset.id).networkAssetImage) } + @Test + @MainActor + func buttonDisabledOnceThePaymentQuoteExpires() async throws { + let payload = try SignMessagePayload.mock( + message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), + payment: .mock(expiresAt: Date(timeIntervalSinceNow: 0.2)), + ) + + let viewModel = SignMessageSceneViewModel( + keystore: KeystoreMock(), + addressNameService: .mock(), + payload: payload, + confirmTransferDelegate: { _ in }, + ) + + #expect(!viewModel.isButtonDisabled) + + await viewModel.paymentExpiry.start() + + #expect(viewModel.isButtonDisabled) + } + @Test @MainActor func buttonEnabledWithNoWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(), @@ -182,9 +184,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func buttonEnabledWithNonCriticalWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [SimulationWarning( @@ -207,9 +209,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func simulationWarningsPassThroughUnlimitedAndFiniteApprovals() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [ @@ -240,9 +242,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func buttonDisabledWithCriticalWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [SimulationWarning(severity: .critical, warning: .suspiciousSpender, message: nil)]), @@ -261,9 +263,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func simulationWarningsPassThroughExternallyOwnedSpenderWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [ @@ -335,9 +337,9 @@ struct SignMessageSceneViewModelTests { } } """ - let payload = SignMessagePayload( + let payload = SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip712, data: Data(message.utf8)), simulation: .mock( @@ -367,9 +369,9 @@ struct SignMessageSceneViewModelTests { @Test @MainActor func simulationWarningsPassThroughValidationWarnings() throws { - let payload = try SignMessagePayload( + let payload = try SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: #require("test".data(using: .utf8))), simulation: .mock(warnings: [ @@ -414,9 +416,9 @@ struct SignMessageSceneViewModelTests { ] .joined(separator: "\n") - let payload = SignMessagePayload( + let payload = SignMessagePayload.mock( chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: Data(message.utf8)), simulation: .mock(warnings: [ diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift new file mode 100644 index 0000000000..141a954407 --- /dev/null +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorInteractableMock.swift @@ -0,0 +1,43 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import SigningRequestService +@testable import WalletConnector +import WalletConnectorService + +final class WalletConnectorInteractableMock: WalletConnectorInteractable, @unchecked Sendable { + var transactionHash = "1" + var signature = "signature" + var signMessageError: Error? + private var failedSignOnce = false + + private(set) var signMessagePayloads: [SignMessagePayload] = [] + + func sessionReject(error _: any Error) async {} + + func sessionApproval(payload _: WCPairingProposal) async throws -> WalletId { + throw AnyError("Not supported") + } + + func signMessage(payload: SignMessagePayload) async throws -> String { + signMessagePayloads.append(payload) + if let signMessageError, !failedSignOnce { + failedSignOnce = true + throw signMessageError + } + return signature + } + + func signTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + func sendTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } + + func sendRawTransaction(transferData _: SigningTransferData) async throws -> String { + transactionHash + } +} diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift index 61a825cacd..b880d70719 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift @@ -1,7 +1,9 @@ import Foundation import struct Gemstone.SignMessage import Primitives +import SigningRequestService import PrimitivesTestKit +import SigningRequestServiceTestKit import Testing @testable import WalletConnector import WalletConnectorService @@ -11,11 +13,74 @@ struct WalletConnectorPresenterTests { @MainActor func completeDismissesSignMessageSheet() { let presenter = WalletConnectorPresenter() - let type = WalletConnectorSheetType.signMessage( - TransferDataCallback( + let type = Self.sheet(id: "request") + + presenter.isPresentingSheet = type + presenter.complete(type: type) + + #expect(presenter.isPresentingSheet == nil) + } + + @Test + @MainActor + func dismissPresentedSheetWaitsForTheSheetToReportItClosed() async { + let presenter = WalletConnectorPresenter() + presenter.isPresentingSheet = Self.sheet(id: "request") + + let dismissed = Task { @MainActor in + await presenter.dismissPresentedSheet() + return true + } + await Task.yield() + + #expect(presenter.isPresentingSheet == nil) + #expect(!dismissed.isCancelled) + + presenter.onSheetDismiss() + + #expect(await dismissed.value) + } + + @Test + @MainActor + func presentWaitsUntilThePreviousSheetHasClosed() async { + let presenter = WalletConnectorPresenter() + presenter.isPresentingSheet = Self.sheet(id: "first") + + let dismissed = Task { @MainActor in await presenter.dismissPresentedSheet() } + await Task.yield() + + let next = Self.sheet(id: "second") + let presented = Task { @MainActor in await presenter.present(sheet: next) } + await Task.yield() + + #expect(presenter.isPresentingSheet == nil) + + presenter.onSheetDismiss() + await dismissed.value + await presented.value + + #expect(presenter.isPresentingSheet?.id == "second") + } + + @Test + @MainActor + func presentDoesNotWaitWhenNoSheetIsShowing() async { + let presenter = WalletConnectorPresenter() + let type = Self.sheet(id: "request") + + await presenter.present(sheet: type) + + #expect(presenter.isPresentingSheet?.id == "request") + } + + private static func sheet(id: String) -> WalletConnectorSheetType { + .signMessage( + SigningRequestCallback( payload: SignMessagePayload( + id: id, chain: .ethereum, - session: .mock(), + appMetadata: .mock(), wallet: .mock(), message: SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), simulation: .mock(), @@ -23,10 +88,5 @@ struct WalletConnectorPresenterTests { delegate: { _ in }, ), ) - - presenter.isPresentingSheet = type - presenter.complete(type: type) - - #expect(presenter.isPresentingSheet == nil) } } diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift b/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift index 801d594421..786fd0316b 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/Extensions/AppMetadata+ChainServices.swift @@ -5,8 +5,8 @@ import Primitives import ReownWalletKit extension AppMetadata { - var metadata: WalletConnectionSessionAppMetadata { - WalletConnectionSessionAppMetadata( + var metadata: WalletConnectAppMetadata { + WalletConnectAppMetadata( name: name, description: description, url: url, diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift b/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift index 3e3548b01f..038784d382 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/Extensions/Session+ChainServices.swift @@ -5,12 +5,12 @@ import Primitives import ReownWalletKit extension Session { - var asSession: Primitives.WalletConnectionSession { + var asSession: Primitives.WalletConnectSession { let sessionChains = namespaces.values .flatMap(\.accounts) .compactMap(\.chain) - return WalletConnectionSession( + return WalletConnectSession( id: topic, sessionId: topic, state: .active, diff --git a/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift b/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift deleted file mode 100644 index be9773e158..0000000000 --- a/ios/Packages/ChainServices/WalletConnectorService/Extensions/WalletConnectTransaction+ChainServices.swift +++ /dev/null @@ -1,38 +0,0 @@ -// 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 Primitives - -extension WalletConnectTransaction { - func map() -> WalletConnectorTransaction { - switch self { - case let .ethereum(data, transactionType): .ethereum(data.map(), transactionType.map()) - case let .solana(data, outputType): .solana(data.transaction, outputType.map()) - case let .sui(data, outputType): .sui(data.transaction, outputType.map()) - case let .ton(data, outputType): .ton(data, outputType.map()) - case let .tron(data, outputType): .tron(data, outputType.map()) - } - } -} - -extension WcEthereumTransactionData { - func map() -> WCEthereumTransaction { - WCEthereumTransaction( - chainId: chainId, - from: from, - to: to, - value: value, - gas: gas, - gasLimit: gasLimit, - gasPrice: gasPrice, - maxFeePerGas: maxFeePerGas, - maxPriorityFeePerGas: maxPriorityFeePerGas, - nonce: nonce, - data: data, - ) - } -} diff --git a/ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift b/ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift deleted file mode 100644 index 56493bb979..0000000000 --- a/ios/Packages/ChainServices/WalletConnectorService/SignMessagePayload.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import struct Gemstone.SignMessage -import Primitives - -public struct SignMessagePayload: Sendable { - public let chain: Chain - public let session: WalletConnectionSession - public let wallet: Wallet - public let message: SignMessage - public let simulation: SimulationResult - - public init( - chain: Chain, - session: WalletConnectionSession, - wallet: Wallet, - message: SignMessage, - simulation: SimulationResult, - ) { - self.chain = chain - self.wallet = wallet - self.session = session - self.message = message - self.simulation = simulation - } -} - -extension SignMessagePayload: Identifiable { - public var id: String { - session.id - } -} diff --git a/ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift b/ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift deleted file mode 100644 index 8fdf772993..0000000000 --- a/ios/Packages/ChainServices/WalletConnectorService/TestKit/SignMessagePayload+TestKit.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import struct Gemstone.SignMessage -import Primitives -import PrimitivesTestKit -import WalletConnectorService - -public extension SignMessagePayload { - static func mock( - chain: Chain = .ethereum, - session: WalletConnectionSession = .mock(), - wallet: Wallet = .mock(), - message: SignMessage = SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), - simulation: SimulationResult = .mock(), - ) -> SignMessagePayload { - SignMessagePayload( - chain: chain, - session: session, - wallet: wallet, - message: message, - simulation: simulation, - ) - } -} diff --git a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift index e0c78f0604..56267485f8 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/TestKit/WalletConnectorSignableMock.swift @@ -3,6 +3,7 @@ import Foundation import struct Gemstone.SignMessage import Primitives +import SigningRequestService import WalletConnectorService import WalletConnectSign @@ -12,7 +13,7 @@ public struct WalletConnectorSignableMock: WalletConnectorSignable { public init() {} public func addConnection(connection _: WalletConnection) throws {} - public func updateSessions(sessions _: [WalletConnectionSession]) throws {} + public func updateSessions(sessions _: [WalletConnectSession]) throws {} public func sessionReject(error _: any Error) async {} public func sessionReject(id _: String, error _: any Error) async throws {} public func getCurrentWallet() throws -> Wallet { @@ -51,11 +52,11 @@ public struct WalletConnectorSignableMock: WalletConnectorSignable { "" } - public func signTransaction(sessionId _: String, chain _: Chain, transaction _: WalletConnectorTransaction, simulation _: SimulationResult) async throws -> String { + public func signTransaction(sessionId _: String, chain _: Chain, transaction _: SignableTransaction, simulation _: SimulationResult) async throws -> String { "" } - public func sendTransaction(sessionId _: String, chain _: Chain, transaction _: WalletConnectorTransaction, simulation _: SimulationResult) async throws -> String { + public func sendTransaction(sessionId _: String, chain _: Chain, transaction _: SignableTransaction, simulation _: SimulationResult) async throws -> String { "" } diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift index 8b8d35a1bb..deb04c9651 100644 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift +++ b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorSignable.swift @@ -3,13 +3,14 @@ import Foundation import struct Gemstone.SignMessage import Primitives +import SigningRequestService import WalletConnectSign public protocol WalletConnectorSignable: Sendable { var allChains: [Primitives.Chain] { get } func addConnection(connection: WalletConnection) throws - func updateSessions(sessions: [WalletConnectionSession]) throws + func updateSessions(sessions: [WalletConnectSession]) throws func sessionReject(error: any Error) async func sessionReject(id: String, error: any Error) async throws func getCurrentWallet() throws -> Primitives.Wallet @@ -21,7 +22,7 @@ public protocol WalletConnectorSignable: Sendable { func getEvents() -> [WalletConnectionEvents] func sessionApproval(payload: WCPairingProposal) async throws -> WalletId func signMessage(sessionId: String, chain: Chain, message: SignMessage, simulation: SimulationResult) async throws -> String - func signTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String - func sendTransaction(sessionId: String, chain: Chain, transaction: WalletConnectorTransaction, simulation: SimulationResult) async throws -> String + func signTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String + func sendTransaction(sessionId: String, chain: Chain, transaction: SignableTransaction, simulation: SimulationResult) async throws -> String func sendRawTransaction(sessionId: String, chain: Chain, transaction: String) async throws -> String } diff --git a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift b/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift deleted file mode 100644 index b6fa0e5424..0000000000 --- a/ios/Packages/ChainServices/WalletConnectorService/WalletConnectorTransaction.swift +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public enum WalletConnectorTransaction { - case ethereum(WCEthereumTransaction, TransactionType) - case solana(String, TransferDataOutputType) - case sui(String, TransferDataOutputType) - case ton(String, TransferDataOutputType) - case tron(String, TransferDataOutputType) -} 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/Primitives/Sources/Extensions/Account+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift index e9edade9e1..d4819d0f21 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Account+Primitives.swift @@ -7,3 +7,9 @@ extension Account: Identifiable { "\(chain)\(address)" } } + +public extension Account { + var chainAddress: ChainAddress { + ChainAddress(chain: chain, address: address) + } +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift index 0d5f80b0fe..7854cedb28 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Date+Primitives.swift @@ -25,4 +25,12 @@ public extension Date { var millisecondsSince1970: Int64 { Int64(timeIntervalSince1970 * 1000) } + + func sleepUntil() async { + let remaining = timeIntervalSinceNow + guard remaining > 0 else { + return + } + try? await Task.sleep(for: .seconds(remaining)) + } } diff --git a/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift new file mode 100644 index 0000000000..a9927b3e1d --- /dev/null +++ b/ios/Packages/Primitives/Sources/Extensions/SimulationResult+Primitives.swift @@ -0,0 +1,12 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public extension SimulationResult { + static let empty = SimulationResult( + warnings: [], + balanceChanges: [], + payload: [], + header: .none, + ) +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift index 82cefb55db..c3325945aa 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Transaction+Primitives.swift @@ -51,6 +51,17 @@ public extension Transaction { var swapProvider: String? { metadata?.decode(TransactionSwapMetadata.self)?.provider } + + var paymentMetadata: TransactionPaymentMetadata? { + metadata?.decode(TransactionPaymentMetadata.self) + } + + var isAwaitingPaymentHash: Bool { + guard let paymentMetadata else { + return false + } + return id.hash == paymentMetadata.paymentId + } } extension Transaction: Identifiable {} diff --git a/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift new file mode 100644 index 0000000000..b23f50104d --- /dev/null +++ b/ios/Packages/Primitives/Sources/Extensions/TransactionAppMetadata+Primitives.swift @@ -0,0 +1,17 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public extension WalletConnectAppMetadata { + var transactionAppMetadata: TransactionAppMetadata { + TransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } +} + +public extension TransactionAppMetadata { + var iconURL: URL? { + guard let icon, let iconURL = icon.asURL else { return .none } + guard iconURL.host() == nil else { return iconURL } + return url.flatMap { ($0 + icon).asURL } + } +} diff --git a/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift index ace48a61e1..cddc68370a 100644 --- a/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift @@ -17,6 +17,10 @@ public extension Wallet { type == .multicoin } + var chainAddresses: [ChainAddress] { + accounts.map(\.chainAddress) + } + var addressChains: [AddressChains] { Dictionary(grouping: accounts, by: \.address) .map { AddressChains(address: $0.key, chains: Set($0.value.map(\.chain)).sorted()) } diff --git a/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift b/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift index 84d21a1c43..c8f9a95e43 100644 --- a/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift +++ b/ios/Packages/Primitives/Sources/Extensions/WalletConnectionSessionProposal+Primitives.swift @@ -2,7 +2,7 @@ import Foundation -extension WalletConnectionSessionProposal: Identifiable { +extension WalletConnectSessionProposal: Identifiable { public var id: String { metadata.url } diff --git a/ios/Packages/Primitives/Sources/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/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 0c7dd9e496..52ce28953e 100644 --- a/ios/Packages/Primitives/Sources/TransferDataType.swift +++ b/ios/Packages/Primitives/Sources/TransferDataType.swift @@ -13,7 +13,8 @@ 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 { @@ -28,7 +29,7 @@ public enum TransferDataType: Hashable, Equatable, Sendable { 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 @@ -68,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 } } @@ -99,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, @@ -117,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] @@ -127,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 } } @@ -156,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/TestKit/PaymentLinkPayableMock.swift b/ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift new file mode 100644 index 0000000000..9c077fdd14 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/PaymentLinkPayableMock.swift @@ -0,0 +1,16 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public struct PaymentLinkPayableMock: PaymentLinkPayable { + public init() {} + + public func pay(link _: PaymentLink, wallet _: Wallet) async {} +} + +public extension PaymentLinkPayable where Self == PaymentLinkPayableMock { + static func mock() -> PaymentLinkPayableMock { + PaymentLinkPayableMock() + } +} diff --git a/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift b/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift index 03d99335fe..04b34f8c62 100644 --- a/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift +++ b/ios/Packages/Primitives/TestKit/Transaction+PrimitivesTestKit.swift @@ -5,6 +5,7 @@ import Primitives public extension Transaction { static func mock( + hash: String = "1", type: TransactionType = .transfer, state: TransactionState = .confirmed, direction: TransactionDirection = .incoming, @@ -16,7 +17,7 @@ public extension Transaction { metadata: AnyCodableValue? = nil, ) -> Transaction { Transaction( - id: TransactionId(chain: .ethereum, hash: "1"), + id: TransactionId(chain: .ethereum, hash: hash), assetId: assetId, from: from, to: to, diff --git a/ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift b/ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift new file mode 100644 index 0000000000..ca67852544 --- /dev/null +++ b/ios/Packages/Primitives/TestKit/TransactionAppMetadata+PrimitivesTestKit.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension TransactionAppMetadata { + static func mock( + name: String = "Test Dapp", + description: String? = .none, + url: String? = "https://example.com", + icon: String? = "https://example.com/icon.png", + ) -> Self { + .init(name: name, description: description, url: url, icon: icon) + } +} diff --git a/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift b/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift index 385234721c..e3760f1d35 100644 --- a/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WCPairingProposal+TestKit.swift @@ -5,7 +5,7 @@ import Primitives public extension WCPairingProposal { static func mock( pairingId: String = "pairing-id", - proposal: WalletConnectionSessionProposal = .mock(), + proposal: WalletConnectSessionProposal = .mock(), verificationStatus: WalletConnectionVerificationStatus = .verified, ) -> WCPairingProposal { WCPairingProposal( diff --git a/ios/Packages/Primitives/TestKit/WalletConnectionSession+TestKit.swift b/ios/Packages/Primitives/TestKit/WalletConnectSession+TestKit.swift similarity index 72% rename from ios/Packages/Primitives/TestKit/WalletConnectionSession+TestKit.swift rename to ios/Packages/Primitives/TestKit/WalletConnectSession+TestKit.swift index 09de672e6e..6e616791e4 100644 --- a/ios/Packages/Primitives/TestKit/WalletConnectionSession+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WalletConnectSession+TestKit.swift @@ -3,7 +3,7 @@ import Foundation import Primitives -public extension WalletConnectionSession { +public extension WalletConnectSession { static func mock( id: String = .empty, sessionId: String = .empty, @@ -11,9 +11,9 @@ public extension WalletConnectionSession { chains: [Chain] = [.ethereum], createdAt: Date = .now, expireAt: Date = .distantFuture, - metadata: WalletConnectionSessionAppMetadata = .mock(), - ) -> WalletConnectionSession { - WalletConnectionSession( + metadata: WalletConnectAppMetadata = .mock(), + ) -> WalletConnectSession { + WalletConnectSession( id: id, sessionId: sessionId, state: state, @@ -25,14 +25,14 @@ public extension WalletConnectionSession { } } -public extension WalletConnectionSessionAppMetadata { +public extension WalletConnectAppMetadata { static func mock( name: String = "", description: String = "", url: String = "", icon: String = "", - ) -> WalletConnectionSessionAppMetadata { - WalletConnectionSessionAppMetadata( + ) -> WalletConnectAppMetadata { + WalletConnectAppMetadata( name: name, description: description, url: url, diff --git a/ios/Packages/Primitives/TestKit/WalletConnectionSessionProposal+TestKit.swift b/ios/Packages/Primitives/TestKit/WalletConnectSessionProposal+TestKit.swift similarity index 59% rename from ios/Packages/Primitives/TestKit/WalletConnectionSessionProposal+TestKit.swift rename to ios/Packages/Primitives/TestKit/WalletConnectSessionProposal+TestKit.swift index fe1b3d591a..20520d3d3d 100644 --- a/ios/Packages/Primitives/TestKit/WalletConnectionSessionProposal+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WalletConnectSessionProposal+TestKit.swift @@ -2,13 +2,13 @@ import Primitives -public extension WalletConnectionSessionProposal { +public extension WalletConnectSessionProposal { static func mock( defaultWallet: Wallet = .mock(), wallets: [Wallet] = [.mock()], - metadata: WalletConnectionSessionAppMetadata = .mock(), - ) -> WalletConnectionSessionProposal { - WalletConnectionSessionProposal( + metadata: WalletConnectAppMetadata = .mock(), + ) -> WalletConnectSessionProposal { + WalletConnectSessionProposal( defaultWallet: defaultWallet, wallets: wallets, metadata: metadata, diff --git a/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift b/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift index 7c63046baf..866375f500 100644 --- a/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift +++ b/ios/Packages/Primitives/TestKit/WalletConnection+TestKit.swift @@ -4,7 +4,7 @@ import Primitives public extension WalletConnection { static func mock( - session: WalletConnectionSession = .mock(), + session: WalletConnectSession = .mock(), wallet: Wallet = .mock(), ) -> WalletConnection { WalletConnection( diff --git a/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift b/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift index f36c36d97e..afa4204eae 100644 --- a/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift +++ b/ios/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift @@ -56,7 +56,8 @@ public enum TransactionHeaderTypeBuilder { .deposit, .withdrawal, .stake, - .generic: + .generic, + .payment: return .amount(showFiat: true) case .tokenApprove: return .assetImage diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/AppPreviewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift similarity index 53% rename from ios/Features/WalletConnector/Sources/WalletConnector/Types/AppPreviewModel.swift rename to ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift index 9e0dae095d..81510a9cc2 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/AppPreviewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/AppPreviewModel.swift @@ -1,10 +1,15 @@ // Copyright (c). Gem Wallet. All rights reserved. import Components -import PrimitivesComponents public struct AppPreviewModel: AssetPreviewable { public let assetImage: AssetImage public let name: String public let subtitleSymbol: String? + + public init(assetImage: AssetImage, name: String, subtitleSymbol: String?) { + self.assetImage = assetImage + self.name = name + self.subtitleSymbol = subtitleSymbol + } } diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift new file mode 100644 index 0000000000..5a90c3389b --- /dev/null +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/PaymentQuoteItem.swift @@ -0,0 +1,92 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import BigInt +import Components +import Formatters +import Foundation +import Primitives +import Style +import SwiftUI + +public struct PaymentQuoteItem: Identifiable, Sendable { + public let quote: PaymentQuote + + private let assetData: AssetData? + private let formatter: ValueFormatter + + public init( + quote: PaymentQuote, + assetData: AssetData? = .none, + formatter: ValueFormatter, + ) { + self.quote = quote + self.assetData = assetData + self.formatter = formatter + } + + public var id: String { + quote.id + } + + public var amountText: String { + guard let value = BigInt(quote.amount.value) else { + return quote.amount.symbol + } + return formatter.string(value, decimals: quote.amount.decimals.asInt, currency: quote.amount.symbol) + } +} + +// MARK: - ListAssetItemViewable + +extension PaymentQuoteItem: ListAssetItemViewable { + public var name: String { + assetData?.asset.name ?? quote.amount.symbol + } + + public var symbol: String? { + .none + } + + public var assetImage: AssetImage { + AssetIdViewModel(assetId: quote.amount.assetId).assetImage + } + + public var subtitleView: ListAssetItemSubtitleView { + .type(TextValue(text: quote.amount.assetId.chain.networkName, style: .calloutSecondary)) + } + + public var rightView: ListAssetItemRightView { + .balance( + balance: TextValue(text: amountText, style: TextStyle(font: .callout, color: Colors.black, fontWeight: .semibold)), + totalFiat: TextValue(text: balanceText, style: TextStyle(font: .footnote, color: Colors.gray)), + ) + } + + public var action: ((ListAssetItemAction) -> Void)? { + get { .none } + set {} + } +} + +// MARK: - Hashable + +extension PaymentQuoteItem: Hashable { + public static func == (lhs: PaymentQuoteItem, rhs: PaymentQuoteItem) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +// MARK: - Private + +extension PaymentQuoteItem { + private var balanceText: String { + guard let assetData else { + return .empty + } + return formatter.string(assetData.balance.available, decimals: assetData.asset.decimals.asInt, currency: assetData.asset.symbol) + } +} diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift index 6ae1191e83..7a96ffc783 100644 --- a/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift @@ -178,6 +178,9 @@ public struct TransactionViewModel: Sendable { let chain = assetId.chain switch transaction.transaction.type { case .transfer, .transferNFT, .tokenApproval, .smartContractCall: + if let merchant = paymentMerchantName { + return String(format: "%@ %@", Localized.Transfer.to, merchant) + } switch transaction.transaction.direction { case .incoming: return participantTitle(prefix: Localized.Transfer.from, address: transaction.transaction.from, chain: chain) @@ -350,6 +353,10 @@ public struct TransactionViewModel: Sendable { return String(format: "%@ %@", prefix, value) } + private var paymentMerchantName: String? { + transaction.transaction.paymentMetadata?.merchant.name + } + private func getResourceTitle() -> String? { guard let resourceType = transaction.transaction.metadata?.decode(TransactionResourceTypeMetadata.self)?.resourceType else { return nil } return ResourceViewModel(resource: resourceType).title From 362dc0fc2290eb96e086e84dc00001b6d23e3ad5 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:25 +0300 Subject: [PATCH 12/27] iOS: wire payments into the app Scanned and opened payment links reach the payment manager instead of a no-op, the pending payment is tracked by the transaction state job, and PaymentError is localized as the buyer's failure, not the gateway's. --- ios/Gem/App.swift | 2 + ios/Gem/Navigation/NavigationHandler.swift | 4 +- .../Wallet/WalletNavigationStack.swift | 23 +++++++++++ ios/Gem/Scenes/RootScene.swift | 9 +++- ios/Gem/Services/AppResolver+Services.swift | 7 ++++ .../Services/AppResolver+ViewInjection.swift | 1 + ios/Gem/Services/ServicesFactory.swift | 41 +++++++++++++++++++ ios/Gem/Services/ViewModelFactory.swift | 6 ++- ios/Gem/Types/Environment.swift | 2 + ios/Gem/Types/Errors.swift | 19 +++++++++ ios/Gem/ViewModels/RootSceneViewModel.swift | 21 ++++++++++ ios/Gem/Views/MainTabView.swift | 2 + 12 files changed, 132 insertions(+), 5 deletions(-) diff --git a/ios/Gem/App.swift b/ios/Gem/App.swift index 2d3b1abd48..bdba0fd6cb 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -29,6 +29,7 @@ struct GemApp: App { model: RootSceneViewModel( observablePreferences: resolver.storages.observablePreferences, walletConnectorPresenter: resolver.services.walletConnectorManager.presenter, + paymentSheetPresenter: resolver.services.paymentSheetPresenter, onstartService: resolver.services.onstartService, onstartWalletService: resolver.services.onstartWalletService, transactionStateScheduler: resolver.services.transactionStateScheduler, @@ -37,6 +38,7 @@ struct GemApp: App { navigationHandler: resolver.services.navigationHandler, lockWindowManager: LockWindowManager(lockModel: LockSceneViewModel()), walletService: resolver.services.walletService, + payService: resolver.services.paymentLinkManager, walletSetupService: resolver.services.walletSetupService, nameService: resolver.services.nameService, releaseAlertService: resolver.services.releaseAlertService, diff --git a/ios/Gem/Navigation/NavigationHandler.swift b/ios/Gem/Navigation/NavigationHandler.swift index ddd7d535f4..e6c1166fda 100644 --- a/ios/Gem/Navigation/NavigationHandler.swift +++ b/ios/Gem/Navigation/NavigationHandler.swift @@ -57,7 +57,7 @@ final class NavigationHandler: Sendable { guard let action = try? URLParser.from(url: url) else { return false } switch action { - case .walletConnect: + case .payment, .walletConnect: return false case .deeplink: Task { await handle(action) } @@ -72,7 +72,7 @@ final class NavigationHandler: Sendable { extension NavigationHandler { private func handleURLAction(_ action: URLAction) async throws { switch action { - case .walletConnect: break + case .payment, .walletConnect: break case let .deeplink(deeplink): try await handleDeepLink(deeplink) } } diff --git a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift index 598bf34fcd..810e59b591 100644 --- a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift +++ b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift @@ -12,6 +12,7 @@ import PriceAlerts import PriceService import Primitives import PrimitivesComponents +import QRScanner import StakeService import Store import SwiftUI @@ -87,6 +88,13 @@ struct WalletNavigationStack: View { ) .liquidGlass() } + if model.showScanner { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: model.onSelectScanner) { + model.scannerImage + } + } + } ToolbarItem(placement: .navigationBarTrailing) { Button(action: model.onToggleSearch) { model.searchImage @@ -94,6 +102,9 @@ struct WalletNavigationStack: View { } } } + .sheet(isPresented: $model.isPresentingScanner) { + ScanQRCodeNavigationStack(action: model.onHandleScan) + } .navigationDestination(for: Scenes.Asset.self) { AssetNavigationView( model: AssetSceneViewModel( @@ -124,6 +135,18 @@ struct WalletNavigationStack: View { ), ) } + .navigationDestination(for: Scenes.NetworkAssets.self) { destination in + NetworkAssetsScene( + model: NetworkAssetsSceneViewModel( + wallet: model.wallet, + chain: destination.chain, + balanceService: balanceService, + assetsEnabler: assetsEnabler, + preferences: preferences.preferences, + onManageAssets: { model.onSelectManage(chains: [destination.chain]) }, + ), + ) + } .navigationDestination(for: Scenes.Transaction.self) { TransactionNavigationView( model: TransactionSceneViewModel( diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index 310f3f2c56..a4baf09e06 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -4,6 +4,7 @@ import Components import GemstonePrimitives import Localization import Onboarding +import Payments import PriceService import Primitives import Style @@ -34,12 +35,18 @@ struct RootScene: View { await model.handleOpenUrl(url) } } - .sheet(item: $model.isPresentingConnectorSheet) { type in + .sheet(item: $model.isPresentingConnectorSheet, onDismiss: model.walletConnectorPresenter.onSheetDismiss) { type in WalletConnectorNavigationStack( type: type, presenter: model.walletConnectorPresenter, ) } + .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.onSheetDismiss) { type in + PaymentNavigationStack( + type: type, + presenter: model.paymentSheetPresenter, + ) + } .sheet(isPresented: $model.isPresentingCreateWalletSheet) { CreateWalletNavigationStack( model: CreateWalletModel( diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index b0c6cb6b7d..89f9489872 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 @@ -77,6 +78,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 +135,8 @@ extension AppResolver { onstartAsyncService: OnstartAsyncService, onstartWalletService: OnstartWalletService, walletConnectorManager: WalletConnectorManager, + paymentLinkManager: PaymentLinkManager, + paymentSheetPresenter: PaymentSheetPresenter, perpetualService: PerpetualService, hyperliquidObserverService: any PerpetualObservable, nameService: NameService, @@ -187,6 +192,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 aaa825fa81..aa7a14771a 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -22,6 +22,7 @@ import FiatService import Foundation import GemAPI import GemAPIDevice +import func Gemstone.paymentWalletConnectUrl import GemstonePrimitives import Keystore import NameService @@ -33,7 +34,10 @@ import PerpetualService import Preferences import PriceAlertService import PriceService +import PaymentService +import Payments import Primitives +import SigningRequestService import RewardsService import ScanService import ServiceStatusService @@ -47,6 +51,7 @@ import TransactionsService import TransactionStateService import Transfer import WalletConnector +import WalletConnectorService import WalletService import WalletSessionService import WebSocketClient @@ -148,6 +153,11 @@ struct ServicesFactory { assetsService: assetsService, addressStore: storeManager.addressStore, ) + let paymentService = PaymentService( + provider: nativeProvider, + appId: Constants.WalletConnect.projectId, + clientId: (try? securePreferences.getDeviceId()) ?? .empty, + ) let transactionStateScheduler = Self.makeTransactionService( transactionStore: storeManager.transactionStore, gatewayService: gatewayService, @@ -155,6 +165,7 @@ struct ServicesFactory { earnService: earnService, nftService: nftService, balanceService: balanceService, + paymentStatusService: paymentService, ) let preferences = storages.observablePreferences.preferences @@ -325,6 +336,32 @@ struct ServicesFactory { hyperliquidObserverService: hyperliquidObserverService, ) + let paymentAssetsProvider = PaymentAssetsProvider(assetStore: storeManager.assetStore) + let paymentSheetPresenter = PaymentSheetPresenter() + + let paymentLinkManager = PaymentLinkManager( + paymentManager: PaymentManager( + service: paymentService, + executor: PaymentActionExecutor( + interactor: walletConnectorManager, + simulator: SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), + approvalExecutor: PaymentApprovalExecutor( + keystore: storages.keystore, + assetsService: assetsService, + chainServiceFactory: chainServiceFactory, + scanService: scanService, + assetsEnabler: assetsEnabler, + transactionStateScheduler: transactionStateScheduler, + ), + assetsProvider: paymentAssetsProvider, + ), + presenter: paymentSheetPresenter, + assetsProvider: paymentAssetsProvider, + transactionStateScheduler: transactionStateScheduler, + ), + eventPresenterService: eventPresenterService, + ) + let viewModelFactory = ViewModelFactory( keystore: storages.keystore, chainServiceFactory: chainServiceFactory, @@ -389,6 +426,8 @@ struct ServicesFactory { onstartAsyncService: onstartAsyncService, onstartWalletService: onstartWalletService, walletConnectorManager: walletConnectorManager, + paymentLinkManager: paymentLinkManager, + paymentSheetPresenter: paymentSheetPresenter, perpetualService: perpetualService, hyperliquidObserverService: hyperliquidObserverService, nameService: nameService, @@ -531,6 +570,7 @@ extension ServicesFactory { earnService: EarnService, nftService: NFTService, balanceService: BalanceService, + paymentStatusService: any PaymentStatusServiceable, ) -> TransactionStateScheduler { let postProcessingService = TransactionPostProcessingService( transactionStore: transactionStore, @@ -543,6 +583,7 @@ extension ServicesFactory { transactionStore: transactionStore, gatewayService: gatewayService, postProcessingService: postProcessingService, + paymentStatusService: paymentStatusService, ) return TransactionStateScheduler( transactionStore: transactionStore, diff --git a/ios/Gem/Services/ViewModelFactory.swift b/ios/Gem/Services/ViewModelFactory.swift index a05f12bb34..a662058f95 100644 --- a/ios/Gem/Services/ViewModelFactory.swift +++ b/ios/Gem/Services/ViewModelFactory.swift @@ -17,7 +17,9 @@ import PerpetualService import Preferences import PriceAlertService import PriceService +import PaymentService import Primitives +import SigningRequestService import PrimitivesComponents import ScanService import Stake @@ -152,7 +154,7 @@ public struct ViewModelFactory: Sendable { public func confirmTransferScene( wallet: Wallet, data: TransferData, - confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate? = nil, + confirmTransferDelegate: StringResultAction? = nil, simulation: SimulationResult? = nil, onComplete: VoidAction, ) -> ConfirmTransferSceneViewModel { @@ -295,7 +297,7 @@ public struct ViewModelFactory: Sendable { @MainActor public func signMessageScene( payload: SignMessagePayload, - confirmTransferDelegate: @escaping TransferDataCallback.ConfirmTransferDelegate, + confirmTransferDelegate: @escaping StringResultAction, ) -> SignMessageSceneViewModel { SignMessageSceneViewModel( keystore: keystore, diff --git a/ios/Gem/Types/Environment.swift b/ios/Gem/Types/Environment.swift index 7ae2a6117b..9d3448d06f 100644 --- a/ios/Gem/Types/Environment.swift +++ b/ios/Gem/Types/Environment.swift @@ -25,6 +25,7 @@ import NotificationService import PerpetualService import PriceAlertService import PriceService +import Primitives import RewardsService import ServiceStatusService import ScanService @@ -49,6 +50,7 @@ extension EnvironmentValues { @Entry var explorerService: ExplorerService = AppResolver.main.services.explorerService @Entry var assetsEnabler: any AssetsEnabler = AppResolver.main.services.assetsEnabler @Entry var assetDiscoveryService: any AssetDiscoverable = AppResolver.main.services.assetDiscoveryService + @Entry var payService: any PaymentLinkPayable = AppResolver.main.services.paymentLinkManager @Entry var walletService: WalletService = AppResolver.main.services.walletService @Entry var walletSessionService: any WalletSessionManageable = AppResolver.main.services.walletSessionService @Entry var priceAlertService: PriceAlertService = AppResolver.main.services.priceAlertService diff --git a/ios/Gem/Types/Errors.swift b/ios/Gem/Types/Errors.swift index efc3cd4806..e5f4d113ef 100644 --- a/ios/Gem/Types/Errors.swift +++ b/ios/Gem/Types/Errors.swift @@ -4,6 +4,7 @@ import Foundation import Gemstone import Localization import Primitives +import WalletConnector extension Gemstone.GatewayError: @retroactive LocalizedError { public var errorDescription: String? { @@ -35,6 +36,24 @@ extension Gemstone.SwapperError: @retroactive LocalizedError { } } +extension Gemstone.PaymentError: @retroactive LocalizedError { + public var errorDescription: String? { + switch self { + case .PaymentExpired, .QuoteExpired: + return Localized.Errors.paymentExpired + case .Rejected: + return Localized.Errors.paymentNotAllowed + case .PaymentNotFound, .RateLimited: + return Localized.Transaction.Status.failed + case .NoPaymentOptions, .UnsupportedAccounts, .NotSupported: + return Localized.Errors.notSupported + case let .InvalidRequest(message), let .Network(message): + debugLog("PaymentError \(message)") + return Localized.Errors.errorOccurred + } + } +} + extension Gemstone.AlienError: @retroactive LocalizedError { public var errorDescription: String? { switch self { diff --git a/ios/Gem/ViewModels/RootSceneViewModel.swift b/ios/Gem/ViewModels/RootSceneViewModel.swift index f7ed73002e..38f4ccd39f 100644 --- a/ios/Gem/ViewModels/RootSceneViewModel.swift +++ b/ios/Gem/ViewModels/RootSceneViewModel.swift @@ -12,6 +12,7 @@ import Localization import LockManager import NameService import Onboarding +import Payments import Preferences import Primitives import SwiftUI @@ -37,9 +38,11 @@ final class RootSceneViewModel { let observablePreferences: ObservablePreferences let walletSetupService: WalletSetupService let walletService: WalletService + let payService: any PaymentLinkPayable let nameService: NameService let avatarService: AvatarService let walletConnectorPresenter: WalletConnectorPresenter + let paymentSheetPresenter: PaymentSheetPresenter let lockManager: any LockWindowManageable var currentWallet: Wallet? { walletService.currentWallet @@ -62,6 +65,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 } @@ -77,6 +85,7 @@ final class RootSceneViewModel { init( observablePreferences: ObservablePreferences, walletConnectorPresenter: WalletConnectorPresenter, + paymentSheetPresenter: PaymentSheetPresenter, onstartService: OnstartService, onstartWalletService: OnstartWalletService, transactionStateScheduler: TransactionStateScheduler, @@ -85,6 +94,7 @@ final class RootSceneViewModel { navigationHandler: NavigationHandler, lockWindowManager: any LockWindowManageable, walletService: WalletService, + payService: any PaymentLinkPayable, walletSetupService: WalletSetupService, nameService: NameService, releaseAlertService: ReleaseAlertService, @@ -95,6 +105,7 @@ final class RootSceneViewModel { ) { self.observablePreferences = observablePreferences self.walletConnectorPresenter = walletConnectorPresenter + self.paymentSheetPresenter = paymentSheetPresenter self.onstartService = onstartService self.onstartWalletService = onstartWalletService self.transactionStateScheduler = transactionStateScheduler @@ -103,6 +114,7 @@ final class RootSceneViewModel { self.navigationHandler = navigationHandler lockManager = lockWindowManager self.walletService = walletService + self.payService = payService self.walletSetupService = walletSetupService self.nameService = nameService self.releaseAlertService = releaseAlertService @@ -155,6 +167,8 @@ extension RootSceneViewModel { switch action { case let .walletConnect(walletConnectAction): try await handleWalletConnect(walletConnectAction) + case let .payment(link): + await handlePayment(link) case .deeplink: await navigationHandler.handle(action) } @@ -227,6 +241,13 @@ extension RootSceneViewModel { ) } + private func handlePayment(_ link: PaymentLink) async { + guard let wallet = currentWallet else { + return + } + await payService.pay(link: link, wallet: wallet) + } + private func handleWalletConnect(_ action: WalletConnectAction) async throws { isPresentingConnectorBar = true switch action { diff --git a/ios/Gem/Views/MainTabView.swift b/ios/Gem/Views/MainTabView.swift index 6263cdb66b..d27aad3f78 100644 --- a/ios/Gem/Views/MainTabView.swift +++ b/ios/Gem/Views/MainTabView.swift @@ -18,6 +18,7 @@ struct MainTabView: View { @Environment(\.navigationState) private var navigationState @Environment(\.navigationPresenter) private var presenter @Environment(\.nftService) private var nftService + @Environment(\.payService) private var payService @Environment(\.priceService) private var priceService @Environment(\.observablePreferences) private var observablePreferences @Environment(\.walletService) private var walletService @@ -46,6 +47,7 @@ struct MainTabView: View { balanceService: balanceService, bannerService: bannerService, walletService: walletService, + payService: payService, nftService: nftService, observablePreferences: observablePreferences, wallet: model.wallet, From e88fbe66643004dc7bca32bb5011eb32d9086de3 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:25 +0300 Subject: [PATCH 13/27] Android: consume the payment models The generated payment models plus the extensions that map a decoded link and an approval across the FFI boundary. --- .../android/PendingNavigationCoordinator.kt | 1 + .../bridge/WalletConnectClient.kt | 6 ++-- .../store/database/entities/DbConnection.kt | 8 ++--- .../reown/ReownWalletConnectClient.kt | 10 +++--- .../viewmodels/ProposalSceneViewModel.kt | 4 +-- .../bridge/viewmodels/WCAuthViewModel.kt | 4 +-- .../bridge/viewmodels/WCRequestViewModel.kt | 8 ++--- .../bridge/viewmodels/model/SessionUI.kt | 4 +-- .../recipient/viewmodel/RecipientViewModel.kt | 12 ++++--- .../viewmodels/ManageContactViewModel.kt | 8 +++-- .../com/gemwallet/android/ext/GemPayment.kt | 32 +++++++++++++++++++ .../com/gemwallet/android/ext/Payment.kt | 17 ++++++++++ .../gemwallet/android/ext/WalletConnector.kt | 14 ++------ .../gemwallet/android/model/ConfirmParams.kt | 6 ++-- .../android/ui/components/QRScanner.kt | 4 ++- 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 ++++ .../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 ++++ .../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 ++++ .../ui/src/main/res/values-zh-rCN/strings.xml | 6 ++++ .../ui/src/main/res/values-zh-rTW/strings.xml | 6 ++++ android/ui/src/main/res/values/strings.xml | 6 ++++ 46 files changed, 279 insertions(+), 45 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 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..7a8c93ad06 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/PendingNavigationCoordinator.kt @@ -56,6 +56,7 @@ class PendingNavigationCoordinator @Inject constructor( return } } + is UrlAction.Payment -> Unit null -> Unit } diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt index d0a529163c..5faf95f461 100644 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt +++ b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/bridge/WalletConnectClient.kt @@ -1,6 +1,6 @@ package com.gemwallet.android.data.repositories.bridge -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import kotlinx.coroutines.flow.Flow interface WalletConnectClient { @@ -54,7 +54,7 @@ sealed interface WalletConnectEvent { data class WalletConnectSession( val topic: String, val expiry: Long, - val metadata: WalletConnectionSessionAppMetadata?, + val metadata: WalletConnectAppMetadata?, val namespaces: Map, val redirect: String?, ) @@ -95,7 +95,7 @@ data class WalletConnectSessionRequest( data class WalletConnectAuthenticationRequest( val id: Long, - val metadata: WalletConnectionSessionAppMetadata?, + val metadata: WalletConnectAppMetadata?, val payloadParams: WalletConnectAuthPayloadParams, ) diff --git a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt index bba1fd5d99..180fb6e127 100644 --- a/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt +++ b/android/data/services/store/src/main/kotlin/com/gemwallet/android/data/service/store/database/entities/DbConnection.kt @@ -8,8 +8,8 @@ import androidx.room.PrimaryKey import com.wallet.core.primitives.Chain import com.wallet.core.primitives.Wallet import com.wallet.core.primitives.WalletConnection -import com.wallet.core.primitives.WalletConnectionSession -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectSession +import com.wallet.core.primitives.WalletConnectAppMetadata import com.wallet.core.primitives.WalletConnectionState @Entity( @@ -38,14 +38,14 @@ data class DbConnection( fun DbConnection.toDTO(wallet: Wallet): WalletConnection { return WalletConnection( wallet = wallet, - session = WalletConnectionSession( + session = WalletConnectSession( id = id, sessionId = sessionId, state = state, createdAt = createdAt, expireAt = expireAt, chains = chains, - metadata = WalletConnectionSessionAppMetadata( + metadata = WalletConnectAppMetadata( name = appName, description = appDescription, icon = appIcon, diff --git a/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt index b3cf24af8e..0327eb8936 100644 --- a/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt +++ b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt @@ -23,7 +23,7 @@ import com.reown.android.CoreClient import com.reown.android.relay.ConnectionType import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import dagger.hilt.android.qualifiers.ApplicationContext import java.net.URI import kotlinx.coroutines.channels.BufferOverflow @@ -294,14 +294,14 @@ private fun Wallet.Model.Session.toWalletConnectSession(): WalletConnectSession return WalletConnectSession( topic = topic, expiry = expiry, - metadata = metaData?.toWalletConnectionSessionAppMetadata(), + metadata = metaData?.toWalletConnectAppMetadata(), namespaces = namespaces.mapValues { it.value.toWalletConnectSessionNamespace() }, redirect = redirect, ) } -private fun Core.Model.AppMetaData.toWalletConnectionSessionAppMetadata(): WalletConnectionSessionAppMetadata { - return WalletConnectionSessionAppMetadata( +private fun Core.Model.AppMetaData.toWalletConnectAppMetadata(): WalletConnectAppMetadata { + return WalletConnectAppMetadata( name = name, description = description, url = url, @@ -359,7 +359,7 @@ private fun Wallet.Model.SessionRequest.toWalletConnectSessionRequest(): WalletC private fun Wallet.Model.SessionAuthenticate.toWalletConnectAuthenticationRequest(): WalletConnectAuthenticationRequest { return WalletConnectAuthenticationRequest( id = id, - metadata = participant.metadata?.toWalletConnectionSessionAppMetadata(), + metadata = participant.metadata?.toWalletConnectAppMetadata(), payloadParams = payloadParams.toWalletConnectAuthPayloadParams(), ) } diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt index cc81cd08b4..776e99d27f 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/ProposalSceneViewModel.kt @@ -13,7 +13,7 @@ import com.gemwallet.android.features.bridge.viewmodels.model.WalletConnectOrigi import com.gemwallet.android.features.bridge.viewmodels.model.toSessionUI import com.gemwallet.android.ui.models.ButtonState import com.gemwallet.android.ui.models.buttonState -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import com.wallet.core.primitives.WalletId import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -48,7 +48,7 @@ class ProposalSceneViewModel @Inject constructor( val proposal = _proposal.map { it ?: return@map null val icons = it.icons - WalletConnectionSessionAppMetadata( + WalletConnectAppMetadata( name = walletConnectAppName(it.name, it.url), description = it.description, url = it.url, diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt index a3deed347d..a217db3103 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCAuthViewModel.kt @@ -26,7 +26,7 @@ import com.wallet.core.primitives.Account import com.wallet.core.primitives.Chain import com.wallet.core.primitives.ChainType import com.wallet.core.primitives.Wallet -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import com.wallet.core.primitives.WalletId import com.wallet.core.primitives.WalletType import dagger.hilt.android.lifecycle.HiltViewModel @@ -316,7 +316,7 @@ class WCAuthViewModel @Inject constructor( } private fun WalletConnectAuthenticationRequest.toSessionUI(): SessionUI { - return WalletConnectionSessionAppMetadata( + return WalletConnectAppMetadata( name = walletConnectAppName(metadata?.name, metadata?.url), description = metadata?.description ?: "", url = metadata?.url ?: "", diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt index 3c0ba8a324..2cb3262186 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/WCRequestViewModel.kt @@ -25,8 +25,8 @@ import com.gemwallet.android.ui.models.hasCriticalWarning import com.wallet.core.primitives.Account import com.wallet.core.primitives.Chain import com.wallet.core.primitives.WalletConnection -import com.wallet.core.primitives.WalletConnectionSession -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectSession +import com.wallet.core.primitives.WalletConnectAppMetadata import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -189,7 +189,7 @@ class WCRequestViewModel @Inject constructor( action: WalletConnectAction, sessionRequest: WalletConnectSessionRequest, account: Account, - appMetadata: WalletConnectionSessionAppMetadata, + appMetadata: WalletConnectAppMetadata, chain: Chain, sessionDomain: String, ): WCRequest = when (action) { @@ -354,7 +354,7 @@ class WCRequestViewModel @Inject constructor( state.update { RequestViewModelState() } } - private fun validateChain(chain: Chain, session: WalletConnectionSession) { + private fun validateChain(chain: Chain, session: WalletConnectSession) { if (!session.chains.contains(chain)) { throw BridgeRequestError.UnresolvedChainId } diff --git a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt index f2a6f783a2..cb09658019 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/SessionUI.kt @@ -2,7 +2,7 @@ package com.gemwallet.android.features.bridge.viewmodels.model import com.gemwallet.android.ext.getShortUrl import com.gemwallet.android.ext.shortName -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata data class SessionUI( val icon: String = "", @@ -10,7 +10,7 @@ data class SessionUI( val uri: String = "", ) -fun WalletConnectionSessionAppMetadata.toSessionUI(): SessionUI { +fun WalletConnectAppMetadata.toSessionUI(): SessionUI { return SessionUI( icon = icon, name = shortName, diff --git a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt index eb3466bb42..d8aa0408a0 100644 --- a/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt +++ b/android/features/recipient/viewmodels/src/main/kotlin/com/gemwallet/android/features/recipient/viewmodel/RecipientViewModel.kt @@ -17,6 +17,8 @@ import com.gemwallet.android.ext.checksumAddress import com.gemwallet.android.ext.getAccount import com.gemwallet.android.ext.isMemoSupport import com.gemwallet.android.ext.mutableStateIn +import com.gemwallet.android.ext.request +import com.gemwallet.android.ext.toPrimitives import com.gemwallet.android.features.recipient.viewmodel.models.QrScanField import com.gemwallet.android.features.recipient.viewmodel.models.RecipientError import com.gemwallet.android.features.recipient.viewmodel.models.RecipientState @@ -223,15 +225,15 @@ class RecipientViewModel @Inject constructor( } fun setQrData(type: RecipientType, field: QrScanField, data: String, confirmAction: ConfirmTransactionAction) { - val paymentWrapper = uniffi.gemstone.paymentDecodeUrl(data) + val request = uniffi.gemstone.paymentDecodeUrl(data).toPrimitives().request ?: return val amount = try { - BigInteger(paymentWrapper.amount ?: throw IllegalArgumentException()) + BigInteger(request.amount ?: throw IllegalArgumentException()) } catch (_: Throwable) { null } val assetInfo = type.assetInfo - val address = assetInfo.asset.chain.checksumAddress(paymentWrapper.address) - val memo = paymentWrapper.memo + val address = assetInfo.asset.chain.checksumAddress(request.address) + val memo = request.memo val owner = assetInfo.owner if ( @@ -253,7 +255,7 @@ class RecipientViewModel @Inject constructor( } QrScanField.Memo -> { _address.value = address.ifEmpty { _address.value } - _memo.value = paymentWrapper.memo ?: data + _memo.value = request.memo ?: data } } } diff --git a/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt b/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt index 177ec28607..0f29b0fd79 100644 --- a/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt +++ b/android/features/settings/contacts/viewmodels/src/main/kotlin/com/gemwallet/android/features/settings/contacts/viewmodels/ManageContactViewModel.kt @@ -8,6 +8,8 @@ import com.gemwallet.android.cases.contacts.AddContact import com.gemwallet.android.cases.contacts.GetContacts import com.gemwallet.android.cases.contacts.UpdateContact import com.gemwallet.android.cases.name.ResolveName +import com.gemwallet.android.ext.request +import com.gemwallet.android.ext.toPrimitives import com.gemwallet.android.features.recipient.viewmodel.NameRecordState import com.gemwallet.android.features.recipient.viewmodel.NameResolveController import com.gemwallet.android.features.settings.contacts.viewmodels.models.ContactAddressInput @@ -124,9 +126,9 @@ class ManageContactViewModel @Inject constructor( private fun applyExternalAddress(data: String) { resolver.reset() - val decoded = runCatching { uniffi.gemstone.paymentDecodeUrl(data) }.getOrNull() - val address = (decoded?.address?.ifBlank { null } ?: data).trim() - val memo = decoded?.memo + val request = runCatching { uniffi.gemstone.paymentDecodeUrl(data).toPrimitives() }.getOrNull()?.request + val address = (request?.address?.ifBlank { null } ?: data).trim() + val memo = request?.memo state.update { val input = it.addressInput ?: return@update it it.copy( diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt new file mode 100644 index 0000000000..4bb9b5a73b --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt @@ -0,0 +1,32 @@ +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 +} 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/WalletConnector.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt index 3abe85a4c1..2066774859 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/WalletConnector.kt @@ -1,17 +1,9 @@ package com.gemwallet.android.ext import com.wallet.core.primitives.Account -import com.wallet.core.primitives.WalletConnectionSessionAppMetadata -import uniffi.gemstone.GemWalletConnectionSessionAppMetadata +import com.wallet.core.primitives.WalletConnectAppMetadata import uniffi.gemstone.walletConnectAppShortName -fun WalletConnectionSessionAppMetadata.toGem() = GemWalletConnectionSessionAppMetadata( - name = name, - description = description, - url = url, - icon = icon, -) - fun Account.toGem() = uniffi.gemstone.Account( chain = chain.string, address = address, @@ -19,8 +11,8 @@ fun Account.toGem() = uniffi.gemstone.Account( extendedPublicKey = extendedPublicKey, ) -val WalletConnectionSessionAppMetadata.shortName: String - get() = walletConnectAppShortName(toGem()) +val WalletConnectAppMetadata.shortName: String + get() = walletConnectAppShortName(name) fun List?.walletConnectIcon(): String { return this?.firstOrNull { it.endsWith("png", ignoreCase = true) || it.endsWith("jpg", ignoreCase = true) } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt index 97decda16e..059ebb53db 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/ConfirmParams.kt @@ -23,7 +23,7 @@ import com.wallet.core.primitives.NFTAsset import com.wallet.core.primitives.PerpetualType import com.wallet.core.primitives.Resource import com.wallet.core.primitives.TransactionType -import com.wallet.core.primitives.swap.ApprovalData +import com.wallet.core.primitives.ApprovalData import kotlinx.serialization.Serializable import uniffi.gemstone.GemAccountDataType import uniffi.gemstone.GemApprovalData @@ -33,7 +33,7 @@ import uniffi.gemstone.GemSwapQuoteDataType import uniffi.gemstone.GemTransactionInputType import uniffi.gemstone.GemTransactionInputType.* import uniffi.gemstone.GemTransferDataExtra -import uniffi.gemstone.GemWalletConnectionSessionAppMetadata +import uniffi.gemstone.GemTransactionAppMetadata import uniffi.gemstone.SwapperProvider import uniffi.gemstone.TransferDataOutputAction import uniffi.gemstone.TransferDataOutputType @@ -195,7 +195,7 @@ sealed class ConfirmParams() { val type = requireNotNull(inputType) { "inputType is required for Generic transactions" } return Generic( asset = asset.toGem(), - metadata = GemWalletConnectionSessionAppMetadata( + appMetadata = GemTransactionAppMetadata( name = name, description = description, url = url, diff --git a/android/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..b83983e62e 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 @@ -161,7 +163,7 @@ fun QRScannerScene( mapOf(DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE)) ) }.decode(binaryBmp) - imageResult = paymentDecodeUrl(result.text).address + imageResult = paymentDecodeUrl(result.text).toPrimitives().request?.address.orEmpty() if (imageResult.isEmpty()) { throw Exception() } 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 From 67fbe403a45c4a36bc0a0b3adfb760f7d54d1048 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:33:33 +0300 Subject: [PATCH 14/27] Localize the payment link strings Merchant, payment expiry and the payment failure copy, in every language file, with the generated iOS and Android outputs. --- 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 +++++++++ 63 files changed, 483 insertions(+) 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 dfc82d32513a3033a630fffef83b8f7c3a23d040 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:38:41 +0300 Subject: [PATCH 15/27] iOS: decode and scan payment links The URL decoder resolves a payment link into the shared model, the scanner and the deep-link route hand it to the payment manager, and the app metadata a signing request carries no longer comes from a live WalletConnect session. --- core/crates/primitives/src/testkit/mod.rs | 3 +- .../testkit/transaction_app_metadata_mock.rs | 12 ++ .../testkit/transaction_load_input_mock.rs | 6 +- ...rs => wallet_connect_app_metadata_mock.rs} | 6 +- .../src/transaction_preload_input.rs | 2 +- .../ScanQRCodeNavigationStack.swift | 8 +- .../ViewModels/WalletSceneViewModel.swift | 32 ++++- .../WalletSceneViewModel+TestKit.swift | 1 + .../Tests/WalletSceneViewModelTests.swift | 15 +++ ios/Gem.xcodeproj/project.pbxproj | 23 ++++ .../Sources/Protocols/ChainServiceable.swift | 2 +- .../ScanService/ScanService.swift | 2 +- .../ChainAddress+GemstonePrimitives.swift | 11 ++ .../GemPayment+GemstonePrimitives.swift | 55 ++++++++ ...GemPaymentOptions+GemstonePrimitives.swift | 117 ++++++++++++++++++ ...nsactionInputType+GemstonePrimitives.swift | 10 +- ...nnectAppMetadata+GemstonePrimitives.swift} | 14 +-- ...actionAppMetadata+GemstonePrimitives.swift | 27 ++++ .../TransferDataType+GemstonePrimitives.swift | 3 +- .../UrlAction+GemstonePrimitives.swift | 1 + .../Sources/PaymentURLDecoder.swift | 6 +- .../Sources/URLParser.swift | 10 +- .../PaymentURLDecoderTests.swift | 37 +++--- .../Models/WalletConnectionRecord.swift | 8 +- .../Sources/Stores/ConnectionsStore.swift | 4 +- 25 files changed, 358 insertions(+), 57 deletions(-) create mode 100644 core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs rename core/crates/primitives/src/testkit/{wallet_connection_session_mock.rs => wallet_connect_app_metadata_mock.rs} (65%) 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 rename ios/Packages/GemstonePrimitives/Sources/Extensions/{GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift => GemWalletConnectAppMetadata+GemstonePrimitives.swift} (51%) create mode 100644 ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift diff --git a/core/crates/primitives/src/testkit/mod.rs b/core/crates/primitives/src/testkit/mod.rs index 2d2617adcc..bf81d3ab32 100644 --- a/core/crates/primitives/src/testkit/mod.rs +++ b/core/crates/primitives/src/testkit/mod.rs @@ -17,6 +17,7 @@ pub mod signer_mock; pub mod simulation_mock; pub mod subscription_mock; pub mod swap_mock; +pub mod transaction_app_metadata_mock; pub mod transaction_fee_mock; pub mod transaction_load_input_mock; pub mod transaction_load_metadata_mock; @@ -24,6 +25,6 @@ pub mod transaction_mock; pub mod transaction_preload_input_mock; pub mod transaction_state_request_mock; pub mod transfer_data_extra_mock; +pub mod wallet_connect_app_metadata_mock; pub mod wallet_connect_mock; -pub mod wallet_connection_session_mock; pub mod zcash_mock; diff --git a/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs b/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs new file mode 100644 index 0000000000..db8dfa5af6 --- /dev/null +++ b/core/crates/primitives/src/testkit/transaction_app_metadata_mock.rs @@ -0,0 +1,12 @@ +use crate::TransactionAppMetadata; + +impl TransactionAppMetadata { + pub fn mock() -> Self { + TransactionAppMetadata { + name: "Test Dapp".to_string(), + description: None, + url: Some("https://example.com".to_string()), + icon: Some("https://example.com/icon.png".to_string()), + } + } +} diff --git a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs index 30aea4aa28..3c2ce082a8 100644 --- a/core/crates/primitives/src/testkit/transaction_load_input_mock.rs +++ b/core/crates/primitives/src/testkit/transaction_load_input_mock.rs @@ -1,7 +1,7 @@ use super::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER, TEST_OSMOSIS_SENDER}; use crate::{ - Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, - TransferDataOutputType, WalletConnectionSessionAppMetadata, + Asset, Chain, GasPriceType, SignerInput, TransactionAppMetadata, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, + TransferDataOutputAction, TransferDataOutputType, }; use num_bigint::BigInt; use std::collections::HashMap; @@ -208,7 +208,7 @@ impl TransactionLoadInput { TransactionLoadInput { input_type: TransactionInputType::Generic( Asset::from_chain(chain), - WalletConnectionSessionAppMetadata::mock(), + TransactionAppMetadata::mock(), TransferDataExtra { data: Some(data.as_bytes().to_vec()), output_type, diff --git a/core/crates/primitives/src/testkit/wallet_connection_session_mock.rs b/core/crates/primitives/src/testkit/wallet_connect_app_metadata_mock.rs similarity index 65% rename from core/crates/primitives/src/testkit/wallet_connection_session_mock.rs rename to core/crates/primitives/src/testkit/wallet_connect_app_metadata_mock.rs index 8a5c641742..86eaf15d14 100644 --- a/core/crates/primitives/src/testkit/wallet_connection_session_mock.rs +++ b/core/crates/primitives/src/testkit/wallet_connect_app_metadata_mock.rs @@ -1,8 +1,8 @@ -use crate::WalletConnectionSessionAppMetadata; +use crate::WalletConnectAppMetadata; -impl WalletConnectionSessionAppMetadata { +impl WalletConnectAppMetadata { pub fn mock() -> Self { - WalletConnectionSessionAppMetadata { + WalletConnectAppMetadata { name: "Test Dapp".to_string(), description: "Test Dapp".to_string(), url: "https://example.com".to_string(), diff --git a/core/crates/primitives/src/transaction_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/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift b/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift index f5fafdcd3b..1a0005a846 100644 --- a/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift +++ b/ios/Features/QRScanner/Sources/Navigation/ScanQRCodeNavigationStack.swift @@ -7,6 +7,8 @@ import SwiftUI public struct ScanQRCodeNavigationStack: View { private let resources: any QRScannerResources + @State private var scanned: String? + let action: (String) -> Void public init(action: @escaping (String) -> Void) { @@ -16,10 +18,14 @@ public struct ScanQRCodeNavigationStack: View { public var body: some View { NavigationStack { - QRScannerScene(resources: resources, action: action) + QRScannerScene(resources: resources, action: { scanned = $0 }) .navigationTitle(Localized.Wallet.scanQrCode) .navigationBarTitleDisplayMode(.inline) } + .onDisappear { + guard let scanned else { return } + action(scanned) + } } } diff --git a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift index e9927b1f52..9632c36921 100644 --- a/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift +++ b/ios/Features/WalletTab/Sources/ViewModels/WalletSceneViewModel.swift @@ -26,6 +26,7 @@ public final class WalletSceneViewModel: Sendable { private let balanceService: BalanceService private let bannerService: BannerService private let walletService: WalletService + private let payService: any PaymentLinkPayable private let balanceCalculator = BalanceCalculator() let observablePreferences: ObservablePreferences @@ -42,6 +43,7 @@ public final class WalletSceneViewModel: Sendable { public var isPresentingSelectedAssetInput: Binding public var isPresentingSheet: WalletSheetType? public var isPresentingSearch = false + public var isPresentingScanner = false public var isPresentingUrl: URL? public var isPresentingToastMessage: ToastMessage? @@ -52,6 +54,7 @@ public final class WalletSceneViewModel: Sendable { balanceService: BalanceService, bannerService: BannerService, walletService: WalletService, + payService: any PaymentLinkPayable, nftService: NFTService, observablePreferences: ObservablePreferences, wallet: Wallet, @@ -62,8 +65,9 @@ public final class WalletSceneViewModel: Sendable { self.balanceService = balanceService self.bannerService = bannerService self.walletService = walletService + self.payService = payService self.observablePreferences = observablePreferences - self.collectionsModel = CollectionsViewModel( + collectionsModel = CollectionsViewModel( nftService: nftService, walletService: walletService, wallet: wallet, @@ -74,7 +78,7 @@ public final class WalletSceneViewModel: Sendable { bannersQuery = ObservableQuery(BannersRequest(walletId: wallet.id, assetId: .none, chain: .none, events: [.accountBlockedMultiSignature, .onboarding]), initialValue: []) self.isPresentingSelectedAssetInput = isPresentingSelectedAssetInput } - + public var totalFiatValue: TotalFiatValue { balanceCalculator.totalFiatValue(fiatValuesQuery.value) } @@ -111,6 +115,14 @@ public final class WalletSceneViewModel: Sendable { Images.System.search } + public var scannerImage: Image { + Images.System.qrCodeViewfinder + } + + public var showScanner: Bool { + observablePreferences.isDeveloperEnabled + } + public var manageImage: Image { Images.Actions.manage } @@ -187,6 +199,22 @@ public extension WalletSceneViewModel { isPresentingSearch.toggle() } + func onSelectScanner() { + isPresentingScanner = true + } + + func onHandleScan(_ result: String) { + guard let action = try? URLParser.from(string: result) else { + return isPresentingToastMessage = .error(Localized.Errors.notSupported) + } + switch action { + case .deeplink, .walletConnect: + isPresentingToastMessage = .error(Localized.Errors.notSupported) + case let .payment(link): + Task { await payService.pay(link: link, wallet: wallet) } + } + } + func onSelectAddCustomToken() { isPresentingSheet = .addAsset } diff --git a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift index bbdc4323de..8630131767 100644 --- a/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift +++ b/ios/Features/WalletTab/TestKit/WalletSceneViewModel+TestKit.swift @@ -18,6 +18,7 @@ public extension WalletSceneViewModel { balanceService: .mock(), bannerService: .mock(), walletService: .mock(), + payService: .mock(), nftService: .mock(), observablePreferences: .mock(), wallet: wallet, diff --git a/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift b/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift index 084ee7f487..4401d1ae42 100644 --- a/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift +++ b/ios/Features/WalletTab/Tests/WalletSceneViewModelTests.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import BannerServiceTestKit +import Localization import Primitives import PrimitivesTestKit @testable import Store @@ -47,4 +48,18 @@ struct WalletSceneViewModelTests { #expect(model.wallet.id == .multicoin(address: "0x2")) } + + @Test + func onHandleScan() { + let model = WalletSceneViewModel.mock() + + model.onHandleScan("https://pay.walletconnect.com/?pid=pay_123") + #expect(model.isPresentingToastMessage == nil) + + model.onHandleScan("wc:abc@2?relay-protocol=irn&symKey=123") + #expect(model.isPresentingToastMessage?.title == Localized.Errors.notSupported) + + model.onHandleScan("WIFI:S:MyNet;T:WPA;P:secret;;") + #expect(model.isPresentingToastMessage?.title == Localized.Errors.notSupported) + } } diff --git a/ios/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index 2951733d2d..23da550288 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 */; }; @@ -226,6 +229,7 @@ 83E041572D0AFCB10031D4BC /* RootScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootScene.swift; sourceTree = ""; }; 83E041592D0AFCB80031D4BC /* RootSceneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootSceneViewModel.swift; sourceTree = ""; }; 83FE37B22D2715BF0048D54C /* WalletConnector */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WalletConnector; sourceTree = ""; }; + 0A11FA0000000000000000B3 /* Payments */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Payments; sourceTree = ""; }; B604C2E72F3F537800CBAFDC /* Contacts */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Contacts; sourceTree = ""; }; B67B3EEE2EFD25A6007AFA9C /* EventPresenterService */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = EventPresenterService; sourceTree = ""; }; B67ED5462DDB5DCC009F74E6 /* NavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHandler.swift; sourceTree = ""; }; @@ -324,7 +328,10 @@ buildActionMask = 2147483647; files = ( 833809D12D273E450055D91F /* WalletConnector in Frameworks */, + 0A11FA0000000000000000B1 /* Payments in Frameworks */, 833809D02D273E450055D91F /* WalletConnectorService in Frameworks */, + 0A11FA0000000000000000A1 /* PaymentService in Frameworks */, + 0A11FA0000000000000000E1 /* SigningRequestService in Frameworks */, C3D1C4E22A2A9485006E8EEA /* Signer in Frameworks */, D85AD2A62CD7017E0010DEF8 /* NativeProviderService in Frameworks */, 8361BB6D2D400E4F008D89CF /* AssetsService in Frameworks */, @@ -712,6 +719,7 @@ 833931A32D2D54060063BB6A /* PriceAlerts */, D9F015752F10654000000001 /* InAppNotifications */, 83FE37B22D2715BF0048D54C /* WalletConnector */, + 0A11FA0000000000000000B3 /* Payments */, D8D83C822CECF90A0083AA53 /* Swap */, 8393897A2CCA8F0000735088 /* InfoSheet */, E1B4C7082E85B00000EARN01 /* Stake */, @@ -855,7 +863,10 @@ 833931A72D2D663C0063BB6A /* PriceService */, B6STREAM02F5000000000002 /* StreamService */, 83385DDD2D27147900D76803 /* WalletConnectorService */, + 0A11FA0000000000000000A2 /* PaymentService */, + 0A11FA0000000000000000E2 /* SigningRequestService */, 83FE37B32D273CC80048D54C /* WalletConnector */, + 0A11FA0000000000000000B2 /* Payments */, 83EB5E2F2D2ECF00006A7CFB /* Onboarding */, 8352C8D42D393E05000DEDD9 /* ExplorerService */, 07A9FE3C2D3565CD00ACDB94 /* NFTService */, @@ -2000,6 +2011,18 @@ isa = XCSwiftPackageProductDependency; productName = WalletConnector; }; + 0A11FA0000000000000000A2 /* PaymentService */ = { + isa = XCSwiftPackageProductDependency; + productName = PaymentService; + }; + 0A11FA0000000000000000E2 /* SigningRequestService */ = { + isa = XCSwiftPackageProductDependency; + productName = SigningRequestService; + }; + 0A11FA0000000000000000B2 /* Payments */ = { + isa = XCSwiftPackageProductDependency; + productName = Payments; + }; AA0000022F26000000000002 /* ConnectionsService */ = { isa = XCSwiftPackageProductDependency; productName = ConnectionsService; diff --git a/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift b/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift index 3aca22fe4f..815a815716 100644 --- a/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift +++ b/ios/Packages/Blockchain/Sources/Protocols/ChainServiceable.swift @@ -34,7 +34,7 @@ public extension ChainFeeRateFetchable { func defaultPriority(for type: TransferDataType) -> FeePriority { switch type { case let .swap(fromAsset, _, _): fromAsset.chain == .bitcoin ? .fast : .normal - case .tokenApprove, .stake, .transfer, .deposit, .transferNft, .generic, .account, .perpetual, .withdrawal, .earn: .normal + case .tokenApprove, .stake, .transfer, .deposit, .transferNft, .generic, .payment, .account, .perpetual, .withdrawal, .earn: .normal } } } diff --git a/ios/Packages/ChainServices/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/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift new file mode 100644 index 0000000000..7d631deaf8 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/ChainAddress+GemstonePrimitives.swift @@ -0,0 +1,11 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension Primitives.ChainAddress { + func map() -> Gemstone.ChainAddress { + Gemstone.ChainAddress(chain: chain.rawValue, address: address) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift new file mode 100644 index 0000000000..85dd8c1f3e --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPayment+GemstonePrimitives.swift @@ -0,0 +1,55 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemPayment { + func map() throws -> Payment { + switch self { + case let .request(request): try .request(request.map()) + case let .link(link): .link(link.map()) + } + } +} + +public extension GemPaymentRequest { + func map() throws -> PaymentRequest { + try PaymentRequest( + address: address, + amount: amount, + memo: memo, + assetId: assetId.map { try AssetId(id: $0) }, + ) + } +} + +public extension GemPaymentLink { + func map() -> PaymentLink { + PaymentLink(provider: provider.map(), id: id) + } +} + +public extension PaymentLink { + func map() -> GemPaymentLink { + GemPaymentLink(provider: provider.map(), id: id) + } +} + +public extension PaymentProviderName { + func map() -> GemPaymentProviderName { + switch self { + case .solanaPay: .solanaPay + case .walletConnectPay: .walletConnectPay + } + } +} + +public extension GemPaymentProviderName { + func map() -> PaymentProviderName { + switch self { + case .solanaPay: .solanaPay + case .walletConnectPay: .walletConnectPay + } + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift new file mode 100644 index 0000000000..74bb55626b --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemPaymentOptions+GemstonePrimitives.swift @@ -0,0 +1,117 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemPaymentOptions { + func map() throws -> PaymentOptions { + switch self { + case let .quotes(quotes): try .quotes(quotes.map()) + case let .outcome(outcome): .outcome(outcome.map()) + } + } +} + +public extension PaymentQuotes { + func map() -> GemPaymentQuotes { + GemPaymentQuotes( + merchant: merchant.map(), + price: price?.map(), + expiresAt: expiresAt.map { Int64($0.timeIntervalSince1970) }, + quotes: quotes.map { $0.map() }, + ) + } +} + +public extension PaymentMerchant { + func map() -> GemPaymentMerchant { + GemPaymentMerchant(name: name, iconUrl: iconUrl) + } +} + +public extension PaymentPrice { + func map() -> GemPaymentPrice { + GemPaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} + +public extension GemPaymentQuotes { + func map() throws -> PaymentQuotes { + try PaymentQuotes( + merchant: merchant.map(), + price: price?.map(), + expiresAt: expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + quotes: quotes.map { try $0.map() }, + ) + } +} + +public extension GemPaymentQuote { + func map() throws -> PaymentQuote { + try PaymentQuote( + id: id, + paymentId: paymentId, + amount: amount.map(), + expiresAt: expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }, + collectDataUrl: collectDataUrl, + providerData: providerData, + ) + } +} + +public extension PaymentQuote { + func map() -> GemPaymentQuote { + GemPaymentQuote( + id: id, + paymentId: paymentId, + amount: amount.map(), + expiresAt: expiresAt.map { Int64($0.timeIntervalSince1970) }, + collectDataUrl: collectDataUrl, + providerData: providerData, + ) + } +} + +public extension PaymentAmount { + func map() -> GemPaymentAmount { + GemPaymentAmount(assetId: assetId.identifier, value: value, symbol: symbol, decimals: decimals) + } +} + +public extension GemPaymentAmount { + func map() throws -> PaymentAmount { + try PaymentAmount(assetId: AssetId(id: assetId), value: value, symbol: symbol, decimals: decimals) + } +} + +public extension GemPaymentMerchant { + func map() -> PaymentMerchant { + PaymentMerchant(name: name, iconUrl: iconUrl) + } +} + +public extension GemPaymentOutcome { + func map() -> PaymentOutcome { + PaymentOutcome(status: status.map(), transactionId: transactionId) + } +} + +public extension GemPaymentStatus { + func map() -> PaymentStatus { + switch self { + case .requiresAction: .requiresAction + case .processing: .processing + case .succeeded: .succeeded + case .failed: .failed + case .expired: .expired + case .cancelled: .cancelled + } + } +} + +public extension GemPaymentPrice { + func map() -> PaymentPrice { + PaymentPrice(symbol: symbol, value: value, decimals: decimals) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift index c5ee1a0be0..d4990bcf4b 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemTransactionInputType+GemstonePrimitives.swift @@ -38,8 +38,8 @@ public extension GemTransactionInputType { try TransferDataType.stake(asset.map(), type.map()) case let .tokenApprove(asset, approvalData): try TransferDataType.tokenApprove(asset.map(), approvalData.map()) - case let .generic(asset, metadata, extra): - try TransferDataType.generic(asset: asset.map(), metadata: metadata.map(), extra: extra.map()) + case let .generic(asset, appMetadata, extra): + try TransferDataType.generic(asset: asset.map(), appMetadata: appMetadata.map(), extra: extra.map()) case let .account(asset, accountType): try TransferDataType.account(asset.map(), accountType.map()) case let .perpetual(asset: asset, perpetualType: perpetualType): @@ -67,8 +67,10 @@ public extension TransferDataType { return .stake(asset: asset.map(), stakeType: stakeType.map()) case let .tokenApprove(asset, approvalData): return .tokenApprove(asset: asset.map(), approvalData: approvalData.map()) - case let .generic(asset, metadata, extra): - return .generic(asset: asset.map(), metadata: metadata.map(), extra: extra.map()) + case let .generic(asset, appMetadata, extra): + return .generic(asset: asset.map(), appMetadata: appMetadata.map(), extra: extra.map()) + case let .payment(asset, payment, extra): + return .generic(asset: asset.map(), appMetadata: TransactionAppMetadata(merchant: payment.merchant).map(), extra: extra.map()) case let .withdrawal(asset): if asset.chain == .hyperCore { return .withdrawal(asset: asset.map()) diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectAppMetadata+GemstonePrimitives.swift similarity index 51% rename from ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift rename to ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectAppMetadata+GemstonePrimitives.swift index 0e9e2aa71b..fc750ccbb5 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectionSessionAppMetadata+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/GemWalletConnectAppMetadata+GemstonePrimitives.swift @@ -4,9 +4,9 @@ import Foundation import Gemstone import Primitives -public extension GemWalletConnectionSessionAppMetadata { - func map() -> WalletConnectionSessionAppMetadata { - WalletConnectionSessionAppMetadata( +public extension GemWalletConnectAppMetadata { + func map() -> WalletConnectAppMetadata { + WalletConnectAppMetadata( name: name, description: description, url: url, @@ -15,9 +15,9 @@ public extension GemWalletConnectionSessionAppMetadata { } } -public extension WalletConnectionSessionAppMetadata { - func map() -> GemWalletConnectionSessionAppMetadata { - GemWalletConnectionSessionAppMetadata( +public extension WalletConnectAppMetadata { + func map() -> GemWalletConnectAppMetadata { + GemWalletConnectAppMetadata( name: name, description: description, url: url, @@ -26,6 +26,6 @@ public extension WalletConnectionSessionAppMetadata { } var shortName: String { - Gemstone.walletConnectAppShortName(metadata: map()) + Gemstone.walletConnectAppShortName(name: name) } } diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift new file mode 100644 index 0000000000..be786334c6 --- /dev/null +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransactionAppMetadata+GemstonePrimitives.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +public extension GemTransactionAppMetadata { + func map() -> TransactionAppMetadata { + TransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } +} + +public extension TransactionAppMetadata { + func map() -> GemTransactionAppMetadata { + GemTransactionAppMetadata(name: name, description: description, url: url, icon: icon) + } + + var shortName: String { + Gemstone.walletConnectAppShortName(name: name) + } +} + +public extension TransactionAppMetadata { + init(merchant: PaymentMerchant) { + self.init(name: merchant.name, description: .none, url: Gemstone.paymentWalletConnectUrl(), icon: merchant.iconUrl) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift index 1f8bb4dbf5..325fa70774 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/TransferDataType+GemstonePrimitives.swift @@ -15,7 +15,8 @@ public extension TransferDataType { let .perpetual(asset, _), let .earn(asset, _, _), let .tokenApprove(asset, _), - let .generic(asset, _, _): + let .generic(asset, _, _), + let .payment(asset, _, _): asset case let .transferNft(asset): asset.chain.asset diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift index 66d181418c..4552ebf395 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/UrlAction+GemstonePrimitives.swift @@ -7,6 +7,7 @@ public extension Gemstone.UrlAction { func map() throws -> Primitives.URLAction { switch self { case let .deeplink(deeplink): try .deeplink(deeplink.map()) + case let .payment(link): .payment(link.map()) case let .walletConnect(link): .walletConnect(link.map()) } } diff --git a/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift b/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift index c7411f8139..dcc007755f 100644 --- a/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift +++ b/ios/Packages/GemstonePrimitives/Sources/PaymentURLDecoder.swift @@ -2,10 +2,10 @@ import Foundation import func Gemstone.paymentDecodeUrl -import struct Gemstone.PaymentWrapper +import Primitives public enum PaymentURLDecoder { - public static func decode(_ string: String) throws -> PaymentWrapper { - try paymentDecodeUrl(string: string) + public static func decode(_ string: String) throws -> Payment { + try paymentDecodeUrl(string: string).map() } } diff --git a/ios/Packages/GemstonePrimitives/Sources/URLParser.swift b/ios/Packages/GemstonePrimitives/Sources/URLParser.swift index 8fe4e73261..890ecb6267 100644 --- a/ios/Packages/GemstonePrimitives/Sources/URLParser.swift +++ b/ios/Packages/GemstonePrimitives/Sources/URLParser.swift @@ -6,13 +6,17 @@ import func Gemstone.urlAction import Primitives enum URLParserError: Error { - case invalidURL(URL) + case unsupported(String) } public enum URLParser { public static func from(url: URL) throws -> URLAction { - guard let action = urlAction(url: url.absoluteString) else { - throw URLParserError.invalidURL(url) + try from(string: url.absoluteString) + } + + public static func from(string: String) throws -> URLAction { + guard let action = urlAction(url: string) else { + throw URLParserError.unsupported(string) } return try action.map() } diff --git a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift index 1971ad2231..5e7bf9bf54 100644 --- a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift +++ b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/PaymentURLDecoderTests.swift @@ -1,40 +1,37 @@ // Copyright (c). Gem Wallet. All rights reserved. -import struct Gemstone.PaymentWrapper @testable import GemstonePrimitives +import Primitives +import PrimitivesTestKit import Testing final class PaymentURLDecoderTests { @Test func testAddress() throws { let result = try PaymentURLDecoder.decode("0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326") - #expect(result == PaymentWrapper( - address: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326", - amount: .none, - memo: .none, - assetId: .none, - paymentLink: .none, - )) + #expect(result == .request(.mock(address: "0x1f9090aaE28b8a3dCeaDf281B0F12828e676c326"))) } @Test func solana() throws { let result1 = try PaymentURLDecoder.decode("HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5") - #expect(result1 == PaymentWrapper( - address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5", - amount: .none, - memo: .none, - assetId: .none, - paymentLink: .none, - )) + #expect(result1 == .request(.mock(address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5"))) let result2 = try PaymentURLDecoder.decode("solana:HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5?amount=0.266232") - #expect(result2 == PaymentWrapper( + #expect(result2 == .request(.mock( address: "HA4hQMs22nCuRN7iLDBsBkboz2SnLM1WkNtzLo6xEDY5", amount: "0.266232", - memo: .none, - assetId: "solana", - paymentLink: .none, - )) + assetId: .mockSolana(), + ))) + } + + @Test + func links() throws { + let solanaPay = "https://api.spherepay.co/v1/public/paymentLink/pay/paymentLink_1" + let result1 = try PaymentURLDecoder.decode("solana:https%3A%2F%2Fapi.spherepay.co%2Fv1%2Fpublic%2FpaymentLink%2Fpay%2FpaymentLink_1") + #expect(result1 == .link(PaymentLink(provider: .solanaPay, id: solanaPay))) + + let result2 = try PaymentURLDecoder.decode("https://pay.walletconnect.com/?pid=pay_123") + #expect(result2 == .link(PaymentLink(provider: .walletConnectPay, id: "pay_123"))) } } diff --git a/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift b/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift index 98478ee161..5dc8bede63 100644 --- a/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift +++ b/ios/Packages/Store/Sources/Models/WalletConnectionRecord.swift @@ -87,15 +87,15 @@ extension WalletConnection { } extension WalletConnectionRecord { - var session: WalletConnectionSession { - WalletConnectionSession( + var session: WalletConnectSession { + WalletConnectSession( id: sessionId, sessionId: sessionId, state: state, chains: chains ?? [], createdAt: createdAt, expireAt: expireAt, - metadata: WalletConnectionSessionAppMetadata( + metadata: WalletConnectAppMetadata( name: appName, description: appDescription, url: appLink, @@ -104,7 +104,7 @@ extension WalletConnectionRecord { ) } - func update(with session: WalletConnectionSession) -> WalletConnectionRecord { + func update(with session: WalletConnectSession) -> WalletConnectionRecord { WalletConnectionRecord( id: session.sessionId, sessionId: session.sessionId, diff --git a/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift b/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift index 37f61e5a3b..6d4443171b 100644 --- a/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift +++ b/ios/Packages/Store/Sources/Stores/ConnectionsStore.swift @@ -35,7 +35,7 @@ public struct ConnectionsStore: Sendable { } } - public func getSessions() throws -> [WalletConnectionSession] { + public func getSessions() throws -> [WalletConnectSession] { try db.read { db in try WalletConnectionRecord .fetchAll(db) @@ -43,7 +43,7 @@ public struct ConnectionsStore: Sendable { } } - public func updateConnectionSession(_ session: WalletConnectionSession) throws { + public func updateConnectionSession(_ session: WalletConnectSession) throws { let connection = try getConnection(id: session.id).update(with: session) try db.write { db in try connection.upsert(db) From bade0b3be6c8239f02cf388ff24dc511c41f26f6 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:50:38 +0300 Subject: [PATCH 16/27] iOS: run the payment tests in the framework plan PaymentsTests was reachable only from the scheme, so the plan CI runs would have skipped it silently. --- ios/GemTests/unit_frameworks.xctestplan | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ios/GemTests/unit_frameworks.xctestplan b/ios/GemTests/unit_frameworks.xctestplan index 2fb124c82f..552e2c57ee 100644 --- a/ios/GemTests/unit_frameworks.xctestplan +++ b/ios/GemTests/unit_frameworks.xctestplan @@ -182,6 +182,13 @@ "name" : "TransferServiceTests" } }, + { + "target" : { + "containerPath" : "container:Features\/Payments", + "identifier" : "PaymentsTests", + "name" : "PaymentsTests" + } + }, { "target" : { "containerPath" : "container:Packages\/GemAPI", From a8978ba8e89d6557ff6d072e5cbe9e95368f88b3 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:48:38 +0300 Subject: [PATCH 17/27] iOS: move the signing sheets out of WalletConnector The transfer and sign-message sheets are the wallet's own approval screens, not WalletConnect's: payments drive them too. SigningRequestService now owns SigningRequestSheetType and the SheetPresenter that serializes presentation, so WalletConnectorSheetType is only the connection proposal and every host shares one dismissal gate. --- .../Scenes/PaymentNavigationStack.swift | 5 +- .../Services/PaymentSheetPresenter.swift | 96 +-------------- .../Payments/Types/PaymentSheetType.swift | 4 + .../PaymentSheetPresenterTests.swift | 67 ----------- .../WalletConnectorInteractable.swift | 2 +- .../ConnectionProposalNavigationStack.swift | 44 +++++++ .../Scenes/ConnectionsScene.swift | 2 +- .../Services/WalletConnectorManager.swift | 42 +------ .../Services/WalletConnectorPresenter.swift | 69 +---------- .../Services/WalletConnectorSigner.swift | 10 +- .../Types/WalletConnectorSheetType.swift | 8 +- .../WalletConnectorPresenterTests.swift | 92 -------------- .../WalletConnectorSignerTests.swift | 2 + ios/Gem.xcodeproj/project.pbxproj | 8 +- ios/Gem/App.swift | 2 + ...ft => SigningRequestNavigationStack.swift} | 19 +-- ios/Gem/Scenes/RootScene.swift | 18 ++- ios/Gem/Services/AppResolver+Services.swift | 4 + ios/Gem/Services/ServicesFactory.swift | 7 +- ios/Gem/ViewModels/RootSceneViewModel.swift | 17 ++- ios/GemTests/unit_frameworks.xctestplan | 7 ++ ios/Packages/ChainServices/Package.swift | 11 +- .../SheetPresenter.swift | 97 +++++++++++++++ .../SigningRequestPresenter.swift | 22 ++++ .../SigningRequestSheetType.swift | 27 +++++ .../Tests/SheetPresenterTests.swift | 112 ++++++++++++++++++ 26 files changed, 395 insertions(+), 399 deletions(-) delete mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift create mode 100644 ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift delete mode 100644 ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift rename ios/Gem/Navigation/WalletConnector/{WalletConnectorNavigationStack.swift => SigningRequestNavigationStack.swift} (73%) create mode 100644 ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift create mode 100644 ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift index fb6935b225..192010228e 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift @@ -1,15 +1,16 @@ // Copyright (c). Gem Wallet. All rights reserved. +import SigningRequestService import Style import SwiftUI public struct PaymentNavigationStack: View { private let type: PaymentSheetType - private let presenter: PaymentSheetPresenter + private let presenter: SheetPresenter public init( type: PaymentSheetType, - presenter: PaymentSheetPresenter, + presenter: SheetPresenter, ) { self.type = type self.presenter = presenter diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift index b111a332bc..32f038f0aa 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift @@ -2,108 +2,18 @@ import Foundation import PaymentService -import Primitives import SigningRequestService -import SwiftUI -@Observable public final class PaymentSheetPresenter: PaymentSheetPresentable, Sendable { - @MainActor - public var isPresentingSheet: PaymentSheetType? - @MainActor - private var isDismissingSheet: Bool = false - @MainActor - private var dismissals: [CheckedContinuation] = [] + public let sheets = SheetPresenter() public init() {} public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { - try await present(payload: request, sheet: { .quotes($0) }) + try await sheets.present(payload: request, sheet: { .quotes($0) }) } public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { - try await present(payload: request, sheet: { .dataCollection($0) }) - } - - @MainActor - public func complete(type: PaymentSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } - dismiss() - } - - @MainActor - public func cancelSheet(type: PaymentSheetType) { - 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 PaymentSheetPresenter { - private func present( - payload: T, - sheet: @Sendable @escaping (SigningRequestCallback) -> PaymentSheetType, - ) async throws -> String where T.ID == String { - let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) - let callback = SigningRequestCallback(payload: payload) { - continuation.yield(with: $0) - continuation.finish() - } - await present(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 - private func present(sheet: PaymentSheetType) 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) } + try await sheets.present(payload: request, sheet: { .dataCollection($0) }) } } diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift index 69b5d36af3..37510f083c 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -23,3 +23,7 @@ public enum PaymentSheetType: Sendable, Identifiable { } } } + +// MARK: - SigningRequestRejectable + +extension PaymentSheetType: SigningRequestRejectable {} diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift deleted file mode 100644 index 6c946e13f8..0000000000 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresenterTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import PaymentService -import PaymentServiceTestKit -@testable import Payments -import Primitives -import PrimitivesTestKit -import SigningRequestService -import Testing - -struct PaymentSheetPresenterTests { - @Test - @MainActor - func selectPaymentQuoteReturnsTheSelectionOnceTheSheetHasClosed() async throws { - let presenter = PaymentSheetPresenter() - let selection = Task { @MainActor in - try await presenter.selectPaymentQuote(request: Self.request()) - } - try await Self.wait { presenter.isPresentingSheet != nil } - - guard case let .quotes(callback) = presenter.isPresentingSheet else { - Issue.record("quotes sheet is not presented") - return - } - callback.delegate(.success("quote_2")) - try await Self.wait { presenter.isPresentingSheet == nil } - presenter.onSheetDismiss() - - #expect(try await selection.value == "quote_2") - } - - @Test - @MainActor - func cancelSheetFailsTheRequestWithUserCancelled() async throws { - let presenter = PaymentSheetPresenter() - let selection = Task { @MainActor in - try await presenter.selectPaymentQuote(request: Self.request()) - } - try await Self.wait { presenter.isPresentingSheet != nil } - - guard let sheet = presenter.isPresentingSheet else { - Issue.record("quotes sheet is not presented") - return - } - presenter.cancelSheet(type: sheet) - presenter.onSheetDismiss() - - await #expect(throws: SigningRequestError.userCancelled) { - try await selection.value - } - } - - private static func request() -> PaymentQuotesRequest { - PaymentQuotesRequest(id: "pay_1", quotes: .mock(), wallet: .mock(), assetsData: []) - } - - private static func wait(until condition: @MainActor () -> Bool) async throws { - for _ in 0 ..< 100 { - if await condition() { - return - } - await Task.yield() - } - throw AnyError("condition never became true") - } -} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift index f229f56802..e157c6abbb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Protocols/WalletConnectorInteractable.swift @@ -4,7 +4,7 @@ import Foundation import Primitives import SigningRequestService -public protocol WalletConnectorInteractable: SigningRequestInteractable { +public protocol WalletConnectorInteractable: Sendable { func sessionReject(error: any Error) async func sessionApproval(payload: WCPairingProposal) async throws -> WalletId func sendRawTransaction(transferData: SigningTransferData) async throws -> String diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift new file mode 100644 index 0000000000..814f77cd67 --- /dev/null +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift @@ -0,0 +1,44 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SigningRequestService +import Style +import SwiftUI + +public struct ConnectionProposalNavigationStack: View { + private let type: WalletConnectorSheetType + private let presenter: SheetPresenter + + public init( + type: WalletConnectorSheetType, + presenter: SheetPresenter, + ) { + self.type = type + self.presenter = presenter + } + + public var body: some View { + NavigationStack { + Group { + switch type { + case let .connectionProposal(data): + ConnectionProposalScene( + model: ConnectionProposalViewModel( + confirmTransferDelegate: data.delegate, + pairingProposal: data.payload, + ), + onComplete: { presenter.complete(type: type) }, + ) + } + } + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("", systemImage: SystemImage.xmark) { + presenter.cancelSheet(type: type) + } + } + } + } + } +} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift index 6909a54eff..b0fb4b7bf1 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift @@ -76,6 +76,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/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index 6c3f7eae54..dbc2a234d3 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift @@ -27,51 +27,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: SigningTransferData) async throws -> String { - try await presentSheet(payload: transferData, sheetType: { .transferData($0) }) - } - - public func signTransaction(transferData: SigningTransferData) async throws -> String { - try await presentSheet(payload: transferData, sheetType: { .transferData($0) }) - } - public func sendRawTransaction(transferData _: SigningTransferData) async throws -> String { throw AnyError.notImplemented } - - // MARK: - Private - - private func presentSheet( - payload: T, - sheetType: @Sendable @escaping (SigningRequestCallback) -> WalletConnectorSheetType, - ) async throws -> String { - let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) - - let callback = SigningRequestCallback(payload: payload) { - continuation.yield(with: $0) - continuation.finish() - } - - await presenter.present(sheet: sheetType(callback)) - - do { - for try await value in stream { - await presenter.dismissPresentedSheet() - return value - } - } catch { - await presenter.dismissPresentedSheet() - throw error - } - await presenter.dismissPresentedSheet() - throw SigningRequestError.userCancelled - } } diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift index d1b69a494f..9f6890c1eb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,79 +1,16 @@ // Copyright (c). Gem Wallet. All rights reserved. -import PaymentService +import Foundation import SigningRequestService -import SwiftUI @Observable public final class WalletConnectorPresenter: Sendable { + public let sheets = SheetPresenter() + @MainActor public var isPresentingError: String? @MainActor public var isPresentingConnectionBar: Bool = false - @MainActor - public var isPresentingSheet: WalletConnectorSheetType? - @MainActor - private var isDismissingSheet: Bool = false - @MainActor - private var dismissals: [CheckedContinuation] = [] public init() {} - - @MainActor - public func present(sheet: WalletConnectorSheetType) async { - await waitForDismiss() - isPresentingSheet = sheet - } - - @MainActor - public func dismissPresentedSheet() async { - dismiss() - await waitForDismiss() - } - - @MainActor - public func complete(type: WalletConnectorSheetType) { - guard isPresentingSheet?.id == type.id else { - return - } - dismiss() - } - - @MainActor - public func cancelSheet(type: WalletConnectorSheetType) { - 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 WalletConnectorPresenter { - @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/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index 0ada27397c..8092db9ae2 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 GemstonePrimitives import Preferences -import PaymentService import Primitives import SigningRequestService import Store @@ -17,15 +16,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 } @@ -97,7 +99,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { message: message, simulation: simulation, ) - return try await walletConnectorInteractor.signMessage(payload: payload) + return try await signingInteractor.signMessage(payload: payload) } public func updateSessions(sessions: [WalletConnectSession]) throws { @@ -136,7 +138,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { transaction: transaction, outputAction: .sign, ) - return try await walletConnectorInteractor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) + return try await signingInteractor.signTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) } } @@ -151,7 +153,7 @@ public final class WalletConnectorSigner: WalletConnectorSignable { transaction: transaction, outputAction: .send, ) - return try await walletConnectorInteractor.sendTransaction(transferData: SigningTransferData(transferData: transferData, wallet: wallet, simulation: simulation)) + 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 { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index eb496dd8d5..9c5d6d65d9 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -6,8 +6,6 @@ import SigningRequestService import WalletConnectorService public enum WalletConnectorSheetType: Sendable, Identifiable { - case transferData(SigningRequestCallback) - case signMessage(SigningRequestCallback) case connectionProposal(SigningRequestCallback) public var id: String { @@ -20,9 +18,11 @@ public enum WalletConnectorSheetType: Sendable, Identifiable { private var callback: any SigningRequestRejectable { switch self { - case let .transferData(callback): callback - case let .signMessage(callback): callback case let .connectionProposal(callback): callback } } } + +// MARK: - SigningRequestRejectable + +extension WalletConnectorSheetType: SigningRequestRejectable {} diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift deleted file mode 100644 index b880d70719..0000000000 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorPresenterTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation -import struct Gemstone.SignMessage -import Primitives -import SigningRequestService -import PrimitivesTestKit -import SigningRequestServiceTestKit -import Testing -@testable import WalletConnector -import WalletConnectorService - -struct WalletConnectorPresenterTests { - @Test - @MainActor - func completeDismissesSignMessageSheet() { - let presenter = WalletConnectorPresenter() - let type = Self.sheet(id: "request") - - presenter.isPresentingSheet = type - presenter.complete(type: type) - - #expect(presenter.isPresentingSheet == nil) - } - - @Test - @MainActor - func dismissPresentedSheetWaitsForTheSheetToReportItClosed() async { - let presenter = WalletConnectorPresenter() - presenter.isPresentingSheet = Self.sheet(id: "request") - - let dismissed = Task { @MainActor in - await presenter.dismissPresentedSheet() - return true - } - await Task.yield() - - #expect(presenter.isPresentingSheet == nil) - #expect(!dismissed.isCancelled) - - presenter.onSheetDismiss() - - #expect(await dismissed.value) - } - - @Test - @MainActor - func presentWaitsUntilThePreviousSheetHasClosed() async { - let presenter = WalletConnectorPresenter() - presenter.isPresentingSheet = Self.sheet(id: "first") - - let dismissed = Task { @MainActor in await presenter.dismissPresentedSheet() } - await Task.yield() - - let next = Self.sheet(id: "second") - let presented = Task { @MainActor in await presenter.present(sheet: next) } - await Task.yield() - - #expect(presenter.isPresentingSheet == nil) - - presenter.onSheetDismiss() - await dismissed.value - await presented.value - - #expect(presenter.isPresentingSheet?.id == "second") - } - - @Test - @MainActor - func presentDoesNotWaitWhenNoSheetIsShowing() async { - let presenter = WalletConnectorPresenter() - let type = Self.sheet(id: "request") - - await presenter.present(sheet: type) - - #expect(presenter.isPresentingSheet?.id == "request") - } - - private static func sheet(id: String) -> WalletConnectorSheetType { - .signMessage( - SigningRequestCallback( - payload: SignMessagePayload( - id: id, - chain: .ethereum, - appMetadata: .mock(), - wallet: .mock(), - message: SignMessage(chain: "ethereum", signType: .eip191, data: Data("test".utf8)), - simulation: .mock(), - ), - delegate: { _ in }, - ), - ) - } -} diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift index bb04fe622d..0315958a02 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: SigningRequestPresenter(), ) } diff --git a/ios/Gem.xcodeproj/project.pbxproj b/ios/Gem.xcodeproj/project.pbxproj index 23da550288..d8ef6ecf1d 100644 --- a/ios/Gem.xcodeproj/project.pbxproj +++ b/ios/Gem.xcodeproj/project.pbxproj @@ -37,7 +37,7 @@ 8361BB6D2D400E4F008D89CF /* AssetsService in Frameworks */ = {isa = PBXBuildFile; productRef = 8361BB6C2D400E4F008D89CF /* AssetsService */; }; 836281B02DEF34DC00CA5C75 /* Assets in Frameworks */ = {isa = PBXBuildFile; productRef = 836281AF2DEF34DC00CA5C75 /* Assets */; }; 837A1E6B2D0B029200733AC1 /* AppResolver+ViewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */; }; - 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */; }; + 837A1E6D2D0B096600733AC1 /* SigningRequestNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837A1E6C2D0B096600733AC1 /* SigningRequestNavigationStack.swift */; }; 83863EE72D19B93E005048A7 /* NodeService in Frameworks */ = {isa = PBXBuildFile; productRef = 83863EE62D19B93E005048A7 /* NodeService */; }; 839C794B2D1C42A500E32072 /* PrimitivesComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 839C794A2D1C42A500E32072 /* PrimitivesComponents */; }; 83B08BDB2D0B4AE200CA1B33 /* AppResolver+Storages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B08BDA2D0B4AE200CA1B33 /* AppResolver+Storages.swift */; }; @@ -217,7 +217,7 @@ 8361BB5C2D3FDBF1008D89CF /* Transactions */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Transactions; sourceTree = ""; }; 836281AD2DEF32F600CA5C75 /* Assets */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Assets; sourceTree = ""; }; 837A1E6A2D0B029200733AC1 /* AppResolver+ViewInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppResolver+ViewInjection.swift"; sourceTree = ""; }; - 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectorNavigationStack.swift; sourceTree = ""; }; + 837A1E6C2D0B096600733AC1 /* SigningRequestNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SigningRequestNavigationStack.swift; sourceTree = ""; }; 837D7DE62E005D2800BBBDDA /* Formatters */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Formatters; sourceTree = ""; }; 838383112D5E677A00D1CAF2 /* WalletTab */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WalletTab; sourceTree = ""; }; 838B9B9C2F0C120100380F29 /* Recents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Recents; sourceTree = ""; }; @@ -485,7 +485,7 @@ 8361BB6F2D402C63008D89CF /* WalletConnector */ = { isa = PBXGroup; children = ( - 837A1E6C2D0B096600733AC1 /* WalletConnectorNavigationStack.swift */, + 837A1E6C2D0B096600733AC1 /* SigningRequestNavigationStack.swift */, ); path = WalletConnector; sourceTree = ""; @@ -1190,7 +1190,7 @@ FADEC0DE000000000000B002 /* AddContactNavigationView.swift in Sources */, B67ED5482DDB5DCD009F74E6 /* NavigationPresenter.swift in Sources */, C366790A2A0B7E5800F1D74D /* Environment.swift in Sources */, - 837A1E6D2D0B096600733AC1 /* WalletConnectorNavigationStack.swift in Sources */, + 837A1E6D2D0B096600733AC1 /* SigningRequestNavigationStack.swift in Sources */, D8D5C1D12E6CAC16007628A1 /* Errors.swift in Sources */, D8C4A3762C8CF956006FABE8 /* StakeNavigationView.swift in Sources */, D8C4A3772C8CF957006FABE8 /* EarnNavigationView.swift in Sources */, diff --git a/ios/Gem/App.swift b/ios/Gem/App.swift index bdba0fd6cb..6431799f4d 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -8,6 +8,7 @@ import LockManager import NodeService import Preferences import Primitives +import SigningRequestService import Store import Style import SwiftUI @@ -30,6 +31,7 @@ struct GemApp: App { observablePreferences: resolver.storages.observablePreferences, walletConnectorPresenter: resolver.services.walletConnectorManager.presenter, paymentSheetPresenter: resolver.services.paymentSheetPresenter, + signingRequestPresenter: resolver.services.signingRequestPresenter, onstartService: resolver.services.onstartService, onstartWalletService: resolver.services.onstartWalletService, transactionStateScheduler: resolver.services.transactionStateScheduler, diff --git a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift b/ios/Gem/Navigation/WalletConnector/SigningRequestNavigationStack.swift similarity index 73% rename from ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift rename to ios/Gem/Navigation/WalletConnector/SigningRequestNavigationStack.swift index 82f8fb9edb..75d8fd016c 100644 --- a/ios/Gem/Navigation/WalletConnector/WalletConnectorNavigationStack.swift +++ b/ios/Gem/Navigation/WalletConnector/SigningRequestNavigationStack.swift @@ -3,21 +3,22 @@ import ExplorerService import Primitives import Signer +import SigningRequestService import Style import SwiftUI import TransactionStateService import Transfer import WalletConnector -struct WalletConnectorNavigationStack: View { +struct SigningRequestNavigationStack: View { @Environment(\.viewModelFactory) private var viewModelFactory - private let type: WalletConnectorSheetType - private let presenter: WalletConnectorPresenter + private let type: SigningRequestSheetType + private let presenter: SheetPresenter init( - type: WalletConnectorSheetType, - presenter: WalletConnectorPresenter, + type: SigningRequestSheetType, + presenter: SheetPresenter, ) { self.type = type self.presenter = presenter @@ -45,14 +46,6 @@ struct WalletConnectorNavigationStack: View { ), onComplete: { presenter.complete(type: type) }, ) - case let .connectionProposal(data): - ConnectionProposalScene( - model: ConnectionProposalViewModel( - confirmTransferDelegate: data.delegate, - pairingProposal: data.payload, - ), - onComplete: { presenter.complete(type: type) }, - ) } } .interactiveDismissDisabled(true) diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index a4baf09e06..637edea778 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -7,8 +7,10 @@ import Onboarding import Payments import PriceService import Primitives +import SigningRequestService import Style import SwiftUI +import WalletConnector struct RootScene: View { @Environment(\.scenePhase) private var scenePhase @@ -35,16 +37,22 @@ struct RootScene: View { await model.handleOpenUrl(url) } } - .sheet(item: $model.isPresentingConnectorSheet, onDismiss: model.walletConnectorPresenter.onSheetDismiss) { type in - WalletConnectorNavigationStack( + .sheet(item: $model.isPresentingConnectorSheet, onDismiss: model.walletConnectorPresenter.sheets.onSheetDismiss) { type in + ConnectionProposalNavigationStack( type: type, - presenter: model.walletConnectorPresenter, + presenter: model.walletConnectorPresenter.sheets, ) } - .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.onSheetDismiss) { type in + .sheet(item: $model.isPresentingSigningRequestSheet, onDismiss: model.signingRequestPresenter.sheets.onSheetDismiss) { type in + SigningRequestNavigationStack( + type: type, + presenter: model.signingRequestPresenter.sheets, + ) + } + .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.sheets.onSheetDismiss) { type in PaymentNavigationStack( type: type, - presenter: model.paymentSheetPresenter, + presenter: model.paymentSheetPresenter.sheets, ) } .sheet(isPresented: $model.isPresentingCreateWalletSheet) { diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index 89f9489872..692e19520c 100644 --- a/ios/Gem/Services/AppResolver+Services.swift +++ b/ios/Gem/Services/AppResolver+Services.swift @@ -28,6 +28,7 @@ import PriceService import RewardsService import ServiceStatusService import ScanService +import SigningRequestService import StakeService import StreamService import SupportChatService @@ -80,6 +81,7 @@ extension AppResolver { let walletConnectorManager: WalletConnectorManager let paymentLinkManager: PaymentLinkManager let paymentSheetPresenter: PaymentSheetPresenter + let signingRequestPresenter: SigningRequestPresenter let perpetualService: PerpetualService let hyperliquidObserverService: any PerpetualObservable let nameService: NameService @@ -137,6 +139,7 @@ extension AppResolver { walletConnectorManager: WalletConnectorManager, paymentLinkManager: PaymentLinkManager, paymentSheetPresenter: PaymentSheetPresenter, + signingRequestPresenter: SigningRequestPresenter, perpetualService: PerpetualService, hyperliquidObserverService: any PerpetualObservable, nameService: NameService, @@ -194,6 +197,7 @@ extension AppResolver { self.walletConnectorManager = walletConnectorManager self.paymentLinkManager = paymentLinkManager self.paymentSheetPresenter = paymentSheetPresenter + self.signingRequestPresenter = signingRequestPresenter self.perpetualService = perpetualService self.hyperliquidObserverService = hyperliquidObserverService self.nameService = nameService diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index aa7a14771a..8c377af714 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -240,10 +240,12 @@ struct ServicesFactory { let presenter = WalletConnectorPresenter() let walletConnectorManager = WalletConnectorManager(presenter: presenter) + let signingRequestPresenter = SigningRequestPresenter() let connectionsService = Self.makeConnectionsService( connectionsStore: storeManager.connectionsStore, walletSessionService: walletSessionService, interactor: walletConnectorManager, + signingInteractor: signingRequestPresenter, nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider, ) @@ -343,7 +345,7 @@ struct ServicesFactory { paymentManager: PaymentManager( service: paymentService, executor: PaymentActionExecutor( - interactor: walletConnectorManager, + interactor: signingRequestPresenter, simulator: SigningSimulator(nodeProvider: nodeProvider, requestInterceptor: nodeAuthProvider), approvalExecutor: PaymentApprovalExecutor( keystore: storages.keystore, @@ -428,6 +430,7 @@ struct ServicesFactory { walletConnectorManager: walletConnectorManager, paymentLinkManager: paymentLinkManager, paymentSheetPresenter: paymentSheetPresenter, + signingRequestPresenter: signingRequestPresenter, perpetualService: perpetualService, hyperliquidObserverService: hyperliquidObserverService, nameService: nameService, @@ -622,6 +625,7 @@ extension ServicesFactory { connectionsStore: ConnectionsStore, walletSessionService: WalletSessionService, interactor: any WalletConnectorInteractable, + signingInteractor: any SigningRequestInteractable, nodeProvider: any NodeURLFetchable, requestInterceptor: any RequestInterceptable, ) -> ConnectionsService { @@ -631,6 +635,7 @@ extension ServicesFactory { connectionsStore: connectionsStore, walletSessionService: walletSessionService, walletConnectorInteractor: interactor, + signingInteractor: signingInteractor, ), nodeProvider: nodeProvider, requestInterceptor: requestInterceptor, diff --git a/ios/Gem/ViewModels/RootSceneViewModel.swift b/ios/Gem/ViewModels/RootSceneViewModel.swift index 38f4ccd39f..90e5deceea 100644 --- a/ios/Gem/ViewModels/RootSceneViewModel.swift +++ b/ios/Gem/ViewModels/RootSceneViewModel.swift @@ -15,6 +15,7 @@ import Onboarding import Payments import Preferences import Primitives +import SigningRequestService import SwiftUI import TransactionsService import TransactionStateService @@ -43,6 +44,7 @@ final class RootSceneViewModel { let avatarService: AvatarService let walletConnectorPresenter: WalletConnectorPresenter let paymentSheetPresenter: PaymentSheetPresenter + let signingRequestPresenter: SigningRequestPresenter let lockManager: any LockWindowManageable var currentWallet: Wallet? { walletService.currentWallet @@ -61,13 +63,18 @@ final class RootSceneViewModel { } var isPresentingConnectorSheet: WalletConnectorSheetType? { - get { walletConnectorPresenter.isPresentingSheet } - set { walletConnectorPresenter.isPresentingSheet = newValue } + get { walletConnectorPresenter.sheets.isPresentingSheet } + set { walletConnectorPresenter.sheets.isPresentingSheet = newValue } + } + + var isPresentingSigningRequestSheet: SigningRequestSheetType? { + get { signingRequestPresenter.sheets.isPresentingSheet } + set { signingRequestPresenter.sheets.isPresentingSheet = newValue } } var isPresentingPaymentSheet: PaymentSheetType? { - get { paymentSheetPresenter.isPresentingSheet } - set { paymentSheetPresenter.isPresentingSheet = newValue } + get { paymentSheetPresenter.sheets.isPresentingSheet } + set { paymentSheetPresenter.sheets.isPresentingSheet = newValue } } var isPresentingConnectorBar: Bool { @@ -86,6 +93,7 @@ final class RootSceneViewModel { observablePreferences: ObservablePreferences, walletConnectorPresenter: WalletConnectorPresenter, paymentSheetPresenter: PaymentSheetPresenter, + signingRequestPresenter: SigningRequestPresenter, onstartService: OnstartService, onstartWalletService: OnstartWalletService, transactionStateScheduler: TransactionStateScheduler, @@ -106,6 +114,7 @@ final class RootSceneViewModel { self.observablePreferences = observablePreferences self.walletConnectorPresenter = walletConnectorPresenter self.paymentSheetPresenter = paymentSheetPresenter + self.signingRequestPresenter = signingRequestPresenter self.onstartService = onstartService self.onstartWalletService = onstartWalletService self.transactionStateScheduler = transactionStateScheduler diff --git a/ios/GemTests/unit_frameworks.xctestplan b/ios/GemTests/unit_frameworks.xctestplan index 552e2c57ee..3321110e30 100644 --- a/ios/GemTests/unit_frameworks.xctestplan +++ b/ios/GemTests/unit_frameworks.xctestplan @@ -353,6 +353,13 @@ "name" : "WalletConnectorServiceTests" } }, + { + "target" : { + "containerPath" : "container:Packages\/ChainServices", + "identifier" : "SigningRequestServiceTests", + "name" : "SigningRequestServiceTests" + } + }, { "target" : { "containerPath" : "container:Packages\/ChainServices", diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 8140e76b25..2dc2853cf6 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -117,7 +117,16 @@ let package = Package( .product(name: "Localization", package: "Localization"), ], path: "SigningRequestService", - exclude: ["TestKit"], + exclude: ["TestKit", "Tests"], + ), + .testTarget( + name: "SigningRequestServiceTests", + dependencies: [ + "SigningRequestService", + "SigningRequestServiceTestKit", + .product(name: "PrimitivesTestKit", package: "Primitives"), + ], + path: "SigningRequestService/Tests", ), .target( name: "SigningRequestServiceTestKit", diff --git a/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift b/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift new file mode 100644 index 0000000000..d423919d86 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift @@ -0,0 +1,97 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +@Observable +public final class SheetPresenter: Sendable where Sheet.ID == String { + @MainActor + public var isPresentingSheet: Sheet? + @MainActor + private var isDismissingSheet: Bool = false + @MainActor + private var dismissals: [CheckedContinuation] = [] + + public init() {} + + public func present( + payload: Payload, + sheet: @Sendable @escaping (SigningRequestCallback) -> Sheet, + ) async throws -> String where Payload.ID == String { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) + let callback = SigningRequestCallback(payload: payload) { + continuation.yield(with: $0) + continuation.finish() + } + await show(sheet: sheet(callback)) + + do { + for try await value in stream { + await dismissPresentedSheet() + return value + } + } catch { + await dismissPresentedSheet() + throw error + } + await dismissPresentedSheet() + throw SigningRequestError.userCancelled + } + + @MainActor + public func complete(type: Sheet) { + guard isPresentingSheet?.id == type.id else { + return + } + dismiss() + } + + @MainActor + public func cancelSheet(type: Sheet) { + guard isPresentingSheet?.id == type.id else { + return + } + type.reject(SigningRequestError.userCancelled) + dismiss() + } + + @MainActor + public func onSheetDismiss() { + isDismissingSheet = false + let waiting = dismissals + dismissals = [] + waiting.forEach { $0.resume() } + } +} + +// MARK: - Private + +extension SheetPresenter { + @MainActor + private func show(sheet: Sheet) async { + await waitForDismiss() + isPresentingSheet = sheet + } + + @MainActor + private func dismissPresentedSheet() async { + dismiss() + await waitForDismiss() + } + + @MainActor + private func dismiss() { + guard isPresentingSheet != nil else { + return + } + isDismissingSheet = true + isPresentingSheet = .none + } + + @MainActor + private func waitForDismiss() async { + guard isPresentingSheet != nil || isDismissingSheet else { + return + } + await withCheckedContinuation { dismissals.append($0) } + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift new file mode 100644 index 0000000000..826aee9f33 --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift @@ -0,0 +1,22 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public final class SigningRequestPresenter: SigningRequestInteractable, Sendable { + public let sheets = SheetPresenter() + + public init() {} + + 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 func sendTransaction(transferData: SigningTransferData) async throws -> String { + try await sheets.present(payload: transferData, sheet: { .transferData($0) }) + } +} diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift new file mode 100644 index 0000000000..9133b0a49c --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum SigningRequestSheetType: Sendable, Identifiable { + case transferData(SigningRequestCallback) + case signMessage(SigningRequestCallback) + + public var id: String { + callback.id + } + + public func reject(_ error: any Error) { + callback.reject(error) + } + + private var callback: any SigningRequestRejectable { + switch self { + case let .transferData(callback): callback + case let .signMessage(callback): callback + } + } +} + +// MARK: - SigningRequestRejectable + +extension SigningRequestSheetType: SigningRequestRejectable {} diff --git a/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift b/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift new file mode 100644 index 0000000000..c900c35fcf --- /dev/null +++ b/ios/Packages/ChainServices/SigningRequestService/Tests/SheetPresenterTests.swift @@ -0,0 +1,112 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import PrimitivesTestKit +@testable import SigningRequestService +import SigningRequestServiceTestKit +import Testing + +struct SheetPresenterTests { + @Test + @MainActor + func completeDismissesThePresentedSheet() { + let presenter = SheetPresenter() + let type = Self.sheet(id: "request") + + presenter.isPresentingSheet = type + presenter.complete(type: type) + + #expect(presenter.isPresentingSheet == nil) + } + + @Test + @MainActor + func presentReturnsTheAnswerOnlyAfterTheSheetReportsItClosed() async throws { + let presenter = SheetPresenter() + let answer = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "request"), sheet: { .signMessage($0) }) + } + try await Self.wait { presenter.isPresentingSheet != nil } + + guard case let .signMessage(callback) = presenter.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + callback.delegate(.success("signature")) + try await Self.wait { presenter.isPresentingSheet == nil } + presenter.onSheetDismiss() + + #expect(try await answer.value == "signature") + } + + @Test + @MainActor + func presentQueuesBehindTheSheetThatIsStillClosing() async throws { + let presenter = SheetPresenter() + let first = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "first"), sheet: { .signMessage($0) }) + } + try await Self.wait { presenter.isPresentingSheet?.id == "first" } + + guard case let .signMessage(callback) = presenter.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + let second = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "second"), sheet: { .signMessage($0) }) + } + callback.delegate(.success("signature")) + try await Self.wait { presenter.isPresentingSheet == nil } + + #expect(presenter.isPresentingSheet == nil) + + presenter.onSheetDismiss() + try await Self.wait { presenter.isPresentingSheet?.id == "second" } + + #expect(try await first.value == "signature") + + guard let sheet = presenter.isPresentingSheet else { + Issue.record("queued sheet is not presented") + return + } + presenter.cancelSheet(type: sheet) + presenter.onSheetDismiss() + _ = try? await second.value + } + + @Test + @MainActor + func cancelSheetFailsTheRequestWithUserCancelled() async throws { + let presenter = SheetPresenter() + let answer = Task { @MainActor in + try await presenter.present(payload: SignMessagePayload.mock(id: "request"), sheet: { .signMessage($0) }) + } + try await Self.wait { presenter.isPresentingSheet != nil } + + guard let sheet = presenter.isPresentingSheet else { + Issue.record("sheet is not presented") + return + } + presenter.cancelSheet(type: sheet) + presenter.onSheetDismiss() + + await #expect(throws: SigningRequestError.userCancelled) { + try await answer.value + } + } + + private static func sheet(id: String) -> SigningRequestSheetType { + .signMessage(SigningRequestCallback(payload: .mock(id: id), delegate: { _ in })) + } + + private static func wait(until condition: @MainActor () -> Bool) async throws { + for _ in 0 ..< 100 { + if await condition() { + return + } + await Task.yield() + } + throw AnyError("condition never became true") + } +} From 073fefdc10fe5bd7df2b8bb3256947577da73db9 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:35:05 +0300 Subject: [PATCH 18/27] iOS: consolidate the sheet presenters and their imports SheetPresenter, its callback and the cancellation error move to Primitives: none of them touch UI or localization, so every feature can host a sheet without depending on SigningRequestService. The per-feature presenter wrappers collapse into conditional conformances, the payment protocols move next to the types that implement them, and SignableTransaction maps the Gemstone Ethereum payload at the boundary instead of exposing it. Removes 21 imports left dead by the move. --- .../Scenes/PaymentDataCollectionScene.swift | 1 - .../Scenes/PaymentNavigationStack.swift | 2 +- .../Services/PaymentActionExecutor.swift | 1 - .../Services/PaymentApprovalExecutor.swift | 3 +- .../Payments/Services/PaymentManager.swift | 1 - .../Services/PaymentSheetPresenter.swift | 15 +++++---- .../Payments/Types/PaymentSheetType.swift | 2 +- .../PaymentQuotesSceneViewModel.swift | 1 - .../PaymentsTests/PaymentManagerTests.swift | 1 - .../PaymentSheetPresentableMock.swift | 32 +++++++++++++++++++ .../ConnectionProposalNavigationStack.swift | 2 +- .../Scenes/ConnectionsScene.swift | 1 - .../Services/WalletConnectorManager.swift | 1 - .../Services/WalletConnectorPresenter.swift | 2 +- .../Services/WalletConnectorSigner.swift | 1 - .../Types/WalletConnectorSheetType.swift | 1 - .../ConnectionProposalViewModel.swift | 2 -- .../SignMessageSceneViewModel.swift | 1 - .../WalletConnectorSignerTests.swift | 2 +- ios/Gem/App.swift | 1 - ios/Gem/Scenes/RootScene.swift | 9 +++--- ios/Gem/Services/AppResolver+Services.swift | 9 +++--- ios/Gem/Services/ServicesFactory.swift | 6 ++-- ios/Gem/ViewModels/RootSceneViewModel.swift | 16 +++++----- .../PaymentAction+Mapping.swift | 2 -- .../PaymentService/PaymentService.swift | 13 +++++++- .../PaymentService/PaymentServiceable.swift | 11 ------- .../PaymentSheetPresentable.swift | 9 ------ .../PaymentStatusServiceable.swift | 9 ------ .../TestKit/PaymentSheetPresentableMock.swift | 31 ------------------ .../SignableTransaction+Mapping.swift | 7 ++-- .../SigningRequestError.swift | 14 -------- .../SigningRequestPresenter.swift | 12 +++---- .../SigningRequestSheetType.swift | 1 + .../SigningSimulator.swift | 2 +- .../Sources}/SheetPresenter.swift | 0 .../Sources}/SigningRequestCallback.swift | 1 - .../Sources/SigningRequestError.swift | 7 ++++ 38 files changed, 93 insertions(+), 139 deletions(-) create mode 100644 ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift delete mode 100644 ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift delete mode 100644 ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift delete mode 100644 ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.swift delete mode 100644 ios/Packages/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift delete mode 100644 ios/Packages/ChainServices/SigningRequestService/SigningRequestError.swift rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SheetPresenter.swift (100%) rename ios/Packages/{ChainServices/SigningRequestService => Primitives/Sources}/SigningRequestCallback.swift (97%) create mode 100644 ios/Packages/Primitives/Sources/SigningRequestError.swift diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift index fea8d5df21..4099afcb3c 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentDataCollectionScene.swift @@ -5,7 +5,6 @@ import Foundation import Localization import PaymentService import Primitives -import SigningRequestService import SwiftUI public struct PaymentDataCollectionScene: View { diff --git a/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift index 192010228e..a39423a631 100644 --- a/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift +++ b/ios/Features/Payments/Sources/Payments/Scenes/PaymentNavigationStack.swift @@ -1,8 +1,8 @@ // Copyright (c). Gem Wallet. All rights reserved. -import SigningRequestService import Style import SwiftUI +import Primitives public struct PaymentNavigationStack: View { private let type: PaymentSheetType diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift index 1281f4e30e..3ab5de6584 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentActionExecutor.swift @@ -2,7 +2,6 @@ import BigInt import Foundation -import GemstonePrimitives import PaymentService import Primitives import SigningRequestService diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift index c5caac0840..b6f2a55c2d 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentApprovalExecutor.swift @@ -2,11 +2,9 @@ import AssetsService import BigInt -import Blockchain import ChainService import BalanceService import Foundation -import GemstonePrimitives import Keystore import PaymentService import Primitives @@ -14,6 +12,7 @@ import ScanService import Signer import TransactionStateService import TransferService +import Blockchain public struct PaymentApprovalExecutor: PaymentApprovalExecutable { private static let confirmationTimeout: Duration = .seconds(120) diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift index 83ace1fd1f..da1e0c9515 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentManager.swift @@ -3,7 +3,6 @@ import Foundation import PaymentService import Primitives -import SigningRequestService import TransactionStateService public final class PaymentManager: Sendable { diff --git a/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift index 32f038f0aa..cab7cda652 100644 --- a/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift +++ b/ios/Features/Payments/Sources/Payments/Services/PaymentSheetPresenter.swift @@ -2,18 +2,19 @@ import Foundation import PaymentService -import SigningRequestService +import Primitives -public final class PaymentSheetPresenter: PaymentSheetPresentable, Sendable { - public let sheets = SheetPresenter() - - public init() {} +public protocol PaymentSheetPresentable: Sendable { + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String +} +extension SheetPresenter: PaymentSheetPresentable where Sheet == PaymentSheetType { public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { - try await sheets.present(payload: request, sheet: { .quotes($0) }) + try await present(payload: request, sheet: { .quotes($0) }) } public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { - try await sheets.present(payload: request, sheet: { .dataCollection($0) }) + try await present(payload: request, sheet: { .dataCollection($0) }) } } diff --git a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift index 37510f083c..9fe0e11ab0 100644 --- a/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift +++ b/ios/Features/Payments/Sources/Payments/Types/PaymentSheetType.swift @@ -2,7 +2,7 @@ import Foundation import PaymentService -import SigningRequestService +import Primitives public enum PaymentSheetType: Sendable, Identifiable { case quotes(SigningRequestCallback) diff --git a/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift index fe44636972..44622621db 100644 --- a/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift +++ b/ios/Features/Payments/Sources/Payments/ViewModels/PaymentQuotesSceneViewModel.swift @@ -8,7 +8,6 @@ import Style import Localization import PaymentService import Primitives -import SigningRequestService import PrimitivesComponents @Observable diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift index f543cac915..1e850ce9f1 100644 --- a/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentManagerTests.swift @@ -1,7 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Localization import PaymentService import Primitives import SigningRequestService diff --git a/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift new file mode 100644 index 0000000000..868366c7c9 --- /dev/null +++ b/ios/Features/Payments/Tests/PaymentsTests/PaymentSheetPresentableMock.swift @@ -0,0 +1,32 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import PaymentService +@testable import Payments +import Primitives + +final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sendable { + init() {} + + var collectDataError: Error? + var selectedQuoteId: String? + + private(set) var collectDataRequests: [PaymentDataCollectionRequest] = [] + private(set) var quotesRequests: [PaymentQuotesRequest] = [] + + func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { + quotesRequests.append(request) + guard let selectedQuoteId else { + throw AnyError("no quote selected") + } + return selectedQuoteId + } + + func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { + collectDataRequests.append(request) + if let collectDataError { + throw collectDataError + } + return .empty + } +} diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift index 814f77cd67..74a253b4a9 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionProposalNavigationStack.swift @@ -1,8 +1,8 @@ // Copyright (c). Gem Wallet. All rights reserved. -import SigningRequestService import Style import SwiftUI +import Primitives public struct ConnectionProposalNavigationStack: View { private let type: WalletConnectorSheetType diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Scenes/ConnectionsScene.swift index b0fb4b7bf1..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 diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorManager.swift index dbc2a234d3..c0252b974d 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 SigningRequestService -import SwiftUI 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 9f6890c1eb..0134b901fb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorPresenter.swift @@ -1,7 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import SigningRequestService +import Primitives @Observable public final class WalletConnectorPresenter: Sendable { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift index 8092db9ae2..d8a87f53eb 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Services/WalletConnectorSigner.swift @@ -4,7 +4,6 @@ import Foundation import class Gemstone.Config import class Gemstone.MessageSigner import struct Gemstone.SignMessage -import GemstonePrimitives import Preferences import Primitives import SigningRequestService diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift index 9c5d6d65d9..040964eade 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/Types/WalletConnectorSheetType.swift @@ -2,7 +2,6 @@ import Foundation import Primitives -import SigningRequestService import WalletConnectorService public enum WalletConnectorSheetType: Sendable, Identifiable { diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift index 17dc3e154b..e34820019a 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/ConnectionProposalViewModel.swift @@ -3,9 +3,7 @@ import Components import Foundation import Localization -import PaymentService import Primitives -import SigningRequestService import PrimitivesComponents import Style import SwiftUI diff --git a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift index 2133dc0203..9e632c77c2 100644 --- a/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift +++ b/ios/Features/WalletConnector/Sources/WalletConnector/ViewModels/SignMessageSceneViewModel.swift @@ -7,7 +7,6 @@ import Foundation import Formatters import BigInt import class Gemstone.MessageSigner -import GemstonePrimitives import Keystore import Localization import Preferences diff --git a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift index 0315958a02..5a2be0ff4d 100644 --- a/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift +++ b/ios/Features/WalletConnector/Tests/WalletConnectorTests/WalletConnectorSignerTests.swift @@ -218,7 +218,7 @@ extension WalletConnectorSigner { connectionsStore: connectionsStore, walletSessionService: walletSessionService, walletConnectorInteractor: WalletConnectorManager(presenter: WalletConnectorPresenter()), - signingInteractor: SigningRequestPresenter(), + signingInteractor: SheetPresenter(), ) } diff --git a/ios/Gem/App.swift b/ios/Gem/App.swift index 6431799f4d..730c870d1a 100644 --- a/ios/Gem/App.swift +++ b/ios/Gem/App.swift @@ -8,7 +8,6 @@ import LockManager import NodeService import Preferences import Primitives -import SigningRequestService import Store import Style import SwiftUI diff --git a/ios/Gem/Scenes/RootScene.swift b/ios/Gem/Scenes/RootScene.swift index 637edea778..44428d53c6 100644 --- a/ios/Gem/Scenes/RootScene.swift +++ b/ios/Gem/Scenes/RootScene.swift @@ -7,7 +7,6 @@ import Onboarding import Payments import PriceService import Primitives -import SigningRequestService import Style import SwiftUI import WalletConnector @@ -43,16 +42,16 @@ struct RootScene: View { presenter: model.walletConnectorPresenter.sheets, ) } - .sheet(item: $model.isPresentingSigningRequestSheet, onDismiss: model.signingRequestPresenter.sheets.onSheetDismiss) { type in + .sheet(item: $model.isPresentingSigningRequestSheet, onDismiss: model.signingRequestPresenter.onSheetDismiss) { type in SigningRequestNavigationStack( type: type, - presenter: model.signingRequestPresenter.sheets, + presenter: model.signingRequestPresenter, ) } - .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.sheets.onSheetDismiss) { type in + .sheet(item: $model.isPresentingPaymentSheet, onDismiss: model.paymentSheetPresenter.onSheetDismiss) { type in PaymentNavigationStack( type: type, - presenter: model.paymentSheetPresenter.sheets, + presenter: model.paymentSheetPresenter, ) } .sheet(isPresented: $model.isPresentingCreateWalletSheet) { diff --git a/ios/Gem/Services/AppResolver+Services.swift b/ios/Gem/Services/AppResolver+Services.swift index 692e19520c..c4f5700419 100644 --- a/ios/Gem/Services/AppResolver+Services.swift +++ b/ios/Gem/Services/AppResolver+Services.swift @@ -38,6 +38,7 @@ import TransactionStateService import WalletConnector import WalletService import WalletSessionService +import Primitives extension AppResolver { struct Services { @@ -80,8 +81,8 @@ extension AppResolver { let onstartWalletService: OnstartWalletService let walletConnectorManager: WalletConnectorManager let paymentLinkManager: PaymentLinkManager - let paymentSheetPresenter: PaymentSheetPresenter - let signingRequestPresenter: SigningRequestPresenter + let paymentSheetPresenter: SheetPresenter + let signingRequestPresenter: SheetPresenter let perpetualService: PerpetualService let hyperliquidObserverService: any PerpetualObservable let nameService: NameService @@ -138,8 +139,8 @@ extension AppResolver { onstartWalletService: OnstartWalletService, walletConnectorManager: WalletConnectorManager, paymentLinkManager: PaymentLinkManager, - paymentSheetPresenter: PaymentSheetPresenter, - signingRequestPresenter: SigningRequestPresenter, + paymentSheetPresenter: SheetPresenter, + signingRequestPresenter: SheetPresenter, perpetualService: PerpetualService, hyperliquidObserverService: any PerpetualObservable, nameService: NameService, diff --git a/ios/Gem/Services/ServicesFactory.swift b/ios/Gem/Services/ServicesFactory.swift index 8c377af714..b82eb37904 100644 --- a/ios/Gem/Services/ServicesFactory.swift +++ b/ios/Gem/Services/ServicesFactory.swift @@ -240,7 +240,7 @@ struct ServicesFactory { let presenter = WalletConnectorPresenter() let walletConnectorManager = WalletConnectorManager(presenter: presenter) - let signingRequestPresenter = SigningRequestPresenter() + let signingRequestPresenter = SheetPresenter() let connectionsService = Self.makeConnectionsService( connectionsStore: storeManager.connectionsStore, walletSessionService: walletSessionService, @@ -307,7 +307,6 @@ struct ServicesFactory { let activityService = ActivityService(store: storeManager.recentActivityStore) let authService = AuthService(apiService: apiService, keystore: storages.keystore) let rewardsService = RewardsService(apiService: apiService, authService: authService) - let eventPresenterService = EventPresenterService() let walletSearchService = WalletSearchService( assetsService: assetsService, searchStore: storeManager.searchStore, @@ -339,8 +338,9 @@ struct ServicesFactory { ) let paymentAssetsProvider = PaymentAssetsProvider(assetStore: storeManager.assetStore) - let paymentSheetPresenter = PaymentSheetPresenter() + let paymentSheetPresenter = SheetPresenter() + let eventPresenterService = EventPresenterService() let paymentLinkManager = PaymentLinkManager( paymentManager: PaymentManager( service: paymentService, diff --git a/ios/Gem/ViewModels/RootSceneViewModel.swift b/ios/Gem/ViewModels/RootSceneViewModel.swift index 90e5deceea..c5e609091d 100644 --- a/ios/Gem/ViewModels/RootSceneViewModel.swift +++ b/ios/Gem/ViewModels/RootSceneViewModel.swift @@ -43,8 +43,8 @@ final class RootSceneViewModel { let nameService: NameService let avatarService: AvatarService let walletConnectorPresenter: WalletConnectorPresenter - let paymentSheetPresenter: PaymentSheetPresenter - let signingRequestPresenter: SigningRequestPresenter + let paymentSheetPresenter: SheetPresenter + let signingRequestPresenter: SheetPresenter let lockManager: any LockWindowManageable var currentWallet: Wallet? { walletService.currentWallet @@ -68,13 +68,13 @@ final class RootSceneViewModel { } var isPresentingSigningRequestSheet: SigningRequestSheetType? { - get { signingRequestPresenter.sheets.isPresentingSheet } - set { signingRequestPresenter.sheets.isPresentingSheet = newValue } + get { signingRequestPresenter.isPresentingSheet } + set { signingRequestPresenter.isPresentingSheet = newValue } } var isPresentingPaymentSheet: PaymentSheetType? { - get { paymentSheetPresenter.sheets.isPresentingSheet } - set { paymentSheetPresenter.sheets.isPresentingSheet = newValue } + get { paymentSheetPresenter.isPresentingSheet } + set { paymentSheetPresenter.isPresentingSheet = newValue } } var isPresentingConnectorBar: Bool { @@ -92,8 +92,8 @@ final class RootSceneViewModel { init( observablePreferences: ObservablePreferences, walletConnectorPresenter: WalletConnectorPresenter, - paymentSheetPresenter: PaymentSheetPresenter, - signingRequestPresenter: SigningRequestPresenter, + paymentSheetPresenter: SheetPresenter, + signingRequestPresenter: SheetPresenter, onstartService: OnstartService, onstartWalletService: OnstartWalletService, transactionStateScheduler: TransactionStateScheduler, diff --git a/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift b/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift index 7ea9a30eb0..bbc8107787 100644 --- a/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift +++ b/ios/Packages/ChainServices/PaymentService/PaymentAction+Mapping.swift @@ -2,9 +2,7 @@ import Foundation import enum Gemstone.PaymentAction -import GemstonePrimitives import Primitives -import SigningRequestService typealias GemPaymentAction = Gemstone.PaymentAction diff --git a/ios/Packages/ChainServices/PaymentService/PaymentService.swift b/ios/Packages/ChainServices/PaymentService/PaymentService.swift index 591da54f42..7b6050aa26 100644 --- a/ios/Packages/ChainServices/PaymentService/PaymentService.swift +++ b/ios/Packages/ChainServices/PaymentService/PaymentService.swift @@ -4,7 +4,18 @@ import Foundation import Gemstone import NativeProviderService import Primitives -import SigningRequestService + +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 diff --git a/ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift b/ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift deleted file mode 100644 index bcb6f213fb..0000000000 --- a/ios/Packages/ChainServices/PaymentService/PaymentServiceable.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public protocol PaymentServiceable: PaymentStatusServiceable { - func getPaymentOptions(link: PaymentLink, wallet: Wallet) async throws -> PaymentOptions - func getPreparedPayment(provider: PaymentProviderName, quotes: PaymentQuotes, quote: PaymentQuote, wallet: Wallet) async throws -> PreparedPayment - func confirmPayment(provider: PaymentProviderName, quote: PaymentQuote, actionResults: [String]) async throws -> PaymentOutcome - func cancelPayment(provider: PaymentProviderName, paymentId: String) async throws -} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift b/ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift deleted file mode 100644 index bca5eebaf0..0000000000 --- a/ios/Packages/ChainServices/PaymentService/PaymentSheetPresentable.swift +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives - -public protocol PaymentSheetPresentable: Sendable { - func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String - func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String -} diff --git a/ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.swift b/ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.swift deleted file mode 100644 index d5b516c01f..0000000000 --- a/ios/Packages/ChainServices/PaymentService/PaymentStatusServiceable.swift +++ /dev/null @@ -1,9 +0,0 @@ -// 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/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift b/ios/Packages/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift deleted file mode 100644 index 73c3faadbf..0000000000 --- a/ios/Packages/ChainServices/PaymentService/TestKit/PaymentSheetPresentableMock.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import PaymentService -import Primitives - -public final class PaymentSheetPresentableMock: PaymentSheetPresentable, @unchecked Sendable { - public init() {} - - public var collectDataError: Error? - public var selectedQuoteId: String? - - public private(set) var collectDataRequests: [PaymentDataCollectionRequest] = [] - public private(set) var quotesRequests: [PaymentQuotesRequest] = [] - - public func selectPaymentQuote(request: PaymentQuotesRequest) async throws -> String { - quotesRequests.append(request) - guard let selectedQuoteId else { - throw AnyError("no quote selected") - } - return selectedQuoteId - } - - public func collectPaymentData(request: PaymentDataCollectionRequest) async throws -> String { - collectDataRequests.append(request) - if let collectDataError { - throw collectDataError - } - return .empty - } -} diff --git a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift index 639b3bcbe6..5c27ba5f9f 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SignableTransaction+Mapping.swift @@ -4,10 +4,7 @@ import Foundation import Gemstone import Primitives -typealias GemSignableTransaction = Gemstone.SignableTransaction -typealias GemEthereumTransactionData = Gemstone.EthereumTransactionData - -public extension GemSignableTransaction { +public extension Gemstone.SignableTransaction { func map() -> SignableTransaction { switch self { case let .ethereum(data, transactionType): .ethereum(data.map(), transactionType.map()) @@ -19,7 +16,7 @@ public extension GemSignableTransaction { } } -extension GemEthereumTransactionData { +extension Gemstone.EthereumTransactionData { func map() -> Primitives.EthereumTransactionData { Primitives.EthereumTransactionData( chainId: chainId, diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestError.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestError.swift deleted file mode 100644 index 56a3da44da..0000000000 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestError.swift +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Localization - -public enum SigningRequestError: LocalizedError { - case userCancelled - - public var errorDescription: String? { - switch self { - case .userCancelled: Localized.Errors.Connections.userCancelled - } - } -} diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift index 826aee9f33..339cd27f5d 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestPresenter.swift @@ -3,20 +3,16 @@ import Foundation import Primitives -public final class SigningRequestPresenter: SigningRequestInteractable, Sendable { - public let sheets = SheetPresenter() - - public init() {} - +extension SheetPresenter: SigningRequestInteractable where Sheet == SigningRequestSheetType { public func signMessage(payload: SignMessagePayload) async throws -> String { - try await sheets.present(payload: payload, sheet: { .signMessage($0) }) + try await present(payload: payload, sheet: { .signMessage($0) }) } public func signTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { .transferData($0) }) + try await present(payload: transferData, sheet: { .transferData($0) }) } public func sendTransaction(transferData: SigningTransferData) async throws -> String { - try await sheets.present(payload: transferData, sheet: { .transferData($0) }) + try await present(payload: transferData, sheet: { .transferData($0) }) } } diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift index 9133b0a49c..7d253b8e65 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SigningRequestSheetType.swift @@ -1,6 +1,7 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import Primitives public enum SigningRequestSheetType: Sendable, Identifiable { case transferData(SigningRequestCallback) diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift b/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift index 4b12bf071f..befabe34f4 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift +++ b/ios/Packages/ChainServices/SigningRequestService/SigningSimulator.swift @@ -7,8 +7,8 @@ import struct Gemstone.SignMessage import class Gemstone.WalletConnectSimulationClient import enum Gemstone.SignableTransactionType import NativeProviderService -import GemstonePrimitives import Primitives +import GemstonePrimitives public protocol SigningSimulatable: Sendable { func simulateSignMessage(chain: Gemstone.Chain, signType: SignDigestType, data: String, sessionDomain: String) async throws -> SimulationResult diff --git a/ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift b/ios/Packages/Primitives/Sources/SheetPresenter.swift similarity index 100% rename from ios/Packages/ChainServices/SigningRequestService/SheetPresenter.swift rename to ios/Packages/Primitives/Sources/SheetPresenter.swift diff --git a/ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift b/ios/Packages/Primitives/Sources/SigningRequestCallback.swift similarity index 97% rename from ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift rename to ios/Packages/Primitives/Sources/SigningRequestCallback.swift index 6d85359efa..4cac675b19 100644 --- a/ios/Packages/ChainServices/SigningRequestService/SigningRequestCallback.swift +++ b/ios/Packages/Primitives/Sources/SigningRequestCallback.swift @@ -1,7 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives public protocol SigningRequestRejectable: Sendable { var id: String { get } 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 +} From 2607188539536c42568b9249081c8f2058c0284f Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:39:30 +0300 Subject: [PATCH 19/27] iOS: drop the unused Localization dependency from ChainServices --- ios/Packages/ChainServices/Package.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/ios/Packages/ChainServices/Package.swift b/ios/Packages/ChainServices/Package.swift index 2dc2853cf6..11332095d1 100644 --- a/ios/Packages/ChainServices/Package.swift +++ b/ios/Packages/ChainServices/Package.swift @@ -29,7 +29,6 @@ let package = Package( ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), - .package(name: "Localization", path: "../Localization"), .package(name: "GemAPI", path: "../GemAPI"), .package(name: "Store", path: "../Store"), .package(name: "Blockchain", path: "../Blockchain"), @@ -114,7 +113,6 @@ let package = Package( "Gemstone", "GemstonePrimitives", "NativeProviderService", - .product(name: "Localization", package: "Localization"), ], path: "SigningRequestService", exclude: ["TestKit", "Tests"], @@ -142,7 +140,6 @@ let package = Package( "SigningRequestService", "Primitives", "Store", - .product(name: "Localization", package: "Localization"), "Gemstone", "GemstonePrimitives", "NativeProviderService", From cc17a00dd432123f879b7f94e827dfe567fa79cb Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:39:30 +0300 Subject: [PATCH 20/27] iOS: remove the duplicate NetworkAssets navigation destination --- .../Navigation/Wallet/WalletNavigationStack.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift index 810e59b591..f05e4170ee 100644 --- a/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift +++ b/ios/Gem/Navigation/Wallet/WalletNavigationStack.swift @@ -135,18 +135,6 @@ struct WalletNavigationStack: View { ), ) } - .navigationDestination(for: Scenes.NetworkAssets.self) { destination in - NetworkAssetsScene( - model: NetworkAssetsSceneViewModel( - wallet: model.wallet, - chain: destination.chain, - balanceService: balanceService, - assetsEnabler: assetsEnabler, - preferences: preferences.preferences, - onManageAssets: { model.onSelectManage(chains: [destination.chain]) }, - ), - ) - } .navigationDestination(for: Scenes.Transaction.self) { TransactionNavigationView( model: TransactionSceneViewModel( From 78e51581f45ac6b7a9aed01070e693cd09218d6a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:15:33 +0300 Subject: [PATCH 21/27] Android: extract the signable transaction mapper WalletConnect built ConfirmParams from a SignableTransaction inline. The payment flow needs the same mapping, so move it to gemcore as a shared extension and have WCRequest delegate to it. --- .../bridge/viewmodels/model/WCRequest.kt | 107 +++--------------- .../android/ext/SignableTransaction.kt | 98 ++++++++++++++++ 2 files changed, 112 insertions(+), 93 deletions(-) create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/ext/SignableTransaction.kt 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 9673c070a1..54df69677e 100644 --- a/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt +++ b/android/features/bridge/viewmodels/src/main/kotlin/com/gemwallet/android/features/bridge/viewmodels/model/WCRequest.kt @@ -4,6 +4,8 @@ import com.gemwallet.android.ext.asset import com.gemwallet.android.ext.getShortUrl import com.gemwallet.android.ext.shortName import com.gemwallet.android.ext.toPrimitives +import com.gemwallet.android.ext.SigningRequestApp +import com.gemwallet.android.ext.toConfirmParams import com.gemwallet.android.math.hexToBigInteger import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.ConfirmParams.TransferParams.Generic @@ -201,96 +203,15 @@ internal fun WalletConnectResponseType.payload(): String = when (this) { private fun SignableTransaction.map( request: WCRequest.Transaction, isSendable: Boolean, -): Generic { - val asset = request.chain.asset() - return when (this) { - is SignableTransaction.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 SignableTransaction.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 SignableTransaction.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 SignableTransaction.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 SignableTransaction.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/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, +) From 06c2d1d91df214f354e795a7a115f14ef50fa78f Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:15:42 +0300 Subject: [PATCH 22/27] Android: share the sign message review components The message preview, payload details sheet and full message sheet were internal to the bridge feature. Move them to ui alongside the simulation payload content so the payment flow can present the same review. --- .../features/bridge/views/AuthRequestScene.kt | 9 +- .../features/bridge/views/RequestScene.kt | 14 +-- .../views/WalletConnectReviewContent.kt | 64 ------------- .../components/message/SignMessageContent.kt | 93 +++++++++++++++++++ 4 files changed, 106 insertions(+), 74 deletions(-) create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/message/SignMessageContent.kt 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/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 abff8064bb76c640677c433e91d1bad62969366a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:15:49 +0300 Subject: [PATCH 23/27] Android: decode payment links picked from the gallery The gallery path reduced every scanned code to a recipient address, so a payment link decoded to nothing and reported an unreadable image. Pass the raw text through as the camera path already does, and keep the address as display text only. Recipient codes also keep their amount and memo now. --- .../gemwallet/android/ui/components/QRScanner.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 b83983e62e..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 @@ -132,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 @@ -163,10 +166,12 @@ fun QRScannerScene( mapOf(DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE)) ) }.decode(binaryBmp) - imageResult = paymentDecodeUrl(result.text).toPrimitives().request?.address.orEmpty() - 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" } @@ -212,7 +217,7 @@ fun QRScannerScene( contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize(), ) - if (imageResult.isNotEmpty()) { + if (imagePreview.isNotEmpty()) { Column( modifier = Modifier .padding(40.dp) @@ -224,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) From c5c42c073ea5ccf71d205177d6a2867019c9368d Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:16:03 +0300 Subject: [PATCH 24/27] Android: pay WalletConnect Pay links Add the payment feature: quote selection, the compliance capture web view, permit signing and token approval, driven from a single view model and reusing the confirm screen for anything that broadcasts. The web view speaks the iOS message handler shape, so a shim exposes it and forwards window messages to the Android bridge. Payment links reach the app through the wallet scanner and a deep link on the payment host. --- android/app/build.gradle.kts | 2 + android/app/src/main/AndroidManifest.xml | 8 + .../android/PendingNavigationCoordinator.kt | 6 +- .../com/gemwallet/android/di/GatewayModule.kt | 15 + .../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 + .../reown/ReownWalletConnectClient.kt | 2 +- .../features/assets/views/AssetsAction.kt | 1 + .../features/assets/views/AssetsScreen.kt | 9 +- .../features/assets/views/AssetsTopBar.kt | 13 + .../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 | 53 +++ .../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 | 84 ++++ .../com/gemwallet/android/ext/GemPayment.kt | 5 + android/settings.gradle.kts | 2 + 30 files changed, 1421 insertions(+), 3 deletions(-) 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 b2e1c2b20a..8b81cfb04a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -261,6 +261,8 @@ dependencies { implementation(project(":features:wallet-details:presents")) implementation(project(":features:bridge:presents")) implementation(project(":features:bridge:viewmodels")) + implementation(project(":features:payment:presents")) + implementation(project(":features:payment:viewmodels")) implementation(project(":features:assets:presents")) implementation(project(":features:assets:viewmodels")) implementation(project(":features:perpetual:presents")) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7fb6394d6a..c4a5535e5d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -65,6 +65,14 @@ + + + + + + + + Unit + 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..bdb3dee9b1 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt @@ -26,6 +26,10 @@ import uniffi.gemstone.GemPreferences import uniffi.gemstone.GemServiceStatus import uniffi.gemstone.serviceStatusTimeoutSeconds import uniffi.gemstone.WalletConnectSimulationClient +import com.gemwallet.android.application.device.coordinators.GetDeviceId +import com.gemwallet.android.data.service.walletconnect.reown.ReownWalletConnectClient +import kotlinx.coroutines.runBlocking +import uniffi.gemstone.GemPaymentService import uniffi.gemstone.WalletConnectSimulationClientInterface import javax.inject.Singleton @@ -105,6 +109,17 @@ object GatewayModule { return ServiceStatusService(GemServiceStatus(provider)) } + @Provides + @Singleton + fun provideGemPaymentService( + alienProvider: AlienProvider, + getDeviceId: GetDeviceId, + ): GemPaymentService = GemPaymentService( + provider = alienProvider, + appId = ReownWalletConnectClient.PROJECT_ID, + clientId = runBlocking { getDeviceId.getDeviceId() }, + ) + @Provides @Singleton fun provideWalletConnectSimulationService( diff --git a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt index 68b80a23a1..40dcac175f 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt @@ -55,6 +55,11 @@ import com.gemwallet.android.ui.navigation.routes.settingsRoute import com.gemwallet.android.ui.navigation.routes.transactionsRoute import com.gemwallet.android.ui.theme.alpha10 import kotlinx.coroutines.launch +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.gemwallet.android.ui.components.QrCodeScannerModal +import uniffi.gemstone.UrlAction +import uniffi.gemstone.urlAction @Composable fun MainScreen( @@ -66,6 +71,18 @@ fun MainScreen( val pendingCount by viewModel.pendingTxCount.collectAsStateWithLifecycle() val assetsViewModel: AssetsViewModel = hiltViewModel() val isRootRouteActive = navigator.backStack.lastOrNull() == WalletRootRoute + var isPresentingScanner by remember { mutableStateOf(false) } + + QrCodeScannerModal( + isVisible = isPresentingScanner, + onDismissRequest = { isPresentingScanner = false }, + onResult = { scanned -> + isPresentingScanner = false + (runCatching { urlAction(scanned) }.getOrNull() as? UrlAction.Payment)?.let { + navigator.openPayment(it.link) + } + }, + ) BackHandler(isRootRouteActive && currentTab.value != assetsRoute) { currentTab.value = assetsRoute @@ -185,6 +202,7 @@ fun MainScreen( AssetsAction.ShowWallets -> navigator.openWallets() AssetsAction.Manage -> navigator.openAssetsManage() AssetsAction.Search -> navigator.openAssetsSearch() + AssetsAction.Scan -> isPresentingScanner = true AssetsAction.Send -> navigator.openRecipient() AssetsAction.Receive -> navigator.openReceive() AssetsAction.Buy -> navigator.openBuy() diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt index 9ee510bcbe..ee90e2cb75 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -40,6 +40,8 @@ import com.gemwallet.android.ui.navigation.routes.BridgeConnectionDetailsRoute import com.gemwallet.android.ui.navigation.routes.BridgeConnectionsRoute import com.gemwallet.android.ui.navigation.routes.AddContactRoute import com.gemwallet.android.ui.navigation.routes.ConfirmRoute +import com.gemwallet.android.ui.navigation.routes.PaymentRoute +import uniffi.gemstone.GemPaymentLink import com.gemwallet.android.ui.navigation.routes.ContactsRoute import com.gemwallet.android.ui.navigation.routes.CurrenciesRoute import com.gemwallet.android.ui.navigation.routes.EditContactRoute @@ -279,6 +281,7 @@ class WalletNavigator( val pack = params.pack() ?: return push(ConfirmRoute(pack)) } + fun openPayment(link: GemPaymentLink) = push(PaymentRoute(link.provider.name, link.id)) fun openNftList() = push(NftListRoute) fun openNftCollection(nftCollectionId: String) = push(NftCollectionRoute(nftCollectionId)) fun openNftUnverifiedCollections() = push(NftUnverifiedCollectionsRoute) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt index b7541bab2c..d46be51edb 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt @@ -39,6 +39,7 @@ import com.gemwallet.android.ui.navigation.routes.assetScreen import com.gemwallet.android.ui.navigation.routes.networkAssetsScreen import com.gemwallet.android.ui.navigation.routes.bridgesScreen import com.gemwallet.android.ui.navigation.routes.confirm +import com.gemwallet.android.ui.navigation.routes.payment import com.gemwallet.android.ui.navigation.routes.fiatScreen import com.gemwallet.android.ui.navigation.routes.nftCollection import com.gemwallet.android.ui.navigation.routes.perpetualScreen @@ -172,6 +173,8 @@ fun WalletNavGraph( cancelAction = onCancel, ) + payment(cancelAction = onCancel) + nftCollection( cancelAction = onCancel, collectionIdAction = navigator::openNftCollection, diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt new file mode 100644 index 0000000000..96252a8038 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Payment.kt @@ -0,0 +1,21 @@ +package com.gemwallet.android.ui.navigation.routes + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.gemwallet.android.features.payment.presents.PaymentScene +import com.gemwallet.android.ui.models.actions.CancelAction +import kotlinx.serialization.Serializable +import uniffi.gemstone.GemPaymentProviderName + +@Serializable +data class PaymentRoute(val provider: String, val paymentId: String) : NavKey + +fun EntryProviderScope.payment(cancelAction: CancelAction) { + entry { key -> + PaymentScene( + provider = GemPaymentProviderName.valueOf(key.provider), + paymentId = key.paymentId, + onCancel = { cancelAction() }, + ) + } +} diff --git a/android/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 0327eb8936..c7f3d82d35 100644 --- a/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt +++ b/android/data/services/walletconnect/reown/src/main/kotlin/com/gemwallet/android/data/service/walletconnect/reown/ReownWalletConnectClient.kt @@ -284,7 +284,7 @@ class ReownWalletConnectClient @Inject constructor( private data class ReownAuthObject(val value: Wallet.Model.Cacao) : WalletConnectAuthObject - private companion object { + companion object { const val TAG = "WalletConnect" const val PROJECT_ID = "3bc07cd7179d11ea65335fb9377702b6" } diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt index 8f10b90b2e..0ffe139c56 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt @@ -7,6 +7,7 @@ sealed interface AssetsAction { data object ShowWallets : AssetsAction data object Manage : AssetsAction data object Search : AssetsAction + data object Scan : AssetsAction data object Send : AssetsAction data object Receive : AssetsAction data object Buy : AssetsAction diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt index d7211afa37..a421be9e6f 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt @@ -110,7 +110,14 @@ fun AssetsScreen( Scaffold( modifier = Modifier.fillMaxSize(), - topBar = { AssetsTopBar(walletSummary, { onAction(AssetsAction.ShowWallets) }, { onAction(AssetsAction.Search) }) }, + topBar = { + AssetsTopBar( + walletSummary, + { onAction(AssetsAction.ShowWallets) }, + { onAction(AssetsAction.Search) }, + { onAction(AssetsAction.Scan) }, + ) + }, snackbarHost = { SnackbarHost(snackbar) }, contentWindowInsets = WindowInsets(0, 0, 0, 0), containerColor = MaterialTheme.colorScheme.surface, diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt index 5b443caf3d..ca60fe5566 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsTopBar.kt @@ -31,6 +31,7 @@ internal fun AssetsTopBar( walletSummary: WalletSummaryAggregate?, onShowWallets: () -> Unit, onSearch: () -> Unit, + onScan: () -> Unit, ) { val walletIcon = walletImageModel(LocalContext.current, walletSummary?.walletIcon?.imageUrl) ?: walletSummary?.walletIcon?.placeholder @@ -62,6 +63,18 @@ internal fun AssetsTopBar( } } }, + navigationIcon = { + IconButton( + onClick = onScan, + Modifier.testTag("assetsScanAction") + ) { + Icon( + imageVector = AppIcons.QrCodeScanner, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "scan_payment", + ) + } + }, actions = { IconButton( onClick = onSearch, diff --git a/android/features/payment/presents/build.gradle.kts b/android/features/payment/presents/build.gradle.kts new file mode 100644 index 0000000000..be829de046 --- /dev/null +++ b/android/features/payment/presents/build.gradle.kts @@ -0,0 +1,71 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + id("com.google.devtools.ksp") +} + +android { + namespace = "com.gemwallet.android.features.payment.presents" + compileSdk = 37 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") + } + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "META-INF/*" + excludes += "META-INF/DEPENDENCIES" + excludes += "/META-INF/LICENSE-notice.md" + excludes += "/META-INF/LICENSE.md" + excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + } + } +} + +dependencies { + implementation(project(":ui")) + api(project(":data:repositories")) + implementation(project(":features:payment:viewmodels")) + implementation(project(":features:confirm:presents")) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.lifecycle.viewmodel.compose) + + + debugImplementation(libs.androidx.ui.tooling) + implementation(libs.androidx.ui.tooling.preview) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/android/features/payment/presents/consumer-rules.pro b/android/features/payment/presents/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/features/payment/presents/proguard-rules.pro b/android/features/payment/presents/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/android/features/payment/presents/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt new file mode 100644 index 0000000000..22b4fbf124 --- /dev/null +++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentDataCollectionScene.kt @@ -0,0 +1,110 @@ +package com.gemwallet.android.features.payment.presents + +import android.annotation.SuppressLint +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.screen.Scene +import org.json.JSONObject + +private const val MESSAGE_HANDLER = "payDataCollectionComplete" +private const val MESSAGE_TYPE_KEY = "type" +private const val MESSAGE_ERROR_KEY = "error" +private const val COMPLETE = "IC_COMPLETE" +private const val ERROR = "IC_ERROR" +private const val ALLOWED_HOST = "walletconnect.com" + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun PaymentDataCollectionScene( + url: String, + onComplete: () -> Unit, + onError: (String?) -> Unit, + onCancel: () -> Unit, +) { + var webView by remember { mutableStateOf(null) } + + BackHandler { + val view = webView + if (view != null && view.canGoBack()) view.goBack() else onCancel() + } + + Scene( + title = stringResource(R.string.transfer_payment_title), + onClose = onCancel, + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = AllowedHostWebViewClient() + addJavascriptInterface(CollectDataBridge(onComplete, onError), MESSAGE_HANDLER) + loadUrl(url) + webView = this + } + }, + ) + } +} + +private class CollectDataBridge( + private val onComplete: () -> Unit, + private val onError: (String?) -> Unit, +) { + @JavascriptInterface + fun postMessage(payload: String) { + val message = runCatching { JSONObject(payload) }.getOrNull() ?: return + when (message.optString(MESSAGE_TYPE_KEY)) { + COMPLETE -> onComplete() + ERROR -> onError(message.optString(MESSAGE_ERROR_KEY).takeIf { it.isNotEmpty() }) + } + } +} + +private class AllowedHostWebViewClient : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: android.webkit.WebResourceRequest?): Boolean { + val uri = request?.url ?: return true + val host = uri.host?.lowercase() ?: return true + val allowed = uri.scheme == "https" && (host == ALLOWED_HOST || host.endsWith(".$ALLOWED_HOST")) + return !allowed + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + view?.evaluateJavascript(BRIDGE_SHIM, null) + } + + override fun onPageFinished(view: WebView?, url: String?) { + view?.evaluateJavascript(BRIDGE_SHIM, null) + } +} + +private val BRIDGE_SHIM = """ +(function() { + if (window.__gemPayBridge) { return; } + window.__gemPayBridge = true; + var android = $MESSAGE_HANDLER; + var post = function(message) { + if (message === null || message === undefined) { return; } + try { + android.postMessage(typeof message === 'string' ? message : JSON.stringify(message)); + } catch (error) {} + }; + window.webkit = window.webkit || {}; + window.webkit.messageHandlers = window.webkit.messageHandlers || {}; + window.webkit.messageHandlers.$MESSAGE_HANDLER = { postMessage: post }; + window.addEventListener('message', function(event) { post(event.data); }); +})(); +""".trimIndent() diff --git a/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt new file mode 100644 index 0000000000..ee085c0a39 --- /dev/null +++ b/android/features/payment/presents/src/main/kotlin/com/gemwallet/android/features/payment/presents/PaymentScene.kt @@ -0,0 +1,369 @@ +package com.gemwallet.android.features.payment.presents + +import android.widget.Toast +import android.widget.Toast.makeText +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.confirm.presents.ConfirmScreen +import com.gemwallet.android.features.payment.viewmodels.PaymentError +import com.gemwallet.android.features.payment.viewmodels.PaymentSceneState +import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIModel +import com.gemwallet.android.features.payment.viewmodels.PaymentViewModel +import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel +import com.gemwallet.android.model.AuthRequest +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.buttons.MainActionButton +import com.gemwallet.android.ui.components.image.IconWithBadge +import com.gemwallet.android.ui.components.list_head.CenteredListHead +import com.gemwallet.android.ui.components.list_item.ListItem +import com.gemwallet.android.ui.components.list_item.ListItemSupportText +import com.gemwallet.android.ui.components.list_item.ListItemTitleText +import com.gemwallet.android.ui.components.list_item.SelectionCheckmark +import com.gemwallet.android.ui.components.list_item.property.DataBadgeChevron +import com.gemwallet.android.ui.components.list_item.property.PropertyDataText +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.list_item.property.PropertyNetworkItem +import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText +import com.gemwallet.android.ui.components.list_item.walletItemIconModel +import com.gemwallet.android.ui.components.message.SignMessageFullMessageSheet +import com.gemwallet.android.ui.components.message.SignMessageSheetType +import com.gemwallet.android.ui.components.message.SignMessagePayloadDetailsSheet +import com.gemwallet.android.ui.components.message.signMessageText +import com.gemwallet.android.ui.components.screen.LoadingScene +import com.gemwallet.android.ui.components.screen.ModalBottomSheet +import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.components.simulation.simulationPayloadFieldsContent +import com.gemwallet.android.ui.models.ButtonState +import com.gemwallet.android.ui.models.ListPosition +import com.gemwallet.android.ui.requestAuth +import com.gemwallet.android.ui.theme.paddingDefault +import com.gemwallet.android.ui.theme.paddingSmall +import kotlinx.coroutines.delay +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.PaymentException + +@Composable +fun PaymentScene( + provider: GemPaymentProviderName, + paymentId: String, + onCancel: () -> Unit, + viewModel: PaymentViewModel = hiltViewModel(), +) { + val state by viewModel.sceneState.collectAsStateWithLifecycle() + + LaunchedEffect(paymentId) { viewModel.onPayment(provider, paymentId) } + + when (val sceneState = state) { + PaymentSceneState.Loading -> LoadingScene( + title = stringResource(R.string.transfer_payment_title), + onCancel = onCancel, + ) + is PaymentSceneState.Quotes -> PaymentQuotesScene( + state = sceneState, + onSelect = viewModel::onSelectQuote, + onConfirm = viewModel::onConfirmQuote, + onCancel = onCancel, + ) + is PaymentSceneState.CollectData -> PaymentDataCollectionScene( + url = sceneState.url, + onComplete = viewModel::onDataCollected, + onError = viewModel::onDataCollectionError, + onCancel = onCancel, + ) + is PaymentSceneState.Approve -> ConfirmScreen( + params = sceneState.params, + finishAction = { hash -> viewModel.onActionResult(hash) }, + cancelAction = onCancel, + onAcquireAsset = { _, _ -> }, + ) + is PaymentSceneState.Confirm -> ConfirmScreen( + params = sceneState.params, + finishAction = { hash -> viewModel.onActionResult(hash) }, + cancelAction = onCancel, + onAcquireAsset = { _, _ -> }, + ) + is PaymentSceneState.SignMessage -> PaymentSignMessageScene( + state = sceneState, + onApprove = viewModel::onSign, + onCancel = onCancel, + ) + is PaymentSceneState.Outcome -> PaymentToastEffect(sceneState.outcome.messageRes(), onCancel) + is PaymentSceneState.Error -> PaymentToastEffect(sceneState.error.messageRes(), onCancel) + } +} + +@Composable +private fun PaymentQuotesScene( + state: PaymentSceneState.Quotes, + onSelect: (String) -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + var isSelectingQuote by remember { mutableStateOf(false) } + + Scene( + title = stringResource(R.string.transfer_payment_title), + backHandle = true, + onClose = onCancel, + mainAction = { + MainActionButton( + title = stringResource(R.string.common_continue), + state = if (state.expired || state.selected == null) ButtonState.Disabled else ButtonState.Enabled, + onClick = onConfirm, + ) + }, + ) { + LazyColumn { + item { + CenteredListHead( + icon = state.merchant.iconUrl, + title = state.price ?: state.selectedQuote?.amountText.orEmpty(), + placeholderText = state.merchant.name.firstOrNull()?.uppercaseChar()?.toString(), + ) + } + item { + PropertyItem( + title = { PropertyTitleText(R.string.transfer_merchant) }, + data = { + PropertyDataText( + text = state.merchant.name, + badge = state.merchant.iconUrl?.let { + { DataBadgeChevron(icon = it, isShowChevron = false) } + }, + ) + }, + listPosition = ListPosition.First, + ) + } + item { + PropertyItem( + title = { PropertyTitleText(R.string.common_wallet) }, + data = { + val walletIcon = walletItemIconModel(state.walletType, state.walletChain) + PropertyDataText( + text = state.walletName, + badge = walletIcon?.let { { DataBadgeChevron(icon = it, isShowChevron = false) } }, + ) + }, + listPosition = if (state.expiresAt == null) ListPosition.Last else ListPosition.Middle, + ) + } + state.expiresAt?.let { expiresAt -> + item { + PropertyExpiryItem( + title = stringResource(R.string.transfer_payment_expires_in), + expiresAt = expiresAt, + listPosition = ListPosition.Last, + ) + } + } + item { + PropertyItem( + modifier = Modifier.clickable { if (!state.expired) isSelectingQuote = true }, + title = { PropertyTitleText(R.string.transfer_pay_with) }, + data = { + PropertyDataText( + text = state.selectedQuote?.amountText.orEmpty(), + badge = { DataBadgeChevron(icon = state.selectedQuote?.iconUrl.orEmpty()) }, + ) + }, + listPosition = ListPosition.Single, + ) + } + } + } + + PaymentQuotesSelectModal( + isVisible = isSelectingQuote, + quotes = state.quotes, + selected = state.selected, + onSelect = { + onSelect(it) + isSelectingQuote = false + }, + onDismissRequest = { isSelectingQuote = false }, + ) +} + +@Composable +private fun PaymentQuotesSelectModal( + isVisible: Boolean, + quotes: List, + selected: String?, + onSelect: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + ModalBottomSheet( + isVisible = isVisible, + onDismissRequest = onDismissRequest, + ) { + LazyColumn { + itemsIndexed(quotes) { index, quote -> + ListItem( + modifier = Modifier.clickable { onSelect(quote.id) }, + leading = { + if (quote.id == selected) { + IconWithBadge( + icon = quote.iconUrl, + placeholder = quote.symbol, + badge = { SelectionCheckmark() }, + ) + } else { + IconWithBadge( + icon = quote.iconUrl, + placeholder = quote.symbol, + supportIcon = quote.supportIconUrl, + ) + } + }, + title = { ListItemTitleText(quote.symbol) }, + trailing = { ListItemSupportText(quote.amount) }, + listPosition = ListPosition.getPosition(index, quotes.size), + ) + } + } + } +} + +@Composable +private fun PropertyExpiryItem( + title: String, + expiresAt: Long, + listPosition: ListPosition, +) { + var remaining by remember(expiresAt) { mutableLongStateOf(expiresAt - System.currentTimeMillis()) } + LaunchedEffect(expiresAt) { + while (remaining > 0) { + delay(1000) + remaining = expiresAt - System.currentTimeMillis() + } + } + val seconds = (remaining / 1000).coerceAtLeast(0) + PropertyItem( + title = title, + data = "%d:%02d".format(seconds / 60, seconds % 60), + listPosition = listPosition, + ) +} + +@Composable +private fun PaymentSignMessageScene( + state: PaymentSceneState.SignMessage, + onApprove: () -> Unit, + onCancel: () -> Unit, +) { + val context = LocalContext.current + var sheetType by remember { mutableStateOf(null) } + + Scene( + title = stringResource(R.string.transfer_review_request), + backHandle = true, + closeIcon = true, + onClose = onCancel, + mainAction = { + MainActionButton(title = stringResource(R.string.transfer_confirm)) { + context.requestAuth(AuthRequest.Confirmation) { onApprove() } + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = paddingValues.calculateBottomPadding() + paddingDefault), + ) { + item { + CenteredListHead( + icon = state.merchant.iconUrl, + title = state.merchant.name, + placeholderText = state.merchant.name.firstOrNull()?.uppercaseChar()?.toString(), + ) + } + item { PropertyItem(R.string.common_wallet, state.walletName, listPosition = ListPosition.First) } + item { PropertyNetworkItem(state.chain, listPosition = ListPosition.Last) } + if (state.hasPayload) { + simulationPayloadFieldsContent( + fields = state.primaryPayloadFields, + onDetailsClick = { sheetType = SignMessageSheetType.Details }, + ) + } else { + signMessageText(state.plainMessage) + } + } + + when (sheetType) { + SignMessageSheetType.Details -> SignMessagePayloadDetailsSheet( + primaryFields = state.primaryPayloadFields, + secondaryFields = state.secondaryPayloadFields, + onViewFullMessage = { sheetType = SignMessageSheetType.FullMessage }, + onDismissRequest = { sheetType = null }, + ) + SignMessageSheetType.FullMessage -> SignMessageFullMessageSheet( + message = state.plainMessage, + onDismissRequest = { sheetType = null }, + ) + null -> Unit + } + } +} + +@Composable +private fun PaymentToastEffect( + @StringRes message: Int?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + LaunchedEffect(message) { + message?.let { makeText(context, it, Toast.LENGTH_SHORT).show() } + onDismiss() + } +} + +private fun PaymentError.messageRes(): Int = when (this) { + PaymentError.NoWallet, + PaymentError.NoQuotes, + PaymentError.QuoteUnavailable, + PaymentError.NoAccount -> R.string.errors_not_supported + PaymentError.WatchWallet -> R.string.wallet_watch_tooltip_title + PaymentError.DataCollection -> R.string.errors_error_occurred + PaymentError.UnknownAsset -> R.string.errors_error_occurred + is PaymentError.Gateway -> error.messageRes() +} + +private fun PaymentException?.messageRes(): Int = when (this) { + is PaymentException.PaymentExpired, + is PaymentException.QuoteExpired -> R.string.errors_payment_expired + is PaymentException.Rejected -> R.string.errors_payment_not_allowed + is PaymentException.PaymentNotFound, + is PaymentException.RateLimited -> R.string.transaction_status_failed + is PaymentException.NoPaymentOptions, + is PaymentException.UnsupportedAccounts, + is PaymentException.NotSupported -> R.string.errors_not_supported + is PaymentException.InvalidRequest, + is PaymentException.Network, + null -> R.string.errors_error_occurred +} + +private fun PaymentOutcomeUIModel.messageRes(): Int? = when (this) { + PaymentOutcomeUIModel.Success -> R.string.transaction_status_confirmed + PaymentOutcomeUIModel.Pending -> R.string.transaction_status_pending + PaymentOutcomeUIModel.Cancelled -> null + PaymentOutcomeUIModel.Expired -> R.string.errors_payment_expired + PaymentOutcomeUIModel.Failed -> R.string.transaction_status_failed +} diff --git a/android/features/payment/viewmodels/build.gradle.kts b/android/features/payment/viewmodels/build.gradle.kts new file mode 100644 index 0000000000..9b9c9ecac1 --- /dev/null +++ b/android/features/payment/viewmodels/build.gradle.kts @@ -0,0 +1,67 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +plugins { + alias(libs.plugins.android.library) + id("com.google.devtools.ksp") +} + +android { + namespace = "com.gemwallet.android.features.payment.viewmodels" + compileSdk = 37 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") + } + } + packaging { + resources { + excludes += "META-INF/*" + excludes += "META-INF/DEPENDENCIES" + excludes += "/META-INF/LICENSE-notice.md" + excludes += "/META-INF/LICENSE.md" + excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF" + } + } +} + +dependencies { + api(project(":ui-models")) + implementation(project(":ui")) + implementation(project(":data:repositories")) + implementation(project(":gemcore")) + implementation(project(":blockchain")) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.lifecycle.viewmodel.savedstate) + + testImplementation(libs.junit) + testImplementation(testFixtures(project(":gemcore"))) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/android/features/payment/viewmodels/consumer-rules.pro b/android/features/payment/viewmodels/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/features/payment/viewmodels/proguard-rules.pro b/android/features/payment/viewmodels/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/android/features/payment/viewmodels/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt new file mode 100644 index 0000000000..bcdba43c48 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePayment.kt @@ -0,0 +1,53 @@ +package com.gemwallet.android.features.payment.viewmodels + +import com.wallet.core.primitives.Wallet +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.PaymentAction + +internal data class ActivePayment( + val provider: GemPaymentProviderName, + val quotes: GemPaymentQuotes, + val wallet: Wallet, + val quote: GemPaymentQuote? = null, + val collecting: GemPaymentQuote? = null, + val actions: List = emptyList(), + val order: List = emptyList(), + val results: List = emptyList(), + val completed: Int = 0, +) { + val step: Step? + get() = order.getOrNull(completed)?.let { Step(actions[it], it) } + + fun collecting(quote: GemPaymentQuote) = copy(collecting = quote) + + fun prepared(quote: GemPaymentQuote, actions: List) = copy( + quote = quote, + collecting = null, + actions = actions, + order = signaturesFirst(actions), + results = List(actions.size) { "" }, + completed = 0, + ) + + fun completing(result: String): ActivePayment { + val index = order.getOrNull(completed) ?: return this + return copy( + results = results.toMutableList().also { it[index] = result }, + completed = completed + 1, + ) + } + + val isRelayed: Boolean + get() = actions.none { it is PaymentAction.SendTransaction } + + data class Step(val action: PaymentAction, val index: Int) +} + +private fun signaturesFirst(actions: List): List { + val signatures = actions.indices.filter { + actions[it] is PaymentAction.SignMessage || actions[it] is PaymentAction.SignTransaction + } + return signatures + actions.indices.filter { it !in signatures } +} diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt new file mode 100644 index 0000000000..5e36302e04 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentSceneState.kt @@ -0,0 +1,66 @@ +package com.gemwallet.android.features.payment.viewmodels + +import com.gemwallet.android.features.payment.viewmodels.model.PaymentMerchantUIModel +import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel +import com.gemwallet.android.features.payment.viewmodels.model.PaymentQuoteUIModel +import com.gemwallet.android.model.ConfirmParams +import com.gemwallet.android.ui.models.PayloadField +import com.wallet.core.primitives.Chain +import com.wallet.core.primitives.WalletType +import uniffi.gemstone.PaymentException + +sealed interface PaymentSceneState { + data object Loading : PaymentSceneState + + data class Quotes( + val merchant: PaymentMerchantUIModel, + val walletName: String, + val walletType: WalletType, + val walletChain: Chain?, + val price: String?, + val quotes: List, + val selected: String?, + val expiresAt: Long?, + val expired: Boolean, + ) : PaymentSceneState { + val selectedQuote: PaymentQuoteUIModel? + get() = quotes.firstOrNull { it.id == selected } + } + + data class CollectData(val url: String) : PaymentSceneState + + data class Approve( + val params: ConfirmParams.TokenApprovalParams, + ) : PaymentSceneState + + data class Confirm( + val params: ConfirmParams.TransferParams.Generic, + ) : PaymentSceneState + + data class SignMessage( + val merchant: PaymentMerchantUIModel, + val chain: Chain, + val walletName: String, + val plainMessage: String, + val primaryPayloadFields: List, + val secondaryPayloadFields: List, + ) : PaymentSceneState { + val hasPayload: Boolean + get() = primaryPayloadFields.isNotEmpty() || secondaryPayloadFields.isNotEmpty() + } + + data class Outcome(val outcome: PaymentOutcomeUIModel) : PaymentSceneState + + data class Error(val error: PaymentError) : PaymentSceneState +} + +sealed interface PaymentError { + data object NoWallet : PaymentError + data object WatchWallet : PaymentError + data object NoQuotes : PaymentError + data object QuoteUnavailable : PaymentError + data object NoAccount : PaymentError + data object DataCollection : PaymentError + data object UnknownAsset : PaymentError + data class Gateway(val error: PaymentException?) : PaymentError +} diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt new file mode 100644 index 0000000000..bfc08b2afb --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/PaymentViewModel.kt @@ -0,0 +1,316 @@ +package com.gemwallet.android.features.payment.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.PasswordStore +import com.gemwallet.android.application.assets.coordinators.GetAssetInfo +import com.gemwallet.android.blockchain.gemstone.toPrimitives +import com.gemwallet.android.blockchain.services.GemSignMessageOperator +import com.gemwallet.android.cases.tokens.SearchTokensCase +import com.gemwallet.android.data.repositories.session.SessionRepository +import com.gemwallet.android.ext.SigningRequestApp +import com.gemwallet.android.ext.runCatchingCancellable +import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.ext.toChain +import com.gemwallet.android.ext.toConfirmParams +import com.gemwallet.android.ext.toPrimitives +import com.gemwallet.android.features.payment.viewmodels.model.PaymentOutcomeUIModel +import com.gemwallet.android.features.payment.viewmodels.model.toPriceText +import com.gemwallet.android.features.payment.viewmodels.model.toUIModel +import com.gemwallet.android.model.ConfirmParams +import com.gemwallet.android.ui.models.withExplorerLinks +import com.wallet.core.primitives.Account +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.Wallet +import com.wallet.core.primitives.WalletType +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import uniffi.gemstone.ChainAddress as GemChainAddress +import uniffi.gemstone.GemPaymentLink +import uniffi.gemstone.GemPaymentOptions +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.GemPaymentService +import uniffi.gemstone.MessageSigner +import uniffi.gemstone.PaymentAction +import uniffi.gemstone.PaymentException +import uniffi.gemstone.SignableTransaction + +@HiltViewModel +class PaymentViewModel @Inject constructor( + private val paymentService: GemPaymentService, + private val sessionRepository: SessionRepository, + private val signMessageOperator: GemSignMessageOperator, + private val passwordStore: PasswordStore, + private val getAssetInfo: GetAssetInfo, + private val searchTokensCase: SearchTokensCase, + private val recordPayment: RecordPayment, +) : ViewModel() { + + private val state = MutableStateFlow(PaymentSceneState.Loading) + val sceneState = state.asStateFlow() + + private val payment = MutableStateFlow(null) + private val lock = Mutex() + private var expiryJob: Job? = null + + fun onPayment(provider: GemPaymentProviderName, paymentId: String) { + val link = GemPaymentLink(provider = provider, id = paymentId) + state.value = PaymentSceneState.Loading + viewModelScope.launch(Dispatchers.IO) { + val wallet = wallet() ?: return@launch + val options = runGateway { paymentService.getPaymentOptions(link, wallet.addresses()) } ?: return@launch + when (options) { + is GemPaymentOptions.Outcome -> state.value = PaymentSceneState.Outcome(options.v1.status.toUIModel()) + is GemPaymentOptions.Quotes -> { + payment.value = ActivePayment(link.provider, options.v1, wallet) + val quotes = options.v1 + if (quotes.quotes.size > 1) { + state.value = quotes.toSceneState(wallet) + watchExpiry(quotes) + } else { + select(quotes.quotes.firstOrNull()) + } + } + } + } + } + + fun onSelectQuote(quoteId: String) { + val current = state.value as? PaymentSceneState.Quotes ?: return + state.value = current.copy(selected = quoteId) + } + + fun onConfirmQuote() { + val selected = (state.value as? PaymentSceneState.Quotes)?.selected ?: return + val quote = payment.value?.quotes?.quotes?.firstOrNull { it.id == selected } + if (quote == null) { + state.value = failure(PaymentError.QuoteUnavailable, "confirm: quote $selected is gone") + return + } + expiryJob?.cancel() + state.value = PaymentSceneState.Loading + viewModelScope.launch(Dispatchers.IO) { select(quote) } + } + + fun onDataCollected() { + val quote = payment.value?.collecting ?: return + state.value = PaymentSceneState.Loading + viewModelScope.launch(Dispatchers.IO) { prepare(quote) } + } + + fun onDataCollectionError(message: String?) { + Log.e(TAG, "Payment data collection failed: $message") + state.value = PaymentSceneState.Error(PaymentError.DataCollection) + } + + fun onActionResult(result: String) { + viewModelScope.launch(Dispatchers.IO) { + lock.withLock { + payment.value?.step ?: return@launch + payment.value = payment.value?.completing(result) + } + advance() + } + } + + fun onSign() { + val current = payment.value ?: return + val action = current.step?.action as? PaymentAction.SignMessage ?: return + viewModelScope.launch(Dispatchers.IO) { + val signature = runGateway { + signMessageOperator.sign( + MessageSigner(action.message), + current.wallet, + passwordStore.getPassword(current.wallet.id.id), + ) + } ?: return@launch + onActionResult(signature) + } + } + + private fun GemPaymentQuotes.toSceneState(wallet: Wallet) = PaymentSceneState.Quotes( + merchant = merchant.toUIModel(), + walletName = wallet.name, + walletType = wallet.type, + walletChain = wallet.accounts.firstOrNull()?.chain, + price = price?.toPriceText(), + quotes = quotes.map { it.toUIModel() }, + selected = quotes.firstOrNull()?.id, + expiresAt = expiresAt?.times(1000), + expired = false, + ) + + private fun watchExpiry(quotes: GemPaymentQuotes) { + val expiresAt = quotes.expiresAt ?: return + expiryJob = viewModelScope.launch(Dispatchers.IO) { + delay((expiresAt * 1000 - System.currentTimeMillis()).coerceAtLeast(0)) + val current = state.value + if (current is PaymentSceneState.Quotes) { + state.value = current.copy(expired = true) + } + } + } + + private suspend fun select(quote: GemPaymentQuote?) { + if (quote == null) { + state.value = PaymentSceneState.Error(PaymentError.NoQuotes) + return + } + val collectDataUrl = quote.collectDataUrl + if (collectDataUrl == null) { + prepare(quote) + return + } + payment.value = payment.value?.collecting(quote) + state.value = PaymentSceneState.CollectData(collectDataUrl) + } + + private suspend fun prepare(quote: GemPaymentQuote) { + val current = payment.value ?: return + val prepared = runGateway { + paymentService.getPreparedPayment(current.provider, current.quotes, quote, current.wallet.addresses()) + } ?: return + payment.value = current.prepared(prepared.quote, prepared.actions) + advance() + } + + private suspend fun advance() { + val current = payment.value ?: return + val step = current.step + if (step == null) { + val quote = current.quote ?: return + if (current.isRelayed) { + recordPayment(current.provider.toPrimitives(), current.quotes, quote, current.wallet) + } + val settled = runCatchingCancellable { + paymentService.confirmPayment(current.provider, quote, current.results) + }.onFailure { Log.e(TAG, "Confirm payment failed", it) }.getOrNull() + state.value = PaymentSceneState.Outcome(settled?.status?.toUIModel() ?: PaymentOutcomeUIModel.Pending) + return + } + state.value = when (val action = step.action) { + is PaymentAction.SignMessage -> signMessageState(action, current) + is PaymentAction.SendTransaction -> confirmState(action.chain, action.transaction, true, current) + is PaymentAction.SignTransaction -> confirmState(action.chain, action.transaction, false, current) + is PaymentAction.ApproveToken -> approvalState(action, current) + } + } + + private fun signMessageState( + action: PaymentAction.SignMessage, + current: ActivePayment, + ): PaymentSceneState { + val chain = action.message.chain.toChain() ?: return PaymentSceneState.Error(PaymentError.NoAccount) + val signer = runCatching { MessageSigner(action.message) }.getOrNull() + val preview = signer?.let { runCatching { it.payloadPreview(emptyList()) }.getOrNull() } + return PaymentSceneState.SignMessage( + merchant = current.quotes.merchant.toUIModel(), + chain = chain, + walletName = current.wallet.name, + plainMessage = signer?.let { runCatching { it.plainPreview() }.getOrNull() }.orEmpty(), + primaryPayloadFields = preview?.primary?.map { it.toPrimitives() }.orEmpty() + .withExplorerLinks(chain, null), + secondaryPayloadFields = preview?.secondary?.map { it.toPrimitives() }.orEmpty() + .withExplorerLinks(chain, null), + ) + } + + private suspend fun approvalState( + action: PaymentAction.ApproveToken, + current: ActivePayment, + ): PaymentSceneState { + val account = current.wallet.account(action.chain) ?: return failure(PaymentError.NoAccount, "approval: no ${action.chain} account") + val assetId = current.quote?.amount?.assetId?.toAssetId() + ?: return failure(PaymentError.UnknownAsset, "approval: bad quote asset ${current.quote?.amount?.assetId}") + val asset = asset(assetId) ?: return failure(PaymentError.UnknownAsset, "approval: unresolved asset ${action.approval.token}") + return PaymentSceneState.Approve( + ConfirmParams.TokenApprovalParams( + asset = asset, + from = account, + data = "", + provider = current.quotes.merchant.name, + contract = action.approval.spender, + ) + ) + } + + private fun confirmState( + chain: String, + transaction: SignableTransaction, + isSendable: Boolean, + current: ActivePayment, + ): PaymentSceneState { + val account = current.wallet.account(chain) ?: return PaymentSceneState.Error(PaymentError.NoAccount) + return PaymentSceneState.Confirm( + transaction.toConfirmParams( + requestId = current.quote?.paymentId.orEmpty(), + account = account, + app = SigningRequestApp( + name = current.quotes.merchant.name, + description = current.quotes.merchant.name, + url = "", + icon = current.quotes.merchant.iconUrl.orEmpty(), + ), + isSendable = isSendable, + inputType = if (isSendable) { + ConfirmParams.TransferParams.InputType.EncodeTransaction + } else { + ConfirmParams.TransferParams.InputType.Signature + }, + ) + ) + } + + private fun failure(error: PaymentError, reason: String): PaymentSceneState { + Log.e(TAG, reason) + return PaymentSceneState.Error(error) + } + + private suspend fun asset(assetId: AssetId): Asset? = getAssetInfo(assetId).firstOrNull()?.asset + ?: sessionRepository.session().firstOrNull()?.currency + ?.also { searchTokensCase.search(assetId, it) } + ?.let { getAssetInfo(assetId).firstOrNull()?.asset } + + private suspend fun wallet(): Wallet? { + val wallet = sessionRepository.session().firstOrNull()?.wallet + if (wallet == null) { + state.value = PaymentSceneState.Error(PaymentError.NoWallet) + return null + } + if (wallet.type == WalletType.View) { + state.value = PaymentSceneState.Error(PaymentError.WatchWallet) + return null + } + return wallet + } + + private suspend fun runGateway(block: suspend () -> T): T? = runCatchingCancellable(block) + .onFailure { err -> + Log.e(TAG, "Payment gateway request failed", err) + state.value = PaymentSceneState.Error(PaymentError.Gateway(err as? PaymentException)) + } + .getOrNull() + + private fun Wallet.addresses(): List = + accounts.map { GemChainAddress(chain = it.chain.string, address = it.address) } + + private fun Wallet.account(chain: String): Account? = + accounts.firstOrNull { it.chain.string == chain } + + private companion object { + const val TAG = "PaymentViewModel" + } +} diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt new file mode 100644 index 0000000000..968fd1ffc9 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/RecordPayment.kt @@ -0,0 +1,70 @@ +package com.gemwallet.android.features.payment.viewmodels + +import android.util.Log +import com.gemwallet.android.cases.transactions.CreateTransaction +import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.model.Fee +import com.gemwallet.android.serializer.toJson +import com.wallet.core.primitives.AssetId +import com.wallet.core.primitives.FeePriority +import com.wallet.core.primitives.PaymentMerchant +import com.wallet.core.primitives.PaymentProviderName +import com.wallet.core.primitives.TransactionDirection +import com.wallet.core.primitives.TransactionPaymentMetadata +import com.wallet.core.primitives.TransactionState +import com.wallet.core.primitives.TransactionType +import com.wallet.core.primitives.Wallet +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import java.math.BigInteger +import javax.inject.Inject + +class RecordPayment @Inject constructor( + private val createTransaction: CreateTransaction, +) { + suspend operator fun invoke( + provider: PaymentProviderName, + quotes: GemPaymentQuotes, + quote: GemPaymentQuote, + wallet: Wallet, + ) { + val assetId = quote.amount.assetId.toAssetId() + if (assetId == null) { + Log.e(TAG, "Record payment: bad asset ${quote.amount.assetId}") + return + } + val account = wallet.accounts.firstOrNull { it.chain == assetId.chain } + if (account == null) { + Log.e(TAG, "Record payment: no ${assetId.chain} account") + return + } + createTransaction.createTransaction( + hash = quote.paymentId, + walletId = wallet.id, + assetId = assetId, + owner = account, + to = "", + state = TransactionState.Pending, + fee = Fee.Plain( + feeAssetId = AssetId(assetId.chain), + priority = FeePriority.Normal, + amount = BigInteger.ZERO, + options = emptyMap(), + ), + amount = BigInteger(quote.amount.value), + memo = "", + type = TransactionType.Transfer, + metadata = TransactionPaymentMetadata( + paymentId = quote.paymentId, + merchant = PaymentMerchant(quotes.merchant.name, quotes.merchant.iconUrl), + provider = provider, + ).toJson(), + direction = TransactionDirection.Outgoing, + blockNumber = "0", + ) + } + + private companion object { + const val TAG = "RecordPayment" + } +} diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt new file mode 100644 index 0000000000..d63dc2269a --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentMerchantUIModel.kt @@ -0,0 +1,13 @@ +package com.gemwallet.android.features.payment.viewmodels.model + +import uniffi.gemstone.GemPaymentMerchant + +data class PaymentMerchantUIModel( + val name: String, + val iconUrl: String?, +) + +fun GemPaymentMerchant.toUIModel() = PaymentMerchantUIModel( + name = name, + iconUrl = iconUrl, +) diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt new file mode 100644 index 0000000000..dd5e843e93 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentOutcomeUIModel.kt @@ -0,0 +1,20 @@ +package com.gemwallet.android.features.payment.viewmodels.model + +import uniffi.gemstone.GemPaymentStatus + +enum class PaymentOutcomeUIModel { + Success, + Pending, + Cancelled, + Expired, + Failed, +} + +fun GemPaymentStatus.toUIModel() = when (this) { + GemPaymentStatus.SUCCEEDED -> PaymentOutcomeUIModel.Success + GemPaymentStatus.PROCESSING -> PaymentOutcomeUIModel.Pending + GemPaymentStatus.CANCELLED -> PaymentOutcomeUIModel.Cancelled + GemPaymentStatus.EXPIRED -> PaymentOutcomeUIModel.Expired + GemPaymentStatus.FAILED, + GemPaymentStatus.REQUIRES_ACTION -> PaymentOutcomeUIModel.Failed +} diff --git a/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt new file mode 100644 index 0000000000..ad5227e2b9 --- /dev/null +++ b/android/features/payment/viewmodels/src/main/kotlin/com/gemwallet/android/features/payment/viewmodels/model/PaymentQuoteUIModel.kt @@ -0,0 +1,35 @@ +package com.gemwallet.android.features.payment.viewmodels.model + +import com.gemwallet.android.domains.asset.getIconUrl +import com.gemwallet.android.domains.asset.getSupportIconUrl +import com.gemwallet.android.ext.toAssetId +import com.gemwallet.android.model.Crypto +import com.gemwallet.android.model.ValueFormatter +import uniffi.gemstone.GemPaymentPrice +import uniffi.gemstone.GemPaymentQuote + +private val amountFormatter = ValueFormatter(ValueFormatter.Style.Short) +private val priceFormatter = ValueFormatter(ValueFormatter.Style.Full) + +data class PaymentQuoteUIModel( + val id: String, + val symbol: String, + val amount: String, + val iconUrl: String?, + val supportIconUrl: String?, +) { + val amountText: String get() = "$amount $symbol" +} + +fun GemPaymentQuote.toUIModel(): PaymentQuoteUIModel { + val assetId = amount.assetId.toAssetId() + return PaymentQuoteUIModel( + id = id, + symbol = amount.symbol, + amount = amountFormatter.string(Crypto(amount.value).value(amount.decimals)), + iconUrl = assetId?.getIconUrl(), + supportIconUrl = assetId?.getSupportIconUrl(), + ) +} + +fun GemPaymentPrice.toPriceText(): String = priceFormatter.string(Crypto(value).value(decimals), currency = symbol) diff --git a/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt b/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt new file mode 100644 index 0000000000..8db2932c17 --- /dev/null +++ b/android/features/payment/viewmodels/src/test/kotlin/com/gemwallet/android/features/payment/viewmodels/ActivePaymentTest.kt @@ -0,0 +1,84 @@ +package com.gemwallet.android.features.payment.viewmodels + +import com.gemwallet.android.testkit.mockWallet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import uniffi.gemstone.GemApprovalData +import uniffi.gemstone.GemPaymentAmount +import uniffi.gemstone.GemPaymentMerchant +import uniffi.gemstone.GemPaymentProviderName +import uniffi.gemstone.GemPaymentQuote +import uniffi.gemstone.GemPaymentQuotes +import uniffi.gemstone.PaymentAction +import uniffi.gemstone.SignDigestType +import uniffi.gemstone.SignMessage + +class ActivePaymentTest { + + @Test + fun `signs before it spends`() { + val payment = payment(listOf(approve(), signMessage())) + + assertEquals(listOf(1, 0), payment.order) + } + + @Test + fun `results keep the gateway's positions while signing first`() { + var payment = payment(listOf(approve(), signMessage())) + + payment = payment.completing("signature") + payment = payment.completing("approval-hash") + + assertEquals(listOf("approval-hash", "signature"), payment.results) + assertNull(payment.step) + } + + @Test + fun `completing past the last action is a no-op`() { + var payment = payment(listOf(signMessage())) + payment = payment.completing("signature") + + val settled = payment.completing("duplicate") + + assertEquals(listOf("signature"), settled.results) + assertEquals(1, settled.completed) + } + + private fun payment(actions: List): ActivePayment = + ActivePayment( + provider = GemPaymentProviderName.WALLET_CONNECT_PAY, + quotes = quotes(), + wallet = mockWallet(), + ).prepared(quote(), actions) + + private fun quotes() = GemPaymentQuotes( + merchant = GemPaymentMerchant(name = "Gem Wallet Test Merchant", iconUrl = null), + price = null, + expiresAt = null, + quotes = listOf(quote()), + ) + + private fun quote() = GemPaymentQuote( + id = "opt_1", + paymentId = "pay_1", + amount = GemPaymentAmount( + assetId = "ethereum", + value = "1", + symbol = "USDT", + decimals = 6, + ), + expiresAt = null, + collectDataUrl = null, + providerData = "", + ) + + private fun signMessage() = PaymentAction.SignMessage( + SignMessage(chain = "ethereum", signType = SignDigestType.EIP712, data = ByteArray(0)), + ) + + private fun approve() = PaymentAction.ApproveToken( + chain = "ethereum", + approval = GemApprovalData(token = "0xtoken", spender = "0xspender", value = "1", isUnlimited = true), + ) +} diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/ext/GemPayment.kt index 4bb9b5a73b..794e8f71ca 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 @@ -30,3 +30,8 @@ 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/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 13c6ceb85fa12146c61f75e963c8d8e614aa87ac Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:16:17 +0300 Subject: [PATCH 25/27] Android: reconcile payments with their chain transaction A relayed payment is recorded under its payment id before a chain hash exists. Poll the gateway for that hash, then either rename the record or, when the indexer already delivered the same transaction, drop the placeholder and adopt the existing row. Stay pending until a hash arrives so the poller keeps checking, and hand the adopted record back so the polling job finishes on it instead of writing the deleted row again at timeout. --- .../repositories/di/TransactionsModule.kt | 3 + .../TransactionsRepositoryImpl.kt | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) 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) { From 341c44179a093f3123eaa16ed286108ae0a12c6b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:16:26 +0300 Subject: [PATCH 26/27] Android: name the merchant on payment activity A payment has no recipient address, so the activity row read "To" with nothing after it and the details screen offered an address link that led nowhere. Take the merchant from the payment metadata for both, and give the details screen a destination that renders as plain text. --- .../transaction/GetTransactionDetailsImpl.kt | 5 ++++- .../data/coordinators/transaction/GetTransactionsImpl.kt | 3 ++- .../details/components/DestinationPropertyItem.kt | 5 +++++ .../transaction/values/TransactionDetailsValue.kt | 1 + .../com/gemwallet/android/ext/TransactionExtendedExt.kt | 9 +++++++++ 5 files changed, 21 insertions(+), 2 deletions(-) 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/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/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/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 || From 8c017053532de5adc7a8ccdff3f3da5efa7916ee Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:16:35 +0300 Subject: [PATCH 27/27] Android: log why a confirm preload failed Any unrecognised failure became a fee calculation error with the cause discarded, so the screen reported an estimation problem for faults that had nothing to do with fees. Log the throwable where it is swallowed. --- .../features/confirm/viewmodels/ConfirmErrorMapper.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt index 589242047a..92dc8739a6 100644 --- a/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt +++ b/android/features/confirm/viewmodels/src/main/kotlin/com/gemwallet/android/features/confirm/viewmodels/ConfirmErrorMapper.kt @@ -1,5 +1,6 @@ package com.gemwallet.android.features.confirm.viewmodels +import android.util.Log import com.gemwallet.android.domains.confirm.ConfirmError import com.gemwallet.android.ext.toGemNetworkError import com.gemwallet.android.model.GemPlatformErrors @@ -12,9 +13,11 @@ internal fun Throwable.toPreloadConfirmError(chain: Chain): ConfirmError { } return toGemNetworkError() ?.let { ConfirmError.NetworkError(it) } - ?: ConfirmError.PreloadError + ?: ConfirmError.PreloadError.also { Log.e(TAG, "Preload failed on $chain", this) } } +private const val TAG = "ConfirmPreload" + internal fun Throwable.toBroadcastConfirmError(): ConfirmError = when (this) { is ConfirmError -> this else -> toGemNetworkError()