Skip to content
Merged
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 @@ -415,6 +415,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject {
signer: signer)

applyUpdatedBalances(updated, context: container.mainContext)
ShieldedTxLookup.shared.refresh()
Task { await self.syncNow() }
}

Expand All @@ -438,6 +439,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject {
signer: signer)

applyUpdatedBalances(updated, context: container.mainContext)
ShieldedTxLookup.shared.refresh()
Task { await self.syncNow() }
}

Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -1059,7 +1094,8 @@ final class ShieldedTxLookup {
// rather than fighting `#Predicate` local-capture rules.
let rows = try container.mainContext.fetch(FetchDescriptor<PersistentAssetLock>())
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 == "<txid display hex>:<vout>"; 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
Expand All @@ -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.
Expand Down
95 changes: 74 additions & 21 deletions DashWallet/Sources/Models/Transactions/Model/Transaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -249,13 +281,19 @@ 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
}

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
}
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 25 additions & 1 deletion DashWallet/Sources/UI/Home/Views/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,30 @@ struct HomeViewContent<Content: View>: 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 {
Expand Down Expand Up @@ -551,7 +571,11 @@ struct HomeViewContent<Content: View>: 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,
Expand Down
Loading
Loading