diff --git a/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1c24089c5c..799c3c0299 100644 --- a/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/p2p_wallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -312,7 +312,7 @@ "location" : "https://github.com/p2p-org/solana-swift", "state" : { "branch" : "main", - "revision" : "f9181079014c474c0e823a2f3f615ba953b41f1e" + "revision" : "3811cabe260e4b88e8096527a50eee8d23b41204" } }, { diff --git a/p2p_wallet/AppDelegate/AppCoordinator/AppCoordinator.swift b/p2p_wallet/AppDelegate/AppCoordinator/AppCoordinator.swift index c854a28527..795f333db5 100644 --- a/p2p_wallet/AppDelegate/AppCoordinator/AppCoordinator.swift +++ b/p2p_wallet/AppDelegate/AppCoordinator/AppCoordinator.swift @@ -131,6 +131,12 @@ final class AppCoordinator: Coordinator { await Resolver.resolve(JupiterTokensRepository.self).load() } + if available(.referralProgramEnabled) { + Task { + await Resolver.resolve(ReferralProgramService.self).register() + } + } + Task { // load services if available(.sellScenarioEnabled) { diff --git a/p2p_wallet/AppDelegate/AppDelegateProxyService/DeeplinkAppDelegateService.swift b/p2p_wallet/AppDelegate/AppDelegateProxyService/DeeplinkAppDelegateService.swift index f6fa47d197..e285d8e54f 100644 --- a/p2p_wallet/AppDelegate/AppDelegateProxyService/DeeplinkAppDelegateService.swift +++ b/p2p_wallet/AppDelegate/AppDelegateProxyService/DeeplinkAppDelegateService.swift @@ -68,9 +68,17 @@ final class DeeplinkAppDelegateService: NSObject, AppDelegateService { } // Swap via link - // https://s.key.app/swap?inputMint=&outputMint= + // https://s.key.app/swap?inputMint=&outputMint=&r= if urlComponents.host == "s.key.app" { GlobalAppState.shared.swapUrl = urlComponents.url + + if let referrer = urlComponents.queryItems?.first { $0.name == "r" }?.value { + setReferrerIfNeeded(r: referrer) + } + } + + if urlComponents.host == "r.key.app" { + setReferrerIfNeeded(r: String(urlComponents.path.dropFirst())) } } @@ -121,6 +129,18 @@ final class DeeplinkAppDelegateService: NSObject, AppDelegateService { else if scheme == "keyapp", host == "swap" { GlobalAppState.shared.swapUrl = components.url } + // keyapp://referral + else if scheme == "keyapp", host == "referral" { + setReferrerIfNeeded(r: components.path) + } + } + + private func setReferrerIfNeeded(r: String) { + guard available(.referralProgramEnabled) else { return } + let referralService: ReferralProgramService = Resolver.resolve() + Task { + _ = await referralService.setReferent(from: r) + } } } diff --git a/p2p_wallet/Common/Services/Defaults.swift b/p2p_wallet/Common/Services/Defaults.swift index 183918f41e..ae83f98b45 100644 --- a/p2p_wallet/Common/Services/Defaults.swift +++ b/p2p_wallet/Common/Services/Defaults.swift @@ -41,6 +41,7 @@ extension DefaultsKeys { var forcedFeeRelayerEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } var forcedNameServiceEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } var forcedNewSwapEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } + var forcedReferralProgramEndpoint: DefaultsKey { .init(#function, defaultValue: nil) } var didBackupOffline: DefaultsKey { .init(#function, defaultValue: false) } var walletName: DefaultsKey<[String: String]> { .init(#function, defaultValue: [:]) } @@ -123,6 +124,10 @@ extension DefaultsKeys { var ethBannerShouldHide: DefaultsKey { .init(#function, defaultValue: false) } + + var referrerRegistered: DefaultsKey { + .init(#function, defaultValue: false) + } } // MARK: - Moonpay Environment diff --git a/p2p_wallet/Common/Services/FeatureFlags/Features.swift b/p2p_wallet/Common/Services/FeatureFlags/Features.swift index 1b15432e67..205b66c64b 100755 --- a/p2p_wallet/Common/Services/FeatureFlags/Features.swift +++ b/p2p_wallet/Common/Services/FeatureFlags/Features.swift @@ -27,4 +27,7 @@ public extension Feature { static let sendViaLinkEnabled = Feature(rawValue: "send_via_link_enabled") static let solanaEthAddressEnabled = Feature(rawValue: "solana_eth_address_enabled") + + // Referral program + static let referralProgramEnabled = Feature(rawValue: "referral_program_enabled") } diff --git a/p2p_wallet/Common/Services/GlobalAppState.swift b/p2p_wallet/Common/Services/GlobalAppState.swift index a766d276df..f88279a0ef 100644 --- a/p2p_wallet/Common/Services/GlobalAppState.swift +++ b/p2p_wallet/Common/Services/GlobalAppState.swift @@ -38,6 +38,15 @@ class GlobalAppState: ObservableObject { } } + @Published var newReferralProgramEndpoint: String { + didSet { + Defaults.forcedReferralProgramEndpoint = newReferralProgramEndpoint + ResolverScope.session.reset() + } + } + + @Published var referralProgramAPIEndoint = String.secretConfig("REFERRAL_PROGRAM_API_ENDPOINT_PROD")! + // TODO: Refactor! @Published var surveyID: String? @Published var sendViaLinkUrl: URL? @@ -55,6 +64,12 @@ class GlobalAppState: ObservableObject { } else { newSwapEndpoint = "https://swap-v6.key.app" } + + if let forcedValue = Defaults.forcedReferralProgramEndpoint { + newReferralProgramEndpoint = forcedValue + } else { + newReferralProgramEndpoint = ReferralProgramViewModel.Constants.urlString + } } @Published var bridgeEndpoint: String = (Environment.current == .release) ? diff --git a/p2p_wallet/Common/Services/ReferralProgram/Model/ReferralTimedSignature.swift b/p2p_wallet/Common/Services/ReferralProgram/Model/ReferralTimedSignature.swift new file mode 100644 index 0000000000..b4ee0f0430 --- /dev/null +++ b/p2p_wallet/Common/Services/ReferralProgram/Model/ReferralTimedSignature.swift @@ -0,0 +1,6 @@ +import Foundation + +struct ReferralTimedSignature: Encodable { + let timestamp: Int64 + let signature: String +} diff --git a/p2p_wallet/Common/Services/ReferralProgram/Model/RegisterUserRequest.swift b/p2p_wallet/Common/Services/ReferralProgram/Model/RegisterUserRequest.swift new file mode 100644 index 0000000000..9267c168b9 --- /dev/null +++ b/p2p_wallet/Common/Services/ReferralProgram/Model/RegisterUserRequest.swift @@ -0,0 +1,30 @@ +import Foundation +import SolanaSwift +import TweetNacl + +struct RegisterUserRequest: Encodable { + let user: String + let timedSignature: ReferralTimedSignature + + enum CodingKeys: String, CodingKey { + case user, timedSignature = "timed_signature" + } +} + +struct RegisterUserSignature: BorshSerializable { + let user: String + let referrent: String? + let timestamp: Int64 + + func serialize(to writer: inout Data) throws { + try user.serialize(to: &writer) + try Optional(referrent)?.serialize(to: &writer) + try timestamp.serialize(to: &writer) + } + + func sign(secretKey: Data) throws -> Data { + var data = Data() + try serialize(to: &data) + return try NaclSign.signDetached(message: data, secretKey: secretKey) + } +} diff --git a/p2p_wallet/Common/Services/ReferralProgram/Model/SetReferentRequest.swift b/p2p_wallet/Common/Services/ReferralProgram/Model/SetReferentRequest.swift new file mode 100644 index 0000000000..81a61118bd --- /dev/null +++ b/p2p_wallet/Common/Services/ReferralProgram/Model/SetReferentRequest.swift @@ -0,0 +1,31 @@ +import Foundation +import SolanaSwift +import TweetNacl + +struct SetReferentRequest: Encodable { + let user: String + let referent: String + let timedSignature: ReferralTimedSignature + + enum CodingKeys: String, CodingKey { + case user, referent, timedSignature = "timed_signature" + } +} + +struct SetReferentSignature: BorshSerializable { + let user: String + let referent: String + let timestamp: Int64 + + func serialize(to writer: inout Data) throws { + try user.serialize(to: &writer) + try referent.serialize(to: &writer) + try timestamp.serialize(to: &writer) + } + + func sign(secretKey: Data) throws -> Data { + var data = Data() + try serialize(to: &data) + return try NaclSign.signDetached(message: data, secretKey: secretKey) + } +} diff --git a/p2p_wallet/Common/Services/ReferralProgram/ReferralProgramService.swift b/p2p_wallet/Common/Services/ReferralProgram/ReferralProgramService.swift new file mode 100644 index 0000000000..ee013117e1 --- /dev/null +++ b/p2p_wallet/Common/Services/ReferralProgram/ReferralProgramService.swift @@ -0,0 +1,114 @@ +import Foundation +import KeyAppBusiness +import KeyAppNetworking +import Resolver +import SolanaSwift +import TweetNacl + +protocol ReferralProgramService { + var referrer: String { get } + var shareLink: URL { get } + + func register() async + func setReferent(from: String) async +} + +enum ReferralProgramServiceError: Error { + case failedSet +} + +final class ReferralProgramServiceImpl { + // MARK: - Dependencies + + @Injected private var nameStorage: NameStorageType + @Injected private var userWallet: UserWalletManager + + private let jsonrpcClient = JSONRPCHTTPClient() + + // MARK: - Private properties + + private var baseURL: String { + GlobalAppState.shared.referralProgramAPIEndoint + } + + private var currentUserAddress: String { + userWallet.wallet?.account.publicKey.base58EncodedString ?? "" + } +} + +// MARK: - ReferralProgramService + +extension ReferralProgramServiceImpl: ReferralProgramService { + var referrer: String { + nameStorage.getName() ?? currentUserAddress + } + + var shareLink: URL { + URL(string: "https://r.key.app/\(referrer)")! + } + + func register() async { + guard !Defaults.referrerRegistered else { return } + do { + guard let secret = userWallet.wallet?.account.secretKey else { throw ReferralProgramServiceError.failedSet } + let timestamp = Int64(Date().timeIntervalSince1970) + let signed = try RegisterUserSignature( + user: currentUserAddress, referrent: nil, timestamp: timestamp + ) + .sign(secretKey: secret) + let _: String? = try await jsonrpcClient.request( + baseURL: baseURL, + body: .init( + method: "register", + params: RegisterUserRequest( + user: currentUserAddress, + timedSignature: ReferralTimedSignature( + timestamp: timestamp, signature: signed.toHexString() + ) + ) + ) + ) + Defaults.referrerRegistered = true + } catch { + debugPrint(error) + DefaultLogManager.shared.log( + event: "\(ReferralProgramService.self)_register", + data: error.localizedDescription, + logLevel: LogLevel.error + ) + } + } + + func setReferent(from: String) async { + guard from != currentUserAddress else { return } + do { + guard let secret = userWallet.wallet?.account.secretKey else { throw ReferralProgramServiceError.failedSet } + let timestamp = Int64(Date().timeIntervalSince1970) + let signed = try SetReferentSignature( + user: currentUserAddress, referent: from, timestamp: timestamp + ) + .sign(secretKey: secret) + let _: String? = try await jsonrpcClient.request( + baseURL: baseURL, + body: .init( + method: "set_referent", + params: SetReferentRequest( + user: currentUserAddress, + referent: from, + timedSignature: ReferralTimedSignature( + timestamp: timestamp, + signature: signed.toHexString() + ) + ) + ) + ) + } catch { + debugPrint(error) + DefaultLogManager.shared.log( + event: "\(ReferralProgramService.self)_setReferent", + data: error.localizedDescription, + logLevel: LogLevel.error + ) + } + } +} diff --git a/p2p_wallet/Common/Services/Storage/UserWalletManager.swift b/p2p_wallet/Common/Services/Storage/UserWalletManager.swift index f1e4e428ab..7584823177 100644 --- a/p2p_wallet/Common/Services/Storage/UserWalletManager.swift +++ b/p2p_wallet/Common/Services/Storage/UserWalletManager.swift @@ -105,6 +105,7 @@ class UserWalletManager: ObservableObject { Defaults.isTokenInputTypeChosen = false Defaults.fromTokenAddress = nil Defaults.toTokenAddress = nil + Defaults.referrerRegistered = false walletSettings.reset() diff --git a/p2p_wallet/Info.plist b/p2p_wallet/Info.plist index 17d104cedb..422744fec3 100644 --- a/p2p_wallet/Info.plist +++ b/p2p_wallet/Info.plist @@ -2,6 +2,8 @@ + REFERRAL_PROGRAM_API_ENDPOINT_PROD + $(REFERRAL_PROGRAM_API_ENDPOINT_PROD) AMPLITUDE_API_KEY $(AMPLITUDE_API_KEY) AMPLITUDE_API_KEY_FEATURE diff --git a/p2p_wallet/Injection/Resolver+registerAllServices.swift b/p2p_wallet/Injection/Resolver+registerAllServices.swift index 30954a250d..2da98573d1 100644 --- a/p2p_wallet/Injection/Resolver+registerAllServices.swift +++ b/p2p_wallet/Injection/Resolver+registerAllServices.swift @@ -291,6 +291,9 @@ extension Resolver: ResolverRegistering { .scope(.application) register { Web3(rpcURL: String.secretConfig("ETH_RPC")!) } + + register { ReferralProgramServiceImpl() } + .implements(ReferralProgramService.self) } /// Session scope: Live when user is authenticated diff --git a/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/Contents.json new file mode 100644 index 0000000000..4946f2a4a2 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image 2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "image 2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/image 2@2x.png b/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/image 2@2x.png new file mode 100644 index 0000000000..f759adbd3e Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/image 2@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/image 2@3x.png b/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/image 2@3x.png new file mode 100644 index 0000000000..682e4bd1db Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/referral-icon.imageset/image 2@3x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Contents.json new file mode 100644 index 0000000000..ad0a652ab3 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Share@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Share@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Share@2x.png b/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Share@2x.png new file mode 100644 index 0000000000..7b50a75d2f Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Share@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Share@3x.png b/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Share@3x.png new file mode 100644 index 0000000000..958e8c4c4a Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/share-3.imageset/Share@3x.png differ diff --git a/p2p_wallet/Resources/Base.lproj/Localizable.strings b/p2p_wallet/Resources/Base.lproj/Localizable.strings index 873d340fee..63df09d372 100644 --- a/p2p_wallet/Resources/Base.lproj/Localizable.strings +++ b/p2p_wallet/Resources/Base.lproj/Localizable.strings @@ -593,10 +593,14 @@ "The token %@ from your swap link seems suspicious, therefore we've refreshed swap pair to default." = "The token %@ from your swap link seems suspicious, therefore we've refreshed swap pair to default."; "Charge that you need to pay to send or receive tokenA. It helps maintain the network and ensure smooth transactions." = "Charge that you need to pay to send or receive tokenA. It helps maintain the network and ensure smooth transactions."; "Unfortunately, you can not cashout in %@, but you can still use other Key App features" = "Unfortunately, you can not cashout in %@, but you can still use other Key App features"; +"Share my link" = "Share my link"; +"Open details" = "Open details"; "Token 2022 details" = "Token 2022 details"; "Token 2022 transfer fee" = "Token 2022 transfer fee"; "Calculated by subtracting the token 2022 transfer fee from your balance" = "Calculated by subtracting the token 2022 transfer fee from your balance"; "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance" = "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance"; +"Referral program" = "Referral program"; +"Hey, let’s swap trendy meme coins with me!" = "Hey, let’s swap trendy meme coins with me!"; "%@ all the time" = "%@ all the time"; "Here’s how do we count your profits for total balance and every single token" = "Here’s how do we count your profits for total balance and every single token"; "Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy." = "Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy."; diff --git a/p2p_wallet/Resources/en.lproj/Localizable.strings b/p2p_wallet/Resources/en.lproj/Localizable.strings index 643697605c..f09851701b 100644 --- a/p2p_wallet/Resources/en.lproj/Localizable.strings +++ b/p2p_wallet/Resources/en.lproj/Localizable.strings @@ -581,10 +581,14 @@ "The token %@ is out of the strict list" = "The token %@ is out of the strict list"; "Make sure the mint address %@ is correct before confirming" = "Make sure the mint address %@ is correct before confirming"; "Unfortunately, you can not cashout in %@, but you can still use other Key App features" = "Unfortunately, you can not cashout in %@, but you can still use other Key App features"; +"Share my link" = "Share my link"; +"Open details" = "Open details"; "Token 2022 details" = "Token 2022 details"; "Token 2022 transfer fee" = "Token 2022 transfer fee"; "Calculated by subtracting the token 2022 transfer fee from your balance" = "Calculated by subtracting the token 2022 transfer fee from your balance"; "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance" = "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance"; +"Referral program" = "Referral program"; +"Hey, let’s swap trendy meme coins with me!" = "Hey, let’s swap trendy meme coins with me!"; "%@ all the time" = "%@ all the time"; "Here’s how do we count your profits for total balance and every single token" = "Here’s how do we count your profits for total balance and every single token"; "Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy." = "Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy."; diff --git a/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift b/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift index b21574904b..669f3beaa7 100644 --- a/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift +++ b/p2p_wallet/Scenes/DebugMenu/View/DebugMenuView.swift @@ -21,6 +21,7 @@ struct DebugMenuView: View { solanaEndpoint swapEndpoint nameServiceEndpoint + referralEndpoint } featureTogglers @@ -145,4 +146,10 @@ struct DebugMenuView: View { TextField("New swap endpoint", text: $globalAppState.newSwapEndpoint) } } + + var referralEndpoint: some View { + Section(header: Text("New referral program endpoint")) { + TextField("Value", text: $globalAppState.newReferralProgramEndpoint) + } + } } diff --git a/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift b/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift index e7916acdf7..bdbd730f83 100644 --- a/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift +++ b/p2p_wallet/Scenes/DebugMenu/ViewModel/DebugMenuViewModel.swift @@ -112,6 +112,8 @@ extension DebugMenuViewModel { case onboardingUsernameEnabled case onboardingUsernameButtonSkipEnabled + case referralProgramEnabled + case investSolend case solendDisablePlaceholder @@ -139,6 +141,7 @@ extension DebugMenuViewModel { case .sendViaLink: return "Send via link" case .solanaEthAddressEnabled: return "solana ETH address enabled" case .swapTransactionSimulation: return "Swap transaction simulation" + case .referralProgramEnabled: return "Referral program enabled" } } @@ -157,6 +160,7 @@ extension DebugMenuViewModel { case .sendViaLink: return .sendViaLinkEnabled case .solanaEthAddressEnabled: return .solanaEthAddressEnabled case .swapTransactionSimulation: return .swapTransactionSimulationEnabled + case .referralProgramEnabled: return .referralProgramEnabled } } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift index 83b86d661e..24e4fc2eab 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift @@ -9,6 +9,7 @@ struct CryptoAccountsView: View { @ObservedObject var viewModel: CryptoAccountsViewModel private let actionsPanelView: CryptoActionsPanelView + private let banner: ReferralProgramBannerView? @State var isHiddenSectionDisabled: Bool = true @State var currentUserInteractionCellID: String? @@ -18,10 +19,12 @@ struct CryptoAccountsView: View { init( viewModel: CryptoAccountsViewModel, - actionsPanelView: CryptoActionsPanelView + actionsPanelView: CryptoActionsPanelView, + banner: ReferralProgramBannerView? ) { self.viewModel = viewModel self.actionsPanelView = actionsPanelView + self.banner = banner } // MARK: - View content @@ -34,6 +37,10 @@ struct CryptoAccountsView: View { .padding(.top, 5) .padding(.bottom, 32) .id(0) + if let banner { + banner + .padding(.horizontal, 16) + } content } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift index 479014b0ba..f3cef204aa 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift @@ -53,7 +53,6 @@ final class CryptoAccountsViewModel: BaseViewModel, ObservableObject { self.userActionService = userActionService self.favouriteAccountsStore = favouriteAccountsStore self.navigation = navigation - super.init() defaultsDisposables.append(Defaults.observe(\.hideZeroBalances) { [weak self] change in diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Empty/CryptoEmptyView.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Empty/CryptoEmptyView.swift index 3b8b6d9e7b..aaa00ca25b 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Empty/CryptoEmptyView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Empty/CryptoEmptyView.swift @@ -6,21 +6,30 @@ struct CryptoEmptyView: View { // MARK: - Properties private let actionsPanelView: CryptoActionsPanelView + private let banner: ReferralProgramBannerView? // MARK: - Initializer init( - actionsPanelView: CryptoActionsPanelView + actionsPanelView: CryptoActionsPanelView, + banner: ReferralProgramBannerView? ) { self.actionsPanelView = actionsPanelView + self.banner = banner } // MARK: - View content var body: some View { - VStack(spacing: 30) { - header - content + ScrollView { + VStack(spacing: 30) { + header + if let banner { + banner + .padding(.horizontal, 16) + } + content + } } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift index 70febb676f..ea96276e84 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift @@ -22,6 +22,8 @@ enum CryptoNavigation: Equatable { case solanaAccount(SolanaAccount) case claim(EthereumAccount, WormholeClaimUserAction?) case actions([WalletActionType]) + case referral + case shareReferral(URL) // Empty case topUpCoin(TokenMetadata) // Error @@ -204,12 +206,24 @@ final class CryptoCoordinator: Coordinator { }) .map { _ in () } .eraseToAnyPublisher() + case .referral: + return coordinate(to: ReferralProgramCoordinator(navigationController: navigationController)) + .eraseToAnyPublisher() + case let .shareReferral(link): + let activityVC = UIActivityViewController( + activityItems: ["\(L10n.heyLetSSwapTrendyMemeCoinsWithMe) \(link)"], + applicationActivities: nil + ) + navigationController.present(activityVC, animated: true) + return Just(()) + .eraseToAnyPublisher() case .allTimePnLInfo: return Just({ [weak self] in guard let self else { return } showPnLInfo() }()) .eraseToAnyPublisher() + default: return Just(()) .eraseToAnyPublisher() diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift index 7393dc0871..bb874c9ca3 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift @@ -32,6 +32,16 @@ struct CryptoView: View { } } + private var banner: ReferralProgramBannerView? { + if viewModel.displayReferralBanner { + return ReferralProgramBannerView( + shareAction: viewModel.shareReferralLink.send, + openDetails: viewModel.openReferralProgramDetails.send + ) + } + return nil + } + var body: some View { ZStack { Color(.smoke) @@ -41,12 +51,14 @@ struct CryptoView: View { CryptoPendingView() case .empty: CryptoEmptyView( - actionsPanelView: actionsPanelView + actionsPanelView: actionsPanelView, + banner: banner ) case .accounts: CryptoAccountsView( viewModel: accountsViewModel, - actionsPanelView: actionsPanelView + actionsPanelView: actionsPanelView, + banner: banner ) } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift index e6adf1a2d1..ff5403b88c 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift @@ -23,11 +23,15 @@ final class CryptoViewModel: BaseViewModel, ObservableObject { @Injected private var nameStorage: NameStorageType @Injected private var sellDataService: any SellDataService @Injected private var createNameService: CreateNameService + @Injected private var referralService: ReferralProgramService let navigation: PassthroughSubject + let openReferralProgramDetails = PassthroughSubject() + let shareReferralLink = PassthroughSubject() // MARK: - Properties + @Published private(set) var displayReferralBanner: Bool @Published var state = State.pending @Published var address = "" @@ -35,6 +39,8 @@ final class CryptoViewModel: BaseViewModel, ObservableObject { init(navigation: PassthroughSubject) { self.navigation = navigation + displayReferralBanner = available(.referralProgramEnabled) + super.init() // bind @@ -176,6 +182,23 @@ private extension CryptoViewModel { } .store(in: &subscriptions) + openReferralProgramDetails + .map { CryptoNavigation.referral } + .sink { [weak self] navigation in + self?.navigation.send(navigation) + } + .store(in: &subscriptions) + + shareReferralLink + .compactMap { [weak self] in + guard let self else { return nil } + return CryptoNavigation.shareReferral(self.referralService.shareLink) + } + .sink { [weak self] navigation in + self?.navigation.send(navigation) + } + .store(in: &subscriptions) + // solana account vs pnl, get for the first time solanaAccountsService.statePublisher .receive(on: RunLoop.main) diff --git a/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift b/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift index 56ffc54be4..fd5a298fb6 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/Settings/View/SettingsView.swift @@ -18,6 +18,9 @@ struct SettingsView: View { List { Group { profileSection + if viewModel.isReferralProgramEnabled { + referralProgramSection + } securitySection appearanceSection communitySection @@ -117,6 +120,17 @@ struct SettingsView: View { } } + private var referralProgramSection: some View { + Section { + ReferralProgramBannerView( + shareAction: viewModel.shareReferralLink.send, + openDetails: viewModel.openReferralProgramDetails.send + ) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + } + private var appearanceSection: some View { Section(header: headerText(L10n.appearance)) { cellView(image: .hideZeroBalance, title: L10n.hideZeroBalances) { diff --git a/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift b/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift index 1a6dc2be46..8b7452aaf4 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/Settings/ViewModel/SettingsViewModel.swift @@ -16,6 +16,7 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { @Injected private var metadataService: WalletMetadataService @Injected private var createNameService: CreateNameService @Injected private var deviceShareMigrationService: DeviceShareMigrationService + @Injected private var referralService: ReferralProgramService @Published var zeroBalancesIsHidden = Defaults.hideZeroBalances { didSet { @@ -50,11 +51,16 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { @Published var deviceShareMigrationAlert: Bool = false + @Published var isReferralProgramEnabled: Bool + let openReferralProgramDetails = PassthroughSubject() + let shareReferralLink = PassthroughSubject() + var appInfo: String { AppInfo.appVersionDetail } override init() { + isReferralProgramEnabled = available(.referralProgramEnabled) super.init() setUpAuthType() updateNameIfNeeded() @@ -171,6 +177,23 @@ final class SettingsViewModel: BaseViewModel, ObservableObject { self.updateNameIfNeeded() } .store(in: &subscriptions) + + openReferralProgramDetails + .map { OpenAction.referral } + .sink { [weak self] navigation in + self?.openActionSubject.send(navigation) + } + .store(in: &subscriptions) + + shareReferralLink + .compactMap { [weak self] in + guard let self else { return nil } + return OpenAction.shareReferral(self.referralService.shareLink) + } + .sink { [weak self] navigation in + self?.openActionSubject.send(navigation) + } + .store(in: &subscriptions) } func openTwitter() { @@ -194,5 +217,7 @@ extension SettingsViewModel { case reserveUsername(userAddress: String) case recoveryKit case yourPin + case referral + case shareReferral(URL) } } diff --git a/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift b/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift index 4be6504eec..13790030fe 100644 --- a/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift +++ b/p2p_wallet/Scenes/Main/NewSettings/SettingsCoordinator.swift @@ -40,6 +40,17 @@ final class SettingsCoordinator: Coordinator { navigationController.popToRootViewController(animated: true) }) .store(in: &subscriptions) + case .referral: + let coordinator = ReferralProgramCoordinator(navigationController: navigationController) + coordinate(to: coordinator) + .sink { _ in } + .store(in: &subscriptions) + case let .shareReferral(link): + let activityVC = UIActivityViewController( + activityItems: ["\(L10n.heyLetSSwapTrendyMemeCoinsWithMe) \(link)"], + applicationActivities: nil + ) + navigationController.present(activityVC, animated: true) } }) .store(in: &subscriptions) diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Banner/ReferralProgramBannerView.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Banner/ReferralProgramBannerView.swift new file mode 100644 index 0000000000..514590d3cd --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Banner/ReferralProgramBannerView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct ReferralProgramBannerView: View { + var shareAction: () -> Void + var openDetails: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top) { + Text(L10n.referralProgram.replacingOccurrences(of: " ", with: "\n")) + .font(uiFont: .font(of: .title3, weight: .semibold)) + Spacer() + Image(uiImage: .init(resource: .referralIcon)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 141) + .padding(.trailing, 12) + } + HStack(spacing: 8) { + // Must apply a button style for the correct behaviour inside a list + // https://stackoverflow.com/a/70400079 + NewTextButton( + title: L10n.shareMyLink, + size: .small, + style: .primary, + expandable: true, + trailing: UIImage(resource: .share3), + action: shareAction + ) + .buttonStyle(.plain) + NewTextButton( + title: L10n.openDetails, + size: .small, + style: .inverted, + expandable: true, + action: openDetails + ) + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color(uiColor: UIColor(resource: .cdf6Cd))) + .cornerRadius(radius: 24, corners: .allCorners) + } +} + +#Preview { + VStack { + ReferralProgramBannerView(shareAction: {}, openDetails: {}) + .padding(.horizontal, 16) + } +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/BridgeModels.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/BridgeModels.swift new file mode 100644 index 0000000000..1a1745538d --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/BridgeModels.swift @@ -0,0 +1,13 @@ +enum ReferralBridgeMethod: String { + case showShareDialog + case nativeLog + case signMessage + case getUserPublicKey +} + +enum ReferralBridgeError: String { + case emptyAddress + case emptyLog + case signFailed + case emptyLink +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/ReferralBridge.js b/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/ReferralBridge.js new file mode 100644 index 0000000000..3b64088dae --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/ReferralBridge.js @@ -0,0 +1,35 @@ +const handleRequest = async (args) => { + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.request) { + return window.webkit.messageHandlers.request.postMessage(args).then((result) => { + console.log(result); + if (result.error) { + console.log(result.error); + return Promise.reject(result.error); + } + + if (result === "null") { + return Promise.resolve(); + } + return {value: result}; + }); + } + return { code: 4900, message: "Host is not ready" } +}; + +window.ReferralBridge = { + getUserPublicKeyAsync: async function() { + const result = await handleRequest({ method: "getUserPublicKey" }); + ReferralBridge.nativeLog(result); + return result + }, + nativeLog: function(info) { + handleRequest({ method: "nativeLog", info: info }); + }, + showShareDialog: function(link) { + handleRequest({ method: "showShareDialog", link: link }); + }, + signMessageAsync: async function(message) { + const result = await handleRequest({ method: "signMessage", message: message }); + return result + } +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/ReferralJSBridge.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/ReferralJSBridge.swift new file mode 100644 index 0000000000..a5077674b7 --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/Bridge/ReferralJSBridge.swift @@ -0,0 +1,138 @@ +import Combine +import Foundation +import Network +import Resolver +import SolanaSwift +import TweetNacl +import WebKit + +protocol ReferralBridge { + var sharePublisher: AnyPublisher { get } +} + +final class ReferralJSBridge: NSObject, ReferralBridge { + var sharePublisher: AnyPublisher { shareSubject.eraseToAnyPublisher() } + + // MARK: - Dependencies + + private let logger = DefaultLogManager.shared + @Injected private var userWalletManager: UserWalletManager + + // MARK: - Properties + + private let shareSubject = PassthroughSubject() + private var subscriptions: [AnyCancellable] = [] + private weak var webView: WKWebView? + + public init(webView: WKWebView) { + self.webView = webView + super.init() + } + + func reload() { + Task { + await MainActor.run { webView?.reload() } + } + } + + func loadScript(name: String) -> String? { + guard let path = Bundle.main.path(forResource: name, ofType: "js") else { return nil } + do { + return try String(contentsOfFile: path) + } catch { + return nil + } + } + + public func inject() { + guard let bridgeScript = loadScript(name: "ReferralBridge") else { + debugPrint("Inject provider failure") + return + } + + guard let contentController = webView?.configuration.userContentController else { return } + contentController.addScriptMessageHandler(self, contentWorld: .page, name: "request") + + let script = WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + contentController.addUserScript(script) + } +} + +extension ReferralJSBridge: WKScriptMessageHandlerWithReply { + public func userContentController( + _: WKUserContentController, + didReceive message: WKScriptMessage, + replyHandler: @escaping (Any?, String?) -> Void + ) { + guard let dict = message.body as? [String: AnyObject] else { + replyHandler(true, "Error") + return + } + + guard let methodRaw = dict["method"] as? String, + let method = ReferralBridgeMethod(rawValue: methodRaw) + else { + replyHandler(true, nil) + return + } + + // Overload reply handler + let handler: (String?, ReferralBridgeError?) -> Void = { [weak self] result, error in + guard let self else { return } + if let result { + self.logger.log(event: "ReferralProgramLog", data: String(describing: result), logLevel: LogLevel.info) + replyHandler(result, nil) + } else { + self.logger.log( + event: "ReferralProgramLog", + data: String(describing: error?.rawValue), + logLevel: LogLevel.error + ) + replyHandler(nil, error?.rawValue) + } + } + + guard let user = userWalletManager.wallet else { + handler(nil, .emptyAddress) + return + } + + switch method { + case .showShareDialog: + if let link = dict["link"] as? String { + shareSubject.send(link) + handler(link, nil) + } else { + handler(nil, .emptyLink) + } + + case .nativeLog: + if let info = dict["info"] as? String { + debugPrint(info) + handler(info, nil) + } else { + handler(nil, .emptyLog) + } + + case .signMessage: + if let message = dict["message"] as? String, + let user = userWalletManager.wallet, + let base64Data = Data(base64Encoded: message, options: .ignoreUnknownCharacters) + { + Task { + do { + let signed = try NaclSign.signDetached(message: base64Data, secretKey: user.account.secretKey) + handler(signed.base64EncodedString(), nil) + } catch { + handler(nil, .signFailed) + } + } + } else { + handler(nil, .signFailed) + } + + case .getUserPublicKey: + handler(user.account.publicKey.base58EncodedString, nil) + } + } +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramCoordinator.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramCoordinator.swift new file mode 100644 index 0000000000..6b3cc89431 --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramCoordinator.swift @@ -0,0 +1,32 @@ +import Combine +import SwiftUI +import UIKit + +final class ReferralProgramCoordinator: Coordinator { + private let navigationController: UINavigationController + private let result = PassthroughSubject() + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + override func start() -> AnyPublisher { + let viewModel = ReferralProgramViewModel() + let view = ReferralProgramView(viewModel: viewModel) + let vc = UIHostingController(rootView: view) + vc.hidesBottomBarWhenPushed = true + navigationController.pushViewController(vc, animated: true) + + viewModel.openShare + .sink { [weak vc] link in + let activityVC = UIActivityViewController( + activityItems: ["\(L10n.heyLetSSwapTrendyMemeCoinsWithMe) \(link)"], + applicationActivities: nil + ) + vc?.present(activityVC, animated: true) + } + .store(in: &subscriptions) + + return result.eraseToAnyPublisher() + } +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramView.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramView.swift new file mode 100644 index 0000000000..32481ed5f8 --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct ReferralProgramView: View { + @ObservedObject private var viewModel: ReferralProgramViewModel + + init(viewModel: ReferralProgramViewModel) { + self.viewModel = viewModel + } + + var body: some View { + ColoredBackground( + { + ReferralWebView( + webView: viewModel.webView, + link: viewModel.link + ) + .ignoresSafeArea(edges: .bottom) + }, + color: Color(uiColor: UIColor(resource: .f2F5Fa)) + ) + .navigationTitle(L10n.referralProgram) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramViewModel.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramViewModel.swift new file mode 100644 index 0000000000..9d8f6d9c0c --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralProgramViewModel.swift @@ -0,0 +1,45 @@ +import Combine +import Foundation +import WebKit + +final class ReferralProgramViewModel: BaseViewModel, ObservableObject { + enum Constants { + static let urlString = "https://referral-2ii.pages.dev" + } + + let link: URL + let bridge: ReferralJSBridge + let webView: WKWebView + + let openShare = PassthroughSubject() + + override init() { + let wkWebView = ReferralProgramViewModel.buildWebView() + webView = wkWebView + bridge = ReferralJSBridge(webView: wkWebView) + link = URL(string: GlobalAppState.shared.newReferralProgramEndpoint) ?? URL(string: Constants.urlString)! + super.init() + + bridge.inject() + + bridge.sharePublisher + .sink(receiveValue: { [weak self] value in + self?.openShare.send(value) + }) + .store(in: &subscriptions) + } + + private static func buildWebView() -> WKWebView { + let userContentController = WKUserContentController() + let configuration = WKWebViewConfiguration() + configuration.userContentController = userContentController + let preferences = WKPreferences() + configuration.preferences = preferences + let webView = WKWebView(frame: .zero, configuration: configuration) + + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + return webView + } +} diff --git a/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralWebView.swift b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralWebView.swift new file mode 100644 index 0000000000..9cf1d242c4 --- /dev/null +++ b/p2p_wallet/Scenes/Main/ReferralProgram/Details/ReferralWebView.swift @@ -0,0 +1,21 @@ +import SwiftUI +import WebKit + +struct ReferralWebView: UIViewRepresentable { + private let url: URL + private let wkWebView: WKWebView + + init(webView: WKWebView, link: URL) { + wkWebView = webView + url = link + } + + func makeUIView(context _: Context) -> WKWebView { + wkWebView + } + + func updateUIView(_: WKWebView, context _: Context) { + let request = URLRequest(url: url) + wkWebView.load(request) + } +} diff --git a/p2p_wallet/Scenes/Main/Swap/Swap/JupiterSwapCoordinator.swift b/p2p_wallet/Scenes/Main/Swap/Swap/JupiterSwapCoordinator.swift index 11a36aeff3..81f05a9bf9 100644 --- a/p2p_wallet/Scenes/Main/Swap/Swap/JupiterSwapCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Swap/Swap/JupiterSwapCoordinator.swift @@ -189,9 +189,12 @@ final class JupiterSwapCoordinator: Coordinator { let from = viewModel.currentState.fromToken.mintAddress let to = viewModel.currentState.toToken.mintAddress + var item = "https://s.key.app/swap?from=\(from)&to=\(to)" + if available(.referralProgramEnabled) { + item.append("&r=\(viewModel.referralProgramService.referrer)") + } - let items = ["https://s.key.app/swap?from=\(from)&to=\(to)"] - let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) + let activityVC = UIActivityViewController(activityItems: [item], applicationActivities: nil) navigationController.present(activityVC, animated: true) } diff --git a/p2p_wallet/Scenes/Main/Swap/Swap/SwapViewModel.swift b/p2p_wallet/Scenes/Main/Swap/Swap/SwapViewModel.swift index ccae471afb..6d49693cbe 100644 --- a/p2p_wallet/Scenes/Main/Swap/Swap/SwapViewModel.swift +++ b/p2p_wallet/Scenes/Main/Swap/Swap/SwapViewModel.swift @@ -18,6 +18,7 @@ final class SwapViewModel: BaseViewModel, ObservableObject { // MARK: - Dependencies + @Injected private(set) var referralProgramService: ReferralProgramService @Injected private var swapWalletsRepository: JupiterTokensRepository @Injected private var notificationService: NotificationService @Injected private var transactionHandler: TransactionHandler diff --git a/p2p_wallet/p2p_wallet.entitlements b/p2p_wallet/p2p_wallet.entitlements index 826fc669b6..ac57a5f3d9 100644 --- a/p2p_wallet/p2p_wallet.entitlements +++ b/p2p_wallet/p2p_wallet.entitlements @@ -10,6 +10,7 @@ com.apple.developer.associated-domains + applinks:r.key.app applinks:keyapp-te.onelink.me applinks:keyapp.onelink.me applinks:t.key.app