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 @@ -1461,6 +1461,19 @@ 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")
Expand Down Expand Up @@ -1508,6 +1521,14 @@ 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 @@ -1563,6 +1584,7 @@ final class ShieldedTxLookup {
if let existing = map[txid], existing.statusRaw >= info.statusRaw { continue }
map[txid] = info
}
addReconstructedLocks(to: &map, 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 @@ -1577,6 +1599,107 @@ 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).
@MainActor
private func addReconstructedLocks(to map: inout [String: ShieldedLockInfo], 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
}

private func store(_ map: [String: ShieldedLockInfo]) {
lock.lock()
infoByTxid = map
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,42 @@ enum RawTransactionInspector {

// MARK: - Special transaction payloads

/// One credit output of an asset-lock payload: the locked value and the
/// destination scriptPubKey it credits on Platform.
struct AssetLockCreditOutput {
let valueDuffs: UInt64
let script: Data
}

/// Consensus-decode the credit outputs of a DIP-2 asset-lock payload
/// (u8 version, varint count, TxOuts). Nil when the bytes don't parse as
/// that shape — callers must treat the lock as undecodable rather than
/// substitute a guess. Shared by the inspector's field list and
/// `ShieldedTxLookup`'s restore-time reconstruction.
static func assetLockCreditOutputs(payload: Data) -> [AssetLockCreditOutput]? {
var reader = ByteReader(payload)
guard (try? reader.readUInt8()) != nil,
let count = try? reader.readVarInt(),
count > 0, count <= 32 else { return nil }
var outputs: [AssetLockCreditOutput] = []
for _ in 0 ..< count {
guard let value = try? reader.readUInt64(),
let scriptLength = try? reader.readVarInt(),
let script = try? reader.readBytes(length: scriptLength) else { return nil }
outputs.append(AssetLockCreditOutput(valueDuffs: value, script: script))
}
return outputs
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// The 20-byte pubkey hash of a P2PKH script, or nil for any other shape.
static func p2pkhKeyHash(script: Data) -> Data? {
let b = [UInt8](script)
guard b.count == 25, b[0] == 0x76, b[1] == 0xa9, b[2] == 0x14, b[23] == 0x88, b[24] == 0xac else {
return nil
}
return Data(b[3 ..< 23])
}

/// DIP-2 special transaction names, keyed by the tx `type` field.
static func specialTypeName(_ type: UInt16) -> String? {
switch type {
Expand Down Expand Up @@ -253,20 +289,15 @@ enum RawTransactionInspector {
}
}
case 8: // Asset lock: u8 version, varint count, credit outputs (TxOuts).
var reader = ByteReader(payload)
if let version = try? reader.readUInt8(),
let count = try? reader.readVarInt() {
fields.append(.init(label: "Payload version", value: "\(version)"))
fields.append(.init(label: "Credit outputs", value: "\(count)"))
for i in 0 ..< min(count, 16) {
guard let value = try? reader.readUInt64(),
let scriptLength = try? reader.readVarInt(),
let script = try? reader.readBytes(Int(scriptLength)) else { break }
let destination = ScriptAddressCodec.address(forScript: script, network: network)
?? script.map { String(format: "%02x", $0) }.joined()
if let creditOutputs = assetLockCreditOutputs(payload: payload) {
fields.append(.init(label: "Payload version", value: "\(payload[payload.startIndex])"))
fields.append(.init(label: "Credit outputs", value: "\(creditOutputs.count)"))
for (i, output) in creditOutputs.enumerated() {
let destination = ScriptAddressCodec.address(forScript: output.script, network: network)
?? output.script.map { String(format: "%02x", $0) }.joined()
fields.append(.init(
label: String(format: "Credit output %d", i),
value: "\(value.formattedDashAmountWithoutCurrencySymbol) → \(destination)"))
value: "\(output.valueDuffs.formattedDashAmountWithoutCurrencySymbol) → \(destination)"))
}
}
default:
Expand Down Expand Up @@ -316,7 +347,7 @@ struct ParsedRawTransaction {
let prevTxid = try reader.readBytes(32)
let prevVout = try reader.readUInt32()
let scriptLength = try reader.readVarInt()
let scriptSig = try reader.readBytes(Int(scriptLength))
let scriptSig = try reader.readBytes(length: scriptLength)
let sequence = try reader.readUInt32()
return Input(prevTxid: prevTxid, prevVout: prevVout, scriptSig: scriptSig, sequence: sequence)
}
Expand All @@ -326,15 +357,15 @@ struct ParsedRawTransaction {
outputs = try (0 ..< outputCount).map { _ in
let value = try reader.readUInt64()
let scriptLength = try reader.readVarInt()
let script = try reader.readBytes(Int(scriptLength))
let script = try reader.readBytes(length: scriptLength)
return Output(valueDuffs: value, scriptPubKey: script)
}

lockTime = try reader.readUInt32()

if type > 0, reader.remaining > 0 {
let payloadLength = try reader.readVarInt()
extraPayload = try reader.readBytes(Int(payloadLength))
extraPayload = try reader.readBytes(length: payloadLength)
} else {
extraPayload = nil
}
Expand Down Expand Up @@ -408,4 +439,12 @@ private struct ByteReader {
defer { offset += count }
return Data(bytes[offset ..< offset + count])
}

/// Consensus lengths arrive as CompactSize `UInt64`s; a plain `Int(_:)`
/// conversion traps on values above `Int.max`, which malformed bytes can
/// encode. Reject those as out-of-bounds instead of crashing.
mutating func readBytes(length: UInt64) throws -> Data {
guard let count = Int(exactly: length) else { throw ReadError.outOfBounds }
return try readBytes(count)
}
}
14 changes: 12 additions & 2 deletions DashWallet/Sources/Models/Transactions/Model/Transaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,15 @@ 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 @@ -389,7 +398,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 { return locked }
if let locked = shieldedTransferAmountDuffs ?? platformFundingAmountDuffs ?? identityFundingAmountDuffs ?? reconstructedLockAmountDuffs { return locked }
let fee = Int64(snapshot.fee ?? 0)
switch direction {
case .received:
Expand Down Expand Up @@ -419,6 +428,7 @@ class Transaction: TransactionDataItem, Identifiable {
?? shieldedTransferAmountDuffs
?? platformFundingAmountDuffs
?? identityFundingAmountDuffs
?? reconstructedLockAmountDuffs
?? _dashAmount
}
var signedDashAmount: Int64 {
Expand Down Expand Up @@ -454,7 +464,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 {
if dashPayPayment != nil || shieldedTransferAmountDuffs != nil || platformFundingAmountDuffs != nil || identityFundingAmountDuffs != nil || reconstructedLockAmountDuffs != nil {
return userInfo?.fiatAmountString(from: dashAmount) ?? NSLocalizedString("Not available", comment: "")
}
return storedFiatAmount
Expand Down
Loading
Loading