Skip to content
Closed
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
8 changes: 8 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1796,6 +1796,8 @@
CB9000022FE1000000000002 /* CoinbaseTransactionMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */; };
CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */; };
CB9200022FE3000000000002 /* PassiveWalletStateUITailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */; };
CB9200042FE3000000000004 /* InitialRestoreSyncStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9200032FE3000000000003 /* InitialRestoreSyncStoreTests.swift */; };
B17000022FE4000000000002 /* PaymentProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17000012FE4000000000001 /* PaymentProtocolTests.swift */; };
CC0000112DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */; };
CC0000122DUMMYID001234567 /* PiggyCardsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */; };
CC0000132DUMMYID001234567 /* PiggyCardsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000072DUMMYID001234567 /* PiggyCardsCache.swift */; };
Expand Down Expand Up @@ -3538,6 +3540,8 @@
CB9000012FE1000000000001 /* CoinbaseTransactionMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseTransactionMetadataTests.swift; sourceTree = "<group>"; };
CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseTransferAmountTests.swift; sourceTree = "<group>"; };
CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassiveWalletStateUITailTests.swift; sourceTree = "<group>"; };
CB9200032FE3000000000003 /* InitialRestoreSyncStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialRestoreSyncStoreTests.swift; sourceTree = "<group>"; };
B17000012FE4000000000001 /* PaymentProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProtocolTests.swift; sourceTree = "<group>"; };
CC0000062DUMMYID001234567 /* PiggyCardsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsAPI.swift; sourceTree = "<group>"; };
CC0000072DUMMYID001234567 /* PiggyCardsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsCache.swift; sourceTree = "<group>"; };
CC0000082DUMMYID001234567 /* PiggyCardsEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiggyCardsEndpoint.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7074,6 +7078,8 @@
CB9100012FE2000000000001 /* CoinbaseTransferAmountTests.swift */,
7A30000130A1000000000001 /* TransactionDirectionTests.swift */,
CB9200012FE3000000000001 /* PassiveWalletStateUITailTests.swift */,
CB9200032FE3000000000003 /* InitialRestoreSyncStoreTests.swift */,
B17000012FE4000000000001 /* PaymentProtocolTests.swift */,
AA0003032CA0F58E00A1B402 /* SwapAddressValidatorTests.swift */,
AA00F1002FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift */,
AA00100D2CA0B10001A0B10D /* SwapKitQuoteDecodingTests.swift */,
Expand Down Expand Up @@ -10252,6 +10258,8 @@
CB9100022FE2000000000002 /* CoinbaseTransferAmountTests.swift in Sources */,
7A30000230A1000000000002 /* TransactionDirectionTests.swift in Sources */,
CB9200022FE3000000000002 /* PassiveWalletStateUITailTests.swift in Sources */,
CB9200042FE3000000000004 /* InitialRestoreSyncStoreTests.swift in Sources */,
B17000022FE4000000000002 /* PaymentProtocolTests.swift in Sources */,
AA0003042CA0F58E00A1B402 /* SwapAddressValidatorTests.swift in Sources */,
AA00F1012FF0A10000A1B402 /* ExchangeAddressLookupContextTests.swift in Sources */,
AA00100E2CA0B10001A0B10E /* SwapKitQuoteDecodingTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,6 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling {
@objc
public var state: State = .unknown {
didSet {
if state == .syncDone {
DWGlobalOptions.sharedInstance().isResyncingWallet = false
}

guard oldValue != state else {
return
}
Expand Down Expand Up @@ -289,7 +285,7 @@ extension SyncingActivityMonitor {
// window (progress.rs — overall is Synced only while ALL managers
// are simultaneously Synced). WaitForEvents is also the pre-start
// default, so disambiguate on progress: fully caught up → done.
if sdkProgress >= 0.999 {
if sdkState.isEffectivelyComplete(progress: sdkProgress) {
mapped = .syncDone
} else {
mapped = (state == .syncing) ? .syncing : .unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ struct AssetLockRecoveryService {
/// still-unlocked transaction includes the IS/CL wait.
func retry(fundingTypeRaw: Int, txidWire: Data, vout: UInt32) async throws {
Self.logger.info("🔁 LOCK-RETRY :: type=\(fundingTypeRaw, privacy: .public) vout=\(vout, privacy: .public)")
// All supported routes resume an existing asset lock and may enter the
// SDK's IS/CL proof wait. Fail before auth while quorum data is absent.
try AssetLockProofAvailability.shared.requireAllowed()
switch fundingTypeRaw {
case 1, 2:
try await retryIdentityTopUp(txidWire: txidWire, vout: vout)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ extension BIP70PaymentService {
BIP70PaymentService(
wallet: SwiftDashSDKWalletSending(),
receiveAddress: SwiftDashSDKReceiveAddressProvider(),
auth: BIP70SendAuthorizer())
auth: BIP70SendAuthorizer(),
coreSpendPreflight: {
do {
try await CoreSpendAvailability.shared.requireAllowed()
} catch CoreSpendAvailabilityError.initialRestoreSync {
throw BIP70Error.initialRestoreSync
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,43 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
let recoveryLock = lookupRegistrationRecoveryLock(
walletId: wallet.walletId,
modelContainer: modelContainer)
let existingIdentityId = lookupExistingIdentityId(
walletId: wallet.walletId,
modelContainer: modelContainer)
if let recoveryLock {
Self.logger.info("🪪 IDENT-COORD :: recoverable Core registration found status=\(recoveryLock.statusRaw, privacy: .public)")
}

let isContestedSubmission = DWContestedNameStatusService.isContestedLabel(username)
let requiredIdentityFundingDuffs = isContestedSubmission
? DWDP_MIN_BALANCE_FOR_CONTESTED_USERNAME
: DWDP_MIN_BALANCE_TO_CREATE_USERNAME
let existingIdentityNeedsCoreTopUp: Bool
if let existingIdentityId, fundingSource == .core {
let requiredCredits = UInt64(requiredIdentityFundingDuffs)
* PlatformPaymentIdentityFundingPolicy.creditsPerDuff
existingIdentityNeedsCoreTopUp =
UsernameMarketplaceService.identityBalanceCredits(
identityId: existingIdentityId,
container: modelContainer) < requiredCredits
} else {
existingIdentityNeedsCoreTopUp = false
}
let resumesCoreAssetLock = recoveryLock != nil
let createsCoreAssetLock = recoveryLock == nil
&& fundingSource == .core
&& (existingIdentityId == nil || existingIdentityNeedsCoreTopUp)

// A fresh Core-funded registration creates a new asset lock. A
// persisted recovery resumes its exact outpoint. Both require live
// quorum data, but only the fresh operation spends another Core UTXO.
if createsCoreAssetLock {
try CoreSpendAvailability.shared.requireAllowed()
}
if createsCoreAssetLock || resumesCoreAssetLock {
try AssetLockProofAvailability.shared.requireAllowed()
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Single-flight guard. The FFI calls we're about to make
// (`registerIdentityWithFunding` / `registerIdentityFromAddresses`
// / `registerDpnsName`) can't be cancelled — `resetState()`
Expand Down Expand Up @@ -520,10 +553,6 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
currentFundingSource = recoveryLock == nil ? fundingSource : .core
failedAtPhase = nil
lastErrorMessage = nil
let isContestedSubmission = DWContestedNameStatusService.isContestedLabel(username)
let requiredIdentityFundingDuffs = isContestedSubmission
? DWDP_MIN_BALANCE_FOR_CONTESTED_USERNAME
: DWDP_MIN_BALANCE_TO_CREATE_USERNAME
Self.logger.info(
"🪪 IDENT-COORD :: contested=\(isContestedSubmission, privacy: .public) identityFundingDuffs=\(requiredIdentityFundingDuffs, privacy: .public)")

Expand Down Expand Up @@ -554,6 +583,15 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
throw CoordinatorError.authFailed
}

// A wallet switch or reconnect can close readiness while the PIN sheet
// is visible. Stop before key preparation and before any asset-lock FFI.
if createsCoreAssetLock {
try CoreSpendAvailability.shared.requireAllowed()
}
if createsCoreAssetLock || resumesCoreAssetLock {
try AssetLockProofAvailability.shared.requireAllowed()
}

// Step 1: pre-derive identity public keys + persist privates
// to Keychain. Synchronous on the FFI side; the resolver
// callback reads the mnemonic via WalletStorage.
Expand Down Expand Up @@ -604,10 +642,7 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
let identityId: Identifier
var shouldTopUpRecoveredIdentity = false
do {
if let existingId = lookupExistingIdentityId(
walletId: wallet.walletId,
modelContainer: modelContainer)
{
if let existingId = existingIdentityId {
Self.logger.info("🪪 IDENT-COORD :: recovery — local identity exists at index \(Self.pinnedIdentityIndex, privacy: .public), skipping IdentityCreate")
identityId = existingId
reconcileConsumedRecoveryLock(
Expand Down Expand Up @@ -926,6 +961,19 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
throw CoordinatorError.noModelContainer
}

// Determine before PIN whether this purchase needs fresh Core
// funding. An already-funded identity buys with Platform credits and
// is not a Core spend.
let requiredCredits = Self.requiredCreditsForUsernamePurchase(priceCredits: priceCredits)
let requiresCoreFunding = purchaseRequiresCoreFunding(
requiredCredits: requiredCredits,
walletId: wallet.walletId,
modelContainer: modelContainer)
if requiresCoreFunding {
try CoreSpendAvailability.shared.requireAllowed()
try AssetLockProofAvailability.shared.requireAllowed()
}

// Single-flight — same rationale as `startCreateUsername`: the
// funding FFI calls race to their terminal even if we stop
// observing, and two funding attempts must never overlap.
Expand Down Expand Up @@ -959,13 +1007,15 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
newController.enterFailed(lastErrorMessage ?? "")
throw CoordinatorError.authFailed
}
if requiresCoreFunding {
try CoreSpendAvailability.shared.requireAllowed()
try AssetLockProofAvailability.shared.requireAllowed()
}

// Credits the buyer identity must hold: the sale price plus the
// same 0.03-DASH headroom a fresh registration funds itself with,
// covering the purchase transition fee (and Core-side asset-lock
// conversion losses).
let headroomDuffs = DWDP_MIN_BALANCE_TO_CREATE_USERNAME
let requiredCredits = priceCredits + headroomDuffs * 1_000
let signer = KeychainSigner(modelContainer: modelContainer)

let identityId: Identifier
Expand Down Expand Up @@ -1441,6 +1491,39 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {
return (try? context.fetch(descriptor))?.first?.identityId
}

/// Whether buying a listed username needs a new transparent asset lock.
/// An existing identity that already holds the price plus fee headroom
/// spends Platform credits only and remains available during Core restore.
func purchaseRequiresCoreFunding(priceCredits: UInt64) -> Bool {
guard let walletId = SwiftDashSDKHost.shared.wallet?.walletId,
let modelContainer = SwiftDashSDKHost.shared.modelContainer else {
return true
}
return purchaseRequiresCoreFunding(
requiredCredits: Self.requiredCreditsForUsernamePurchase(priceCredits: priceCredits),
walletId: walletId,
modelContainer: modelContainer)
}

private static func requiredCreditsForUsernamePurchase(priceCredits: UInt64) -> UInt64 {
priceCredits + DWDP_MIN_BALANCE_TO_CREATE_USERNAME * 1_000
}

private func purchaseRequiresCoreFunding(
requiredCredits: UInt64,
walletId: Data,
modelContainer: ModelContainer
) -> Bool {
guard let identityId = lookupExistingIdentityId(
walletId: walletId,
modelContainer: modelContainer) else {
return true
}
return UsernameMarketplaceService.identityBalanceCredits(
identityId: identityId,
container: modelContainer) < requiredCredits
}

/// Bring a previously-created identity up to the amount this name
/// requires before resuming at DPNS registration.
///
Expand Down Expand Up @@ -1469,6 +1552,10 @@ final class DWIdentityRegistrationCoordinator: ObservableObject {

switch fundingSource {
case .core:
// Resuming the original asset lock is allowed, but this is an
// additional transparent top-up and therefore a NEW Core spend.
try CoreSpendAvailability.shared.requireAllowed()
try AssetLockProofAvailability.shared.requireAllowed()
let roundedShortfallDuffs =
(missingCredits + PlatformPaymentIdentityFundingPolicy.creditsPerDuff - 1)
/ PlatformPaymentIdentityFundingPolicy.creditsPerDuff
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,10 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject {
/// Rust side carves from the single remainder recipient (the wallet's
/// own next unused Platform address).
public func fundFromCore(amountDuffs: UInt64) async throws {
// Defensive boundary for callers that bypass the transfer coordinator.
// A committed lock uses `resumeFundFromCore` and is exempt.
try CoreSpendAvailability.shared.requireAllowed()
try AssetLockProofAvailability.shared.requireAllowed()
let (addressWallet, container, recipient, accountIndex) = try resolveFundEnvironment()
let signer = KeychainSigner(modelContainer: container, network: runningNetwork!)

Expand All @@ -566,6 +570,7 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject {
/// lock. See `ShieldedTransferCoordinator.resumeAssetLock` for the same
/// pattern on the shielded route.
public func resumeFundFromCore(outPointTxid: Data, outPointVout: UInt32) async throws {
try AssetLockProofAvailability.shared.requireAllowed()
let (addressWallet, container, recipient, accountIndex) = try resolveFundEnvironment()
let signer = KeychainSigner(modelContainer: container, network: runningNetwork!)

Expand Down
Loading
Loading