Skip to content
57 changes: 50 additions & 7 deletions DashWallet/Sources/Models/DashConnect/DashConnectDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,55 @@ protocol DashConnectDataSource {
func parseQR(_ content: String) async throws -> DashConnectQr
func makeConnectionRequest(from loginRequest: DashKeyRequest) async -> ConnectionRequest
func approveLogin(_ request: DashKeyRequest) async throws -> DAppConnection
func completeKeyRegistration(_ request: DashStRequest) async throws
/// Parses a scanned `dash-st:` payload and either completes key
/// registration immediately or returns a token purchase that awaits
/// explicit user approval via `approveTokenPurchase(_:)`.
func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction
/// Rebuilds, signs and submits a token purchase the user approved.
///
/// Throws `DashConnectTokenPurchaseFailure`, which says whether the
/// transition could already have reached Platform — the caller must not
/// offer a retry when it could have.
func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws
func disconnect(id: String) async
func remove(id: String) async
}

/// Why a token purchase failed, and — the part that decides what the UI may
/// offer next — whether the transition could already have reached Platform.
///
/// A purchase is not idempotent: each approval builds and signs a new direct
/// purchase against the identity's next nonce. Retrying after a failure that
/// only looked like a failure buys the tokens a second time and debits the
/// credits a second time, so "did this reach Platform?" has to survive as far
/// as the screen.
enum DashConnectTokenPurchaseFailure: LocalizedError {
/// Refused before anything was signed or submitted — a wrong identity, a
/// mismatched token id, a cancelled authentication, a missing runtime.
/// Nothing was charged and approving again is safe.
case beforeSubmission(Error)
/// The transition was signed and handed to Platform, and the failure came
/// out of that call. Platform may have accepted it anyway (a finality
/// timeout, a dropped DAPI response), so the purchase must be treated as
/// possibly complete.
case outcomeUnknown(Error)

var underlying: Error {
switch self {
case .beforeSubmission(let error), .outcomeUnknown(let error):
return error
}
}

var errorDescription: String? { underlying.localizedDescription }
}

enum DashConnectMockError: LocalizedError, Equatable {
case parseFailed
case approveFailed
case notDashConnectQrCode
case unsupportedNetwork(expected: DashConnectNetwork, actual: DashConnectNetwork)
case keyRegistrationNotSupported
case stateTransitionNotSupported

var errorDescription: String? {
switch self {
Expand All @@ -48,8 +86,8 @@ enum DashConnectMockError: LocalizedError, Equatable {
return "This QR code is not a DashConnect QR code."
case let .unsupportedNetwork(expected, actual):
return "This DashConnect QR is for \(Self.displayName(for: actual)), but this wallet currently supports \(Self.displayName(for: expected)) only."
case .keyRegistrationNotSupported:
return "Key registration is not supported by the mock."
case .stateTransitionNotSupported:
return "State transitions are not supported by the mock."
}
}

Expand Down Expand Up @@ -150,7 +188,7 @@ final class MockDashConnectDataSource: DashConnectDataSource {
if DashConnectUri.isStUri(trimmed) {
let request = try DashConnectUri.parseStRequest(trimmed)
try validateNetwork(request.network)
return .keyRegistration(request)
return .stateTransition(request)
}

throw DashConnectMockError.notDashConnectQrCode
Expand Down Expand Up @@ -191,9 +229,14 @@ final class MockDashConnectDataSource: DashConnectDataSource {
)
}

func completeKeyRegistration(_ request: DashStRequest) async throws {
func handleStateTransition(_ request: DashStRequest) async throws -> DashConnectStAction {
try await Task.sleep(nanoseconds: 400_000_000)
throw DashConnectMockError.keyRegistrationNotSupported
throw DashConnectMockError.stateTransitionNotSupported
}

func approveTokenPurchase(_ request: DashConnectTokenPurchaseRequest) async throws {
throw DashConnectTokenPurchaseFailure.beforeSubmission(
DashConnectMockError.stateTransitionNotSupported)
}

func disconnect(id: String) async {
Expand Down
56 changes: 55 additions & 1 deletion DashWallet/Sources/Models/DashConnect/DashConnectModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,61 @@ import SwiftDashSDK

enum DashConnectQr: Equatable {
case login(DashKeyRequest)
case keyRegistration(DashStRequest)
/// A `dash-st:` payload — a serialized state transition whose kind is
/// only known once the wallet parses it (key registration or token
/// purchase).
case stateTransition(DashStRequest)
}

/// What the wallet did — or still needs the user to do — with a scanned
/// `dash-st:` state transition.
enum DashConnectStAction: Equatable {
/// Key registration was validated and published (or the derived keys
/// were already on the identity).
case keyRegistrationCompleted
/// The payload is a token purchase; nothing was signed or sent yet — it
/// awaits explicit user approval via `approveTokenPurchase(_:)`.
case tokenPurchaseApprovalRequired(DashConnectTokenPurchaseRequest)
}

/// A pending token purchase parsed from a `dash-st:` payload, awaiting user
/// approval. Carries the raw values the purchase is rebuilt from and the
/// display fields the approval sheet renders.
struct DashConnectTokenPurchaseRequest: Equatable {
/// Name of an already-connected app whose contract id matches the
/// purchase's data contract, when one is stored locally. Display only.
let appName: String?
/// Identity the purchase debits — validated to be the wallet's own both
/// when the request is built and again on approve.
let ownerId: Data
let dataContractId: Data
let tokenId: Data
let tokenContractPosition: UInt16
let tokenCount: UInt64
/// Total price in Platform credits, as the payload asked for it. Passed
/// to `tokenPurchase(...)` as `expectedTotalCost`, which is the MAXIMUM
/// the user approves: Platform rejects the transition if the current
/// price is higher, and charges the lower amount if it is lower.
let totalAgreedPriceCredits: UInt64
let walletUsername: String?
let walletIdentityId: String
}

extension DashConnectTokenPurchaseRequest {
/// The total price converted to DASH for display, over the one
/// credits-per-DASH definition this module already has. A second copy of
/// the divisor is how a money display drifts from the money.
var totalPriceDash: Decimal {
Decimal(totalAgreedPriceCredits) / Decimal(PlatformCreditsFormatter.creditsPerDash)
}

/// The total price rendered for the approval sheet, at the full credit
/// precision. Credits are 1e11 per DASH, so an eight-digit rendering
/// silently rounds away a sub-duff remainder that is nevertheless
/// charged — not something a money-authorization surface should hide.
var totalPriceDashText: String {
PlatformCreditsFormatter.dashString(totalAgreedPriceCredits)
Comment on lines +87 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Render the full Platform-credit precision on the approval sheet

Using PlatformCreditsFormatter removes the former eight-fractional-digit limit, but that formatter first converts the UInt64 credits through Double. Executing the current implementation with 10_000_000_000_000_001 credits renders "100000 DASH" instead of "100000.00000000001 DASH", while tokenPurchase receives the exact integer as expectedTotalCost. The switch to an eleven-digit formatter therefore fixes ordinary sub-duff values but does not preserve full credit precision across the accepted range. Use decimal or integer arithmetic in the shared formatter and add boundary assertions against totalPriceDashText; the existing totalPriceDash assertions do not exercise the approval sheet's rendering path.

source: ['claude']

}
Comment thread
romchornyi marked this conversation as resolved.
}

/// Lifecycle of an app connection.
Expand Down
Loading
Loading