From cf0601b28c22288295bb882a165de496b03ce398 Mon Sep 17 00:00:00 2001 From: wuyueyang Date: Tue, 23 Sep 2025 13:43:34 +0800 Subject: [PATCH] Check rent exemption for Solana --- Mixin/Resources/en.lproj/Localizable.strings | 2 + Mixin/Resources/es.lproj/Localizable.strings | 2 + Mixin/Resources/ja.lproj/Localizable.strings | 2 + Mixin/Resources/ru.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 4 +- .../zh-Hant.lproj/Localizable.strings | 4 +- Mixin/Service/API/Model/SwapToken.swift | 8 -- .../Web3/SolanaTransferOperation.swift | 29 +++-- .../Wallet/Models/Web3AddressValidator.swift | 35 ++++-- .../Controllers/Web3/Model/Solana.swift | 78 +++++++++++- .../Web3TokenReceiverViewController.swift | 10 +- ...eb3TransferInputAmountViewController.swift | 115 +++++++++--------- .../Transfer Link/ExternalTransfer.swift | 11 +- .../Services/ExternalTransferTests.swift | 2 +- 14 files changed, 200 insertions(+), 104 deletions(-) diff --git a/Mixin/Resources/en.lproj/Localizable.strings b/Mixin/Resources/en.lproj/Localizable.strings index d7fc8b629b..d2c38ef40c 100644 --- a/Mixin/Resources/en.lproj/Localizable.strings +++ b/Mixin/Resources/en.lproj/Localizable.strings @@ -720,6 +720,7 @@ "insufficient_balance" = "Insufficient balance"; "insufficient_balance_symbol" = "Insufficient %@"; "insufficient_fee_description" = "You need %1$@ on the %2$@ network to cover the network fee"; +"insufficient_sol_for_sending_spl_token" = "Insufficient SOL balance. Reserve at least %@ SOL."; "insufficient_transaction_fee" = "Insufficient transaction fee"; "interface_style" = "Interface Style"; "invalid_address" = "Invalid Address"; @@ -1176,6 +1177,7 @@ "request_rejected_reason_another_request_in_process" = "Another request is in process, please retry after current request is finished"; "resend_code" = "Resend code"; "resend_code_pending" = "Resend code in %@"; +"reserve_sol_for_rent" = "Reserve at least %@ SOL for the rent"; "reset" = "Reset"; "reset_link" = "Reset Link"; "restore" = "Restore"; diff --git a/Mixin/Resources/es.lproj/Localizable.strings b/Mixin/Resources/es.lproj/Localizable.strings index b6e7a26bea..941649470d 100644 --- a/Mixin/Resources/es.lproj/Localizable.strings +++ b/Mixin/Resources/es.lproj/Localizable.strings @@ -720,6 +720,7 @@ "insufficient_balance" = "Insufficient balance"; "insufficient_balance_symbol" = "Insufficient %@"; "insufficient_fee_description" = "You need %1$@ on the %2$@ network to cover the network fee"; +"insufficient_sol_for_sending_spl_token" = "Insufficient SOL balance. Reserve at least %@ SOL."; "insufficient_transaction_fee" = "Tarifa de transacción insuficiente"; "interface_style" = "Estilo de interfaz"; "invalid_address" = "Invalid Address"; @@ -1176,6 +1177,7 @@ "request_rejected_reason_another_request_in_process" = "Another request is in process, please retry after current request is finished"; "resend_code" = "Reenviar codigo"; "resend_code_pending" = "Reenviar código en %@"; +"reserve_sol_for_rent" = "Reserve at least %@ SOL for the rent"; "reset" = "Reiniciar"; "reset_link" = "Restablecer enlace"; "restore" = "Restaurar"; diff --git a/Mixin/Resources/ja.lproj/Localizable.strings b/Mixin/Resources/ja.lproj/Localizable.strings index 71b94001c3..3aae10d125 100644 --- a/Mixin/Resources/ja.lproj/Localizable.strings +++ b/Mixin/Resources/ja.lproj/Localizable.strings @@ -720,6 +720,7 @@ "insufficient_balance" = "残高が不足しています"; "insufficient_balance_symbol" = "Insufficient %@"; "insufficient_fee_description" = "You need %1$@ on the %2$@ network to cover the network fee"; +"insufficient_sol_for_sending_spl_token" = "Insufficient SOL balance. Reserve at least %@ SOL."; "insufficient_transaction_fee" = "取引手数料が不足しています"; "interface_style" = "外観モード"; "invalid_address" = "Invalid Address"; @@ -1176,6 +1177,7 @@ "request_rejected_reason_another_request_in_process" = "Another request is in process, please retry after current request is finished"; "resend_code" = "コードを再送する"; "resend_code_pending" = "%@ 後にコードを再送"; +"reserve_sol_for_rent" = "Reserve at least %@ SOL for the rent"; "reset" = "リセット"; "reset_link" = "リンクを取り消す"; "restore" = "復元"; diff --git a/Mixin/Resources/ru.lproj/Localizable.strings b/Mixin/Resources/ru.lproj/Localizable.strings index 5f6626b8a3..95229b9a40 100644 --- a/Mixin/Resources/ru.lproj/Localizable.strings +++ b/Mixin/Resources/ru.lproj/Localizable.strings @@ -720,6 +720,7 @@ "insufficient_balance" = "Insufficient balance"; "insufficient_balance_symbol" = "Insufficient %@"; "insufficient_fee_description" = "You need %1$@ on the %2$@ network to cover the network fee"; +"insufficient_sol_for_sending_spl_token" = "Insufficient SOL balance. Reserve at least %@ SOL."; "insufficient_transaction_fee" = "Недостаточная комиссия за транзакцию"; "interface_style" = "Стиль интерфейса"; "invalid_address" = "Invalid Address"; @@ -1176,6 +1177,7 @@ "request_rejected_reason_another_request_in_process" = "Another request is in process, please retry after current request is finished"; "resend_code" = "Отправить код еще раз"; "resend_code_pending" = "Повторно отправить код в %@"; +"reserve_sol_for_rent" = "Reserve at least %@ SOL for the rent"; "reset" = "Перезагрузить"; "reset_link" = "Сбросить ссылку"; "restore" = "Восстановить"; diff --git a/Mixin/Resources/zh-Hans.lproj/Localizable.strings b/Mixin/Resources/zh-Hans.lproj/Localizable.strings index 4e824deb3d..dad9104600 100644 --- a/Mixin/Resources/zh-Hans.lproj/Localizable.strings +++ b/Mixin/Resources/zh-Hans.lproj/Localizable.strings @@ -720,6 +720,7 @@ "insufficient_balance" = "余额不足"; "insufficient_balance_symbol" = "%@ 余额不足"; "insufficient_fee_description" = "需要 %1$@ 以支付 %2$@ 网络费用"; +"insufficient_sol_for_sending_spl_token" = "SOL 余额不足,请至少预留 %@ SOL。"; "insufficient_transaction_fee" = "手续费不足"; "interface_style" = "外观"; "invalid_address" = "无效的地址"; @@ -1176,6 +1177,7 @@ "request_rejected_reason_another_request_in_process" = "正在处理另一请求,请完成当前请求后重试"; "resend_code" = "重发验证码"; "resend_code_pending" = "%@ 后重新发送验证码"; +"reserve_sol_for_rent" = "请预留至少 %@ SOL 以支付租金"; "reset" = "重置"; "reset_link" = "重置邀请链接"; "restore" = "恢复"; @@ -1273,7 +1275,7 @@ "send_message" = "发消息"; "send_photo" = "发送 1 张图片"; "send_photo_count" = "发送 %d 张图片"; -"send_sol_for_rent" = "发送至少 %@ SOL 以支付租金"; +"send_sol_for_rent" = "请发送至少 %@ SOL 以支付租金"; "send_this_location" = "发送这个位置"; "send_to" = "发送给 %@"; "send_to_address_description" = "转到我地址薄中的地址,地址受到 PIN 保护,避免地址钓鱼攻击。"; diff --git a/Mixin/Resources/zh-Hant.lproj/Localizable.strings b/Mixin/Resources/zh-Hant.lproj/Localizable.strings index 021bdfd7fd..8e306453d3 100644 --- a/Mixin/Resources/zh-Hant.lproj/Localizable.strings +++ b/Mixin/Resources/zh-Hant.lproj/Localizable.strings @@ -720,6 +720,7 @@ "insufficient_balance" = "餘額不足"; "insufficient_balance_symbol" = "%@ 餘額不足"; "insufficient_fee_description" = "需要 %1$@ 以支付 %2$@ 網路費用"; +"insufficient_sol_for_sending_spl_token" = "SOL 餘額不足,請至少預留 %@ SOL。"; "insufficient_transaction_fee" = "手續費不足"; "interface_style" = "外觀"; "invalid_address" = "無效的地址"; @@ -1176,6 +1177,7 @@ "request_rejected_reason_another_request_in_process" = "正在處理另一請求,請完成當前請求後重試"; "resend_code" = "重發驗證碼"; "resend_code_pending" = "%@ 後重新發送驗證碼"; +"reserve_sol_for_rent" = "請預留至少 %@ SOL 以支付租金"; "reset" = "重置"; "reset_link" = "重置邀請連結"; "restore" = "恢復"; @@ -1273,7 +1275,7 @@ "send_message" = "發訊息"; "send_photo" = "傳送 1 張圖片"; "send_photo_count" = "傳送 %d 張圖片"; -"send_sol_for_rent" = "傳送至少 %@ SOL 以支付租金"; +"send_sol_for_rent" = "請傳送至少 %@ SOL 以支付租金"; "send_this_location" = "傳送這個位置"; "send_to" = "傳送給 %@"; "send_to_address_description" = "轉到我地址薄中的地址,地址受到 PIN 保護,避免地址釣魚攻擊。"; diff --git a/Mixin/Service/API/Model/SwapToken.swift b/Mixin/Service/API/Model/SwapToken.swift index 53f25b611f..530e5149b8 100644 --- a/Mixin/Service/API/Model/SwapToken.swift +++ b/Mixin/Service/API/Model/SwapToken.swift @@ -74,14 +74,6 @@ extension SwapToken { } } - func isEqual(to token: Web3Token) -> Bool { - if address == Web3Token.AssetKey.wrappedSOL && token.assetKey == Web3Token.AssetKey.sol { - true - } else { - address == token.assetKey - } - } - func decimalAmount(nativeAmount: Decimal) -> NSDecimalNumber? { let nativeAmountNumber = nativeAmount as NSDecimalNumber return nativeAmountNumber.multiplying(byPowerOf10: -decimals) diff --git a/Mixin/Service/Web3/SolanaTransferOperation.swift b/Mixin/Service/Web3/SolanaTransferOperation.swift index 666f8e8bfb..722cc9c50e 100644 --- a/Mixin/Service/Web3/SolanaTransferOperation.swift +++ b/Mixin/Service/Web3/SolanaTransferOperation.swift @@ -236,6 +236,9 @@ final class SolanaTransferWithCustomRespondingOperation: ArbitraryTransactionSol final class SolanaTransferToAddressOperation: SolanaTransferOperation { + @MainActor + private(set) var receiverAccountExists: Bool? + private let payment: Web3SendingTokenToAddressPayment private let decimalAmount: Decimal private let amount: UInt64 @@ -268,17 +271,24 @@ final class SolanaTransferToAddressOperation: SolanaTransferOperation { override func loadFee() async throws -> DisplayFee { let tokenProgramID = try await RouteAPI.solanaGetAccountInfo(pubkey: payment.token.assetKey).owner - let ata = try Solana.tokenAssociatedAccount( - walletAddress: payment.toAddress, - mint: payment.token.assetKey, - tokenProgramID: tokenProgramID - ) - let receiverAccountExists = try await RouteAPI.solanaAccountExists(pubkey: ata) - let createAccount = !receiverAccountExists + let receiverAccountExists: Bool + let createAssociatedTokenAccountForReceiver: Bool + if payment.sendingNativeToken { + receiverAccountExists = try await RouteAPI.solanaAccountExists(pubkey: payment.toAddress) + createAssociatedTokenAccountForReceiver = false + } else { + let ata = try Solana.tokenAssociatedAccount( + walletAddress: payment.toAddress, + mint: payment.token.assetKey, + tokenProgramID: tokenProgramID + ) + receiverAccountExists = try await RouteAPI.solanaAccountExists(pubkey: ata) + createAssociatedTokenAccountForReceiver = !receiverAccountExists + } let transaction = try Solana.Transaction( from: payment.fromAddress.destination, to: payment.toAddress, - createAssociatedTokenAccountForReceiver: createAccount, + createAssociatedTokenAccountForReceiver: createAssociatedTokenAccountForReceiver, tokenProgramID: tokenProgramID, mint: payment.token.assetKey, amount: amount, @@ -295,7 +305,8 @@ final class SolanaTransferToAddressOperation: SolanaTransferOperation { let fee = DisplayFee(tokenAmount: tokenAmount, fiatMoneyAmount: fiatMoneyAmount) await MainActor.run { - self.createAssociatedTokenAccountForReceiver = createAccount + self.receiverAccountExists = receiverAccountExists + self.createAssociatedTokenAccountForReceiver = createAssociatedTokenAccountForReceiver self.tokenProgramID = tokenProgramID self.priorityFee = priorityFee self.fee = fee diff --git a/Mixin/UserInterface/Controllers/Wallet/Models/Web3AddressValidator.swift b/Mixin/UserInterface/Controllers/Wallet/Models/Web3AddressValidator.swift index 495aba077e..323d430835 100644 --- a/Mixin/UserInterface/Controllers/Wallet/Models/Web3AddressValidator.swift +++ b/Mixin/UserInterface/Controllers/Wallet/Models/Web3AddressValidator.swift @@ -7,7 +7,7 @@ enum Web3AddressValidator { enum Web3TransferValidationResult { case address(address: String, label: AddressLabel?) case insufficientBalance(transferring: BalanceRequirement, fee: BalanceRequirement) - case solAmountTooSmall + case rentExemptionFailed(Solana.RentExemptionFailedReason) case transfer(operation: Web3TransferOperation, toAddressLabel: AddressLabel?) } @@ -84,15 +84,6 @@ enum Web3AddressValidator { assetID: token.assetID, destination: link.destination ) - if chain.kind == .solana && payment.sendingNativeToken { - let accountExists = try await RouteAPI.solanaAccountExists(pubkey: address) - if !accountExists && amount < Solana.accountCreationCost { - await MainActor.run { - onSuccess(.solAmountTooSmall) - } - return - } - } let addressPayment = Web3SendingTokenToAddressPayment( payment: payment, toAddress: address, @@ -112,6 +103,30 @@ enum Web3AddressValidator { ) } let fee = try await operation.loadFee() + if let operation = operation as? SolanaTransferToAddressOperation, + let accountExists = await operation.receiverAccountExists + { + let reason = if payment.sendingNativeToken { + Solana.checkRentExemptionForSOLTransfer( + sendingAmount: amount, + feeAmount: fee.tokenAmount, + senderSOLBalance: payment.token.decimalBalance, + receiverAccountExists: accountExists + ) + } else { + Solana.checkRentExemptionForSPLTokenTransfer( + senderSOLBalance: operation.feeToken.decimalBalance, + feeAmount: fee.tokenAmount, + receiverAccountExists: accountExists + ) + } + if let reason { + await MainActor.run { + onSuccess(.rentExemptionFailed(reason)) + } + return + } + } let transferRequirement = BalanceRequirement(token: token, amount: amount) let feeRequirement = BalanceRequirement(token: operation.feeToken, amount: fee.tokenAmount) let requirements = transferRequirement.merging(with: feeRequirement) diff --git a/Mixin/UserInterface/Controllers/Web3/Model/Solana.swift b/Mixin/UserInterface/Controllers/Web3/Model/Solana.swift index 9c71d3afd4..42ac80ebe0 100644 --- a/Mixin/UserInterface/Controllers/Web3/Model/Solana.swift +++ b/Mixin/UserInterface/Controllers/Web3/Model/Solana.swift @@ -19,7 +19,6 @@ enum Solana { static let lamportsPerSOL = Decimal(SOLANA_LAMPORTS_PER_SOL) static let microLamportsPerLamport: Decimal = 1_000_000 - static let accountCreationCost: Decimal = 0.002_039_28 static let keyPairCount = 64 static func publicKey(seed: Data) throws -> String { @@ -172,8 +171,7 @@ extension Solana { priorityFee: PriorityFee?, token: Web3Token ) throws { - let isSendingSOL = token.chainID == ChainID.solana - && (token.assetKey == Web3Token.AssetKey.sol || token.assetKey == Web3Token.AssetKey.wrappedSOL) + let isSendingSOL = token.chainID == ChainID.solana && token.assetKey == Web3Token.AssetKey.sol let solanaPriorityFee: SolanaPriorityFee? = if let fee = priorityFee { SolanaPriorityFee(price: fee.unitPrice, limit: fee.unitLimit) } else { @@ -261,3 +259,77 @@ extension Solana { } } + +extension Solana { + + enum RentExemptionFailedReason { + + case reserveSOLForRent(Decimal) + case sendSOLForRent(Decimal) + case insufficientSOL(requiredAmount: Decimal) + + var localizedDescription: String { + switch self { + case .reserveSOLForRent(let amount): + R.string.localizable.reserve_sol_for_rent( + CurrencyFormatter.localizedString( + from: amount, + format: .precision, + sign: .never + ) + ) + case .sendSOLForRent(let amount): + R.string.localizable.send_sol_for_rent( + CurrencyFormatter.localizedString( + from: amount, + format: .precision, + sign: .never + ) + ) + case .insufficientSOL(let requiredAmount): + R.string.localizable.insufficient_sol_for_sending_spl_token( + CurrencyFormatter.localizedString( + from: requiredAmount, + format: .precision, + sign: .never + ) + ) + } + } + + } + + enum RentExemptionValue { + static let systemAccount: Decimal = 0.00089088 + static let tokenAccount: Decimal = 0.00203928 + } + + static func checkRentExemptionForSOLTransfer( + sendingAmount: Decimal, + feeAmount: Decimal, + senderSOLBalance: Decimal, + receiverAccountExists: Bool + ) -> RentExemptionFailedReason? { + if senderSOLBalance - sendingAmount - feeAmount < RentExemptionValue.systemAccount { + .reserveSOLForRent(RentExemptionValue.systemAccount) + } else if !receiverAccountExists && sendingAmount < RentExemptionValue.tokenAccount { + .sendSOLForRent(RentExemptionValue.tokenAccount) + } else { + nil + } + } + + static func checkRentExemptionForSPLTokenTransfer( + senderSOLBalance: Decimal, + feeAmount: Decimal, + receiverAccountExists: Bool + ) -> RentExemptionFailedReason? { + let minBalance = RentExemptionValue.systemAccount + RentExemptionValue.tokenAccount + feeAmount + if receiverAccountExists || senderSOLBalance >= minBalance { + return nil + } else { + return .insufficientSOL(requiredAmount: minBalance) + } + } + +} diff --git a/Mixin/UserInterface/Controllers/Web3/Web3TokenReceiverViewController.swift b/Mixin/UserInterface/Controllers/Web3/Web3TokenReceiverViewController.swift index 493bcb915f..d91fc7a679 100644 --- a/Mixin/UserInterface/Controllers/Web3/Web3TokenReceiverViewController.swift +++ b/Mixin/UserInterface/Controllers/Web3/Web3TokenReceiverViewController.swift @@ -66,14 +66,8 @@ final class Web3TokenReceiverViewController: TokenReceiverViewController { ) transfer.manipulateNavigationStackOnFinished = true Web3PopupCoordinator.enqueue(popup: .request(transfer)) - case .solAmountTooSmall: - let cost = CurrencyFormatter.localizedString( - from: Solana.accountCreationCost, - format: .precision, - sign: .never, - ) - let description = R.string.localizable.send_sol_for_rent(cost) - self.showError(description: description) + case let .rentExemptionFailed(reason): + self.showError(description: reason.localizedDescription) } } onFailure: { [weak self] error in self?.showError(description: error.localizedDescription) diff --git a/Mixin/UserInterface/Controllers/Web3/Web3TransferInputAmountViewController.swift b/Mixin/UserInterface/Controllers/Web3/Web3TransferInputAmountViewController.swift index 1e6fb8c1d6..a4cf5e46a5 100644 --- a/Mixin/UserInterface/Controllers/Web3/Web3TransferInputAmountViewController.swift +++ b/Mixin/UserInterface/Controllers/Web3/Web3TransferInputAmountViewController.swift @@ -8,7 +8,7 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon private var fee: Web3TransferOperation.DisplayFee? private var feeToken: Web3TokenItem? - private var minimumTransferAmount: Decimal? + private var solanaReceiverAccountExists: Bool? init(payment: Web3SendingTokenToAddressPayment) { self.payment = payment @@ -58,7 +58,6 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon tokenBalanceLabel.text = payment.token.localizedBalanceWithSymbol addFeeView() reloadFee(payment: payment) - reloadMinimumTransferAmount(payment: payment) } override func review(_ sender: Any) { @@ -115,34 +114,49 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon } let multiplier = self.multiplier(tag: sender.tag) if payment.sendingNativeToken { - let availableBalance = max(0, token.decimalBalance - fee.tokenAmount) - replaceAmount(availableBalance * multiplier) + let availableBalance = switch payment.chain.kind { + case .evm: + token.decimalBalance - fee.tokenAmount + case .solana: + token.decimalBalance - fee.tokenAmount - Solana.RentExemptionValue.systemAccount + } + replaceAmount(max(0, availableBalance) * multiplier) } else { replaceAmount(token.decimalBalance * multiplier) } } override func reloadViewsWithBalanceRequirements() { - guard let fee, let feeToken, let minimumTransferAmount else { + guard let fee, let feeToken, !tokenAmount.isZero else { insufficientBalanceLabel.text = nil + removeAddFeeButton() reviewButton.isEnabled = false return } - guard tokenAmount.isZero || tokenAmount >= minimumTransferAmount else { - insufficientBalanceLabel.text = R.string.localizable.send_sol_for_rent( - CurrencyFormatter.localizedString( - from: minimumTransferAmount, - format: .precision, - sign: .never + + let solanaRentExemptionFailedReason: Solana.RentExemptionFailedReason? + if let accountExists = solanaReceiverAccountExists { + solanaRentExemptionFailedReason = if payment.sendingNativeToken { + Solana.checkRentExemptionForSOLTransfer( + sendingAmount: tokenAmount, + feeAmount: fee.tokenAmount, + senderSOLBalance: payment.token.decimalBalance, + receiverAccountExists: accountExists ) - ) - removeAddFeeButton() - reviewButton.isEnabled = false - return + } else { + Solana.checkRentExemptionForSPLTokenTransfer( + senderSOLBalance: feeToken.decimalBalance, + feeAmount: fee.tokenAmount, + receiverAccountExists: accountExists + ) + } + } else { + solanaRentExemptionFailedReason = nil } + let feeRequirement = BalanceRequirement(token: feeToken, amount: fee.tokenAmount) let requirements = inputAmountRequirement.merging(with: feeRequirement) - if requirements.allSatisfy(\.isSufficient) { + if requirements.allSatisfy(\.isSufficient) && solanaRentExemptionFailedReason == nil { insufficientBalanceLabel.text = nil removeAddFeeButton() reviewButton.isEnabled = tokenAmount > 0 @@ -155,12 +169,18 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon } else if !inputAmountRequirement.isSufficient { insufficientBalanceLabel.text = R.string.localizable.insufficient_balance() removeAddFeeButton() - } else { + } else if !feeRequirement.isSufficient { insufficientBalanceLabel.text = R.string.localizable.web3_transfer_insufficient_fee_count( feeRequirement.localizedAmountWithSymbol, feeRequirement.token.localizedBalanceWithSymbol ) insertAddFeeButton(symbol: feeRequirement.token.symbol) + } else if let reason = solanaRentExemptionFailedReason { + insufficientBalanceLabel.text = reason.localizedDescription + // Rent-exemption depends only on SOL balance + // Show "Add SOL" button if insufficient + insertAddFeeButton(symbol: feeToken.symbol) + reviewButton.isEnabled = false } reviewButton.isEnabled = false } @@ -187,20 +207,32 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon sign: .never, symbol: .custom(feeToken.symbol) ) - let availableBalance = if payment.sendingNativeToken { - CurrencyFormatter.localizedString( - from: max(0, payment.token.decimalBalance - fee.tokenAmount), + let availableBalance: String + if payment.sendingNativeToken { + let amount = switch payment.chain.kind { + case .evm: + payment.token.decimalBalance - fee.tokenAmount + case .solana: + payment.token.decimalBalance - fee.tokenAmount - Solana.RentExemptionValue.systemAccount + } + availableBalance = CurrencyFormatter.localizedString( + from: max(0, amount), format: .precision, sign: .never, symbol: .custom(feeToken.symbol) ) } else { - payment.token.localizedBalanceWithSymbol + availableBalance = payment.token.localizedBalanceWithSymbol } let isFeeWaived = payment.toAddressLabel?.isFeeWaived() ?? false await MainActor.run { self.fee = fee self.feeToken = feeToken + if let operation = operation as? SolanaTransferToAddressOperation { + self.solanaReceiverAccountExists = operation.receiverAccountExists + } else { + self.solanaReceiverAccountExists = nil + } self.feeActivityIndicator?.stopAnimating() self.tokenBalanceLabel.text = R.string.localizable.available_balance_count(availableBalance) self.updateFeeView(style: isFeeWaived ? .waived : .normal) @@ -210,10 +242,8 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon button.configuration?.image = nil button.isUserInteractionEnabled = false } - if self.minimumTransferAmount != nil { - self.reviewButton.isBusy = false - self.reloadViewsWithBalanceRequirements() - } + self.reviewButton.isBusy = false + self.reloadViewsWithBalanceRequirements() } } catch MixinAPIResponseError.unauthorized { return @@ -227,41 +257,6 @@ final class Web3TransferInputAmountViewController: FeeRequiredInputAmountViewCon } } - private func reloadMinimumTransferAmount(payment: Web3SendingTokenToAddressPayment) { - // Only SOL transfers invoke minimum amount checking - // Review `balanceSufficiency` if it involves EVM transfers - Task { - do { - let amount: Decimal - switch payment.chain.kind { - case .evm: - amount = 0 - case .solana: - if payment.sendingNativeToken { - let accountExists = try await RouteAPI.solanaAccountExists(pubkey: payment.toAddress) - amount = accountExists ? 0 : Solana.accountCreationCost - } else { - amount = 0 - } - } - await MainActor.run { - self.minimumTransferAmount = amount - if self.fee != nil { - self.reviewButton.isBusy = false - self.reloadViewsWithBalanceRequirements() - } - } - } catch MixinAPIResponseError.unauthorized { - return - } catch { - Logger.general.debug(category: "Web3InputAmount", message: "\(error)") - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - self?.reloadMinimumTransferAmount(payment: payment) - } - } - } - } - } extension Web3TransferInputAmountViewController: AddTokenMethodSelectorViewController.Delegate { diff --git a/MixinServices/MixinServices/Services/Transfer Link/ExternalTransfer.swift b/MixinServices/MixinServices/Services/Transfer Link/ExternalTransfer.swift index 85d8e261eb..a7b819e3d8 100644 --- a/MixinServices/MixinServices/Services/Transfer Link/ExternalTransfer.swift +++ b/MixinServices/MixinServices/Services/Transfer Link/ExternalTransfer.swift @@ -34,15 +34,20 @@ public struct ExternalTransfer { guard let components = URLComponents(string: raw) else { throw TransferLinkError.notTransferLink } - guard let scheme = components.scheme?.lowercased(), let queryItems = components.queryItems else { + guard let scheme = components.scheme?.lowercased() else { throw TransferLinkError.notTransferLink } guard let schemeAssetID = Self.supportedAssetIDs[scheme] else { // Drop schemes which are not listed in `supportedAssetIds` throw TransferLinkError.notTransferLink } - let queries = queryItems.reduce(into: [:]) { queries, item in - queries[item.name] = item.value + let queries: [String: String] + if let queryItems = components.queryItems { + queries = queryItems.reduce(into: [:]) { queries, item in + queries[item.name] = item.value + } + } else { + queries = [:] } if scheme == "ethereum" { // https://eips.ethereum.org/EIPS/eip-681 diff --git a/MixinServices/MixinServicesTests/Services/ExternalTransferTests.swift b/MixinServices/MixinServicesTests/Services/ExternalTransferTests.swift index b9fe4c9f5d..cfdfbde7e1 100644 --- a/MixinServices/MixinServicesTests/Services/ExternalTransferTests.swift +++ b/MixinServices/MixinServicesTests/Services/ExternalTransferTests.swift @@ -159,7 +159,7 @@ final class ExternalTransferTests: XCTestCase { resolvedAmount: "1", memo: "OrderId12345") - let c2 = try? ExternalTransfer(string: "solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=0.01&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + let c2 = try? ExternalTransfer(string: "solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=0.01&spl-token=Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB") XCTAssertNil(c2) let c3 = try? ExternalTransfer(string: "solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=1e7&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId12345")