diff --git a/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift b/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift index 7e06094c7a..ab34eb29e6 100644 --- a/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift +++ b/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift @@ -398,6 +398,7 @@ extension ServiceCoordinator { let audioSessionManager = AudioSessionManager() let paymentsSupport = PaymentsSupport(coinageService: coinageServices.coinageService) + truapiRuntimeProvider.attach(paymentsSupport: paymentsSupport, hostProvider: spaFlowState.hostProvider) let truApiDependencies = TruApiDependenciesLocator() truApiDependencies.setDependency(allowanceSupport) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPayments.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPayments.swift new file mode 100644 index 0000000000..d08d717e38 --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPayments.swift @@ -0,0 +1,152 @@ +import Foundation +import AsyncExtensions +import Coinage +import Products +import SubstrateSdk + +/// The payment steps both product bridges run: the JS-bridge handlers in +/// `ProductsNativeApi+Payment` and the Rust core's ``RustPaymentsBridge``. It speaks the engines' +/// own types; each caller maps their errors and statuses to its wire. +struct ProductPayments { + let support: PaymentsSupport + let approvalRequester: PaymentApprovalRequesting + let privacyConfirmer: PaymentPrivacyConfirming + let recyclingStrategy: any CoinageRecyclingStrategyProviding + + // MARK: - Balance + + func balanceStream() async throws -> AnyAsyncSequence { + try await support.coinageService.coinageBalanceService().balanceStream + } + + /// Whether what is spendable on-chain right now (private plus gaining-privacy funds; minting + /// funds cannot be waited for) covers `amount`. + func canSpend(_ amount: Balance) async throws -> Bool { + var balance = CoinageBalance.empty + for try await value in try await balanceStream().prefix(1) { + balance = value + } + + return balance.spendableByPayment >= amount + } + + // MARK: - Payment + + /// Shows the payment request sheet (auto-approved for allowlisted products), then warns whenever + /// private vouchers alone cannot pay — a voucher still gaining privacy or a coin loaded just to + /// be unloaded gives up privacy — unless the preset is `minPrivacy`. The privacy warning is never + /// allowlisted. `false` when the user declined either. + func awaitUserConsent(productId: String, amount: Balance, destination: AccountId) async throws -> Bool { + let decision = await approvalRequester.requestApproval( + productId: productId, + amount: amount, + destination: destination + ) + guard decision == .approved else { return false } + + guard recyclingStrategy.strategy != .minPrivacy else { return true } + guard try await !support.coinageService.canExecuteExternalPaymentPrivately(amount: amount) else { + return true + } + + return await privacyConfirmer.confirmGainingPrivacySpend(amount: amount) + } + + /// Registers the payment. Throws `ExternalPaymentError`. + func initiatePayment( + productId: String, + id: PaymentRequestId, + amount: Balance, + destination: AccountId + ) async throws { + try await support.coinageService.initiateExternalPayment( + productId: productId, + paymentId: Self.paymentKey(id), + amountInPlanks: amount, + destination: destination + ) + } + + /// Ends after the first terminal status; fails with `ExternalPaymentError.notFound` for an + /// unknown id. + func paymentStatuses(productId: String, id: PaymentRequestId) -> AnyAsyncSequence { + support.coinageService.subscribeExternalPaymentStatus( + productId: productId, + paymentId: Self.paymentKey(id) + ) + } + + // MARK: - Top-up + + /// Registers an idempotent top-up bound to `(productId, id)` and returns once initialization has + /// concluded; `IncomingPaymentService` drives the claim. Throws ``ProductTopUpSourceError`` when + /// the source cannot be described, `IncomingPaymentError` otherwise. + func acceptTopUp(productId: String, id: PaymentTopUpId, amount: Balance, source: PaymentTopUpSource) async throws { + let descriptor: IncomingPaymentSourceDescriptor + do { + descriptor = try Self.incomingPaymentDescriptor(from: source, productId: productId) + } catch { + throw ProductTopUpSourceError(underlying: error) + } + + try await support.incomingPaymentService.accept( + amount: amount, + descriptor: descriptor, + paymentId: Self.topUpKey(id), + productId: productId + ) + } + + /// The live statuses of an active top-up, or the stored verdict of a settled one. Throws + /// `IncomingPaymentError.notFound` for an unknown id. + func topUpStatuses(productId: String, id: PaymentTopUpId) async throws -> AnyAsyncSequence { + try await support.incomingPaymentService.subscribeStatus(for: Self.topUpKey(id), productId: productId) + } +} + +/// A top-up source that could not be described as persisted bytes. +struct ProductTopUpSourceError: Error { + let underlying: Error +} + +extension CoinageBalance { + /// What a product payment can spend right now: private plus gaining-privacy funds. + var spendableByPayment: Balance { + availablePrivate + gainingPrivacy.amount + } +} + +// MARK: - Engine keys + +private extension ProductPayments { + /// The engines key records by these strings, so both bridges must spell an id the same way. + static func paymentKey(_ id: PaymentRequestId) -> String { + id.toHex(includePrefix: true) + } + + static func topUpKey(_ id: PaymentTopUpId) -> String { + id.toHex() + } + + /// Describes the product-facing source as the persisted bytes the claim is later resolved from — + /// the full derivation **path** for a product account (never a derived key), the raw key otherwise. + /// Resolution + validation happen later, in `IncomingPaymentSourceResolver`. + static func incomingPaymentDescriptor( + from source: PaymentTopUpSource, + productId: String + ) throws -> IncomingPaymentSourceDescriptor { + switch source { + case let .productAccount(derivationIndex): + let derivationPath = try ProductAccountId( + productId: productId, + derivationIndex: derivationIndex + ).derivationPath() + + return .productAccount(derivationPath: derivationPath) + case let .privateKey(secretKey): + return .privateKey(secretKey: secretKey) + case let .coins(secretKeys): + return .coins(secretKeys: secretKeys) + } + } +} diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Payment.swift b/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Payment.swift index c7951fae47..2e6705c0f0 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Payment.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductsNativeApi+Payment.swift @@ -19,9 +19,7 @@ extension ProductsNativeApi { throw ProductNativeApiError.permissionDenied } - let coinageService = try requirePaymentsSupport().coinageService - let balanceService = try await coinageService.coinageBalanceService() - return balanceService.balanceStream + return try await requirePayments().balanceStream() .map { balance in PaymentBalance(available: balance.total) } @@ -29,30 +27,22 @@ extension ProductsNativeApi { } func requestPayment(amount: Balance, destination: AccountId, id: PaymentRequestId) async throws { - let coinageService = try requirePaymentsSupport().coinageService + let payments = try requirePayments() - try await checkSufficientBalance(amount: amount) - try await awaitUserApproval(amount: amount, destination: destination) - try await awaitPrivacyConsentIfNeeded(amount: amount) + try await checkSufficientBalance(amount: amount, payments: payments) + guard try await payments.awaitUserConsent(productId: productId, amount: amount, destination: destination) else { + throw HostPaymentRequestError.rejected + } do { - try await coinageService.initiateExternalPayment( - productId: productId, - paymentId: id.toHex(includePrefix: true), - amountInPlanks: amount, - destination: destination - ) + try await payments.initiatePayment(productId: productId, id: id, amount: amount, destination: destination) } catch ExternalPaymentError.alreadyExists { throw HostPaymentRequestError.alreadyExists } } func subscribePaymentStatus(id: PaymentRequestId) async throws -> AnyAsyncSequence { - let coinageService = try requirePaymentsSupport().coinageService - let statuses = coinageService.subscribeExternalPaymentStatus( - productId: productId, - paymentId: id.toHex(includePrefix: true) - ) + let statuses = try requirePayments().paymentStatuses(productId: productId, id: id) return AsyncThrowingStream { continuation in let task = Task { @@ -76,23 +66,13 @@ extension ProductsNativeApi { /// concluded. The claim is driven by `IncomingPaymentService`; the product observes progress via /// ``subscribePaymentTopUpStatus(id:)``. func paymentTopUp(amount: Balance, source: PaymentTopUpSource, id: PaymentTopUpId) async throws { - let incomingPaymentService = try requirePaymentsSupport().incomingPaymentService + let payments = try requirePayments() - let descriptor: IncomingPaymentSourceDescriptor do { - descriptor = try Self.incomingPaymentDescriptor(from: source, productId: productId) - } catch { - logger.error("Top-up source could not be described: \(error)") + try await payments.acceptTopUp(productId: productId, id: id, amount: amount, source: source) + } catch let error as ProductTopUpSourceError { + logger.error("Top-up source could not be described: \(error.underlying)") throw HostPaymentTopUpError.invalidSource - } - - do { - try await incomingPaymentService.accept( - amount: amount, - descriptor: descriptor, - paymentId: id.toHex(), - productId: productId - ) } catch { logger.error("Top-up could not be registered: \(error)") throw HostPaymentTopUpError(error, unknownReason: Self.topUpRegistrationFailed) @@ -103,14 +83,9 @@ extension ProductsNativeApi { id: PaymentTopUpId ) async throws -> AnyAsyncSequence { do { - let incomingPaymentService = try requirePaymentsSupport().incomingPaymentService - - return try await incomingPaymentService.subscribeStatus( - for: id.toHex(), - productId: productId - ) - .map { HostPaymentTopUpStatus(status: $0) } - .eraseToAnyAsyncSequence() + return try await requirePayments().topUpStatuses(productId: productId, id: id) + .map { HostPaymentTopUpStatus(status: $0) } + .eraseToAnyAsyncSequence() } catch { logger.error("Top-up status could not be observed: \(error)") throw HostPaymentTopUpError(error, unknownReason: Self.topUpStatusUnavailable) @@ -139,76 +114,25 @@ private extension ProductsNativeApi { return paymentsSupport } + func requirePayments() throws -> ProductPayments { + try ProductPayments( + support: requirePaymentsSupport(), + approvalRequester: paymentApprovalRequester, + privacyConfirmer: paymentPrivacyConfirmer, + recyclingStrategy: recyclingStrategy + ) + } + /// Checks the amount against what is spendable on-chain right now (private plus gaining-privacy /// funds; minting funds cannot be waited for). The permission is only read, never prompted: with /// `balanceAccess` the product already knows balances and gets `insufficientBalance`; without it /// the shortfall is reported as `rejected` so nothing leaks. - func checkSufficientBalance(amount: Balance) async throws { - let coinageService = try requirePaymentsSupport().coinageService - let balanceService = try await coinageService.coinageBalanceService() - - var balance = CoinageBalance.empty - for try await value in balanceService.balanceStream.prefix(1) { - balance = value - } - - guard balance.availablePrivate + balance.gainingPrivacy.amount < amount else { return } + func checkSufficientBalance(amount: Balance, payments: ProductPayments) async throws { + guard try await !payments.canSpend(amount) else { return } let knowsBalance = try await permissionGuard.check(productId: productId, permission: .balanceAccess) throw knowsBalance ? HostPaymentRequestError.insufficientBalance : HostPaymentRequestError.rejected } - - /// Warns whenever private vouchers alone cannot pay — a voucher still gaining privacy or a coin - /// loaded just to be unloaded gives up privacy — unless the preset is `minPrivacy`. Not allowlisted. - func awaitPrivacyConsentIfNeeded(amount: Balance) async throws { - guard recyclingStrategy.strategy != .minPrivacy else { return } - - let coinageService = try requirePaymentsSupport().coinageService - guard try await !coinageService.canExecuteExternalPaymentPrivately(amount: amount) else { return } - - guard await paymentPrivacyConfirmer.confirmGainingPrivacySpend(amount: amount) else { - throw HostPaymentRequestError.rejected - } - } - - /// Auto-approved for allowlisted products; everyone else sees the payment request sheet. - func awaitUserApproval(amount: Balance, destination: AccountId) async throws { - let decision = await paymentApprovalRequester.requestApproval( - productId: productId, - amount: amount, - destination: destination - ) - - guard decision == .approved else { - throw HostPaymentRequestError.rejected - } - } -} - -// MARK: - Top-Up Source Description - -private extension ProductsNativeApi { - /// Describes the product-facing source as the persisted bytes the claim is later resolved from — - /// the full derivation **path** for a product account (never a derived key), the raw key otherwise. - /// Resolution + validation happen later, in `IncomingPaymentSourceResolver`. - static func incomingPaymentDescriptor( - from source: PaymentTopUpSource, - productId: String - ) throws -> IncomingPaymentSourceDescriptor { - switch source { - case let .productAccount(derivationIndex): - let derivationPath = try ProductAccountId( - productId: productId, - derivationIndex: derivationIndex - ).derivationPath() - - return .productAccount(derivationPath: derivationPath) - case let .privateKey(secretKey): - return .privateKey(secretKey: secretKey) - case let .coins(secretKeys): - return .coins(secretKeys: secretKeys) - } - } } // MARK: - Wire Mapping diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustPaymentsBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustPaymentsBridge.swift new file mode 100644 index 0000000000..ed0bf38fd0 --- /dev/null +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustPaymentsBridge.swift @@ -0,0 +1,410 @@ +import Foundation +import AsyncExtensions +import BigInt +import Coinage +import Products +import TrUAPIHost + +/// Serves the Rust core's top-up, payment and balance platforms from the engines the JS bridge +/// uses, through the same ``ProductPayments`` steps. Installed once on the shared runtime. +/// +/// The core checks the session, the source keys, a positive amount and balance access itself, so +/// none of that is repeated here, and a short balance is always `InsufficientBalance`: the core +/// turns it into `Rejected` for a product without balance access. +/// +/// The core asks a top-up's or payment's current status once, then expects every later one pushed. +/// So each id is followed, once, on its engine's status stream from the first time the core asks +/// about it or the product starts it, and each status is pushed until a terminal one. Asking after +/// a restart re-reads the engine, which keeps its records. +final class RustPaymentsBridge: TopUpHostBridge, PaymentHostBridge, BalanceHostBridge, @unchecked Sendable { + private let payments: ProductPayments + private let logger: LoggerProtocol + private let target: RuntimeTarget + private let topUps: EngineStatusFollowers + private let paymentStatuses: EngineStatusFollowers + + private let lock = NSLock() + private var balanceTask: Task? + + init(payments: ProductPayments, logger: LoggerProtocol) { + let target = RuntimeTarget() + self.payments = payments + self.logger = logger + self.target = target + + topUps = EngineStatusFollowers( + statuses: { [payments] key in + try await payments.topUpStatuses(productId: key.productId, id: key.id) + .map { ($0.wireStatus, $0.isTerminal) } + .eraseToAnyAsyncSequence() + }, + isNotFound: { error in + if case .notFound = error as? IncomingPaymentError { return true } + return false + }, + notify: { [target] key, status in + target.runtime?.notifyTopUpStatus(productId: key.productId, id: key.id, status: status) + }, + logger: logger + ) + paymentStatuses = EngineStatusFollowers( + statuses: { [payments] key in + // The engine ends the stream after its first terminal status. + payments.paymentStatuses(productId: key.productId, id: key.id) + .map { ($0.wireStatus, false) } + .eraseToAnyAsyncSequence() + }, + isNotFound: { ($0 as? ExternalPaymentError) == .notFound }, + notify: { [target] key, status in + target.runtime?.notifyPaymentStatus(productId: key.productId, id: key.id, status: status) + }, + logger: logger + ) + } + + deinit { + balanceTask?.cancel() + } + + /// Serve `runtime`'s payment platforms and start pushing balance changes to it. + func install(on runtime: TrUAPIHostRuntime) { + target.runtime = runtime + + runtime.setTopUp(self) + runtime.setPayments(self) + runtime.setBalance(self) + followBalance() + } + + // MARK: - TopUpHostBridge + + func topUp(productId: String, request: HostPaymentTopUpRequest) async throws { + guard request.into == nil else { + throw TrUAPIHostPaymentTopUpError.Unknown(reason: Self.onlyMainPurse) + } + guard let amount = BigUInt(request.amount) else { + throw TrUAPIHostPaymentTopUpError.Unknown(reason: Self.invalidAmount) + } + guard let source = try? request.source.toAppSource() else { + throw TrUAPIHostPaymentTopUpError.InvalidSource + } + + do { + try await payments.acceptTopUp(productId: productId, id: request.id, amount: amount, source: source) + } catch { + logger.error("[truapi] top-up could not be registered: \(error)") + throw TrUAPIHostPaymentTopUpError(error, unknownReason: Self.topUpRegistrationFailed) + } + + topUps.follow(EngineStatusKey(productId: productId, id: request.id)) + } + + func topUpStatus(productId: String, id: Data) throws -> HostPaymentTopUpStatusSubscribeItem? { + try topUps.current(EngineStatusKey(productId: productId, id: id)) + } + + // MARK: - PaymentHostBridge + + func requestPayment(productId: String, request: HostPaymentRequest) async throws { + guard request.from == nil else { + throw HostPaymentError.Unknown(reason: Self.onlyMainPurse) + } + guard let amount = BigUInt(request.amount) else { + throw HostPaymentError.Unknown(reason: Self.invalidAmount) + } + + do { + guard try await payments.canSpend(amount) else { + throw HostPaymentError.InsufficientBalance + } + guard try await payments.awaitUserConsent( + productId: productId, + amount: amount, + destination: request.destination + ) else { + throw HostPaymentError.Rejected + } + try await payments.initiatePayment( + productId: productId, + id: request.id, + amount: amount, + destination: request.destination + ) + } catch let error as HostPaymentError { + throw error + } catch ExternalPaymentError.alreadyExists { + throw HostPaymentError.AlreadyExists + } catch { + logger.error("[truapi] payment could not be started: \(error)") + throw HostPaymentError.Unknown(reason: Self.paymentFailed) + } + + paymentStatuses.follow(EngineStatusKey(productId: productId, id: request.id)) + } + + func paymentStatus(productId: String, id: Data) throws -> HostPaymentStatusSubscribeItem? { + try paymentStatuses.current(EngineStatusKey(productId: productId, id: id)) + } + + // MARK: - BalanceHostBridge + + func balance(productId _: String, purse: UInt32?) async throws -> U128 { + guard purse == nil else { + throw HostPaymentBalanceSubscribeError.Unknown(reason: Self.onlyMainPurse) + } + + do { + for try await balance in try await payments.balanceStream().prefix(1) { + return String(balance.spendableByPayment) + } + } catch { + logger.error("[truapi] balance is unavailable: \(error)") + } + throw HostPaymentBalanceSubscribeError.Unknown(reason: Self.balanceUnavailable) + } +} + +// MARK: - Balance + +private extension RustPaymentsBridge { + /// One subscription for the runtime's lifetime, resubscribed after a failure (the balance + /// service is unavailable until Coinage is set up). + func followBalance() { + lock.lock() + defer { lock.unlock() } + guard balanceTask == nil else { return } + + balanceTask = Task { [payments, target, logger] in + while !Task.isCancelled { + do { + for try await balance in try await payments.balanceStream() { + target.runtime?.notifyBalance(purse: nil, available: String(balance.spendableByPayment)) + } + } catch { + logger.warning("[truapi] balance stream failed, resubscribing: \(error)") + } + try? await Task.sleep(for: Self.balanceResubscribeDelay) + } + } + } +} + +// MARK: - Wire reasons + +private extension RustPaymentsBridge { + /// What a product is told on an unclassified failure. The real error is logged; a CoreData or + /// Keychain dump is not for product scripts. + static let topUpRegistrationFailed = "top-up could not be registered" + static let paymentFailed = "payment could not be started" + static let balanceUnavailable = "balance is unavailable" + static let onlyMainPurse = "only the main purse is supported" + static let invalidAmount = "amount is not a decimal number" + static let balanceResubscribeDelay: Duration = .seconds(5) +} + +// MARK: - Runtime target + +/// The runtime statuses are pushed to, held weakly: the runtime retains this bridge. +private final class RuntimeTarget: @unchecked Sendable { + private let lock = NSLock() + private weak var storedRuntime: TrUAPIHostRuntime? + + var runtime: TrUAPIHostRuntime? { + get { + lock.lock() + defer { lock.unlock() } + return storedRuntime + } + set { + lock.lock() + storedRuntime = newValue + lock.unlock() + } + } +} + +// MARK: - Engine status followers + +private struct EngineStatusKey: Hashable, Sendable { + let productId: String + let id: Data +} + +/// Follows an engine's status streams, at most one per id, remembering each id's latest status so +/// the core's synchronous "current status" read can be answered. +private final class EngineStatusFollowers: @unchecked Sendable { + /// A stream of statuses, each with whether it is terminal. + typealias Statuses = (EngineStatusKey) async throws -> AnyAsyncSequence<(Status, Bool)> + + /// How long a status read waits for the engine's first answer on an id nobody follows yet. The + /// read is synchronous on the core's thread; the engines answer from their stores. + private static var firstStatusTimeout: TimeInterval { 5 } + + final class Entry { + var latest: Status? + /// Set once the first status arrived or the stream ended. + var settled = false + var failure: Error? + } + + private let statuses: Statuses + private let isNotFound: (Error) -> Bool + private let notify: (EngineStatusKey, Status) -> Void + private let logger: LoggerProtocol + + private let condition = NSCondition() + private var entries: [EngineStatusKey: Entry] = [:] + + init( + statuses: @escaping Statuses, + isNotFound: @escaping (Error) -> Bool, + notify: @escaping (EngineStatusKey, Status) -> Void, + logger: LoggerProtocol + ) { + self.statuses = statuses + self.isNotFound = isNotFound + self.notify = notify + self.logger = logger + } + + /// Start following `key` unless it already is. + func follow(_ key: EngineStatusKey) { + _ = entry(for: key) + } + + /// The latest status of `key`, following it when nobody does and waiting for the engine's first + /// answer if none arrived yet. `nil` when the engine holds no such id. + func current(_ key: EngineStatusKey) throws -> Status? { + let entry = entry(for: key) + + condition.lock() + defer { condition.unlock() } + + let deadline = Date(timeIntervalSinceNow: Self.firstStatusTimeout) + while !entry.settled { + guard condition.wait(until: deadline) else { + logger.error("[truapi] no status within \(Self.firstStatusTimeout)s") + throw HostRejection.Rejected(reason: "status is unavailable") + } + } + + if let latest = entry.latest { return latest } + if let failure = entry.failure { throw failure } + return nil + } +} + +private extension EngineStatusFollowers { + /// The entry following `key`, starting one when there is none. Each status is remembered, then + /// pushed; following ends after a terminal status or when the stream ends, and the entry is + /// dropped so a later read follows afresh from the engine's record. + func entry(for key: EngineStatusKey) -> Entry { + condition.lock() + if let existing = entries[key] { + condition.unlock() + return existing + } + let entry = Entry() + entries[key] = entry + condition.unlock() + + Task { [self] in + do { + for try await (status, terminal) in try await statuses(key) { + settle(entry) { $0.latest = status } + notify(key, status) + if terminal { break } + } + finish(key, entry, failure: nil) + } catch { + let notFound = isNotFound(error) + if !notFound { + logger.error("[truapi] status stream failed: \(error)") + } + finish(key, entry, failure: notFound ? nil : error) + } + } + + return entry + } + + func settle(_ entry: Entry, _ update: (Entry) -> Void) { + condition.lock() + update(entry) + entry.settled = true + condition.broadcast() + condition.unlock() + } + + func finish(_ key: EngineStatusKey, _ entry: Entry, failure: Error?) { + settle(entry) { entry in + if entry.latest == nil { + entry.failure = failure + } + } + + condition.lock() + if entries[key] === entry { + entries[key] = nil + } + condition.unlock() + } +} + +// MARK: - Wire mapping + +private extension TrUAPIHostPaymentTopUpSource { + func toAppSource() throws -> Products.PaymentTopUpSource { + switch self { + case let .productAccount(derivationIndex): + try .productAccount(derivationIndex: derivationIndex.toSelector()) + case let .privateKey(secretKey): + .privateKey(secretKey) + case let .coins(secretKeys): + .coins(secretKeys: secretKeys) + } + } +} + +private extension TrUAPIHostPaymentTopUpError { + /// The coded error for `error`; anything that is not a classified `IncomingPaymentError` becomes + /// `Unknown` with the generic `unknownReason` rather than the error's own description. + init(_ error: any Error, unknownReason: String) { + if error is ProductTopUpSourceError { + self = .InvalidSource + return + } + switch error as? IncomingPaymentError { + case .alreadyExists: self = .AlreadyExists + case .invalidSource: self = .InvalidSource + case .sourceBusy: self = .SourceBusy + case .invalidAmount: self = .Unknown(reason: "amount must be positive") + case .notFound, + .unknown, + .none: self = .Unknown(reason: unknownReason) + } + } +} + +private extension IncomingPaymentStatus { + var wireStatus: HostPaymentTopUpStatusSubscribeItem { + switch self { + case .detecting: .detecting + case .claiming: .claiming + case let .claimed(finalized): .claimed(finalized: finalized) + case let .claimedPartially(actualClaimed): .claimedPartially(actualClaimed: String(actualClaimed)) + case .notClaimed: .notClaimed + } + } +} + +private extension ExternalPaymentStatus { + var wireStatus: HostPaymentStatusSubscribeItem { + switch self { + case .processing: .processing + case .completed: .completed + case let .partiallyCompleted(settled): .partiallyClaimed(actualClaimed: String(settled)) + case let .failed(reason): .failed(reason: reason) + } + } +} diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift index 24ccde863c..6b91793749 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift @@ -51,6 +51,10 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se /// first use, which is long after, so the manager is in place by then. private var workerManager: (any TrUAPIWorkerManaging)? + /// Set once at startup, after the payment engines are assembled. Installed on the runtime when + /// it is built, or right away if it already is. + private var paymentsBridge: RustPaymentsBridge? + init( chainRegistry: ChainRegistryProtocol, entropyManager: RootEntropyManaging, @@ -81,6 +85,33 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se self.workerManager = workerManager } + /// Serve top-ups, payments and balance to the core from `paymentsSupport`. Approvals and + /// privacy warnings are presented like the core's own host-level prompts. + func attach(paymentsSupport: PaymentsSupport, hostProvider: ProductHostProviding) { + let productsRouter = confirmationRouterFacade.productsRouter + let bridge = RustPaymentsBridge( + payments: ProductPayments( + support: paymentsSupport, + approvalRequester: PaymentApprovalRequesterFactory.create( + router: productsRouter, + fundingProvider: FundingDomainProvider(hostProvider: hostProvider) + ), + privacyConfirmer: PaymentPrivacyConfirmer(router: productsRouter), + recyclingStrategy: CoinageRecyclingStrategyStore.shared + ), + logger: logger + ) + + lock.lock() + defer { lock.unlock() } + + guard paymentsBridge == nil else { return } + paymentsBridge = bridge + if let cachedRuntime { + bridge.install(on: cachedRuntime) + } + } + func sharedRuntime() throws -> TrUAPIHostRuntime { lock.lock() defer { lock.unlock() } @@ -127,6 +158,7 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se routerFacade: confirmationRouterFacade ) runtime.setContacts(contactsBridge) + paymentsBridge?.install(on: runtime) contactsChangeNotifier = ContactsChangeNotifier( dataProviderFactory: ChatContactDataProviderFactory(), logger: logger, diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostTypes.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostTypes.swift index 00b1a68c47..3f3989f653 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostTypes.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostTypes.swift @@ -11,6 +11,8 @@ typealias TrUAPIHostAllocatableResource = AllocatableResource typealias TrUAPIHostVrfTranscriptItem = VrfTranscriptItem typealias TrUAPIHostSignVrfRequest = HostAccountSignVrfRequest typealias TrUAPIHostRingLocation = RingLocation +typealias TrUAPIHostPaymentTopUpSource = PaymentTopUpSource +typealias TrUAPIHostPaymentTopUpError = HostPaymentTopUpError // Renderer leaves, named apart so SwiftUI-importing files can extend them // without colliding with SwiftUI's own types of the same name. diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index f8cbb828b9..e424bf7ad0 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -323,6 +323,35 @@ the user's calendar, keeps the reminder across app kill and device reboot, and drops it once the game has started. A `scheduleReminder` that throws reaches the product as a host failure carrying its reason. +## Payments + +A host with a wallet serves top-ups, payments and the spendable balance by +installing `TopUpHostBridge`, `PaymentHostBridge` and `BalanceHostBridge` once +on the runtime, before any product opens. A runtime without one answers the +matching calls `Unsupported`. + +```swift +runtime.setTopUp(wallet) +runtime.setPayments(wallet) +runtime.setBalance(wallet) + +// Later, from the wallet's own status and balance streams: +runtime.notifyTopUpStatus(productId: productId, id: id, status: .claiming) +runtime.notifyPaymentStatus(productId: productId, id: id, status: .completed) +runtime.notifyBalance(purse: nil, available: "1500000000") +``` + +The core checks the session, the source keys, a positive amount and the +product's balance access, so the host never prompts for balance access. A +payment the balance cannot cover throws `HostPaymentError.InsufficientBalance`, +which the core turns into `Rejected` for a product without balance access. The +balance is what a payment can spend now, as a decimal string of CASH units. + +`topUpStatus` and `paymentStatus` answer the current status of an id inline, +or `nil` when the host holds none. Every later status goes through +`notifyTopUpStatus` and `notifyPaymentStatus`, ending with a terminal one; the +core relays each to the products following that id. + ## Architecture ```text diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index d76b12f331..7aab4068f5 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -303,6 +303,60 @@ public protocol ContactsHostBridge: AnyObject, Sendable { func pickContact(productId: String) async throws -> HostContactPick } +/// Host-implemented top-up engine: claims a source's funds into the user's +/// balance. +/// +/// Installed once on the runtime with ``TrUAPIHostRuntime/setTopUp(_:)``. +/// The core has already checked the session and the source keys; a +/// `productAccount` source arrives as its derivation index for the host to +/// derive. The host owns claiming, retries, partial claims, persistence and +/// scoping ids to the product. A runtime without one answers `topUp` and +/// `topUpStatusSubscribe` with `Unsupported`. +public protocol TopUpHostBridge: AnyObject, Sendable { + /// Start `request` for `productId`, returning once the host has accepted + /// it. Throw a `HostPaymentTopUpError` to answer with its code; anything + /// else reaches the product as `Unknown`. + func topUp(productId: String, request: HostPaymentTopUpRequest) async throws + + /// The current status of `productId`'s top-up `id`, or `nil` when the + /// host holds none. Report every later status with + /// ``TrUAPIHostRuntime/notifyTopUpStatus(productId:id:status:)``. + func topUpStatus(productId: String, id: Data) throws -> HostPaymentTopUpStatusSubscribeItem? +} + +/// Host-implemented payment engine: pays from the user's balance to an +/// account once the user approves. +/// +/// Installed once on the runtime with ``TrUAPIHostRuntime/setPayments(_:)``. +/// Answer `InsufficientBalance` whenever the balance is short: the core turns +/// it into `Rejected` for a product without balance access. A runtime without +/// one answers `request` and `statusSubscribe` with `Unsupported`. +public protocol PaymentHostBridge: AnyObject, Sendable { + /// Ask the user to approve `request` for `productId`, returning once the + /// user authorized it and the host took it on. Throw a `HostPaymentError` + /// to answer with its code; anything else reaches the product as + /// `Unknown`. + func requestPayment(productId: String, request: HostPaymentRequest) async throws + + /// The current status of `productId`'s payment `id`, or `nil` when the + /// host holds none. Report every later status with + /// ``TrUAPIHostRuntime/notifyPaymentStatus(productId:id:status:)``. + func paymentStatus(productId: String, id: Data) throws -> HostPaymentStatusSubscribeItem? +} + +/// Host-implemented balance view: what a payment request can spend right now. +/// +/// Installed once on the runtime with ``TrUAPIHostRuntime/setBalance(_:)``. +/// The core checks the product's balance access itself, so never prompt for +/// it here. A runtime without one answers `balanceSubscribe` with +/// `Unsupported`. +public protocol BalanceHostBridge: AnyObject, Sendable { + /// The current balance of `purse` (`nil` for the main purse), a decimal + /// string of CASH units. Report every change with + /// ``TrUAPIHostRuntime/notifyBalance(purse:available:)``. + func balance(productId: String, purse: UInt32?) async throws -> U128 +} + public extension HostBridge { /// Default no-op logger. Override to plumb into your logging framework. func onCoreLog(marker: String, detail: String) {} @@ -497,6 +551,86 @@ private final class ContactsCallbackAdapter: NativeContactsCallbacks, @unchecked } } +/// Adapter that bridges the public `TopUpHostBridge` to the generated UniFFI +/// `NativeTopUpCallbacks` protocol. +private final class TopUpCallbackAdapter: NativeTopUpCallbacks, @unchecked Sendable { + private let bridge: TopUpHostBridge + + init(bridge: TopUpHostBridge) { + self.bridge = bridge + } + + func topUp(productId: String, request: HostPaymentTopUpRequest) async throws { + do { + try await bridge.topUp(productId: productId, request: request) + } catch let error as HostPaymentTopUpError { + throw error + } catch { + throw HostPaymentTopUpError.Unknown(reason: hostRejectionReason(error)) + } + } + + func topUpStatus(productId: String, id: Bytes32) throws -> HostPaymentTopUpStatusSubscribeItem? { + do { + return try bridge.topUpStatus(productId: productId, id: id) + } catch let error as HostRejection { + throw error + } catch { + throw HostRejection.Rejected(reason: hostRejectionReason(error)) + } + } +} + +/// Adapter that bridges the public `PaymentHostBridge` to the generated UniFFI +/// `NativePaymentCallbacks` protocol. +private final class PaymentCallbackAdapter: NativePaymentCallbacks, @unchecked Sendable { + private let bridge: PaymentHostBridge + + init(bridge: PaymentHostBridge) { + self.bridge = bridge + } + + func requestPayment(productId: String, request: HostPaymentRequest) async throws { + do { + try await bridge.requestPayment(productId: productId, request: request) + } catch let error as HostPaymentError { + throw error + } catch { + throw HostPaymentError.Unknown(reason: hostRejectionReason(error)) + } + } + + func paymentStatus(productId: String, id: Bytes32) throws -> HostPaymentStatusSubscribeItem? { + do { + return try bridge.paymentStatus(productId: productId, id: id) + } catch let error as HostRejection { + throw error + } catch { + throw HostRejection.Rejected(reason: hostRejectionReason(error)) + } + } +} + +/// Adapter that bridges the public `BalanceHostBridge` to the generated UniFFI +/// `NativeBalanceCallbacks` protocol. +private final class BalanceCallbackAdapter: NativeBalanceCallbacks, @unchecked Sendable { + private let bridge: BalanceHostBridge + + init(bridge: BalanceHostBridge) { + self.bridge = bridge + } + + func balance(productId: String, purse: UInt32?) async throws -> U128 { + do { + return try await bridge.balance(productId: productId, purse: purse) + } catch let error as HostPaymentBalanceSubscribeError { + throw error + } catch { + throw HostPaymentBalanceSubscribeError.Unknown(reason: hostRejectionReason(error)) + } + } +} + /// Adapter that bridges the public `HostBridge` to the generated UniFFI /// `HostCallbacks` protocol. Kept private so the generated names never /// leak into consumers. @@ -740,6 +874,9 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { private let notificationCenter: NotificationCenter private let foregroundObserver: NSObjectProtocol private var contactsRetainer: NativeContactsCallbacks? + private var topUpRetainer: NativeTopUpCallbacks? + private var paymentsRetainer: NativePaymentCallbacks? + private var balanceRetainer: NativeBalanceCallbacks? public convenience init(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) throws { try self.init(bridge: bridge, runtimeConfig: runtimeConfig, notificationCenter: .default) @@ -795,6 +932,51 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { inner.notifyContactsChanged() } + /// Install the host's top-up engine. Set-once; answers whether this call + /// installed it. Call it before opening any product execution. + @discardableResult + public func setTopUp(_ topUp: TopUpHostBridge) -> Bool { + let adapter = TopUpCallbackAdapter(bridge: topUp) + topUpRetainer = adapter + return inner.setTopUpCallbacks(callbacks: adapter) + } + + /// Report a later status of `productId`'s top-up `id` to the products + /// following it. A terminal status ends their streams. + public func notifyTopUpStatus(productId: String, id: Data, status: HostPaymentTopUpStatusSubscribeItem) { + inner.notifyTopUpStatus(productId: productId, id: id, status: status) + } + + /// Install the host's payment engine. Set-once; answers whether this call + /// installed it. Call it before opening any product execution. + @discardableResult + public func setPayments(_ payments: PaymentHostBridge) -> Bool { + let adapter = PaymentCallbackAdapter(bridge: payments) + paymentsRetainer = adapter + return inner.setPaymentCallbacks(callbacks: adapter) + } + + /// Report a later status of `productId`'s payment `id` to the products + /// following it. A terminal status ends their streams. + public func notifyPaymentStatus(productId: String, id: Data, status: HostPaymentStatusSubscribeItem) { + inner.notifyPaymentStatus(productId: productId, id: id, status: status) + } + + /// Install the host's balance view. Set-once; answers whether this call + /// installed it. Call it before opening any product execution. + @discardableResult + public func setBalance(_ balance: BalanceHostBridge) -> Bool { + let adapter = BalanceCallbackAdapter(bridge: balance) + balanceRetainer = adapter + return inner.setBalanceCallbacks(callbacks: adapter) + } + + /// Report the new balance of `purse` (`nil` for the main purse), a decimal + /// string of CASH units, to the products following it. + public func notifyBalance(purse: UInt32?, available: U128) { + inner.notifyBalance(purse: purse, available: available) + } + /// Open one executable connection with a host-assigned immutable context. /// Pass `chat` to install the host's Chat adapter; hosts without the Chat /// modality omit it. Pass `pocket` to install the card collection, and