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 @@ -1251,8 +1251,19 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
&& row.fundingTypeRaw == 0
},
sortBy: [SortDescriptor(\.createdAt, order: .forward)])
// Unfinished = anything but the Consumed (4) tombstone. That
// includes 5 (RecoveredFromChain): a registration lock at 5 with
// no identity is a genuinely incomplete registration — a stranded
// broadcast whose block chain-locked after an app kill, or a
// restored wallet whose registration never finished — and the SDK
// resume path explicitly supports consuming it (Platform rejects
// an already-spent outpoint with a typed error). Restored wallets
// whose registration DID complete never resume from this lock:
// `hasPendingRegistrationRecovery` checks the identity row first,
// and the start flow probes the local row and then the Platform
// slot (reconciling this lock to Consumed) before any resume.
guard let rows = try? context.fetch(descriptor),
let row = rows.first(where: { (0...3).contains($0.statusRaw) })
let row = rows.first(where: { $0.statusRaw != 4 })
else {
return nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1461,28 +1461,17 @@ final class ShieldedTxLookup {
/// 2 = IdentityTopUpNotBound, 3 = IdentityInvitation.
private static let identityFundingTypes = 0...3

/// App-side sentinel (never emitted by the SDK's funding-type enum) for a
/// lock reconstructed from raw transaction bytes whose destination can't
/// be proven: the amount is consensus-parsed truth, but whether it funded
/// an identity, a Platform address, or the shielded pool is unknown.
static let reconstructedUnknownFundingType = -1

/// App-side sentinel for `statusRaw` on reconstructed entries: the lock is
/// confirmed on-chain but its consumption state is unknown (the SDK's
/// `PersistentAssetLock` row didn't survive the wallet restore). Outside
/// both the pending window (1…3) and consumed (4), so reconstructed rows
/// never render "Pending" and never claim success.
static let reconstructedStatus = -1

private static let logger = Logger(
subsystem: "org.dashfoundation.dash",
category: "swift-sdk-migration.shielded-tx-lookup")

/// Snapshot value for one shielded funding tx: the locked amount plus the
/// asset lock's current status and funding output index. `statusRaw`
/// distinguishes a still-pending/stuck shield (1…3 — Broadcast/IS/CL) from
/// a consumed, successful one (4); `vout` + the txid form the outpoint a
/// recovery resume needs.
/// a consumed, successful one (4) and from a restore-scan recovery whose
/// Platform-side consumption is unknown (5 — RecoveredFromChain, neither
/// pending nor done); `vout` + the txid form the outpoint a recovery
/// resume needs.
struct ShieldedLockInfo: Sendable {
let amountDuffs: UInt64
let statusRaw: Int
Expand Down Expand Up @@ -1521,14 +1510,6 @@ final class ShieldedTxLookup {
entry(forTxidHex: txidHex, fundingType: Self.platformFundingType)
}

/// Snapshot entry for an asset lock reconstructed from raw tx bytes after
/// a restore — real locked amount, unknown destination and consumption
/// (see `reconstructedUnknownFundingType`). Thread-safe; touches no
/// SwiftData.
func reconstructedLockInfo(forTxidHex txidHex: String) -> ShieldedLockInfo? {
entry(forTxidHex: txidHex, fundingType: Self.reconstructedUnknownFundingType)
}

/// Snapshot entry for an identity funding lock (types 0…3 — registration,
/// top-up, invitation). `fundingTypeRaw` distinguishes the variants.
/// Thread-safe; touches no SwiftData.
Expand Down Expand Up @@ -1569,8 +1550,9 @@ final class ShieldedTxLookup {
// 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
// higher statusRaw so a consumed (4) row wins over a stale
// pending (1…3) one.
// most informative status: consumed (4, consumption known)
// over recovered-from-chain (5, consumption unknown) over the
// live pending window (0…3).
guard let colon = row.outPointHex.firstIndex(of: ":") else { continue }
let txid = row.outPointHex[..<colon].lowercased()
// Skip rows whose vout doesn't parse rather than inventing vout 0 —
Expand All @@ -1581,10 +1563,11 @@ final class ShieldedTxLookup {
statusRaw: row.statusRaw,
vout: vout,
fundingTypeRaw: row.fundingTypeRaw)
if let existing = map[txid], existing.statusRaw >= info.statusRaw { continue }
func rank(_ statusRaw: Int) -> Int { statusRaw == 4 ? .max : statusRaw }
if let existing = map[txid], rank(existing.statusRaw) >= rank(info.statusRaw) { continue }
map[txid] = info
}
addReconstructedLocks(to: &map, context: container.mainContext)
logUnclassifiedAssetLocks(coveredTxids: Set(map.keys), context: container.mainContext)
store(map)
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
Expand All @@ -1599,105 +1582,29 @@ final class ShieldedTxLookup {
}
}

/// Restore-time fallback: `PersistentAssetLock` rows are SDK-recorded at
/// execution and do NOT survive a wipe & recover, so a restored wallet's
/// asset-lock funding txs otherwise render "Internal Transfer — 0 DASH".
/// For every persisted AssetLock transaction with no store row, parse the
/// credit outputs from the raw bytes (consensus truth) and classify the
/// destination through the wallet's persisted funding-account address
/// pools: each credit output pays a one-time address the wallet derived
/// from a purpose-specific account (identity registration/top-up/
/// invitation, Platform address top-up, shielded top-up — accountType
/// 2…7), and those pools DO survive a restore as `PersistentCoreAddress`
/// rows. A match yields the exact funding type and the full existing
/// route treatment; no match yields a `reconstructedUnknownFundingType`
/// entry — real amount, no destination claim. Store-backed entries
/// always win (`map` is checked first).
/// Coverage diagnostic. Every wallet-own asset-lock funding tx is
/// expected to have a store row: recorded live at build time, or —
/// after a wipe & recover — rewritten by the SDK's restore-scan
/// reconstruction (platform #4342; verified on a restored testnet
/// wallet 2026-08-09: 9/9 funding txs classified. The rows currently
/// arrive at `statusRaw` 1/3 rather than the intended 5 — an SDK-side
/// enrichment gap tracked for a platform follow-up).
/// An asset-lock tx with no row therefore indicates a reconstruction
/// gap (it renders "Internal Transfer — 0 DASH"); log it so a single
/// test run surfaces the txid. This replaced an app-side fallback that
/// re-parsed raw tx bytes into synthetic map entries — dead weight once
/// the SDK rows exist, since store-backed entries always beat it.
@MainActor
private func addReconstructedLocks(to map: inout [String: ShieldedLockInfo], context: ModelContext) {
private func logUnclassifiedAssetLocks(coveredTxids: Set<String>, context: ModelContext) {
let assetLockKind = TransactionTypeKind.assetLock.rawValue
let descriptor = FetchDescriptor<PersistentTransaction>(
predicate: #Predicate { $0.transactionTypeKind == assetLockKind })
guard let rows = try? context.fetch(descriptor), !rows.isEmpty else { return }
guard let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { return }

let fundingTypeByAddress = Self.fundingAccountTypeByAddress(walletId: walletId, in: context)
let network: PaymentNetwork = WalletEnvironment.isTestnet ? .testnet : .mainnet

var reconstructed = 0
for row in rows {
let txid = Transaction.displayHex(row.txid).lowercased()
if map[txid] != nil { continue }
guard !row.transactionData.isEmpty,
let parsed = try? ParsedRawTransaction(data: row.transactionData),
let payload = parsed.extraPayload,
let creditOutputs = RawTransactionInspector.assetLockCreditOutputs(payload: payload) else {
continue
}
let amount = creditOutputs.reduce(UInt64(0)) { $0 + $1.valueDuffs }
guard amount > 0 else { continue }
// The lock outpoint's vout is the index of the OP_RETURN output
// that carries the locked value on L1. Skip on ambiguity — a
// reconstructed entry never feeds a recovery resume, but a wrong
// vout shouldn't exist even unused.
let opReturnIndexes = parsed.outputs.enumerated()
.filter { $0.element.scriptPubKey.first == 0x6a }
.map { $0.offset }
guard opReturnIndexes.count == 1, let voutIndex = opReturnIndexes.first else { continue }

// Funding type: every credit output must resolve to the SAME
// funding account — mixed or unmatched destinations stay unknown.
let matchedTypes = Set(creditOutputs.map { output -> Int in
guard let address = ScriptAddressCodec.address(forScript: output.script, network: network),
let fundingType = fundingTypeByAddress[address] else {
return Self.reconstructedUnknownFundingType
}
return fundingType
})
let fundingType = matchedTypes.count == 1
? matchedTypes.first ?? Self.reconstructedUnknownFundingType
: Self.reconstructedUnknownFundingType

map[txid] = ShieldedLockInfo(
amountDuffs: amount,
statusRaw: Self.reconstructedStatus,
vout: UInt32(voutIndex),
fundingTypeRaw: fundingType)
reconstructed += 1
}
if reconstructed > 0 {
Self.logger.info("🛡️ SHIELD-TX :: reconstructed \(reconstructed, privacy: .public) asset lock(s) from raw tx bytes (no store row)")
}
}

/// Credit-output address → `ManagedAssetLockManager.FundingType` raw
/// value, from the active wallet's persisted funding-account pools.
/// Account type tags (see `accountTypeName`): 2 Identity Registration,
/// 3 Identity Top-Up, 4 Identity Top-Up (Unbound), 5 Identity
/// Invitation, 6 Asset Lock Address Top-Up, 7 Asset Lock Shielded
/// Address Top-Up — mapped to funding types 0…5 in the same order.
@MainActor
private static func fundingAccountTypeByAddress(walletId: Data, in context: ModelContext) -> [String: Int] {
// Accounts are a tiny table; fetch all and filter in Swift rather
// than fighting `#Predicate` relationship-traversal rules.
let accounts = (try? context.fetch(FetchDescriptor<PersistentAccount>())) ?? []
var byAddress: [String: Int] = [:]
for account in accounts where account.wallet.walletId == walletId {
let fundingType: Int
switch account.accountType {
case 2: fundingType = 0 // IdentityRegistration
case 3: fundingType = 1 // IdentityTopUp
case 4: fundingType = 2 // IdentityTopUpNotBound
case 5: fundingType = 3 // IdentityInvitation
case 6: fundingType = 4 // AssetLockAddressTopUp
case 7: fundingType = 5 // AssetLockShieldedAddressTopUp
default: continue
}
for address in account.coreAddresses {
byAddress[address.address] = fundingType
}
}
return byAddress
let uncovered = rows
.map { Transaction.displayHex($0.txid).lowercased() }
.filter { !coveredTxids.contains($0) }
guard !uncovered.isEmpty else { return }
Self.logger.error("🛡️ SHIELD-TX :: \(uncovered.count, privacy: .public) asset-lock tx(s) have no PersistentAssetLock row (SDK reconstruction gap?): \(uncovered.joined(separator: ","), privacy: .public)")
}

private func store(_ map: [String: ShieldedLockInfo]) {
Expand Down
14 changes: 2 additions & 12 deletions DashWallet/Sources/Models/Transactions/Model/Transaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,6 @@ class Transaction: TransactionDataItem, Identifiable {

private var identityFundingAmountDuffs: UInt64? { identityFundingLockInfo?.amountDuffs }

/// Locked amount for an asset lock reconstructed from raw tx bytes after
/// a restore (`ShieldedTxLookup.reconstructedLockInfo`): the destination
/// is unprovable, so the row keeps its generic "Internal Transfer"
/// presentation, but the amount is consensus-parsed truth instead of the
/// 0 the net-change view derives for a self-directed lock.
private var reconstructedLockAmountDuffs: UInt64? {
ShieldedTxLookup.shared.reconstructedLockInfo(forTxidHex: shieldedDisplayTxid)?.amountDuffs
}

/// True when this is the funding tx of an identity registration/top-up/
/// invitation.
var isIdentityFundingTransfer: Bool { identityFundingLockInfo != nil }
Expand Down Expand Up @@ -398,7 +389,7 @@ class Transaction: TransactionDataItem, Identifiable {
// 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 ?? reconstructedLockAmountDuffs { return locked }
if let locked = shieldedTransferAmountDuffs ?? platformFundingAmountDuffs ?? identityFundingAmountDuffs { return locked }
let fee = Int64(snapshot.fee ?? 0)
switch direction {
case .received:
Expand Down Expand Up @@ -428,7 +419,6 @@ class Transaction: TransactionDataItem, Identifiable {
?? shieldedTransferAmountDuffs
?? platformFundingAmountDuffs
?? identityFundingAmountDuffs
?? reconstructedLockAmountDuffs
?? _dashAmount
}
var signedDashAmount: Int64 {
Expand Down Expand Up @@ -464,7 +454,7 @@ class Transaction: TransactionDataItem, Identifiable {
// The shielded / DashPay-payment amount is read live (see
// `dashAmount`), so compute its fiat live too; other rows keep the
// lazily-cached value.
if dashPayPayment != nil || shieldedTransferAmountDuffs != nil || platformFundingAmountDuffs != nil || identityFundingAmountDuffs != nil || reconstructedLockAmountDuffs != nil {
if dashPayPayment != nil || shieldedTransferAmountDuffs != nil || platformFundingAmountDuffs != nil || identityFundingAmountDuffs != nil {
return userInfo?.fiatAmountString(from: dashAmount) ?? NSLocalizedString("Not available", comment: "")
}
return storedFiatAmount
Expand Down
9 changes: 8 additions & 1 deletion DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,14 @@ class HomeViewModel: ObservableObject {
"Date unknown",
comment: "History group header for restored shielded operations whose original date is not recoverable")
let groupedItems = Dictionary(
grouping: items.sorted(by: { $0.date > $1.date }),
grouping: items.sorted(by: { lhs, rhs in
guard lhs.date == rhs.date else { return lhs.date > rhs.date }
// Equal dates are, in practice, the shared `.distantPast`
// sentinel of the "Date unknown" band; order it by exact
// on-chain sequence, newest (highest note position) first.
// Nil keys (no chain-order information) sink to its end.
return (lhs.chainOrderKey ?? 0) > (rhs.chainOrderKey ?? 0)
}),
by: {
$0.hasKnownDate
? DWDateFormatter.sharedInstance.dateOnly(from: $0.date)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ struct ShieldedActivityItem: Identifiable {
/// per-note block time and the scan clock must not masquerade as
/// one. Render the date as unknown, never as the epoch.
let hasKnownDate: Bool
/// Chain-order key: the smallest commitment-tree position among the
/// entry's own received notes. Tree positions are exact append-only
/// chain order, so this sequences the unknown-date restored entries
/// (whose `date` is all `.distantPast`) identically on every device.
/// Nil on live-recorded entries (which order by their real date) and
/// on rows persisted before the SDK carried the field.
let minNotePosition: UInt64?
/// Decoded UTF-8 text memo, when the 36-byte Dash memo is kind-1 text.
let memoText: String?
/// Created identity id (hex) for `identityCreate` entries.
Expand Down Expand Up @@ -135,6 +142,7 @@ struct ShieldedActivityItem: Identifiable {
date = hasKnownDate
? Date(timeIntervalSince1970: Double(row.createdAtMs) / 1000.0)
: .distantPast
minNotePosition = row.hasMinNotePosition ? row.minNotePosition : nil
memoText = Self.decodeTextMemo(row.memo)
createdIdentityIdHex = effectiveKind == .identityCreate && row.identityId.count == 32
? row.identityId.map { String(format: "%02x", $0) }.joined()
Expand Down
13 changes: 13 additions & 0 deletions DashWallet/Sources/UI/Home/Views/TransactionListDataItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,17 @@ extension TransactionListDataItem: Identifiable {
return true
}
}

/// On-chain sequence key for items whose `date` is the shared
/// `.distantPast` sentinel: the restored shielded entries' smallest
/// note commitment-tree position (exact append-only chain order).
/// Nil everywhere else — dated items order by `date` alone.
var chainOrderKey: UInt64? {
switch self {
case .shieldedActivity(let item):
return item.minNotePosition
default:
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,9 @@ struct ShieldedRecoverySheet: View {
alreadyComplete = true
return
}
// statusRaw 1...3 (still pending), or nil/0 (status unavailable, e.g. a
// statusRaw 1...3 (still pending), 5 (restored from chain — consumption
// unknown; a resume either completes it or Platform rejects the spent
// outpoint with a typed error), or nil/0 (status unavailable, e.g. a
// failed refresh): attempt the resume. A genuinely gone/consumed lock
// surfaces a real SDK error rather than a false "complete".
await coordinator.resumeAssetLock(outPointTxidWire: op.txidWire, outPointVout: op.vout)
Expand Down
Loading
Loading