diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index de1b164a5..508c1e586 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -415,6 +415,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { signer: signer) applyUpdatedBalances(updated, context: container.mainContext) + ShieldedTxLookup.shared.refresh() Task { await self.syncNow() } } @@ -438,6 +439,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { signer: signer) applyUpdatedBalances(updated, context: container.mainContext) + ShieldedTxLookup.shared.refresh() Task { await self.syncNow() } } @@ -1003,6 +1005,13 @@ final class ShieldedTxLookup { /// (`ManagedAssetLockManager.FundingType.assetLockShieldedAddressTopUp`). private static let shieldedFundingType = 5 + /// `AssetLockAddressTopUp` — the Core → Platform address funding lock. + private static let platformFundingType = 4 + + /// Identity funding locks: 0 = IdentityRegistration, 1 = IdentityTopUp, + /// 2 = IdentityTopUpNotBound, 3 = IdentityInvitation. + private static let identityFundingTypes = 0...3 + private static let logger = Logger( subsystem: "org.dashfoundation.dash", category: "swift-sdk-migration.shielded-tx-lookup") @@ -1016,6 +1025,8 @@ final class ShieldedTxLookup { let amountDuffs: UInt64 let statusRaw: Int let vout: UInt32 + /// 5 = shielded funding, 4 = platform address funding. + let fundingTypeRaw: Int } private let lock = NSLock() @@ -1038,10 +1049,34 @@ final class ShieldedTxLookup { /// matching the txid component of `PersistentAssetLock.outPointHex`. /// Thread-safe; touches no SwiftData. func info(forTxidHex txidHex: String) -> ShieldedLockInfo? { + entry(forTxidHex: txidHex, fundingType: Self.shieldedFundingType) + } + + /// Same snapshot entry for a Core → Platform (type 4) address-funding + /// lock — powers the "Platform transfer" rows the way `info` powers the + /// shielded ones. Thread-safe; touches no SwiftData. + func platformFundingInfo(forTxidHex txidHex: String) -> ShieldedLockInfo? { + entry(forTxidHex: txidHex, fundingType: Self.platformFundingType) + } + + /// Snapshot entry for an identity funding lock (types 0…3 — registration, + /// top-up, invitation). `fundingTypeRaw` distinguishes the variants. + /// Thread-safe; touches no SwiftData. + func identityFundingInfo(forTxidHex txidHex: String) -> ShieldedLockInfo? { + let key = txidHex.lowercased() + lock.lock() + defer { lock.unlock() } + guard let entry = infoByTxid[key], + Self.identityFundingTypes.contains(entry.fundingTypeRaw) else { return nil } + return entry + } + + private func entry(forTxidHex txidHex: String, fundingType: Int) -> ShieldedLockInfo? { let key = txidHex.lowercased() lock.lock() defer { lock.unlock() } - return infoByTxid[key] + guard let entry = infoByTxid[key], entry.fundingTypeRaw == fundingType else { return nil } + return entry } /// Rebuild the snapshot from the active container's shielded asset-lock @@ -1059,7 +1094,8 @@ final class ShieldedTxLookup { // rather than fighting `#Predicate` local-capture rules. let rows = try container.mainContext.fetch(FetchDescriptor()) var map: [String: ShieldedLockInfo] = [:] - for row in rows where row.fundingTypeRaw == Self.shieldedFundingType && row.amountDuffs > 0 { + let trackedTypes = Array(Self.identityFundingTypes) + [Self.platformFundingType, Self.shieldedFundingType] + for row in rows where trackedTypes.contains(row.fundingTypeRaw) && row.amountDuffs > 0 { // outPointHex == ":"; key on the txid, // parse the vout after the colon. One shielded asset-lock row // per funding txid in practice; if one ever recurs, prefer the @@ -1073,12 +1109,13 @@ final class ShieldedTxLookup { let info = ShieldedLockInfo( amountDuffs: UInt64(row.amountDuffs), statusRaw: row.statusRaw, - vout: vout) + vout: vout, + fundingTypeRaw: row.fundingTypeRaw) if let existing = map[txid], existing.statusRaw >= info.statusRaw { continue } map[txid] = info } store(map) - Self.logger.info("🛡️ SHIELD-TX :: snapshot \(map.count, privacy: .public) shielded funding tx(s)") + Self.logger.info("🛡️ SHIELD-TX :: snapshot \(map.count, privacy: .public) funding tx(s) (shielded + platform)") // Diagnostic: if asset locks exist but none matched the shielded // funding type, surface the types actually present so a single // test run reveals whether the discriminant assumption is wrong. diff --git a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift index c262c6ea4..39927faad 100644 --- a/DashWallet/Sources/Models/Transactions/Model/Transaction.swift +++ b/DashWallet/Sources/Models/Transactions/Model/Transaction.swift @@ -227,6 +227,38 @@ class Transaction: TransactionDataItem, Identifiable { /// True when this is the funding tx of a "to Shielded" transfer. var isShieldedTransfer: Bool { shieldedLockInfo != nil } + /// Live info for this tx as a Core → Platform address funding (a Type-8 + /// asset lock with funding type 4). Same live-read rationale as + /// `shieldedLockInfo`. + var platformFundingLockInfo: ShieldedTxLookup.ShieldedLockInfo? { + ShieldedTxLookup.shared.platformFundingInfo(forTxidHex: shieldedDisplayTxid) + } + + private var platformFundingAmountDuffs: UInt64? { platformFundingLockInfo?.amountDuffs } + + /// True when this is the funding tx of a Core → Platform transfer. + var isPlatformFundingTransfer: Bool { platformFundingLockInfo != nil } + + /// Live info for this tx as an identity funding asset lock (types 0…3 — + /// registration / top-up / invitation). Same live-read rationale as + /// `shieldedLockInfo`. + var identityFundingLockInfo: ShieldedTxLookup.ShieldedLockInfo? { + ShieldedTxLookup.shared.identityFundingInfo(forTxidHex: shieldedDisplayTxid) + } + + private var identityFundingAmountDuffs: UInt64? { identityFundingLockInfo?.amountDuffs } + + /// True when this is the funding tx of an identity registration/top-up/ + /// invitation. + var isIdentityFundingTransfer: Bool { identityFundingLockInfo != nil } + + /// Identity sibling of `isPendingShieldedTransfer` — drives the home + /// row's "Pending" pill. + var isPendingIdentityFunding: Bool { + guard let status = identityFundingLockInfo?.statusRaw else { return false } + return (1...3).contains(status) + } + /// True when this incoming tx is the L1 payout of a Shielded → Core /// withdrawal the app performed — matched by destination address via /// `ShieldedWithdrawalStore` (the SDK's opaque withdraw call returns no @@ -249,6 +281,10 @@ class Transaction: TransactionDataItem, Identifiable { case coreToShielded /// L1 payout of a shielded withdrawal (Shielded → Core). case shieldedToCore + /// "To Platform" address-funding asset lock (Core → Platform). + case coreToPlatform + /// Identity registration / top-up / invitation asset lock. + case coreToIdentity /// Self-send within the transparent wallet. case coreToCore } @@ -256,6 +292,8 @@ class Transaction: TransactionDataItem, Identifiable { var internalTransferRoute: InternalTransferRoute? { if isShieldedTransfer { return .coreToShielded } if isShieldedWithdrawalReceipt { return .shieldedToCore } + if isPlatformFundingTransfer { return .coreToPlatform } + if isIdentityFundingTransfer { return .coreToIdentity } if direction == .moved { return .coreToCore } return nil } @@ -269,6 +307,15 @@ class Transaction: TransactionDataItem, Identifiable { return (1...3).contains(status) } + /// Platform-funding sibling of `isPendingShieldedTransfer`: the type-4 + /// lock is committed but the address-funding transition hasn't consumed + /// it. Drives the home row's "Pending" pill (no tap-to-recover surface + /// yet — retry lives in the transfer confirm sheet). + var isPendingPlatformFunding: Bool { + guard let status = platformFundingLockInfo?.statusRaw else { return false } + return (1...3).contains(status) + } + /// Outpoint (wire-order txid + vout) of this transfer's shielded asset /// lock, for a recovery resume. `txHashData` is already wire order, so it /// is returned verbatim (the display↔wire reversal only happens when @@ -279,10 +326,11 @@ class Transaction: TransactionDataItem, Identifiable { } private lazy var _dashAmount: UInt64 = { - // A "to Shielded" transfer's L1 funding tx is a Type-18 asset lock; - // surface the real locked amount the SDK recorded instead of the 0 - // the generic logic below derives for a self-directed move. - if let shielded = shieldedTransferAmountDuffs { return shielded } + // A "to Shielded" / "to Platform" transfer's L1 funding tx is an + // asset lock; surface the real locked amount the SDK recorded + // instead of the 0 the generic logic below derives for a + // self-directed move. + if let locked = shieldedTransferAmountDuffs ?? platformFundingAmountDuffs ?? identityFundingAmountDuffs { return locked } let fee = Int64(snapshot.fee ?? 0) switch direction { case .received: @@ -307,7 +355,9 @@ class Transaction: TransactionDataItem, Identifiable { /// the lazily-cached generic `_dashAmount`, so a row that became a shielded /// transfer after its first render still shows the locked amount — matching /// the now-live `stateTitle` / `isPendingShieldedTransfer`. - var dashAmount: UInt64 { shieldedTransferAmountDuffs ?? _dashAmount } + var dashAmount: UInt64 { + shieldedTransferAmountDuffs ?? platformFundingAmountDuffs ?? identityFundingAmountDuffs ?? _dashAmount + } var signedDashAmount: Int64 { if dashAmount == UInt64.max { return Int64.max @@ -319,7 +369,7 @@ class Transaction: TransactionDataItem, Identifiable { var fiatAmount: String { // The shielded amount is read live (see `dashAmount`), so compute its // fiat live too; non-shielded rows keep the lazily-cached value. - if shieldedTransferAmountDuffs != nil { + if shieldedTransferAmountDuffs != nil || platformFundingAmountDuffs != nil || identityFundingAmountDuffs != nil { return userInfo?.fiatAmountString(from: dashAmount) ?? NSLocalizedString("Not available", comment: "") } return storedFiatAmount @@ -389,23 +439,26 @@ class Transaction: TransactionDataItem, Identifiable { } var stateTitle: String { - // A "to Shielded" transfer surfaces as a Type-18 asset lock that the - // generic logic would label "Internal Transfer"; relabel it from the - // SDK-sourced shielded lookup (see `shieldedTransferAmountDuffs`). - if isShieldedTransfer { - if isPendingShieldedTransfer { - return NSLocalizedString("Shielded transfer (pending)", - comment: "A to-Shielded transfer whose asset lock is committed but the shield hasn't completed yet") + // Identity funding locks name their purpose — they buy identity + // credits rather than moving between the wallet's balances. + if let identityType = identityFundingLockInfo?.fundingTypeRaw { + switch identityType { + case 0: + return NSLocalizedString("Identity registration", comment: "Asset lock funding a DashPay identity registration") + case 3: + return NSLocalizedString("Invitation", comment: "") + default: + return NSLocalizedString("Identity top-up", comment: "Asset lock topping up a DashPay identity's credits") } - return NSLocalizedString("Shielded transfer", - comment: "Transfer of own funds into the private shielded balance") } - // The L1 payout of a Shielded → Core withdrawal is a transfer of own - // funds, not an external receive — label it as such (the row's route - // icons show the direction). - if isShieldedWithdrawalReceipt { - return NSLocalizedString("Shielded withdrawal", - comment: "Transfer of own funds from the private shielded balance back to the transparent wallet") + // Every balance-to-balance transfer reads "Internal Transfer"; the + // route is carried visually (source icon → destination badge), by + // the home row's route pill, and by the detail sheet's From/To rows. + // Covers the asset-lock fundings (which the generic .moved logic + // would also label "Internal Transfer", but with a 0 amount) and the + // Shielded → Transparent payout (which would read "Received"). + if let route = internalTransferRoute, route != .coreToCore { + return NSLocalizedString("Internal Transfer", comment: "Transaction within the wallet, transfer of own funds") } switch transactionType { case .classic: diff --git a/DashWallet/Sources/UI/Home/Views/HomeView.swift b/DashWallet/Sources/UI/Home/Views/HomeView.swift index 027ec0e5d..b3814e0cb 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeView.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeView.swift @@ -472,10 +472,30 @@ struct HomeViewContent: View { switch txItem.internalTransferRoute { case .coreToShielded: return ("d.circle.fill", "shield.fill") case .shieldedToCore: return ("shield.fill", "d.circle.fill") + case .coreToPlatform: return ("d.circle.fill", "creditcard.fill") + case .coreToIdentity: return ("d.circle.fill", "person.crop.circle.fill") case .coreToCore, nil: return nil } } + /// Route pill text for an internal transfer row ("Transparent → + /// Platform"), shown in the small badge next to the time. Nil for plain + /// self-sends and non-transfers. + private func transferRouteDetails(txItem: Transaction) -> String? { + switch txItem.internalTransferRoute { + case .coreToShielded: + return NSLocalizedString("Transparent → Shielded", comment: "Transfer of own funds into the private shielded balance") + case .shieldedToCore: + return NSLocalizedString("Shielded → Transparent", comment: "Transfer of own funds from the private shielded balance back to the transparent wallet") + case .coreToPlatform: + return NSLocalizedString("Transparent → Platform", comment: "Transfer of own funds into the Platform balance") + case .coreToIdentity, .coreToCore, nil: + // Identity fundings carry their purpose in the title + // ("Identity registration"), so no route pill. + return nil + } + } + /// Blue glyph in a tinted circle — the InternalTransferScreen card icon /// treatment, scaled to the 30 pt tx-row icon slot. private func transferRouteIcon(_ systemName: String) -> AnyView { @@ -551,7 +571,11 @@ struct HomeViewContent: View { subtitle: txItem.shortTimeString, details: txItem.isPendingShieldedTransfer ? NSLocalizedString("Pending — tap to finish", comment: "InternalTransfer recovery") - : (metadata?.details?.isEmpty == false ? metadata?.details : nil), + : txItem.isPendingPlatformFunding || txItem.isPendingIdentityFunding + ? NSLocalizedString("Pending", comment: "") + : (metadata?.details?.isEmpty == false + ? metadata?.details + : transferRouteDetails(txItem: txItem)), dashAmount: txItem.signedDashAmount, amountSign: .always, fiat: txItem.fiatAmount, diff --git a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift index 59f51e4fe..56384b749 100644 --- a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift +++ b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift @@ -31,19 +31,16 @@ class TxDetailModel: NSObject { fileprivate var rawFeeCache: UInt64? var title: String { - // Shielded transfers carry their own identity — the generic - // direction titles ("Moved to Address" / "Amount received") hide - // what actually happened. - if transaction.isShieldedTransfer { - return transaction.isPendingShieldedTransfer - ? NSLocalizedString("Shielded transfer (pending)", - comment: "A to-Shielded transfer whose asset lock is committed but the shield hasn't completed yet") - : NSLocalizedString("Shielded transfer", - comment: "Transfer of own funds into the private shielded balance") + // Identity fundings and balance transfers carry their own identity — + // the generic direction titles ("Moved to Address") hide what + // actually happened. Identity fundings name their purpose (matching + // the home row); other transfer routes read "Internal Transfer" with + // the From/To/Status rows naming the route. + if transaction.isIdentityFundingTransfer { + return transaction.stateTitle } - if transaction.isShieldedWithdrawalReceipt { - return NSLocalizedString("Shielded withdrawal", - comment: "Transfer of own funds from the private shielded balance back to the transparent wallet") + if let route = transaction.internalTransferRoute, route != .coreToCore { + return NSLocalizedString("Internal Transfer", comment: "Transaction within the wallet, transfer of own funds") } return direction.title } @@ -195,7 +192,8 @@ extension TxDetailModel { // labeling them "Internally moved to" reads like the transfer's // destination. The From/To route rows carry that instead, and the // raw transaction inspector shows every output for the curious. - if transaction.isShieldedTransfer { + if transaction.isShieldedTransfer || transaction.isPlatformFundingTransfer + || transaction.isIdentityFundingTransfer { return false } if direction == .received && hasDestinationUser { @@ -358,6 +356,40 @@ extension TxDetailModel { DWTitleDetailCellModel(style: .default, title: NSLocalizedString("To", comment: ""), plainDetail: transparent), ] } + if transaction.isPlatformFundingTransfer { + var rows: [DWTitleDetailItem] = [ + DWTitleDetailCellModel(style: .default, title: NSLocalizedString("From", comment: ""), plainDetail: transparent), + DWTitleDetailCellModel( + style: .default, + title: NSLocalizedString("To", comment: ""), + plainDetail: NSLocalizedString("Platform balance", comment: "The Dash Platform credits balance")), + ] + if let statusRaw = transaction.platformFundingLockInfo?.statusRaw, + let status = Self.lockStatusText(statusRaw) { + rows.append(DWTitleDetailCellModel( + style: .default, + title: NSLocalizedString("Status", comment: "Transaction details"), + plainDetail: status)) + } + return rows + } + if transaction.isIdentityFundingTransfer { + var rows: [DWTitleDetailItem] = [ + DWTitleDetailCellModel(style: .default, title: NSLocalizedString("From", comment: ""), plainDetail: transparent), + DWTitleDetailCellModel( + style: .default, + title: NSLocalizedString("To", comment: ""), + plainDetail: NSLocalizedString("Identity credits", comment: "Destination of an identity funding asset lock")), + ] + if let statusRaw = transaction.identityFundingLockInfo?.statusRaw, + let status = Self.lockStatusText(statusRaw) { + rows.append(DWTitleDetailCellModel( + style: .default, + title: NSLocalizedString("Status", comment: "Transaction details"), + plainDetail: status)) + } + return rows + } return [] } @@ -369,6 +401,12 @@ extension TxDetailModel { guard let statusRaw = ShieldedTxLookup.shared.info(forTxidHex: transactionId)?.statusRaw else { return nil } + return Self.lockStatusText(statusRaw) + } + + /// User-facing name of an asset-lock status (shared by the shielded and + /// platform funding routes). + private static func lockStatusText(_ statusRaw: Int) -> String? { switch statusRaw { case 0, 1: return NSLocalizedString("Broadcasting", comment: "") diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 52c485f9d..4cfbab062 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -1571,12 +1571,21 @@ /* Voting */ "Identity" = "Identity"; +/* Destination of an identity funding asset lock */ +"Identity credits" = "Identity credits"; + /* SDK identity profile sheet */ "Identity ID" = "Identity ID"; +/* Asset lock funding a DashPay identity registration */ +"Identity registration" = "Identity registration"; + /* DashPay */ "Identity registration already in progress" = "Identity registration already in progress"; +/* Asset lock topping up a DashPay identity's credits */ +"Identity top-up" = "Identity top-up"; + /* About screen tech info: SPV state */ "idle" = "idle"; @@ -3215,16 +3224,10 @@ /* Transaction filter */ "Shielded sent" = "Shielded sent"; -/* Transfer of own funds into the private shielded balance */ -"Shielded transfer" = "Shielded transfer"; - "Shielded wallet starting…" = "Shielded wallet starting…"; -/* A to-Shielded transfer whose asset lock is committed but the shield hasn't completed yet */ -"Shielded transfer (pending)" = "Shielded transfer (pending)"; - /* Transfer of own funds from the private shielded balance back to the transparent wallet */ -"Shielded withdrawal" = "Shielded withdrawal"; +"Shielded → Transparent" = "Shielded → Transparent"; /* Explore Dash */ "Show all locations" = "Show all locations"; @@ -3813,6 +3816,12 @@ /* No comment provided by engineer. */ "This withdraws your entire Platform balance in one transfer. The Dash arrives in your Transparent balance once the network processes the withdrawal." = "This withdraws your entire Platform balance in one transfer. The Dash arrives in your Transparent balance once the network processes the withdrawal."; +/* Transfer of own funds into the Platform balance */ +"Transparent → Platform" = "Transparent → Platform"; + +/* Transfer of own funds into the private shielded balance */ +"Transparent → Shielded" = "Transparent → Shielded"; + /* Raw transaction inspector */ "Unknown special type %d" = "Unknown special type %d";