Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
152 changes: 152 additions & 0 deletions hosts/ios/polkadot-app/Modules/Products/ProductPayments.swift
Original file line number Diff line number Diff line change
@@ -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<CoinageBalance> {
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<ExternalPaymentStatus> {
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<IncomingPaymentStatus> {
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,30 @@ 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)
}
.eraseToAnyAsyncSequence()
}

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<HostPaymentStatus> {
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<HostPaymentStatus, Error> { continuation in
let task = Task {
Expand All @@ -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)
Expand All @@ -103,14 +83,9 @@ extension ProductsNativeApi {
id: PaymentTopUpId
) async throws -> AnyAsyncSequence<HostPaymentTopUpStatus> {
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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading