diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/LoadIdentityScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/LoadIdentityScreen.kt index 7230be7e3b2..034a382641b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/LoadIdentityScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/LoadIdentityScreen.kt @@ -50,9 +50,12 @@ import org.dashfoundation.example.util.Base58 /** * Load an existing identity by id — port of `LoadIdentityView.swift`. Paste * or scan an identity id, fetch it via `sdk.identities.fetch`, and persist a - * Room [IdentityEntity] with `isLocal = false` (matching the Swift - * `PersistentIdentity(isLocal: false)` on load). Key-material import (voting / - * owner / payout / user private keys) rides the key-management milestone. + * Room [IdentityEntity] with `isLocal = true`: a manual add is an identity the + * owner deliberately tracks, which is what the flag means under the owner + * semantics. It carries no `walletId`, so neither the persister's wallet-link + * promotion nor the load-path heal ever reaches it — this write is the only + * thing that can set it. Key-material import (voting / owner / payout / user + * private keys) rides the key-management milestone. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -160,7 +163,9 @@ fun LoadIdentityScreen(navController: NavHostController) { IdentityEntity( identityId = idBytes, balance = balance, - isLocal = false, + // A manual add is a tracked identity under the + // owner's semantics — see the file header. + isLocal = true, alias = alias.trim().ifBlank { null }, networkRaw = network.ffiValue, ), diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 993fd2f8a7d..58d487d00b1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1015,6 +1015,8 @@ class PlatformWalletPersistenceHandler( ?: db.walletDao().getByWalletId(walletId)?.networkRaw ?: NETWORK_TESTNET val existing = db.identityDao().getByIdentityId(identityId) + val resolvedWalletId = + if (walletIdIsSome) identityWalletId else existing?.walletId val row = (existing ?: IdentityEntity( identityId = identityId, networkRaw = networkRaw, @@ -1024,7 +1026,16 @@ class PlatformWalletPersistenceHandler( revision = revision, identityIndex = if (identityIndexIsSome) identityIndex else existing?.identityIndex ?: 0, - walletId = if (walletIdIsSome) identityWalletId else existing?.walletId, + walletId = resolvedWalletId, + // Things from the wallet are always local — promote as soon + // as the row carries a wallet link. One-way: no path here + // writes `false` over a `true`, so a manual add (tracked, + // wallet-less) keeps its flag. An observed out-of-wallet + // identity has no link and stays `false`. The old constant + // `false` mis-marked every wallet-owned identity. Mirrors + // Swift `persistIdentities` + // (PlatformWalletPersistenceHandler.swift:1827-1829). + isLocal = existing?.isLocal == true || resolvedWalletId != null, lastUpdated = now(), ) db.identityDao().upsert(row) @@ -1039,8 +1050,22 @@ class PlatformWalletPersistenceHandler( .map(::normalizeDpnsLabel) .toSet() for (persisted in db.dpnsNameDao().getAllByIdentity(identityId)) { - if (persisted.isOwned && persisted.normalizedLabel !in canonicalLabels) { + if (persisted.normalizedLabel in canonicalLabels) continue + if (persisted.documentId == null) { + // No marketplace history is attached, so this is only a + // stale label-cache row and can be removed entirely. db.dpnsNameDao().delete(persisted) + } else { + // Marketplace-tracked: the row survives as departed + // history (its sale status and counterparty are the + // record of where the name went), but owned-name + // queries and UI selection must not surface it. The + // identity snapshot's authority stops at `isOwned` — + // deleting here permanently destroyed the sale history + // whenever the departure round could not classify it. + db.dpnsNameDao().upsert( + persisted.copy(isOwned = false, lastUpdated = now()), + ) } } @@ -1061,9 +1086,18 @@ class PlatformWalletPersistenceHandler( identityId = identityId, documentId = existingName?.documentId, isOwned = true, + // Marketplace columns belong to the marketplace + // reconciliation lane, not to the identity + // snapshot: this branch refreshes only + // acquiredAt/label (+ `isOwned = true`) and carries + // every marketplace field through untouched. + // Writing 0/null here clobbered a live listing or a + // recorded sale on the next identity sweep. Mirrors + // Swift `upsertDPNSNames` + // (PlatformWalletPersistenceHandler.swift:1932-1958). priceCredits = existingName?.priceCredits, - saleStatusRaw = 0, - counterpartyIdentityId = null, + saleStatusRaw = existingName?.saleStatusRaw ?: 0, + counterpartyIdentityId = existingName?.counterpartyIdentityId, documentCreatedAtMs = existingName?.documentCreatedAtMs ?: 0L, documentUpdatedAtMs = existingName?.documentUpdatedAtMs ?: 0L, documentTransferredAtMs = existingName?.documentTransferredAtMs ?: 0L, @@ -1600,6 +1634,24 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> val outPointHex = encodeOutPointHex(outPoint) val existing = db.assetLockDao().getByOutPointHex(outPointHex) + // Consumed (4) is the terminal lifecycle state — never let a + // non-Consumed snapshot regress it. Writers race: the + // wallet-event adapter's batched drain can deliver a stale + // reconstruction/enrichment snapshot AFTER the live flow's + // synchronous consumption write, and this upsert is otherwise + // last-write-wins. Mirrors the same guard in + // `AssetLockChangeSet::merge`, the rs-platform-wallet-storage + // sqlite upsert, and Swift `persistAssetLocks` + // (PlatformWalletPersistenceHandler.swift:270). All other + // transitions stay last-write-wins because non-terminal + // statuses legitimately move both ways. + val incomingStatus = status.toInt() and 0xFF + if (existing != null && + existing.statusRaw == ASSET_LOCK_STATUS_CONSUMED && + incomingStatus != ASSET_LOCK_STATUS_CONSUMED + ) { + return@stage + } db.assetLockDao().upsert( AssetLockEntity( outPointHex = outPointHex, @@ -1609,7 +1661,7 @@ class PlatformWalletPersistenceHandler( identityIndexRaw = identityIndex, accountIndexRaw = accountIndex, amountDuffs = amountDuffs, - statusRaw = status.toInt() and 0xFF, + statusRaw = incomingStatus, proofBytes = proofBytes, createdAt = existing?.createdAt ?: java.util.Date(), updatedAt = now(), @@ -1620,7 +1672,20 @@ class PlatformWalletPersistenceHandler( } override fun onPersistAssetLockRemoval(walletId: ByteArray, outPoint: ByteArray): Int = guarded { - stage(walletId) { db -> db.assetLockDao().deleteByOutPointHex(encodeOutPointHex(outPoint)) } + stage(walletId) { db -> + val outPointHex = encodeOutPointHex(outPoint) + // Same terminal rule as the upsert guard above: a Consumed (4) + // row is deliberately retained for historical lookup and the + // only removal emitter (`untrack_asset_lock`) targets rejected + // Built rows — a removal reaching a consumed row is by + // construction a stale write. Mirrors Swift `persistAssetLocks` + // (PlatformWalletPersistenceHandler.swift:310). + val existing = db.assetLockDao().getByOutPointHex(outPointHex) + if (existing != null && existing.statusRaw == ASSET_LOCK_STATUS_CONSUMED) { + return@stage + } + db.assetLockDao().deleteByOutPointHex(outPointHex) + } 0 } @@ -1838,8 +1903,43 @@ class PlatformWalletPersistenceHandler( // ── Load callbacks ──────────────────────────────────────────────── + /** + * One-shot upgrade heal: promote `isLocal` on wallet-linked identity + * rows still carrying `false` — the persister used to write a constant + * `false`, so a wallet's own identities (which are always local) were + * mis-marked on stores from that era. + * + * Promote-only and idempotent; a `true` on an unlinked row (a manual + * add) is never touched. Runs from the load path because that is the + * one guaranteed per-launch pass over the store, outside any changeset + * round — a round in flight would interleave this blanket UPDATE with + * the round's own staged writes, so it is skipped while one is open + * and picked up on the next launch. Mirror of Swift + * `healIdentityIsLocalFlags` (PlatformWalletPersistenceHandler.swift:4688, + * called from `loadWalletList` :4719). + * + * Safe on Android precisely because the Kotlin persister never + * mislinked `walletId`: it only ever writes the link the FFI entry + * declared, so "has a wallet link" is exactly "is wallet-owned". + */ + private suspend fun healIdentityIsLocalFlags() { + if (buffers.isNotEmpty()) return + val healed = runCatching { database.identityDao().healIsLocalFlags() } + .onFailure { + // Non-fatal: the next launch retries. The restore fetches + // below read the same rows and are unaffected by a skipped + // heal (they never consult `isLocal`). + Log.w(TAG, "load: isLocal heal failed; retrying next launch", it) + } + .getOrDefault(0) + if (healed > 0) { + Log.i(TAG, "load: healed isLocal on $healed identity row(s)") + } + } + override fun onLoadWalletList(): Array = guardedLoad(emptyArray()) { runBlockingResult { + healIdentityIsLocalFlags() // Restorable = wallet with ≥1 account carrying an xpub, // scoped to the manager's network (see the constructor doc). val wallets = network @@ -3193,6 +3293,9 @@ class PlatformWalletPersistenceHandler( /** DIP-13 IdentityInvitation account type tag (`AccountTypeTagFFI` 5). */ private const val ACCOUNT_TYPE_IDENTITY_INVITATION = 5 + /** `AssetLockStatus::Consumed` — the terminal lifecycle state. */ + private const val ASSET_LOCK_STATUS_CONSUMED = 4 + private val HEX = "0123456789abcdef".toCharArray() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/IdentityDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/IdentityDao.kt index 16353f7bac1..4b89b39119b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/IdentityDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/IdentityDao.kt @@ -89,6 +89,20 @@ interface IdentityDao { ) suspend fun updateMainDpnsName(identityId: ByteArray, mainDpnsName: String?, nowMillis: Long): Int + /** + * One-shot upgrade heal — promote `isLocal` on wallet-linked rows + * still carrying `false`. The persister used to write a constant + * `false`, so a wallet's own identities (which are always local) were + * mis-marked on stores from that era. + * + * Promote-only and idempotent: a `true` on an unlinked row (a manual + * add) is never touched, and a second run matches no rows. Returns the + * number of rows healed. Mirror of Swift `healIdentityIsLocalFlags` + * (PlatformWalletPersistenceHandler.swift:4688). + */ + @Query("UPDATE identities SET isLocal = 1 WHERE walletId IS NOT NULL AND isLocal = 0") + suspend fun healIsLocalFlags(): Int + @Delete suspend fun delete(identity: IdentityEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 07d143c1c57..98bb59e9a89 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2077,9 +2077,23 @@ class PlatformWalletManager( suspend fun unlockWalletFromKeystore(managed: ManagedPlatformWallet): Boolean { val walletId = managed.walletId require(walletId.size == 32) { "walletId must be 32 bytes, got ${walletId.size}" } - if (!walletStorage.hasMnemonic(walletId)) return false val key = walletId.toHex() + // Pure delegation — the WHOLE guard sequence (storage read, status-key + // derivation, stale-mismatch clear, early-return decision) lives in + // [isGenuineWatchOnly] so `WatchOnlySeedMismatchTest` pins it without + // the native library. Don't inline any of it back here: logic at this + // call site is exactly what the unit tests cannot see. + if (isGenuineWatchOnly( + walletId = walletId, + hasMnemonic = walletStorage::hasMnemonic, + updateUnlockStatus = { statusKey, transform -> + updateUnlockStatus(statusKey, transform) + }, + ) + ) { + return false + } // Wrong-seed / wrong-wallet gate. `seedMismatch` is published from // the verify result itself, scoped to JUST this call: the verify // FFI maps Rust `SeedMismatch` → ErrorInvalidParameter (de-offset @@ -2355,6 +2369,51 @@ data class DashPaySyncSummary( val syncUnixSeconds: Long, ) +/** + * The genuine-watch-only guard of + * [PlatformWalletManager.unlockWalletFromKeystore] — the COMPLETE sequence + * the unlock runs ahead of the binding verify: probe mnemonic existence + * through [hasMnemonic] ([WalletStorage.hasMnemonic] in production — an + * existence check, no decrypt), and when nothing is stored clear any stale + * `seedMismatch` under the wallet's hex status key BEFORE reporting + * watch-only. + * + * A wallet with no stored mnemonic (imported by xpub, or one whose Keystore + * entry was removed) is genuine watch-only. Reporting that MUST first clear + * any stale `seedMismatch`: no mnemonic is not a mismatch, and a wallet whose + * seed once failed to bind and whose Keystore entry was then deleted would + * otherwise keep publishing the unlock banner forever for a seed that is no + * longer there — nothing downstream of the early return can clear it. + * + * The ordering is the whole contract, which is why the WHOLE guard — the + * storage read, the status-key derivation, the clearing transform, and the + * early-return decision — lives here rather than inline. The call site in + * [PlatformWalletManager.unlockWalletFromKeystore] is pure delegation, so + * this function IS the call-site shape and `WatchOnlySeedMismatchTest` pins + * it without the native library. (An earlier cut took a pre-computed + * `hasMnemonic: Boolean` and a bare clear lambda, which left the real + * read/clear/return sequence living untested at the call site.) Mirror of + * the Swift `verifySeedBinding` watch-only arm + * (PlatformWalletManager.swift:764-770). + * + * @param hasMnemonic the storage existence probe, invoked with [walletId]. + * @param updateUnlockStatus the manager's status-map updater, invoked with + * the wallet's hex status key and the `seedMismatch = false` transform. + * @return true when the wallet is watch-only and the caller must report it as + * such; false when a mnemonic exists and the binding verify must run — + * this path must not touch `seedMismatch` (the verify publishes the real + * result). + */ +internal suspend fun isGenuineWatchOnly( + walletId: ByteArray, + hasMnemonic: suspend (walletId: ByteArray) -> Boolean, + updateUnlockStatus: (key: String, transform: (DashPayUnlockStatus) -> DashPayUnlockStatus) -> Unit, +): Boolean { + if (hasMnemonic(walletId)) return false + updateUnlockStatus(walletId.toHex()) { it.copy(seedMismatch = false) } + return true +} + /** * Decode the tagged shielded-create payload the JNI returns: * `[tag || identity_id[32] || diagnostic_utf8...]`. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 0ab618d6db4..d48cd2c81a5 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -2880,4 +2880,362 @@ class PlatformWalletPersistenceHandlerTest { // Unchanged pre-invitation behavior: no account row conjured. assertTrue(db.accountDao().observeByWallet(walletId).first().isEmpty()) } + + // ── Asset locks: Consumed is terminal ───────────────────────────── + // + // Swift parity with `persistAssetLocks` + // (PlatformWalletPersistenceHandler.swift:270 upsert, :310 removal). + // Consumed (4) is the terminal lifecycle state and the writers race: + // the wallet-event adapter's batched drain can deliver a stale + // reconstruction snapshot AFTER the live flow's consumption write. + + /** Upsert [outpoint] at [status] in its own committed round. */ + private fun persistAssetLock( + outpoint: ByteArray, + status: Byte, + amountDuffs: Long = 100_000, + proofBytes: ByteArray? = null, + ) { + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 41 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = amountDuffs, + status = status, + proofBytes = proofBytes, + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun shouldNotRegressAConsumedAssetLockOnAStaleNonConsumedUpsert() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 60 }, 0) + persistAssetLock(outpoint, status = 4, proofBytes = ByteArray(8) { 1 }) + + // Stale replay of an older Broadcast snapshot. + persistAssetLock(outpoint, status = 1, amountDuffs = 999, proofBytes = null) + + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint))!! + assertEquals(4, row.statusRaw) + // The whole row is skipped, not merely the status column. + assertEquals(100_000L, row.amountDuffs) + assertNotNull(row.proofBytes) + } + + @Test + fun shouldStillAcceptAnotherConsumedWriteOnAConsumedAssetLock() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 61 }, 0) + persistAssetLock(outpoint, status = 4) + persistAssetLock(outpoint, status = 4, amountDuffs = 250_000, proofBytes = ByteArray(4) { 7 }) + + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint))!! + assertEquals(4, row.statusRaw) + assertEquals(250_000L, row.amountDuffs) + assertNotNull(row.proofBytes) + } + + @Test + fun shouldKeepNonConsumedAssetLockStatusesLastWriteWinsInBothDirections() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 62 }, 0) + val hex = encodeOutPointHex(outpoint) + + persistAssetLock(outpoint, status = 1) // Broadcast + persistAssetLock(outpoint, status = 3) // ChainLocked + assertEquals(3, db.assetLockDao().getByOutPointHex(hex)!!.statusRaw) + + // The guard is narrow: non-terminal statuses legitimately move both + // ways, so they stay last-write-wins. + persistAssetLock(outpoint, status = 1) + assertEquals(1, db.assetLockDao().getByOutPointHex(hex)!!.statusRaw) + } + + @Test + fun shouldRetainAConsumedAssetLockThroughAStaleRemoval() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 63 }, 0) + persistAssetLock(outpoint, status = 4) + + handler.onChangesetBegin(walletId) + assertEquals(0, handler.onPersistAssetLockRemoval(walletId, outpoint)) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + // Retained for historical lookup: the only removal emitter + // (`untrack_asset_lock`) targets rejected Built rows, so a removal + // reaching a consumed row is by construction a stale write. + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) + assertNotNull(row) + assertEquals(4, row!!.statusRaw) + } + + @Test + fun shouldStillRemoveANonConsumedAssetLock() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 64 }, 0) + persistAssetLock(outpoint, status = 0) // Built + + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockRemoval(walletId, outpoint) + handler.onChangesetEnd(walletId, success = true) + + assertNull(db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint))) + } + + // ── Identity sweep vs marketplace column authority ──────────────── + // + // Swift parity with `upsertDPNSNames` + // (PlatformWalletPersistenceHandler.swift:1912-1941). The identity + // snapshot owns `isOwned` / `acquiredAt` / `label`; the marketplace + // reconciliation lane owns `documentId` / price / sale status / + // counterparty. Neither may overwrite the other's columns. + + /** Commit one canonical identity snapshot carrying exactly [names]. */ + private fun persistIdentitySnapshot(identityId: ByteArray, vararg names: String) { + handler.onChangesetBegin(walletId) + handler.onPersistIdentityUpsert( + walletId, identityId, 1, 0, false, 0, 0, true, walletId, + names.toList().toTypedArray(), LongArray(names.size), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onChangesetEnd(walletId, success = true) + } + + /** Commit one marketplace row for [documentId]. */ + private fun persistMarketplaceRow( + identityId: ByteArray, + documentId: ByteArray, + label: String, + normalizedLabel: String, + status: Byte, + counterpartyId: ByteArray? = null, + priceCredits: Long? = null, + ) { + handler.onChangesetBegin(walletId) + handler.onPersistDpnsNameState( + walletId = walletId, + documentId = documentId, + walletIdentityId = identityId, + hasCounterparty = counterpartyId != null, + counterpartyId = counterpartyId ?: ByteArray(32), + label = label, + normalizedLabel = normalizedLabel, + normalizedParentDomainName = "dash", + hasPrice = priceCredits != null, + priceCredits = priceCredits ?: 0, + status = status, + createdAtMs = 100, + updatedAtMs = 200, + transferredAtMs = 300, + lastSyncedAtMs = 400, + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun shouldKeepAListedNameAsDepartedHistoryThroughTheIdentitySweepInsteadOfDestroyingIt() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 20 } + val documentId = ByteArray(32) { 21 } + + persistIdentitySnapshot(identityId, "Alice") + // Listed for sale: still owned, and marketplace-tracked. + persistMarketplaceRow( + identityId, documentId, "Alice", "a11ce", + status = 0, priceCredits = 5_000, + ) + assertTrue(db.dpnsNameDao().getByDocumentId(documentId)!!.isOwned) + + // The label leaves the canonical set before the marketplace pass can + // classify the departure — the unclassifiable-departure round. The + // old sweep deleted the row here, permanently destroying the only + // record of where the name went (Android-only; Swift never did). + persistIdentitySnapshot(identityId) + + val retained = db.dpnsNameDao().getByDocumentId(documentId) + assertNotNull("marketplace history must survive the identity sweep", retained) + assertFalse("a departed name must not read as owned", retained!!.isOwned) + assertEquals(5_000L, retained.priceCredits) + assertEquals(400L, retained.marketplaceUpdatedAt) + // ...but owned-name queries and UI selection must not surface it. + assertTrue(db.dpnsNameDao().observeByIdentity(identityId).first().isEmpty()) + } + + @Test + fun shouldKeepASoldNameWithItsCounterpartyThroughTheIdentitySweep() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 22 } + val documentId = ByteArray(32) { 23 } + val buyerId = ByteArray(32) { 24 } + + persistIdentitySnapshot(identityId, "Bob") + persistMarketplaceRow( + identityId, documentId, "Bob", "b0b", + status = 1, counterpartyId = buyerId, + ) + + persistIdentitySnapshot(identityId) + + val retained = db.dpnsNameDao().getByDocumentId(documentId) + assertNotNull(retained) + assertFalse(retained!!.isOwned) + assertEquals(1, retained.saleStatusRaw) + assertTrue(buyerId.contentEquals(retained.counterpartyIdentityId!!)) + } + + @Test + fun shouldStillDeleteLabelCacheRowsWithNoMarketplaceHistoryOnTheIdentitySweep() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 25 } + + persistIdentitySnapshot(identityId, "Alice", "Bob") + assertEquals(2, db.dpnsNameDao().getAllByIdentity(identityId).size) + + persistIdentitySnapshot(identityId, "Alice") + + // No documentId attached → a pure stale label-cache row, removed + // entirely (unchanged behavior). + assertEquals( + listOf("Alice"), + db.dpnsNameDao().getAllByIdentity(identityId).map { it.label }, + ) + } + + @Test + fun shouldNotClobberMarketplaceColumnsWhenAnIdentitySnapshotStillCarriesTheLabel() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 26 } + val documentId = ByteArray(32) { 27 } + val recipientId = ByteArray(32) { 28 } + + persistIdentitySnapshot(identityId, "Carol") + persistMarketplaceRow( + identityId, documentId, "Carol", "car01", + status = 2, counterpartyId = recipientId, priceCredits = 9_000, + ) + + // A later identity flush still carries the label — the two lanes + // disagree for a round. The canonical branch refreshes only + // acquiredAt/label (+ isOwned); it used to blank saleStatusRaw and + // counterpartyIdentityId on every such flush. + persistIdentitySnapshot(identityId, "Carol") + + val row = db.dpnsNameDao().getByDocumentId(documentId) + assertNotNull(row) + assertEquals(2, row!!.saleStatusRaw) + assertTrue(recipientId.contentEquals(row.counterpartyIdentityId!!)) + assertEquals(9_000L, row.priceCredits) + assertEquals(300L, row.documentTransferredAtMs) + assertTrue("the identity lane still asserts ownership", row.isOwned) + } + + // ── isLocal: wallet-link promotion + load-path heal ─────────────── + // + // Swift parity with `persistIdentities` + // (PlatformWalletPersistenceHandler.swift:1827-1829) and + // `healIdentityIsLocalFlags` (:4688, called from loadWalletList :4719). + + @Test + fun shouldMarkAPersisterCreatedWalletLinkedIdentityLocal() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 30 } + + persistIdentitySnapshot(identityId) + + val row = db.identityDao().getByIdentityId(identityId)!! + assertTrue(walletId.contentEquals(row.walletId!!)) + assertTrue("a wallet's own identity is always local", row.isLocal) + } + + @Test + fun shouldKeepAnObservedOutOfWalletIdentityNonLocal() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 31 } + + handler.onChangesetBegin(walletId) + handler.onPersistIdentityUpsert( + walletId, identityId, 1, 0, false, 0, 0, false, ByteArray(32), + emptyArray(), longArrayOf(), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.identityDao().getByIdentityId(identityId)!! + assertNull(row.walletId) + assertFalse("an observed identity is not local", row.isLocal) + } + + @Test + fun shouldPromoteAnAlreadyPersistedIdentityToLocalOnLaterWalletLinkage() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 32 } + + handler.onChangesetBegin(walletId) + handler.onPersistIdentityUpsert( + walletId, identityId, 1, 0, false, 0, 0, false, ByteArray(32), + emptyArray(), longArrayOf(), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onChangesetEnd(walletId, success = true) + assertFalse(db.identityDao().getByIdentityId(identityId)!!.isLocal) + + // The wallet relationship attaches on a later flush — promote. + persistIdentitySnapshot(identityId) + + assertTrue(db.identityDao().getByIdentityId(identityId)!!.isLocal) + } + + @Test + fun shouldHealLegacyIsLocalFalseOnWalletLinkedRowsOnlyDuringLoad() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val legacyOwned = ByteArray(32) { 33 } + val manualAdd = ByteArray(32) { 34 } + val observed = ByteArray(32) { 35 } + + // Rows exactly as the pre-fix persister wrote them: a constant + // `false` even on the wallet's own identities. + db.identityDao().upsert( + IdentityEntity( + identityId = legacyOwned, + networkRaw = testnet, + walletId = walletId, + isLocal = false, + ), + ) + db.identityDao().upsert( + IdentityEntity( + identityId = manualAdd, + networkRaw = testnet, + walletId = null, + isLocal = true, + ), + ) + db.identityDao().upsert( + IdentityEntity( + identityId = observed, + networkRaw = testnet, + walletId = null, + isLocal = false, + ), + ) + + handler.onLoadWalletList() + + assertTrue( + "a wallet-linked legacy row is promoted", + db.identityDao().getByIdentityId(legacyOwned)!!.isLocal, + ) + assertTrue( + "a manual add (no wallet link) keeps its flag", + db.identityDao().getByIdentityId(manualAdd)!!.isLocal, + ) + assertFalse( + "an observed row is never promoted", + db.identityDao().getByIdentityId(observed)!!.isLocal, + ) + + // Promote-only and idempotent: a second pass matches nothing. + assertEquals(0, db.identityDao().healIsLocalFlags()) + } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WatchOnlySeedMismatchTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WatchOnlySeedMismatchTest.kt new file mode 100644 index 00000000000..48bcaa02f90 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WatchOnlySeedMismatchTest.kt @@ -0,0 +1,124 @@ +package org.dashfoundation.dashsdk.wallet + +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.persistence.toHex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The watch-only guard of `unlockWalletFromKeystore` — Swift parity with + * `verifySeedBinding` (PlatformWalletManager.swift:764-770). + * + * The regression these pin: the mnemonic-existence check used to return + * watch-only BEFORE any status update, so a `seedMismatch` published by an + * earlier failed verify survived the removal of the Keystore entry and the + * unlock banner never went away. + * + * These run against [isGenuineWatchOnly] — the seam the production call site + * delegates to WHOLE. The storage read, the hex status-key derivation, the + * clearing transform, and the early-return decision are all inside the unit + * under test, exercised through a fake storage probe and a manager-shaped + * status map; nothing here tests a lambda against itself. Reverting the + * guard to the pre-fix call-site shape (return watch-only straight off the + * missing mnemonic, no clear) fails the first and third tests. + */ +class WatchOnlySeedMismatchTest { + + private val walletId = ByteArray(32) { (it + 1).toByte() } + private val statusKey = walletId.toHex() + + /** Manager-shaped status store: keyed map, transform-based updates. */ + private class RecordingStatusMap { + val map = mutableMapOf() + val updatedKeys = mutableListOf() + + fun update(key: String, transform: (DashPayUnlockStatus) -> DashPayUnlockStatus) { + updatedKeys += key + map[key] = transform(map[key] ?: DashPayUnlockStatus()) + } + } + + @Test + fun noStoredMnemonicClearsSeedMismatchBeforeReportingWatchOnly() = runTest { + val order = mutableListOf() + val statuses = RecordingStatusMap() + // The state an earlier failed verify would have left behind. + statuses.map[statusKey] = DashPayUnlockStatus(seedMismatch = true) + + val watchOnly = isGenuineWatchOnly( + walletId = walletId, + hasMnemonic = { id -> + order += "read:${id.toHex()}" + false + }, + updateUnlockStatus = { key, transform -> + order += "clear:$key" + statuses.update(key, transform) + }, + ) + order += "returned" + + assertTrue("no mnemonic must report genuine watch-only", watchOnly) + // Ordering is the fix: the probe runs on OUR wallet id, the clear + // lands on OUR status key, and the clear cannot run after the early + // return. + assertEquals( + listOf("read:$statusKey", "clear:$statusKey", "returned"), + order, + ) + assertFalse( + "the stale mismatch must be gone once watch-only is reported", + statuses.map.getValue(statusKey).seedMismatch, + ) + } + + @Test + fun storedMnemonicDoesNotTouchSeedMismatchAndFallsThroughToVerify() = runTest { + val statuses = RecordingStatusMap() + statuses.map[statusKey] = DashPayUnlockStatus(seedMismatch = true) + + val watchOnly = isGenuineWatchOnly( + walletId = walletId, + hasMnemonic = { true }, + updateUnlockStatus = statuses::update, + ) + + assertFalse("a wallet holding a mnemonic is not watch-only", watchOnly) + // The verify publishes the real result; this path must not pre-empt it. + assertEquals( + "seedMismatch must be left to the binding verify", + emptyList(), + statuses.updatedKeys, + ) + assertTrue(statuses.map.getValue(statusKey).seedMismatch) + } + + @Test + fun clearingDropsAPreviouslyPublishedMismatchAndLeavesSiblingFieldsAlone() = runTest { + val statuses = RecordingStatusMap() + statuses.map[statusKey] = DashPayUnlockStatus( + draining = true, + seedMismatch = true, + pendingAccountBuilds = 3, + ) + + val watchOnly = isGenuineWatchOnly( + walletId = walletId, + hasMnemonic = { false }, + updateUnlockStatus = statuses::update, + ) + + assertTrue(watchOnly) + assertEquals( + "exactly one status write, on the wallet's own key", + listOf(statusKey), + statuses.updatedKeys, + ) + val status = statuses.map.getValue(statusKey) + assertFalse(status.seedMismatch) + assertTrue("unrelated unlock state must survive", status.draining) + assertEquals(3, status.pendingAccountBuilds) + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d331530e9b2..2b4c2c5af91 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -979,6 +979,34 @@ impl PlatformWalletPersistence for SqlitePersister { let conn = self.conn().map_err(PersistenceError::from)?; schema::core_state::get_tx_record(&conn, &wallet_id, txid).map_err(PersistenceError::from) } + + /// Served from the `dpns_name_states` table this persister already + /// writes (`DPNS_NAME_STATES` is attested above), so the marketplace + /// sync pass can recover a departed name's `document_id` after a + /// restart instead of orphaning the row. + /// + /// Reads the *committed* table. In [`FlushMode::Manual`] a row that + /// is still sitting in the write buffer is not visible yet and this + /// returns `Ok(None)` — the caller degrades to the pre-fix behaviour + /// for that one name, never to a wrong id. Not a concern for the + /// departure path: it is looking for a row written by an earlier + /// session, and the default [`FlushMode::Immediate`] commits on every + /// `store`. + fn get_dpns_name_state( + &self, + wallet_id: WalletId, + wallet_identity_id: &dpp::prelude::Identifier, + normalized_label: &str, + ) -> Result, PersistenceError> { + let conn = self.conn().map_err(PersistenceError::from)?; + schema::dpns_name_states::get_by_identity_and_label( + &conn, + &wallet_id, + wallet_identity_id, + normalized_label, + ) + .map_err(PersistenceError::from) + } } // ----- Helpers ----- diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs index cada877a297..7175ba5a09f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs @@ -6,19 +6,18 @@ //! `counterparty_id` column (NULL for `owned`), with the pairing enforced by //! a table CHECK. -use rusqlite::{params, Transaction}; +use dpp::prelude::Identifier; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; -use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateChangeSet}; +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry}; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; +use crate::sqlite::util::safe_cast::i64_to_u64; -// Imports used only by the test-gated reader below. +// Import used only by the test-gated whole-table reader below. #[cfg(any(test, feature = "__test-helpers"))] -use { - dpp::prelude::Identifier, platform_wallet::changeset::DpnsNameStateEntry, rusqlite::Connection, - std::collections::BTreeMap, -}; +use std::collections::BTreeMap; pub fn apply( tx: &Transaction<'_>, @@ -111,7 +110,9 @@ pub(crate) fn status_columns(s: &DpnsNameSaleStatus) -> (&'static str, Option<[u } } -#[cfg(any(test, feature = "__test-helpers"))] +/// Inverse of [`status_columns`]. Ungated: the production +/// [`get_by_identity_and_label`] reader decodes rows too, not just the +/// test-only whole-table reader. fn status_from_columns( status: &str, counterparty: Option>, @@ -133,6 +134,169 @@ fn status_from_columns( } } +/// The projection every reader in this module selects, in the exact order +/// [`row_columns`] indexes and [`entry_from_columns`] destructures. Shared +/// so the two readers cannot drift apart. +const ROW_PROJECTION: &str = "document_id, identity_id, label, normalized_label, \ + normalized_parent_domain, price, status, counterparty_id, created_at_ms, \ + updated_at_ms, transferred_at_ms, last_synced_at_ms"; + +/// One raw row in [`ROW_PROJECTION`] order. Named so the readers stay under +/// clippy's `type_complexity` bar. +type RowColumns = ( + Vec, + Vec, + String, + String, + String, + Option, + String, + Option>, + Option, + Option, + Option, + i64, +); + +/// Pull one row's columns out inside a rusqlite row callback, which may +/// only fail with [`rusqlite::Error`]. Typed decoding (which needs +/// [`WalletStorageError`]) happens afterwards in [`entry_from_columns`]. +fn row_columns(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, Option>>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, i64>(11)?, + )) +} + +/// Rebuild a [`DpnsNameStateEntry`] from one [`ROW_PROJECTION`] row. +/// +/// Every signed column crosses back to `u64` through [`i64_to_u64`] +/// rather than `as`. Only `price` is barred from holding a negative by a +/// column `CHECK`; the four timestamp columns are unconstrained, so an +/// externally modified or corrupted `-1` would otherwise decode as +/// `u64::MAX` — a year-584-million timestamp that reads as valid +/// marketplace state. Checked, it surfaces as the typed +/// [`WalletStorageError::IntegerOverflow`] naming the offending column. +/// This matters more than it did: the decoder now backs a production +/// read ([`get_by_identity_and_label`]), not only the test-gated +/// whole-table helper. A malformed identifier blob is likewise rejected +/// rather than truncated. +fn entry_from_columns(columns: RowColumns) -> Result { + let ( + doc_bytes, + identity_bytes, + label, + normalized_label, + normalized_parent, + price, + status, + counterparty, + created_at, + updated_at, + transferred_at, + last_synced, + ) = columns; + let document_id = Identifier::from_bytes(&doc_bytes) + .map_err(|_| WalletStorageError::blob_decode("document_id is not 32 bytes"))?; + let wallet_identity_id = Identifier::from_bytes(&identity_bytes) + .map_err(|_| WalletStorageError::blob_decode("identity_id is not 32 bytes"))?; + Ok(DpnsNameStateEntry { + document_id, + wallet_identity_id, + label, + normalized_label, + normalized_parent_domain_name: normalized_parent, + price: price + .map(|value| i64_to_u64("dpns_name_states.price", value)) + .transpose()?, + status: status_from_columns(&status, counterparty)?, + created_at_ms: created_at + .map(|value| i64_to_u64("dpns_name_states.created_at_ms", value)) + .transpose()?, + updated_at_ms: updated_at + .map(|value| i64_to_u64("dpns_name_states.updated_at_ms", value)) + .transpose()?, + transferred_at_ms: transferred_at + .map(|value| i64_to_u64("dpns_name_states.transferred_at_ms", value)) + .transpose()?, + last_synced_at_ms: i64_to_u64("dpns_name_states.last_synced_at_ms", last_synced)?, + }) +} + +/// The one DPNS marketplace row a wallet identity holds for +/// `normalized_label`, if any. +/// +/// Backs `PlatformWalletPersistence::get_dpns_name_state` — the durable +/// fallback the marketplace sync pass uses to recover a departed name's +/// `document_id` when the session-scoped in-memory map is empty (i.e. on +/// the first sync pass after any process start). Without it the removal +/// delta is skipped and this very table keeps an orphaned row for a name +/// the wallet no longer holds. +/// +/// Filtered on all three of `wallet_id`, `identity_id` and +/// `normalized_label`: `sold` / `transferred` rows are retained here, so +/// dropping the identity predicate could return a row that belongs to a +/// different identity and remove the wrong document. +/// +/// **Which of several matches wins.** The triple is NOT unique. The +/// schema's primary key is `(wallet_id, document_id)`; a DPNS name can be +/// deleted and re-registered under a fresh document id, and this table +/// deliberately retains the earlier `sold` / `transferred` row. One +/// identity can therefore hold a historical row AND the current row for +/// the same normalized label. The `ORDER BY` picks the CURRENT one +/// deterministically — `owned` ahead of any retained historical status, +/// then the most recently synced row, then the highest document id as a +/// final tie-break — and matches the preference +/// `PlatformWalletPersistence::get_dpns_name_state` documents for every +/// backend. An unordered `LIMIT 1` instead follows primary-key scan +/// order, which is document-id order and carries no relation to +/// recency: it can hand back the historical row, whose removal delta +/// then deletes that row and drops the identity's label, leaving the +/// CURRENT row orphaned with nothing left to ever trigger its removal. +/// +/// **Query cost.** No dedicated index exists for this predicate; SQLite +/// serves it from the `(wallet_id, document_id)` primary-key index, +/// scanning only the rows of this one wallet — bounded by the wallet's +/// DPNS name count, and hit at most once per departed name per sync pass. +/// The `ORDER BY` sorts only the rows that already satisfied the +/// three-way filter (normally one), so it adds no scan. +pub fn get_by_identity_and_label( + conn: &Connection, + wallet_id: &WalletId, + wallet_identity_id: &Identifier, + normalized_label: &str, +) -> Result, WalletStorageError> { + let sql = format!( + "SELECT {ROW_PROJECTION} FROM dpns_name_states \ + WHERE wallet_id = ?1 AND identity_id = ?2 AND normalized_label = ?3 \ + ORDER BY CASE status WHEN 'owned' THEN 0 ELSE 1 END, \ + last_synced_at_ms DESC, document_id DESC \ + LIMIT 1" + ); + let columns = conn + .prepare_cached(&sql)? + .query_row( + params![ + wallet_id.as_slice(), + wallet_identity_id.as_slice(), + normalized_label + ], + row_columns, + ) + .optional()?; + columns.map(entry_from_columns).transpose() +} + /// Read every DPNS name-state row for a wallet, keyed by document id. /// Test/round-trip helper (the production load path does not re-hydrate /// name states into the Rust manager; the Swift SwiftData mirror is the UI @@ -142,64 +306,13 @@ pub fn read_all( conn: &Connection, wallet_id: &WalletId, ) -> Result, WalletStorageError> { - let mut stmt = conn.prepare( - "SELECT document_id, identity_id, label, normalized_label, normalized_parent_domain, \ - price, status, counterparty_id, created_at_ms, updated_at_ms, \ - transferred_at_ms, last_synced_at_ms \ - FROM dpns_name_states WHERE wallet_id = ?1", - )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, Vec>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, String>(6)?, - row.get::<_, Option>>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, i64>(11)?, - )) - })?; + let sql = format!("SELECT {ROW_PROJECTION} FROM dpns_name_states WHERE wallet_id = ?1"); + let mut stmt = conn.prepare_cached(&sql)?; + let rows = stmt.query_map(params![wallet_id.as_slice()], row_columns)?; let mut out = BTreeMap::new(); for row in rows { - let ( - doc_bytes, - identity_bytes, - label, - normalized_label, - normalized_parent, - price, - status, - counterparty, - created_at, - updated_at, - transferred_at, - last_synced, - ) = row?; - let document_id = Identifier::from_bytes(&doc_bytes) - .map_err(|_| WalletStorageError::blob_decode("document_id is not 32 bytes"))?; - let wallet_identity_id = Identifier::from_bytes(&identity_bytes) - .map_err(|_| WalletStorageError::blob_decode("identity_id is not 32 bytes"))?; - out.insert( - document_id, - DpnsNameStateEntry { - document_id, - wallet_identity_id, - label, - normalized_label, - normalized_parent_domain_name: normalized_parent, - price: price.map(|p| p as u64), - status: status_from_columns(&status, counterparty)?, - created_at_ms: created_at.map(|v| v as u64), - updated_at_ms: updated_at.map(|v| v as u64), - transferred_at_ms: transferred_at.map(|v| v as u64), - last_synced_at_ms: last_synced as u64, - }, - ); + let entry = entry_from_columns(row?)?; + out.insert(entry.document_id, entry); } Ok(out) } @@ -304,4 +417,372 @@ mod tests { "a zero-credit listing is valid" ); } + + /// The durable fallback behind `get_dpns_name_state`, exercised + /// against real SQL rather than a test double. + /// + /// Covers the three-way filter the doc comment promises: a match is + /// found by NORMALIZED label, and neither another identity's row nor + /// another wallet's row can satisfy the lookup. A reader that dropped + /// the identity predicate would remove the wrong document on + /// departure; one that dropped the wallet predicate would cross + /// wallets. + #[test] + fn get_by_identity_and_label_is_scoped_to_wallet_identity_and_normalized_label() { + let wallet_id: WalletId = [0x44; 32]; + let other_wallet_id: WalletId = [0x55; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + for w in [&wallet_id, &other_wallet_id] { + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + } + + // Ours, plus a same-label row owned by a DIFFERENT identity in the + // same wallet, plus a same-label row in a DIFFERENT wallet. + let ours = entry(0, DpnsNameSaleStatus::Owned, Some(5_000)); + let mut other_identity = entry(1, DpnsNameSaleStatus::Owned, None); + other_identity.wallet_identity_id = Identifier::from([0xCC; 32]); + other_identity.label = ours.label.clone(); + other_identity.normalized_label = ours.normalized_label.clone(); + let mut other_wallet = entry(2, DpnsNameSaleStatus::Owned, None); + other_wallet.label = ours.label.clone(); + other_wallet.normalized_label = ours.normalized_label.clone(); + + let mut cs = DpnsNameStateChangeSet::default(); + cs.names.insert(ours.document_id, ours.clone()); + cs.names + .insert(other_identity.document_id, other_identity.clone()); + let mut cs_other = DpnsNameStateChangeSet::default(); + cs_other + .names + .insert(other_wallet.document_id, other_wallet.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + apply(&tx, &other_wallet_id, &cs_other).unwrap(); + tx.commit().unwrap(); + } + + // Exact round-trip of every field, by normalized label. + assert_eq!( + get_by_identity_and_label( + &conn, + &wallet_id, + &ours.wallet_identity_id, + &ours.normalized_label, + ) + .unwrap(), + Some(ours.clone()), + ); + + // The same label under another identity resolves to THAT row, not ours. + assert_eq!( + get_by_identity_and_label( + &conn, + &wallet_id, + &other_identity.wallet_identity_id, + &ours.normalized_label, + ) + .unwrap() + .map(|e| e.document_id), + Some(other_identity.document_id), + ); + + // Wallet scoping discriminates: the SAME identity and label in + // another wallet resolves to that wallet's row, never ours. (A + // reader missing the wallet predicate would return whichever row + // the PK index reached first.) + assert_ne!(other_wallet.document_id, ours.document_id); + assert_eq!( + get_by_identity_and_label( + &conn, + &other_wallet_id, + &ours.wallet_identity_id, + &ours.normalized_label, + ) + .unwrap() + .map(|e| e.document_id), + Some(other_wallet.document_id), + ); + + // A wallet holding no such row at all answers None. + let empty_wallet_id: WalletId = [0x77; 32]; + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&empty_wallet_id[..]], + ) + .unwrap(); + assert_eq!( + get_by_identity_and_label( + &conn, + &empty_wallet_id, + &ours.wallet_identity_id, + &ours.normalized_label, + ) + .unwrap(), + None, + ); + + // Display label is not the key — only the normalized form is. + assert_eq!( + get_by_identity_and_label(&conn, &wallet_id, &ours.wallet_identity_id, &ours.label) + .unwrap(), + None, + ); + + // Unknown label. + assert_eq!( + get_by_identity_and_label(&conn, &wallet_id, &ours.wallet_identity_id, "nope").unwrap(), + None, + ); + } + + /// THE ROUND-3 REGRESSION. `(wallet_id, identity_id, + /// normalized_label)` is not unique — a name can be deleted and + /// re-registered under a fresh document id while this table retains + /// the earlier `sold` row — so one identity can hold BOTH a + /// historical row and the current one for the same label. An + /// unordered `LIMIT 1` follows primary-key (document-id) scan order, + /// which is unrelated to recency; when it hands back the historical + /// row the caller removes THAT row plus the identity's label, and the + /// current row is orphaned with nothing left to trigger its removal. + /// + /// The historical row is given the LOWER document id here precisely + /// so scan order favours it: the raw unordered query is asserted + /// first, so this test cannot pass vacuously. + #[test] + fn get_by_identity_and_label_prefers_the_current_owned_row_over_a_retained_one() { + let wallet_id: WalletId = [0x88; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + // Same identity, same normalized label, two documents. + let mut historical = entry(0x01, DpnsNameSaleStatus::Owned, None); + historical.status = DpnsNameSaleStatus::Sold { + to: Identifier::from([0xBB; 32]), + }; + historical.transferred_at_ms = Some(1_500_000_000_000); + historical.last_synced_at_ms = 1_500_000_000_000; + let mut current = entry(0x02, DpnsNameSaleStatus::Owned, Some(9_000)); + current.label = historical.label.clone(); + current.normalized_label = historical.normalized_label.clone(); + current.last_synced_at_ms = 1_900_000_000_000; + assert!( + historical.document_id < current.document_id, + "fixture: the historical row must sort FIRST in primary-key order" + ); + + let mut cs = DpnsNameStateChangeSet::default(); + cs.names.insert(historical.document_id, historical.clone()); + cs.names.insert(current.document_id, current.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Precondition: the pre-fix query really does pick the wrong row. + let unordered: Vec = conn + .query_row( + "SELECT document_id FROM dpns_name_states \ + WHERE wallet_id = ?1 AND identity_id = ?2 AND normalized_label = ?3 LIMIT 1", + params![ + &wallet_id[..], + current.wallet_identity_id.as_slice(), + ¤t.normalized_label + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + unordered, + historical.document_id.to_vec(), + "test precondition: an unordered LIMIT 1 selects the historical row, \ + which is the orphaning bug this ORDER BY closes" + ); + + assert_eq!( + get_by_identity_and_label( + &conn, + &wallet_id, + ¤t.wallet_identity_id, + ¤t.normalized_label, + ) + .unwrap(), + Some(current), + "the CURRENT owned row must win over the retained historical one" + ); + } + + /// With no `owned` row left — the name departed and was later + /// re-acquired and sold again — recency decides. The tie-break order + /// is `last_synced_at_ms DESC` BEFORE `document_id DESC`, so the + /// fixture gives the fresher row the LOWER document id: a reader that + /// ordered by document id alone would pick the stale one. + #[test] + fn get_by_identity_and_label_breaks_ties_on_last_synced_before_document_id() { + let wallet_id: WalletId = [0x99; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let buyer = Identifier::from([0xBB; 32]); + let mut fresher = entry(0x03, DpnsNameSaleStatus::Sold { to: buyer }, None); + fresher.transferred_at_ms = Some(1_900_000_000_000); + fresher.last_synced_at_ms = 1_900_000_000_000; + let mut staler = entry(0x04, DpnsNameSaleStatus::Transferred { to: buyer }, None); + staler.label = fresher.label.clone(); + staler.normalized_label = fresher.normalized_label.clone(); + staler.transferred_at_ms = Some(1_400_000_000_000); + staler.last_synced_at_ms = 1_400_000_000_000; + assert!( + fresher.document_id < staler.document_id, + "fixture: document-id order must DISAGREE with recency order" + ); + + let mut cs = DpnsNameStateChangeSet::default(); + cs.names.insert(fresher.document_id, fresher.clone()); + cs.names.insert(staler.document_id, staler.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + get_by_identity_and_label( + &conn, + &wallet_id, + &fresher.wallet_identity_id, + &fresher.normalized_label, + ) + .unwrap() + .map(|e| e.document_id), + Some(fresher.document_id), + ); + } + + /// The timestamp columns carry no `CHECK`, so a hand-edited or + /// corrupted row can hold a negative. `as u64` turned `-1` into + /// `u64::MAX`, which reads back as a perfectly valid (year + /// 584-million) timestamp; the checked decode surfaces the typed + /// [`WalletStorageError::IntegerOverflow`] naming the column instead. + #[test] + fn negative_timestamps_are_rejected_by_the_decoder() { + use crate::sqlite::util::safe_cast::SafeCastTarget; + + let wallet_id: WalletId = [0xAB; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + // One row per timestamp column, each with exactly that column + // negative, so the error must name the right one. The row is + // inserted valid and then corrupted with an UPDATE: that is also + // the only way `last_synced_at_ms` (NOT NULL) can be reached. + let columns = [ + (0xC1u8, "created_at_ms", "dpns_name_states.created_at_ms"), + (0xC2u8, "updated_at_ms", "dpns_name_states.updated_at_ms"), + ( + 0xC3u8, + "transferred_at_ms", + "dpns_name_states.transferred_at_ms", + ), + ( + 0xC4u8, + "last_synced_at_ms", + "dpns_name_states.last_synced_at_ms", + ), + ]; + for (tag, column, field) in columns { + let doc = [tag; 32]; + let label = format!("a11ce{tag}"); + conn.execute( + "INSERT INTO dpns_name_states \ + (wallet_id, document_id, identity_id, label, normalized_label, \ + normalized_parent_domain, price, status, counterparty_id, \ + created_at_ms, updated_at_ms, transferred_at_ms, last_synced_at_ms) \ + VALUES (?1, ?2, ?3, 'Alice', ?4, 'dash', NULL, 'owned', NULL, 1, 1, 1, 1)", + params![&wallet_id[..], &doc[..], &[0xAAu8; 32][..], &label], + ) + .unwrap(); + conn.execute( + &format!( + "UPDATE dpns_name_states SET {column} = -1 \ + WHERE wallet_id = ?1 AND document_id = ?2" + ), + params![&wallet_id[..], &doc[..]], + ) + .unwrap(); + + let error = + get_by_identity_and_label(&conn, &wallet_id, &Identifier::from([0xAA; 32]), &label) + .expect_err("a negative timestamp must not decode"); + match error { + WalletStorageError::IntegerOverflow { + field: got, target, .. + } => { + assert_eq!(got, field, "the error must name the offending column"); + assert_eq!(target, SafeCastTarget::U64); + } + other => panic!("expected a typed IntegerOverflow, got {other:?}"), + } + } + } + + /// A retained `Sold` row — the exact shape a departed name leaves + /// behind — must still be recoverable, since that is what the + /// post-restart departure path looks for. + #[test] + fn get_by_identity_and_label_recovers_a_retained_sold_row() { + let wallet_id: WalletId = [0x66; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet_id[..]], + ) + .unwrap(); + + let mut sold = entry(7, DpnsNameSaleStatus::Owned, None); + sold.status = DpnsNameSaleStatus::Sold { + to: Identifier::from([0xBB; 32]), + }; + sold.transferred_at_ms = Some(1_800_000_100_000); + let mut cs = DpnsNameStateChangeSet::default(); + cs.names.insert(sold.document_id, sold.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + get_by_identity_and_label( + &conn, + &wallet_id, + &sold.wallet_identity_id, + &sold.normalized_label, + ) + .unwrap(), + Some(sold), + ); + } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs b/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs index 68ed59a7661..91626d2693e 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs @@ -243,6 +243,138 @@ fn tc001_get_core_tx_record_roundtrip() { assert!(persister.get_core_tx_record(w, &unknown).unwrap().is_none()); } +/// `get_dpns_name_state` round-trips a stored marketplace row and is +/// scoped to all three of wallet / identity / normalized label. +/// +/// This read is the DPNS marketplace departure path's durable fallback. +/// The Rust-side working set (`PlatformWalletInfo::dpns_name_states`) is +/// session-scoped and starts empty on every process start, so a name that +/// departs during the first sync pass after a restart can only recover the +/// `document_id` its removal delta needs from this table — otherwise the +/// row persisted here is orphaned for good. +/// +/// The scoping assertions are the load-bearing ones: `sold` / +/// `transferred` rows are retained after a name leaves an identity, so a +/// lookup that dropped the identity (or wallet) predicate could hand back +/// a different document and remove the wrong row. +#[test] +fn dpns_name_state_lookup_round_trips_and_is_scoped() { + use dpp::prelude::Identifier; + use platform_wallet::changeset::{ + DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, + }; + use platform_wallet::wallet::platform_wallet::WalletId; + use platform_wallet_storage::SqlitePersister; + + fn entry( + document_id: u8, + identity: Identifier, + label: &str, + normalized_label: &str, + status: DpnsNameSaleStatus, + ) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([document_id; 32]), + wallet_identity_id: identity, + label: label.to_string(), + normalized_label: normalized_label.to_string(), + normalized_parent_domain_name: "dash".to_string(), + price: Some(5_000_000_000), + status, + created_at_ms: Some(1_700_000_000_000), + updated_at_ms: None, + transferred_at_ms: None, + last_synced_at_ms: 1_800_000_000_000, + } + } + + fn store_rows(persister: &SqlitePersister, wallet: WalletId, rows: Vec) { + let mut names = DpnsNameStateChangeSet::default(); + for row in rows { + names.names.insert(row.document_id, row); + } + let mut cs = PlatformWalletChangeSet::default(); + cs.dpns_name_states = Some(names); + persister.store(wallet, cs).expect("store dpns rows"); + } + + let (persister, _tmp, _path) = fresh_persister(); + let wallet = wid(0xD1); + let other_wallet = wid(0xD2); + ensure_wallet_meta(&persister, &wallet); + ensure_wallet_meta(&persister, &other_wallet); + + let identity = Identifier::from([0xA1; 32]); + let other_identity = Identifier::from([0xA2; 32]); + + // "Alice" normalizes to "a11ce"; "Bob" to "b0b". + let target = entry(0x01, identity, "Alice", "a11ce", DpnsNameSaleStatus::Owned); + let same_label_other_identity = entry( + 0x02, + other_identity, + "Alice", + "a11ce", + DpnsNameSaleStatus::Sold { to: identity }, + ); + let other_label = entry(0x03, identity, "Bob", "b0b", DpnsNameSaleStatus::Owned); + let cross_wallet = entry(0x04, identity, "Alice", "a11ce", DpnsNameSaleStatus::Owned); + + store_rows( + &persister, + wallet, + vec![ + target.clone(), + same_label_other_identity.clone(), + other_label.clone(), + ], + ); + store_rows(&persister, other_wallet, vec![cross_wallet.clone()]); + + // Full-row round-trip, every column reconstructed. + assert_eq!( + persister + .get_dpns_name_state(wallet, &identity, "a11ce") + .expect("lookup"), + Some(target), + ); + + // Same label, different identity in the SAME wallet → that identity's + // own (retained `Sold`) row, complete with its counterparty payload. + assert_eq!( + persister + .get_dpns_name_state(wallet, &other_identity, "a11ce") + .expect("lookup"), + Some(same_label_other_identity), + ); + + // Same identity + label, different wallet → that wallet's row only. + assert_eq!( + persister + .get_dpns_name_state(other_wallet, &identity, "a11ce") + .expect("lookup") + .map(|row| row.document_id), + Some(cross_wallet.document_id), + ); + + // The predicate is the NORMALIZED label — the display label misses. + assert!(persister + .get_dpns_name_state(wallet, &identity, "Alice") + .expect("lookup") + .is_none()); + + // A label this identity never held misses. + assert!(persister + .get_dpns_name_state(wallet, &identity, "car01") + .expect("lookup") + .is_none()); + + // A wallet with no rows at all misses rather than erroring. + assert!(persister + .get_dpns_name_state(wid(0xD3), &identity, "a11ce") + .expect("lookup") + .is_none()); +} + /// two wallets coexist without key collisions. #[test] fn tc015_two_wallets_in_one_db() { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 69c92fb7718..12c5cb863f5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -76,10 +76,6 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "invitations.rs", "SELECT outpoint, status, funding_index, amount_duffs", ), - ( - "dpns_name_states.rs", - "SELECT document_id, identity_id, label, normalized_label", - ), ]; /// TC-P1-003: writer paths in `src/sqlite/schema/*.rs` must not call diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 7dcd3bee816..9b245817057 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -5,11 +5,12 @@ use std::error::Error as StdError; -use crate::changeset::changeset::PlatformWalletChangeSet; +use crate::changeset::changeset::{DpnsNameStateEntry, PlatformWalletChangeSet}; use crate::changeset::client_start_state::ClientStartState; use crate::changeset::persistence_capabilities::PersistenceCapabilities; use crate::wallet::platform_wallet::WalletId; use dashcore::Txid; +use dpp::prelude::Identifier; use key_wallet::managed_account::transaction_record::TransactionRecord; /// One row of [`PlatformWalletPersistence::list_wallet_core_txids`]: @@ -407,6 +408,106 @@ pub trait PlatformWalletPersistence: Send + Sync { Ok(None) } + /// Look up the persisted DPNS marketplace row for one + /// `(wallet_id, wallet_identity_id, normalized_label)` triple. + /// + /// Used by the DPNS marketplace sync pass to recover a departed + /// name's `document_id` when the in-memory working set cannot + /// supply it. + /// + /// ## Why this read has to exist + /// + /// `PlatformWalletInfo::dpns_name_states` — the map the sync pass + /// diffs against — is **session-scoped**: the load path initializes + /// it EMPTY on every process start and nothing rehydrates it (the + /// SQLite backend deliberately does not attest + /// [`PersistenceCapabilities::WALLET_RESTORE`](crate::changeset::PersistenceCapabilities), + /// and `ClientStartState::wallets` is still unimplemented). The + /// durable copy is the host mirror this trait feeds — Swift + /// `PersistentDPNSName`, the Android Room `dpns_names` table. + /// + /// Marketplace rows are keyed by `document_id`, but a departure is + /// only ever *detected* by label: the sync pass notices a label on + /// the identity's list that the ownership scan no longer returns. + /// Turning that label back into the `document_id` the removal delta + /// must carry is precisely what the in-memory map was doing — so + /// when a name departs during the FIRST sync pass after a restart, + /// the map is empty, the id is unknown, and no removal is emitted. + /// The label is dropped locally all the same, so the departure never + /// comes back around on a later pass: the host mirror keeps a stale + /// owned/listed row for a name the wallet no longer holds, forever. + /// This lookup closes that hole by asking the mirror itself. + /// + /// ## Contract + /// + /// - `normalized_label` is the homograph-normalized label + /// (`convert_to_homograph_safe_chars`), matching + /// [`DpnsNameStateEntry::normalized_label`] as written through + /// [`Self::store`]. Implementations MUST compare against the + /// stored normalized column, never the display label. + /// - The returned row MUST belong to `wallet_id` AND carry + /// `wallet_identity_id == wallet_identity_id`. Rows for a name + /// that already left the wallet are retained (`Sold` / + /// `Transferred`), so an implementation that dropped the identity + /// filter could hand back another identity's row and remove the + /// wrong document. + /// - Several rows CAN match, and WHICH one is returned matters. A + /// name can be deleted and re-registered under a fresh + /// `document_id`, while rows for names that already left are + /// retained, so one identity may hold a historical row and the + /// current row under the same normalized label. Implementations + /// MUST return the CURRENT row, chosen deterministically: + /// [`Owned`](crate::changeset::DpnsNameSaleStatus::Owned) ahead of + /// any retained + /// [`Sold`](crate::changeset::DpnsNameSaleStatus::Sold) / + /// [`Transferred`](crate::changeset::DpnsNameSaleStatus::Transferred) + /// row, then the greatest + /// [`DpnsNameStateEntry::last_synced_at_ms`], then the + /// greatest `document_id` as a final tie-break. Returning an + /// arbitrary match is NOT acceptable: a historical row makes the + /// caller delete that row and drop the identity's label, leaving + /// the current row permanently orphaned — the very failure this + /// lookup exists to prevent, just aimed at the wrong row. The + /// SQLite backend implements exactly this order; see + /// `schema::dpns_name_states::get_by_identity_and_label`. + /// - Returning `Ok(None)` while a matching row exists is likewise + /// not acceptable — that reintroduces the orphan. + /// + /// ## Default + /// + /// `Ok(None)` — "this backend does not index DPNS rows by label". + /// Backends without the lookup keep the default and the caller + /// degrades to exactly the pre-fallback behaviour: the departure is + /// still classified and the label still removed, only the removal + /// delta is skipped — the restart orphan described above remains. + /// `Ok(None)` is therefore never an error condition — it means "no + /// better answer than the in-memory map already gave". + /// + /// ## Who actually implements this + /// + /// Today: `SqlitePersister` only. + /// [`NoPlatformPersistence`](crate::wallet::persister::NoPlatformPersistence) + /// keeps the default by design. So does the FFI persister — and not + /// as a host choice: the persistence vtable has NO read slot for + /// this lookup, so there is no callback a host could set. The + /// mobile mirrors that persist + /// [`DpnsNameStateChangeSet`](crate::changeset::DpnsNameStateChangeSet) + /// rows (the Android Room `dpns_names` table, the iOS SwiftData + /// `PersistentDPNSName`) hold exactly the row this method asks for, + /// but cannot be asked for it until a `get_dpns_name_state` read + /// callback is added to the vtable — planned with the other batched + /// vtable/ABI additions, deliberately not part of the change that + /// introduced this method. Until then the restart orphan is closed + /// on SQLite-backed hosts only and is still live on FFI hosts. + fn get_dpns_name_state( + &self, + _wallet_id: WalletId, + _wallet_identity_id: &Identifier, + _normalized_label: &str, + ) -> Result, PersistenceError> { + Ok(None) + } + // TODO: `list_wallets` and `delete_wallet` are deferred contract // candidates. They live as inherent methods on the SQLite backend // today; they may return to this trait once a cross-backend contract diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 68ff854db93..309a30f8800 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -48,7 +48,9 @@ use dash_sdk::platform::{DocumentQuery, FetchMany}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use crate::changeset::{DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry}; +use crate::changeset::{ + DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, PersistenceError, +}; use crate::error::PlatformWalletError; use crate::wallet::identity::types::key_storage::DpnsNameInfo; @@ -253,6 +255,25 @@ pub struct DpnsPriceChange { pub current: Option, } +/// A queued departure whose resolution failed terminally this pass. +/// +/// Emitted when the persistence lookup for the departed name's +/// `document_id` fails with a NON-retryable error while Platform +/// confirms the domain document is absent: the removal delta cannot be +/// built and retrying cannot make it buildable, so the departure is NOT +/// resolved — the identity keeps its label (which is what lets a later +/// pass re-detect the departure once the backend is repaired) and +/// nothing is written or removed. Reported on the summary so a pass +/// that had to skip a departure is distinguishable from a pass that +/// resolved everything. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FailedDpnsDeparture { + pub identity_id: Identifier, + pub label: String, + /// Rendered [`PersistenceError`] (the typed error is not cloneable). + pub error: String, +} + /// Summary of one [`IdentityWallet::sync_dpns_marketplace`] pass. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DpnsMarketplaceSyncSummary { @@ -262,6 +283,10 @@ pub struct DpnsMarketplaceSyncSummary { pub names_added: Vec<(Identifier, String)>, /// Names that left a wallet identity since the local snapshot. pub names_departed: Vec, + /// Departures whose resolution failed terminally this pass; their + /// labels and durable rows are untouched, so a later pass retries. + /// See [`FailedDpnsDeparture`]. + pub departures_failed: Vec, /// Listed-price changes since the local snapshot. pub prices_changed: Vec, /// Wall-clock ms at which the pass completed. @@ -384,6 +409,170 @@ fn insert_sync_row(rows: &mut BTreeMap, entry: D } } +/// Resolve the `document_id` of the name `label` as last tracked for +/// `identity_id` — the id a departure's removal delta has to carry. +/// +/// Two sources, in order: +/// +/// 1. `previous_rows`, the snapshot of the in-memory working set taken +/// at the top of the sync pass. Authoritative when populated, and +/// free. Retained `Sold`/`Transferred` history can coexist with the +/// current row under one normalized label, so when several rows +/// match, the CURRENT one is chosen with the same deterministic +/// preference the persistence contract requires of backends: +/// `Owned` ahead of retained history, then the greatest +/// `last_synced_at_ms`, then the greatest `document_id`. +/// 2. The persister — when the snapshot has nothing AND the backend +/// actually implements `get_dpns_name_state`. Today that is the +/// SQLite backend only. `FFIPersister` has no read slot for this +/// lookup in its vtable — there is no callback a host could set — +/// so on the mobile hosts (the Android Room and iOS SwiftData +/// mirrors) the trait's `Ok(None)` default answers, step 2 finds +/// nothing, and the restart orphan described below is STILL LIVE +/// there. That holds until a `get_dpns_name_state` read callback is +/// added to the persistence vtable, planned with the other batched +/// vtable/ABI additions rather than piecemeal. +/// +/// Step 2 is not belt-and-braces; it is the only source that survives a +/// restart. `PlatformWalletInfo::dpns_name_states` is session-scoped: +/// the load path builds it EMPTY and nothing rehydrates it (see +/// [`IdentityWallet::local_dpns_name_states`]). A name that departs +/// during the FIRST sync pass after a process start therefore finds an +/// empty snapshot, and without this fallback the pass emits no removal +/// while still dropping the label — the host mirror is left holding an +/// owned/listed row that no later pass will ever revisit, because the +/// label that triggers departure detection is gone. +/// +/// This function reports the lookup, it does not decide policy. Notably +/// it does NOT flatten `Err` into `Ok(None)`: those two outcomes call +/// for opposite handling and the caller +/// ([`IdentityWallet::resolve_departed_name`]) is the one placed to +/// tell them apart. +/// +/// - `Ok(None)` — the backend answered, and either does not index DPNS +/// rows by label or holds no row. Nothing better is coming; proceed. +/// - `Err(_)` — a read was attempted and failed. Whether a row exists +/// is UNKNOWN, so treating it as `Ok(None)` would let a confirmed +/// Platform absence remove the label with no removal delta behind it, +/// orphaning the durable row for good. +fn previous_document_id_for( + persister: &crate::wallet::persister::WalletPersister, + identity_id: &Identifier, + label: &str, + previous_rows: &BTreeMap, +) -> Result, PersistenceError> { + let normalized_label = convert_to_homograph_safe_chars(label); + // Several snapshot rows can match: the map is keyed by document id + // and retains `Sold`/`Transferred` history, so one identity can + // hold a historical row AND the current row under the same + // normalized label (delete + re-register). A first-match in + // document-id order could hand back the historical row — removing + // it would drop the identity's label while orphaning the actual + // current row, and any hit here also prevents the (corrected) + // persistence fallback from running. Apply the same deterministic + // current-row preference the persistence contract demands of + // `get_dpns_name_state` implementations. + let in_memory = previous_rows + .values() + .filter(|entry| { + entry.wallet_identity_id == *identity_id && entry.normalized_label == normalized_label + }) + .max_by_key(|entry| { + ( + matches!(entry.status, DpnsNameSaleStatus::Owned), + entry.last_synced_at_ms, + entry.document_id, + ) + }) + .map(|entry| entry.document_id); + if in_memory.is_some() { + return Ok(in_memory); + } + Ok(persister + .get_dpns_name_state(identity_id, &normalized_label)? + .map(|entry| entry.document_id)) +} + +/// The exact-match domain query behind [`IdentityWallet::dpns_name_state`]: +/// one document, keyed on the parent domain plus the normalized label. +/// +/// A named builder rather than an inline literal so a test can construct +/// the identical query when priming a mock SDK — an expectation is keyed +/// by the encoded request, so a hand-copied duplicate that drifted would +/// silently stop matching and leave the test asserting nothing. +fn domain_by_normalized_label_query( + contract: Arc, + normalized_label: String, +) -> DocumentQuery { + DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: DPNS_DOCUMENT_TYPE.to_string(), + where_clauses: vec![ + WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(DPNS_PARENT_DOMAIN.to_string()), + }, + WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(normalized_label), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + } +} + +/// One server page of the Document History `byDocument` query behind +/// [`IdentityWallet::fetch_history_documents`]: history documents of one +/// type for one source document, in ascending creation order. +/// +/// A named builder for the same reason as +/// [`domain_by_normalized_label_query`]: a mock-SDK expectation is keyed +/// by the encoded request, so a test priming the history lookup must +/// construct the exact query the production path issues, and a +/// hand-copied duplicate that drifted would silently stop matching. +fn history_by_source_document_query( + contract: Arc, + history_doc_type: &str, + source_contract_id: &Identifier, + source_document_id: &Identifier, + start: Option, +) -> DocumentQuery { + DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: history_doc_type.to_string(), + where_clauses: vec![ + WhereClause { + field: "dataContractId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(source_contract_id.to_buffer()), + }, + WhereClause { + field: "documentId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(source_document_id.to_buffer()), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }], + limit: HISTORY_QUERY_LIMIT, + offset: None, + start, + } +} + fn direct_departure_candidate( event: DpnsNameHistoryEvent, departing_identity: &Identifier, @@ -401,11 +590,38 @@ fn direct_departure_candidate( } } +/// How [`IdentityWallet::resolve_departed_name`] left one queued +/// departure, and therefore what the sync loop is allowed to do with it. +#[derive(Debug)] +enum DepartureResolution { + /// Fully resolved: the caller may drop the identity's label and + /// apply the row deltas. + Resolved, + /// Transient failure (network read, history classification, or a + /// retryable persistence read): the caller requeues the departure at + /// the front and breaks; the next pass retries with the label and + /// the durable row untouched. + Retry, + /// Terminal per-item failure: a NON-retryable persistence read + /// failure at a point where the recovered document id is + /// load-bearing (a confirmed-absent document's removal delta, a + /// live history-unrelated document's choice of WHICH row departs, + /// or a classified departure's prior-incarnation retirement), so + /// the right deltas are unknown and will not become known by + /// retrying. The caller MUST NOT take the + /// successful-departure path — no label drop, no deltas, no entry in + /// `names_departed` — because the retained label is the only trigger + /// that lets a later pass re-detect the departure and finish the + /// removal once the backend is repaired. Surfaced on the summary as + /// a [`FailedDpnsDeparture`] rather than silently swallowed. + Failed(PersistenceError), +} + struct ResolvedDepartedName { summary: DepartedDpnsName, entry: Option, remove_document_id: Option, - retry: bool, + resolution: DepartureResolution, } impl IdentityWallet { @@ -472,30 +688,11 @@ impl IdentityWallet { "DPNS name must not be empty".to_string(), )); } - let query = DocumentQuery { - select: SelectProjection::documents(), - data_contract: contract, - document_type_name: DPNS_DOCUMENT_TYPE.to_string(), - where_clauses: vec![ - WhereClause { - field: "normalizedParentDomainName".to_string(), - operator: WhereOperator::Equal, - value: Value::Text(DPNS_PARENT_DOMAIN.to_string()), - }, - WhereClause { - field: "normalizedLabel".to_string(), - operator: WhereOperator::Equal, - value: Value::Text(normalized), - }, - ], - group_by: vec![], - having: vec![], - order_by_clauses: vec![], - limit: 1, - offset: None, - start: None, - }; - Ok(self.fetch_domain_states(query).await?.into_iter().next()) + Ok(self + .fetch_domain_states(domain_by_normalized_label_query(contract, normalized)) + .await? + .into_iter() + .next()) } /// Fetch the domain documents associated with `identity_id` via the @@ -849,11 +1046,23 @@ impl IdentityWallet { /// List (or re-price) `name` for sale at `price` credits. /// - /// Pre-flight: the name must resolve to a domain document owned by - /// `owner_identity_id` (typed contested/not-found errors otherwise). - /// The signing key is auto-selected on the owner. On success the - /// local sale state is persisted from the confirmed document and the - /// updated state returned. + /// Pre-flight: `price` must be non-zero (see below); the name must + /// resolve to a domain document owned by `owner_identity_id` (typed + /// contested/not-found errors otherwise). The signing key is + /// auto-selected on the owner. On success the local sale state is + /// persisted from the confirmed document and the updated state + /// returned. + /// + /// **`price == 0` is rejected** with + /// [`PlatformWalletError::InvalidParameter`] before any network + /// work. Consensus would accept the listing, and it would then be + /// purchasable by anyone for nothing — the name is gone, credited + /// zero, and only a delist (or a race the owner loses) undoes it. + /// There is no legitimate caller: a deliberate free handover is + /// [`Self::transfer_dpns_name`], which names the recipient. A zero + /// reaching here is a host-side bug or a fat-fingered amount field, + /// so it fails loudly rather than broadcasting an irreversible + /// giveaway. pub async fn set_dpns_name_price( &self, owner_identity_id: &Identifier, @@ -864,6 +1073,16 @@ impl IdentityWallet { where S: Signer + Send + Sync, { + // Ahead of the operation gate and the domain fetch: nothing about + // the on-chain state can make a zero-credit listing valid, so no + // lock is worth holding and no round-trip is worth spending. + if price == 0 { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} cannot be listed at 0 credits — a zero price is not a \ + sale, it lets anyone take the name for free. Use transfer_dpns_name to \ + hand it over deliberately, or list at a non-zero price." + ))); + } let _operation = self.dpns_operation_gate.lock().await; let state = self.fetch_dpns_domain_state_required(name).await?; if state.owner_id != *owner_identity_id { @@ -1035,6 +1254,9 @@ impl IdentityWallet { /// /// Pre-flight, all typed: name resolution (contested-aware), a /// self-purchase guard, [`PlatformWalletError::DocumentNotForSale`], + /// [`PlatformWalletError::InvalidParameter`] for a `$price` of 0 + /// (not a valid listing — see [`Self::set_dpns_name_price`], which + /// refuses to create one), /// [`PlatformWalletError::DocumentPriceChanged`] when the listing no /// longer matches `expected_price`, and /// [`PlatformWalletError::InsufficientIdentityCredits`] when the @@ -1073,16 +1295,7 @@ impl IdentityWallet { "identity {purchaser_identity_id} already owns DPNS name {name:?}" ))); } - let listed_price = state.price.ok_or(PlatformWalletError::DocumentNotForSale { - document_id: state.document_id, - })?; - if listed_price != expected_price { - return Err(PlatformWalletError::DocumentPriceChanged { - document_id: state.document_id, - expected: expected_price, - actual: listed_price, - }); - } + preflight_purchase_price(&state, name, expected_price)?; // Credit pre-flight against the local balance snapshot: Platform // deducts the price as principal first, then the processing fee // must fit in the remainder. The consensus-side @@ -1250,32 +1463,13 @@ impl IdentityWallet { let mut cursor: Option = None; loop { - let query = DocumentQuery { - select: SelectProjection::documents(), - data_contract: Arc::clone(&contract), - document_type_name: history_doc_type.to_string(), - where_clauses: vec![ - WhereClause { - field: "dataContractId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(source_contract_id.to_buffer()), - }, - WhereClause { - field: "documentId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(source_document_id.to_buffer()), - }, - ], - group_by: vec![], - having: vec![], - order_by_clauses: vec![OrderClause { - field: "$createdAt".to_string(), - ascending: true, - }], - limit: HISTORY_QUERY_LIMIT, - offset: None, - start: cursor.map(|id| Start::StartAfter(id.to_vec())), - }; + let query = history_by_source_document_query( + Arc::clone(&contract), + history_doc_type, + source_contract_id, + source_document_id, + cursor.map(|id| Start::StartAfter(id.to_vec())), + ); let documents = Document::fetch_many(&self.sdk, query).await.map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to fetch {history_doc_type} history documents: {e}" @@ -1459,9 +1653,29 @@ impl IdentityWallet { let resolved = self .resolve_departed_name(&identity_id, &previous_name.label, &previous_rows, now) .await; - if resolved.retry { - progress.pending_departures.push_front(previous_name); - break; + match resolved.resolution { + DepartureResolution::Retry => { + progress.pending_departures.push_front(previous_name); + break; + } + // Terminal for this pass, but NOT resolved: the label + // stays (so a later scan re-detects the departure and + // requeues it once the backend is repaired), no deltas + // are applied, and the failure is surfaced on the + // summary. Deliberately not requeued in + // `pending_departures`: retrying a non-retryable + // failure within this process cannot succeed, and the + // retained label already guarantees re-detection. + DepartureResolution::Failed(error) => { + departures_processed += 1; + summary.departures_failed.push(FailedDpnsDeparture { + identity_id, + label: previous_name.label, + error: error.to_string(), + }); + continue; + } + DepartureResolution::Resolved => {} } departures_processed += 1; self.remove_dpns_label(&identity_id, &previous_name.label) @@ -1521,6 +1735,57 @@ impl IdentityWallet { /// A confirmed missing document removes the stale local row. A /// transport/query error requests a retry and leaves both the label /// and local row untouched. + /// + /// A live document whose history never departs this identity removes + /// the identity's own RECOVERED row, which is the live document only + /// when the two ids match: DPNS domain documents are deletable, and + /// a label re-registered under a fresh document id leaves the live + /// document belonging to the replacement owner, so the removal must + /// target the recovered prior incarnation and leave the replacement + /// untouched. + /// + /// A classified Sold/Transferred departure writes the live + /// document's historical row; when the recovered prior incarnation + /// sits under a different id (the label cycled through this identity + /// after a delete + re-registration), that prior row is retired in + /// the same changeset — the label drop would otherwise orphan it. + /// + /// The removal delta needs the departed name's `document_id`, which + /// [`previous_document_id_for`] resolves from the in-memory snapshot + /// and — when that is empty, as it always is on the first pass after + /// a process start — from the persister, on backends that implement + /// the lookup (SQLite today; FFI hosts have no read slot yet and + /// still resolve nothing — see [`previous_document_id_for`]). + /// + /// A FAILED persistence read is treated like a failed network read, + /// not like "no row": a transient error requests a retry and leaves + /// the label, the pending departure and the durable row untouched. + /// The alternative — carrying on with no id — is the worst of the + /// available outcomes, because the very next step can be a confirmed + /// Platform absence, which removes the label with no removal delta + /// behind it; the label is what triggers departure detection, so + /// nothing ever revisits that row and the mirror keeps an + /// owned/listed row for a name the wallet no longer holds, forever. + /// Retrying costs one more pass; getting it wrong costs the row. + /// + /// A NON-transient persistence error (`Fatal` / `Constraint` / + /// `LockPoisoned`) cannot be retried into success, so it must not + /// park the departure queue — but it does not establish that no + /// durable row exists, either. The error is HELD until the pass + /// learns whether the id is actually needed: the retry arms resolve + /// without it, while every branch that consumes it — a + /// confirmed-absent domain document, a live one whose history never + /// departs this identity (where the recovered id decides WHICH row + /// is removed), or a classified Sold/Transferred departure (where it + /// decides whether a prior incarnation must be retired) — + /// turns the held error into a + /// terminal per-item failure ([`DepartureResolution::Failed`]) — + /// the label and the durable row are preserved and the failure is + /// surfaced on the sync summary, so the still-present label lets a + /// later pass re-detect the departure and finish the removal once + /// the backend is repaired, instead of the old degrade-to-`None` + /// path resolving the departure with no removal delta and orphaning + /// the persisted row for good. async fn resolve_departed_name( &self, identity_id: &Identifier, @@ -1528,16 +1793,79 @@ impl IdentityWallet { previous_rows: &BTreeMap, now: u64, ) -> ResolvedDepartedName { - let previous_document_id = previous_rows - .values() - .find(|entry| { - entry.wallet_identity_id == *identity_id - && entry.normalized_label == convert_to_homograph_safe_chars(label) - }) - .map(|entry| entry.document_id); + let previous_document_id = + match previous_document_id_for(&self.persister, identity_id, label, previous_rows) { + Ok(document_id) => Ok(document_id), + Err(error) if error.is_transient() => { + tracing::warn!( + identity = %identity_id, + name = label, + "persisted DPNS row lookup failed transiently for a departed name; \ + retaining the departure for the next sync pass rather than \ + resolving it without a removal delta: {error}" + ); + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: None, + status: None, + }, + entry: None, + remove_document_id: None, + resolution: DepartureResolution::Retry, + }; + } + // Non-retryable: HOLD the error instead of acting on it. + // Whether it matters depends on what Platform says next — + // every RESOLVED outcome consumes the recovered id (as + // the removal delta under a confirmed-absent or + // history-unrelated live document, or to decide whether a + // classified departure must retire a prior incarnation), + // but the retry arms (domain fetch, history + // classification) fire before the id is read, and failing + // them over a broken read slot would turn an ordinary + // network retry into a terminal failure. + Err(error) => Err(error), + }; let state = match self.dpns_name_state(label).await { Ok(Some(state)) => state, Ok(None) => { + let previous_document_id = match previous_document_id { + Ok(document_id) => document_id, + // Platform confirms the document is gone, but the + // persistence read failed non-retryably: whether a + // durable row exists — and under which id — is + // UNKNOWN. Resolving anyway would drop the label (the + // only trigger for future departure detection) while + // emitting no removal delta, orphaning any persisted + // row for good and reporting a successful sync over + // it. Fail this one departure instead: the label and + // the durable row survive, so the departure is + // re-detected on a later pass and completes once the + // backend is repaired. + Err(error) => { + tracing::warn!( + identity = %identity_id, + name = label, + "persisted DPNS row lookup failed unrecoverably for a departed \ + name whose domain document is confirmed absent; preserving the \ + label and any durable row rather than resolving the departure \ + without a removal delta: {error}" + ); + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: None, + status: None, + }, + entry: None, + remove_document_id: None, + resolution: DepartureResolution::Failed(error), + }; + } + }; return ResolvedDepartedName { summary: DepartedDpnsName { identity_id: *identity_id, @@ -1547,7 +1875,7 @@ impl IdentityWallet { }, entry: None, remove_document_id: previous_document_id, - retry: false, + resolution: DepartureResolution::Resolved, }; } Err(error) => { @@ -1560,12 +1888,14 @@ impl IdentityWallet { summary: DepartedDpnsName { identity_id: *identity_id, label: label.to_string(), - document_id: previous_document_id, + // Informational only; a held non-transient + // persistence error reads as "id unknown" here. + document_id: previous_document_id.ok().flatten(), status: None, }, entry: None, remove_document_id: None, - retry: true, + resolution: DepartureResolution::Retry, }; } }; @@ -1590,20 +1920,130 @@ impl IdentityWallet { }, entry: None, remove_document_id: None, - retry: true, + resolution: DepartureResolution::Retry, + }; + } + }; + let Some(sale_status) = status else { + // No history event departs THIS identity from the live + // document. Usually the live document IS the identity's own + // row with its ownership rewritten out from under it, and + // removing the live id retires the right row. But DPNS + // domain documents are deletable, and a label can be + // re-registered under a fresh document id: when the + // RECOVERED prior id differs from the live document's, the + // live document is that replacement — a document this + // identity never held — while the identity's own durable + // row still sits under the prior id. Removing the live id + // would drop the wrong row AND orphan the durable one for + // good, because the label this removal resolves is the only + // trigger that would ever revisit it. Resolve the prior + // incarnation instead: report and remove the recovered id, + // and leave the replacement untouched. + let departed_document_id = match previous_document_id { + Ok(previous_id) => previous_id.unwrap_or(state.document_id), + // The held non-retryable persistence error turns out to + // be load-bearing: with a live, history-unrelated + // document on the label, WHICH row departs depends on + // the recovered id, so resolving without it would + // either remove the replacement's document id or orphan + // the identity's durable row. Fail this one departure + // exactly like the confirmed-absent branch: the label + // and the durable row survive, the failure is surfaced + // on the summary, and a later pass finishes the removal + // once the backend is repaired. + Err(error) => { + tracing::warn!( + identity = %identity_id, + name = label, + document = %state.document_id, + "persisted DPNS row lookup failed unrecoverably for a departed \ + name whose label carries a live, history-unrelated domain \ + document; preserving the label and any durable row rather \ + than guessing which row the removal delta targets: {error}" + ); + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: None, + status: None, + }, + entry: None, + remove_document_id: None, + resolution: DepartureResolution::Failed(error), + }; + } + }; + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: Some(departed_document_id), + status: None, + }, + entry: None, + remove_document_id: Some(departed_document_id), + resolution: DepartureResolution::Resolved, + }; + }; + // A classified departure normally concerns the identity's own + // durable row: the historical entry is keyed by the live + // document's id, so writing it replaces that row in place and no + // separate removal is needed. But the recovered PRIOR incarnation + // may sit under a DIFFERENT id — persisted document A was + // deleted, the label was re-registered as B, and B itself passed + // through this identity and departed, all before this pass. The + // entry then lands under B while the durable row stays under A, + // and the caller drops the label — the only trigger that would + // ever revisit A — so A would survive as `Owned` forever. Retire + // the recovered incarnation alongside the classified entry + // whenever its id differs from the live document's. + let previous_document_id = match previous_document_id { + Ok(document_id) => document_id, + // The recovered id decides whether a prior incarnation must + // be retired with this entry, so the held non-retryable + // persistence error is load-bearing here exactly as in the + // other id-consuming branches: resolving without it could + // orphan an unknown prior row. Fail this one departure; the + // label and any durable row survive, and a later pass + // completes the retirement once the backend is repaired. + Err(error) => { + tracing::warn!( + identity = %identity_id, + name = label, + document = %state.document_id, + "persisted DPNS row lookup failed unrecoverably for a classified \ + departure; preserving the label and any prior durable row rather \ + than resolving without knowing which incarnation to retire: {error}" + ); + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: None, + status: None, + }, + entry: None, + remove_document_id: None, + resolution: DepartureResolution::Failed(error), }; } }; + let remove_document_id = match previous_document_id { + Some(document_id) if document_id != state.document_id => Some(document_id), + _ => None, + }; ResolvedDepartedName { summary: DepartedDpnsName { identity_id: *identity_id, label: label.to_string(), document_id: Some(state.document_id), - status, + status: Some(sale_status), }, - entry: status.map(|sale_status| state.to_entry(*identity_id, sale_status, now)), - remove_document_id: status.is_none().then_some(state.document_id), - retry: false, + entry: Some(state.to_entry(*identity_id, sale_status, now)), + remove_document_id, + resolution: DepartureResolution::Resolved, } } @@ -1710,6 +2150,51 @@ fn history_event_from_document( }) } +/// The listing-side pre-flight of [`IdentityWallet::purchase_dpns_name`], +/// as a pure decision over the freshly fetched domain state. +/// +/// Three typed rejections, and the ORDER is part of the contract the +/// method's API documentation promises: +/// +/// 1. no `$price` at all → [`PlatformWalletError::DocumentNotForSale`]; +/// 2. a `$price` of exactly 0 → +/// [`PlatformWalletError::InvalidParameter`]. Not a listing this +/// wallet will act on — see [`IdentityWallet::set_dpns_name_price`], +/// which refuses to create one. Checked BEFORE the `expected_price` +/// comparison so the caller is told the listing itself is not +/// purchasable, rather than being told the price moved (which would +/// invite a retry at 0 that can never succeed); +/// 3. anything else that differs from `expected_price` → +/// [`PlatformWalletError::DocumentPriceChanged`]. +/// +/// Only `== 0` is special-cased; every `> 0` listing takes the unchanged +/// price-match path. Extracted from the `async` method so the ordering +/// can be pinned directly, without a live Platform to serve the domain +/// fetch that precedes it. +fn preflight_purchase_price( + state: &DpnsDomainState, + name: &str, + expected_price: Credits, +) -> Result<(), PlatformWalletError> { + let listed_price = state.price.ok_or(PlatformWalletError::DocumentNotForSale { + document_id: state.document_id, + })?; + if listed_price == 0 { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} carries a listed price of 0 credits, which is not a \ + valid sale listing and will not be purchased by this wallet" + ))); + } + if listed_price != expected_price { + return Err(PlatformWalletError::DocumentPriceChanged { + document_id: state.document_id, + expected: expected_price, + actual: listed_price, + }); + } + Ok(()) +} + fn required_purchase_credits(expected_price: Credits) -> Result { expected_price .checked_add(DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS) @@ -1826,4 +2311,1691 @@ mod tests { ); assert_eq!(direct_departure_candidate(later_transfer, &seller), None); } + + // ----------------------------------------------------------------- + // Departed-name document-id recovery + // + // The bug these cover: `info.dpns_name_states` is session-scoped and + // starts EMPTY on every process start (the load path builds it that + // way and nothing rehydrates it). A name that departs during the + // FIRST sync pass after a restart therefore had no in-memory row to + // resolve its `document_id` from — so the pass emitted no removal + // delta while still dropping the label, and the host's persisted + // mirror kept an owned/listed row for a name the wallet no longer + // holds, with nothing left to ever trigger its removal. + // ----------------------------------------------------------------- + + use crate::changeset::{ + ClientStartState, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::wallet::persister::WalletPersister; + use crate::wallet::platform_wallet::WalletId; + + const MIRROR_WALLET_ID: WalletId = [0x7A; 32]; + /// Display label whose homograph normalization is visibly different + /// ("Alice" → "a11ce"), so a reader that forgot to normalize — or + /// normalized the wrong string — cannot pass by accident. + const DEPARTED_LABEL: &str = "Alice"; + + /// Stand-in for the durable host mirror (Swift `PersistentDPNSName`, + /// the Android Room `dpns_names` table, the SQLite + /// `dpns_name_states` table) that survives a process restart. + /// + /// Answers `get_dpns_name_state` from a hydrated map keyed exactly + /// as the trait contract specifies — `(wallet_identity_id, + /// normalized_label)` — and records every lookup, so a test can + /// assert both whether the fallback was consulted and what key it + /// was consulted with. + struct MirrorPersister { + rows: BTreeMap<(Identifier, String), DpnsNameStateEntry>, + lookups: std::sync::Mutex>, + /// `Some(kind)` makes every read fail with that retry + /// classification; [`Self::heal`] clears it so a later pass sees + /// a working backend, which is how the retry arm is proven to + /// actually make progress rather than merely defer forever. + fail: std::sync::Mutex>, + /// Every DPNS name-state changeset handed to [`Self::store`], in + /// order — the row deltas a real host would apply to its durable + /// mirror. Lets a test assert not merely what a sync summary + /// CLAIMS but what actually reached the persistence boundary. + stored_dpns: std::sync::Mutex>, + } + + impl MirrorPersister { + fn hydrated(rows: Vec) -> Self { + Self { + rows: rows + .into_iter() + .map(|entry| { + ( + (entry.wallet_identity_id, entry.normalized_label.clone()), + entry, + ) + }) + .collect(), + lookups: std::sync::Mutex::new(Vec::new()), + fail: std::sync::Mutex::new(None), + stored_dpns: std::sync::Mutex::new(Vec::new()), + } + } + + /// Holds `rows`, but every read fails with `kind` until + /// [`Self::heal`] is called. + fn hydrated_but_failing(rows: Vec, kind: PersistenceErrorKind) -> Self { + let mirror = Self::hydrated(rows); + *mirror.fail.lock().expect("fail switch") = Some(kind); + mirror + } + + fn failing_with(kind: PersistenceErrorKind) -> Self { + Self::hydrated_but_failing(Vec::new(), kind) + } + + /// The backend recovers: subsequent reads answer from `rows`. + fn heal(&self) { + *self.fail.lock().expect("fail switch") = None; + } + + fn lookups(&self) -> Vec<(WalletId, Identifier, String)> { + self.lookups.lock().expect("lookup log").clone() + } + + /// Every document id a stored DPNS name-state delta removed, in + /// store order. + fn stored_dpns_removals(&self) -> Vec { + self.stored_dpns + .lock() + .expect("stored log") + .iter() + .flat_map(|cs| cs.removed.iter().copied()) + .collect() + } + } + + impl PlatformWalletPersistence for MirrorPersister { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if let Some(cs) = changeset.dpns_name_states { + self.stored_dpns.lock().expect("stored log").push(cs); + } + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + + fn get_dpns_name_state( + &self, + wallet_id: WalletId, + wallet_identity_id: &Identifier, + normalized_label: &str, + ) -> Result, PersistenceError> { + self.lookups.lock().expect("lookup log").push(( + wallet_id, + *wallet_identity_id, + normalized_label.to_string(), + )); + if let Some(kind) = *self.fail.lock().expect("fail switch") { + return Err(PersistenceError::backend_with_kind( + kind, + "simulated mirror read failure", + )); + } + Ok(self + .rows + .get(&(*wallet_identity_id, normalized_label.to_string())) + .cloned()) + } + } + + /// A persisted row for `DEPARTED_LABEL` owned by `identity_id`, as the + /// host mirror would hold it after a previous session's sync pass. + fn mirrored_row(document_id: Identifier, identity_id: Identifier) -> DpnsNameStateEntry { + let mut entry = name_state_entry(document_id, identity_id, DpnsNameSaleStatus::Owned); + entry.label = DEPARTED_LABEL.to_string(); + entry.normalized_label = convert_to_homograph_safe_chars(DEPARTED_LABEL); + entry + } + + fn mirror_wallet_persister(mirror: Arc) -> WalletPersister { + WalletPersister::new(MIRROR_WALLET_ID, mirror) + } + + /// Sanity-check the fixture itself: if normalization were a no-op for + /// `DEPARTED_LABEL`, the "looked the row up by its NORMALIZED label" + /// assertion below would hold vacuously. + #[test] + fn departed_label_fixture_actually_normalizes() { + assert_eq!(convert_to_homograph_safe_chars(DEPARTED_LABEL), "a11ce"); + assert_ne!( + convert_to_homograph_safe_chars(DEPARTED_LABEL), + DEPARTED_LABEL + ); + } + + /// Steady state (any pass after the first): the in-memory snapshot has + /// the row, so the persister is never touched. Pins that the fallback + /// is a fallback — not an extra read on the hot path. + #[test] + fn departed_document_id_uses_the_in_memory_row_without_reading_the_persister() { + let document_id = Identifier::from([0x11; 32]); + let identity_id = Identifier::from([0x22; 32]); + let row = mirrored_row(document_id, identity_id); + + // The mirror also holds a row — for a DIFFERENT document — so a + // wrong-source regression would return the wrong id, not None. + let mirror = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + Identifier::from([0xEE; 32]), + identity_id, + )])); + let persister = mirror_wallet_persister(Arc::clone(&mirror)); + + let mut previous_rows = BTreeMap::new(); + previous_rows.insert(document_id, row); + + assert_eq!( + previous_document_id_for(&persister, &identity_id, DEPARTED_LABEL, &previous_rows) + .expect("the in-memory hit must not consult the persister at all"), + Some(document_id) + ); + assert!( + mirror.lookups().is_empty(), + "a populated in-memory snapshot must not trigger a persistence read" + ); + } + + /// `previous_rows` is keyed by document id and retains + /// `Sold`/`Transferred` history, so one identity can hold a + /// historical row AND the current `Owned` row under the same + /// normalized label (delete + re-register). The in-memory selection + /// must prefer the CURRENT row with the same deterministic ordering + /// the persistence contract demands of backends — `Owned` first — + /// in BOTH document-id orders: a first-match scan in map order + /// returns whichever row sorts first, and picking the historical + /// one makes recovery remove it, drop the identity's label, and + /// orphan the actual current row for good. + #[test] + fn departed_document_id_prefers_the_current_owned_row_in_the_snapshot() { + let identity_id = Identifier::from([0x22; 32]); + let buyer = Identifier::from([0x99; 32]); + + for (historical_doc, owned_doc) in [ + // Historical row FIRST in map order: the first-match scan + // returns it — this is the orphaning bug. + (Identifier::from([0x01; 32]), Identifier::from([0x02; 32])), + // And the reverse, so passing by iteration luck is impossible. + (Identifier::from([0x03; 32]), Identifier::from([0x02; 32])), + ] { + let mut historical = mirrored_row(historical_doc, identity_id); + historical.status = DpnsNameSaleStatus::Sold { to: buyer }; + // The historical row is deliberately FRESHER: `Owned` must + // outrank recency, exactly as in the backends' ordering. + historical.last_synced_at_ms = 2_000; + let mut owned = mirrored_row(owned_doc, identity_id); + owned.last_synced_at_ms = 1_000; + // Decoy for another identity, "better" on every tie-break — + // the identity filter must exclude it outright. + let mut decoy = + mirrored_row(Identifier::from([0xFE; 32]), Identifier::from([0xFD; 32])); + decoy.last_synced_at_ms = 9_000; + + let mut previous_rows = BTreeMap::new(); + for entry in [historical, owned, decoy] { + previous_rows.insert(entry.document_id, entry); + } + + // A failing mirror proves the in-memory hit still + // short-circuits: consulting the persister here would error. + let persister = mirror_wallet_persister(Arc::new(MirrorPersister::failing_with( + PersistenceErrorKind::Fatal, + ))); + assert_eq!( + previous_document_id_for(&persister, &identity_id, DEPARTED_LABEL, &previous_rows) + .expect("an in-memory hit must not consult the persister"), + Some(owned_doc), + "the current Owned row must win over retained history regardless \ + of document-id order" + ); + } + } + + /// With no `Owned` row in the snapshot (both matches are retained + /// history), the tie-breaks mirror the persistence contract: + /// greatest `last_synced_at_ms` first, then greatest `document_id`. + #[test] + fn departed_document_id_breaks_snapshot_ties_like_the_persistence_contract() { + let identity_id = Identifier::from([0x23; 32]); + let buyer = Identifier::from([0x99; 32]); + let persister = mirror_wallet_persister(Arc::new(MirrorPersister::failing_with( + PersistenceErrorKind::Fatal, + ))); + + // Freshness decides between two historical rows. The fresher row + // gets the SMALLER document id, so a map-order or id-order scan + // cannot pass by accident. + let mut stale = mirrored_row(Identifier::from([0x0A; 32]), identity_id); + stale.status = DpnsNameSaleStatus::Sold { to: buyer }; + stale.last_synced_at_ms = 1_000; + let mut fresh = mirrored_row(Identifier::from([0x09; 32]), identity_id); + fresh.status = DpnsNameSaleStatus::Transferred { to: buyer }; + fresh.last_synced_at_ms = 2_000; + let fresh_doc = fresh.document_id; + let mut rows = BTreeMap::new(); + for entry in [stale.clone(), fresh] { + rows.insert(entry.document_id, entry); + } + assert_eq!( + previous_document_id_for(&persister, &identity_id, DEPARTED_LABEL, &rows) + .expect("an in-memory hit must not consult the persister"), + Some(fresh_doc), + "the fresher retained row must win" + ); + + // An exact freshness tie falls to the greatest document id. + let mut twin = mirrored_row(Identifier::from([0x0B; 32]), identity_id); + twin.status = DpnsNameSaleStatus::Sold { to: buyer }; + twin.last_synced_at_ms = 1_000; + let twin_doc = twin.document_id; + let mut rows = BTreeMap::new(); + for entry in [stale, twin] { + rows.insert(entry.document_id, entry); + } + assert_eq!( + previous_document_id_for(&persister, &identity_id, DEPARTED_LABEL, &rows) + .expect("an in-memory hit must not consult the persister"), + Some(twin_doc), + "an exact freshness tie must fall to the greatest document id" + ); + } + + /// THE REGRESSION. First pass after a process restart: the in-memory + /// snapshot is empty (exactly what the load path produces) but the + /// durable mirror still holds the row, so the departure recovers the + /// `document_id` its removal delta needs. Before the fix this + /// returned `None` and the mirror row was orphaned forever. + #[test] + fn departed_document_id_falls_back_to_the_persisted_row_after_a_restart() { + let document_id = Identifier::from([0x33; 32]); + let identity_id = Identifier::from([0x44; 32]); + let mirror = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + document_id, + identity_id, + )])); + let persister = mirror_wallet_persister(Arc::clone(&mirror)); + + // `BTreeMap::new()` IS the post-restart state: see the wallet load + // path, which initializes `dpns_name_states` empty. + let previous_rows = BTreeMap::new(); + + assert_eq!( + previous_document_id_for(&persister, &identity_id, DEPARTED_LABEL, &previous_rows) + .expect("a healthy mirror read must succeed"), + Some(document_id), + "an empty in-memory snapshot must fall back to the durable mirror" + ); + assert_eq!( + mirror.lookups(), + vec![( + MIRROR_WALLET_ID, + identity_id, + convert_to_homograph_safe_chars(DEPARTED_LABEL) + )], + "the mirror must be queried once, scoped to this wallet and identity, \ + keyed by the NORMALIZED label" + ); + } + + /// The mirror is asked for this identity's row specifically. A + /// `Sold`/`Transferred` row is retained after a name leaves, so a + /// lookup that dropped the identity scope could remove another + /// identity's document. + #[test] + fn departed_document_id_does_not_return_another_identitys_row() { + let identity_id = Identifier::from([0x55; 32]); + let other_identity_id = Identifier::from([0x66; 32]); + let mirror = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + Identifier::from([0x77; 32]), + other_identity_id, + )])); + let persister = mirror_wallet_persister(Arc::clone(&mirror)); + + assert_eq!( + previous_document_id_for(&persister, &identity_id, DEPARTED_LABEL, &BTreeMap::new()) + .expect("a healthy mirror read must succeed"), + None + ); + assert_eq!( + mirror.lookups().first().map(|(_, id, _)| *id), + Some(identity_id), + "the lookup must carry the departing identity, not any row's identity" + ); + } + + /// A backend that cannot answer (the `Ok(None)` default, e.g. + /// `NoPlatformPersistence` or an unwired FFI vtable) degrades to the + /// pre-fix behaviour rather than failing the departure. + #[test] + fn departed_document_id_is_none_when_the_backend_does_not_index_dpns_rows() { + let persister = WalletPersister::new( + MIRROR_WALLET_ID, + Arc::new(crate::wallet::persister::NoPlatformPersistence), + ); + assert_eq!( + previous_document_id_for( + &persister, + &Identifier::from([0x88; 32]), + DEPARTED_LABEL, + &BTreeMap::new() + ) + .expect("the Ok(None) default is not an error"), + None + ); + } + + /// A persistence read FAILURE must stay distinguishable from + /// `Ok(None)`. `Ok(None)` means "the backend answered and has no + /// better id"; `Err` means "we do not know". Flattening the two here + /// is what let a failed read reach the departure path as a confirmed + /// absence — see + /// [`resolve_departed_name_retains_the_departure_when_the_persister_read_fails`]. + #[test] + fn departed_document_id_surfaces_the_error_instead_of_flattening_it_to_none() { + let mirror = Arc::new(MirrorPersister::failing_with( + PersistenceErrorKind::Transient, + )); + let persister = mirror_wallet_persister(Arc::clone(&mirror)); + + let error = previous_document_id_for( + &persister, + &Identifier::from([0x99; 32]), + DEPARTED_LABEL, + &BTreeMap::new(), + ) + .expect_err("a failed read must not read as an empty mirror"); + assert!( + error.is_transient(), + "the backend's retry classification must survive the hop: {error}" + ); + assert_eq!( + mirror.lookups().len(), + 1, + "the failing read must have actually been attempted" + ); + } + + /// End-to-end through the real `resolve_departed_name`, proving the + /// fallback is wired into the production path and not just reachable + /// as a standalone helper. + /// + /// The SDK is a mock with no expectations, so the domain-document + /// lookup fails and resolution takes its retry arm — the one arm + /// reachable without a live Platform. That arm still reports the + /// departed name's `document_id`, which is sourced from exactly the + /// same resolution the removal delta uses, so a regression that + /// unwired the persister fallback fails here too. + #[tokio::test] + async fn resolve_departed_name_recovers_the_document_id_from_the_persister() { + let document_id = Identifier::from([0xAB; 32]); + let identity_id = Identifier::from([0xCD; 32]); + let mirror = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + document_id, + identity_id, + )])); + let wallet = mirror_backed_identity_wallet(Arc::clone(&mirror)); + + // Post-restart in-memory state: empty. + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + assert_eq!( + resolved.summary.document_id, + Some(document_id), + "resolve_departed_name must resolve the departed name's document id \ + through the persister when the in-memory snapshot is empty" + ); + assert_eq!( + mirror.lookups(), + vec![( + wallet.wallet_id, + identity_id, + convert_to_homograph_safe_chars(DEPARTED_LABEL) + )] + ); + assert!( + matches!(resolved.resolution, DepartureResolution::Retry), + "test precondition: the mock SDK has no expectations, so the domain \ + lookup must fail and request a retry" + ); + } + + /// THE ROUND-3 REGRESSION for the departure path. Platform CONFIRMS + /// the name is gone (the mock answers the domain query with an empty + /// document set), which is the branch that resolves the departure, + /// drops the identity's label, and emits the removal delta. When the + /// persistence lookup for the `document_id` FAILED rather than + /// answering "no row", the old code could not tell the two apart: + /// resolution carried on with no id, the label — the only trigger + /// for future departure detection — was removed, and the durable row + /// was orphaned for good. + /// + /// The first assertion block establishes that this mock really does + /// take the confirmed-absent branch, so the retention assertion that + /// follows cannot pass by accident. + #[tokio::test] + async fn resolve_departed_name_retains_the_departure_when_the_persistence_read_fails() { + let document_id = Identifier::from([0xA1; 32]); + let identity_id = Identifier::from([0xA2; 32]); + + // Control: healthy mirror, Platform confirms absence. The + // departure RESOLVES and carries its removal delta. + let healthy = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + document_id, + identity_id, + )])); + let control = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&healthy), + sdk_with_absent_dpns_domain(DEPARTED_LABEL).await, + ); + let resolved = control + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + assert!( + matches!(resolved.resolution, DepartureResolution::Resolved), + "test precondition: with the mock answering 'no such document', the \ + departure must take the confirmed-absent branch, not a retry arm" + ); + assert_eq!( + resolved.remove_document_id, + Some(document_id), + "test precondition: the confirmed-absent branch emits the removal delta" + ); + + // Same Platform answer, but the mirror read fails transiently. + // The departure must be RETAINED instead of resolved: `retry` + // makes the sync loop push it back on the queue and break before + // it can call `remove_dpns_label`, so the label, the queue entry + // and the durable row all survive to the next pass. + let failing = Arc::new(MirrorPersister::hydrated_but_failing( + vec![mirrored_row(document_id, identity_id)], + PersistenceErrorKind::Transient, + )); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&failing), + sdk_with_absent_dpns_domain(DEPARTED_LABEL).await, + ); + let retained = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + assert!( + matches!(retained.resolution, DepartureResolution::Retry), + "a transiently failed persistence read must retain the departure for \ + the next pass" + ); + assert_eq!( + retained.remove_document_id, None, + "nothing may be removed while the document id is UNKNOWN" + ); + assert!(retained.entry.is_none()); + assert_eq!(retained.summary.status, None); + assert_eq!( + retained.summary.document_id, None, + "the summary must not claim an id the lookup never produced" + ); + + // Next pass, backend recovered: the retained departure resolves + // normally and finally carries its removal delta. + failing.heal(); + let healed = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + assert!( + matches!(healed.resolution, DepartureResolution::Resolved), + "a healed backend must let the departure resolve" + ); + assert_eq!(healed.remove_document_id, Some(document_id)); + assert_eq!(healed.summary.document_id, Some(document_id)); + } + + /// THE ROUND-4 REGRESSION. A NON-retryable persistence error + /// (`Fatal` / `Constraint` / `LockPoisoned`) cannot be retried into + /// success, so it must not park this identity's departure queue — + /// but it does not establish that no durable row exists, either. + /// The old arm degraded it to "no previous id" and carried on; with + /// Platform confirming the document absent, that RESOLVED the + /// departure, dropped the label — the only trigger for future + /// departure detection — and reported a successful sync, leaving + /// any persisted row orphaned for good. It must instead be a + /// terminal per-item FAILURE: no retry, no label drop, no deltas. + #[tokio::test] + async fn resolve_departed_name_fails_terminally_when_the_persistence_error_is_not_retryable() { + let document_id = Identifier::from([0xA4; 32]); + let identity_id = Identifier::from([0xA3; 32]); + let mirror = Arc::new(MirrorPersister::hydrated_but_failing( + vec![mirrored_row(document_id, identity_id)], + PersistenceErrorKind::Fatal, + )); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_with_absent_dpns_domain(DEPARTED_LABEL).await, + ); + + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + match &resolved.resolution { + DepartureResolution::Failed(error) => assert!( + !error.is_transient(), + "the terminal failure must carry the non-retryable error: {error}" + ), + other => panic!( + "an unrecoverable read under a confirmed-absent document must be a \ + terminal per-item failure — not a retry (which would park the queue \ + for the life of the process) and not a resolution (which would \ + orphan the durable row), got {other:?}" + ), + } + assert_eq!( + resolved.remove_document_id, None, + "nothing may be removed while the document id is unknown" + ); + assert!(resolved.entry.is_none()); + assert_eq!( + resolved.summary.document_id, None, + "the summary must not claim an id the lookup never produced" + ); + + // Once the backend is repaired, the SAME departure — re-detected + // through the label the failure preserved — resolves normally and + // finally carries its removal delta. + mirror.heal(); + let healed = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + assert!( + matches!(healed.resolution, DepartureResolution::Resolved), + "a repaired backend must let the preserved departure resolve" + ); + assert_eq!(healed.remove_document_id, Some(document_id)); + assert_eq!(healed.summary.document_id, Some(document_id)); + } + + /// The held non-retryable error must not fail departures that never + /// need the persisted id: with the domain document still PRESENT, + /// resolution proceeds past the confirmed-absent branch into history + /// classification (whose fetch fails on this mock and requests a + /// plain retry). A regression that failed the departure eagerly — + /// before knowing whether the id is needed — would turn every + /// departure whose network state is merely transiently unreadable + /// into a permanent failure instead of an ordinary retry. + #[tokio::test] + async fn resolve_departed_name_defers_a_fatal_persistence_error_until_the_id_is_needed() { + let document_id = Identifier::from([0xA5; 32]); + let identity_id = Identifier::from([0xA6; 32]); + let new_owner = Identifier::from([0xA7; 32]); + let mirror = Arc::new(MirrorPersister::hydrated_but_failing( + vec![mirrored_row(document_id, identity_id)], + PersistenceErrorKind::Fatal, + )); + let mut documents = dash_sdk::query_types::Documents::new(); + documents.insert( + document_id, + Some(listed_domain_document(document_id, new_owner, None)), + ); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_answering_dpns_domain_query(DEPARTED_LABEL, documents).await, + ); + + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + assert!( + matches!(resolved.resolution, DepartureResolution::Retry), + "with the document still present the fatal persistence error is not yet \ + load-bearing; the history-classification fetch failure must yield an \ + ordinary retry, got {:?}", + resolved.resolution + ); + assert_eq!( + resolved.summary.document_id, + Some(document_id), + "the id comes from the live document, not the failed persistence read" + ); + } + + /// THE ROUND-5 REGRESSION. DPNS domain documents are deletable, and + /// a label can be re-registered under a fresh document id: persisted + /// document A (the identity's own row) was deleted and an unrelated + /// identity registered document B under the same normalized label. + /// The domain query answers with B, whose history never departs the + /// wallet identity, so classification yields no sale status. The old + /// code then reported and removed B — the replacement owner's + /// document, never a row of this departure — while the identity's + /// durable row A survived with no label left to ever trigger its + /// reconciliation. The removal delta must target the RECOVERED prior + /// incarnation A and leave the replacement B untouched. + #[tokio::test] + async fn resolve_departed_name_removes_the_prior_incarnation_when_the_label_was_re_registered() + { + let prior_document_id = Identifier::from([0xC1; 32]); + let identity_id = Identifier::from([0xC2; 32]); + let replacement_document_id = Identifier::from([0xC3; 32]); + let replacement_owner = Identifier::from([0xC4; 32]); + + let mirror = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + prior_document_id, + identity_id, + )])); + let mut documents = dash_sdk::query_types::Documents::new(); + documents.insert( + replacement_document_id, + Some(listed_domain_document( + replacement_document_id, + replacement_owner, + None, + )), + ); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_with_history_unrelated_dpns_domain( + DEPARTED_LABEL, + documents, + replacement_document_id, + ) + .await, + ); + + // Post-restart in-memory state: empty, so the prior incarnation + // is recovered through the persister — the restart shape in which + // the orphan was originally reported. + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + assert!( + matches!(resolved.resolution, DepartureResolution::Resolved), + "test precondition: with the domain and history lookups primed and the \ + mirror healthy, the departure must resolve, got {:?}", + resolved.resolution + ); + assert_eq!( + resolved.remove_document_id, + Some(prior_document_id), + "the removal delta must target the identity's recovered prior \ + incarnation, not the re-registered replacement" + ); + assert_eq!( + resolved.summary.document_id, + Some(prior_document_id), + "the departed document is the prior incarnation, not the replacement" + ); + assert_eq!( + resolved.summary.status, None, + "a deleted-and-re-registered name departs without a sale" + ); + assert!( + resolved.entry.is_none(), + "the replacement belongs to an unrelated identity — no row may be \ + written for it" + ); + } + + /// The companion failure arm of the round-5 regression: with a live, + /// history-unrelated document on the label, the recovered prior id + /// decides WHICH row the removal delta targets, so the held + /// non-retryable persistence error is load-bearing here exactly as it + /// is under a confirmed-absent document. Resolving anyway would + /// either remove the replacement's document id or orphan the + /// identity's durable row; the departure must fail terminally, + /// preserving the label, and complete once the backend is repaired. + #[tokio::test] + async fn resolve_departed_name_fails_terminally_when_a_re_registered_label_needs_the_failed_lookup( + ) { + let prior_document_id = Identifier::from([0xC5; 32]); + let identity_id = Identifier::from([0xC6; 32]); + let replacement_document_id = Identifier::from([0xC7; 32]); + let replacement_owner = Identifier::from([0xC8; 32]); + + let mirror = Arc::new(MirrorPersister::hydrated_but_failing( + vec![mirrored_row(prior_document_id, identity_id)], + PersistenceErrorKind::Fatal, + )); + let mut documents = dash_sdk::query_types::Documents::new(); + documents.insert( + replacement_document_id, + Some(listed_domain_document( + replacement_document_id, + replacement_owner, + None, + )), + ); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_with_history_unrelated_dpns_domain( + DEPARTED_LABEL, + documents, + replacement_document_id, + ) + .await, + ); + + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + match &resolved.resolution { + DepartureResolution::Failed(error) => assert!( + !error.is_transient(), + "the terminal failure must carry the non-retryable error: {error}" + ), + other => panic!( + "an unrecoverable read under a live, history-unrelated document \ + must be a terminal per-item failure — resolving would remove the \ + wrong row or orphan the durable one, got {other:?}" + ), + } + assert_eq!( + resolved.remove_document_id, None, + "nothing may be removed while WHICH row departs is unknown" + ); + assert!(resolved.entry.is_none()); + assert_eq!( + resolved.summary.document_id, None, + "the summary must not claim an id the lookup never produced" + ); + + // Backend repaired: the SAME departure — re-detected through the + // label the failure preserved — resolves against the prior + // incarnation and leaves the replacement untouched. + mirror.heal(); + let healed = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + assert!( + matches!(healed.resolution, DepartureResolution::Resolved), + "a repaired backend must let the preserved departure resolve" + ); + assert_eq!(healed.remove_document_id, Some(prior_document_id)); + assert_eq!(healed.summary.document_id, Some(prior_document_id)); + } + + /// A Document History `transfer` document recording that `from` + /// transferred the source domain document to `to` at `at_ms`. Only + /// the fields [`history_event_from_document`] reads are populated + /// (`$ownerId` is the departing side, `toIdentityId` the recipient). + fn transfer_history_document( + history_document_id: Identifier, + from: Identifier, + to: Identifier, + at_ms: u64, + ) -> Document { + let mut properties = BTreeMap::new(); + properties.insert( + "toIdentityId".to_string(), + Value::Identifier(to.to_buffer()), + ); + Document::V0(dpp::document::DocumentV0 { + id: history_document_id, + owner_id: from, + properties, + revision: Some(1), + created_at: Some(at_ms), + ..Default::default() + }) + } + + /// THE ROUND-6 REGRESSION (successor to the round-5 one above): the + /// re-registered replacement itself passed through this wallet + /// identity and departed. Persisted document A (the identity's own + /// row) was deleted, the label was re-registered as B, and B was + /// acquired by this identity and transferred away — all before this + /// sync pass. `classify_departure(B)` correctly yields + /// `Transferred`, but the old code wrote B's historical entry with + /// `remove_document_id: None`; the caller then dropped the label — + /// the only trigger that would ever revisit the durable row — + /// leaving recovered row A persisted as `Owned` forever. B's + /// classified entry and A's retirement must land in the same + /// changeset. + #[tokio::test] + async fn resolve_departed_name_retires_the_prior_incarnation_when_the_replacement_also_departed( + ) { + let prior_document_id = Identifier::from([0xD1; 32]); + let identity_id = Identifier::from([0xD2; 32]); + let replacement_document_id = Identifier::from([0xD3; 32]); + let new_owner = Identifier::from([0xD4; 32]); + + let mirror = Arc::new(MirrorPersister::hydrated(vec![mirrored_row( + prior_document_id, + identity_id, + )])); + let mut documents = dash_sdk::query_types::Documents::new(); + documents.insert( + replacement_document_id, + Some(listed_domain_document( + replacement_document_id, + new_owner, + None, + )), + ); + let transfer_id = Identifier::from([0xD5; 32]); + let mut transfers = dash_sdk::query_types::Documents::new(); + transfers.insert( + transfer_id, + Some(transfer_history_document( + transfer_id, + identity_id, + new_owner, + 900, + )), + ); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_with_dpns_domain_history( + DEPARTED_LABEL, + documents, + replacement_document_id, + dash_sdk::query_types::Documents::new(), + transfers, + ) + .await, + ); + + // Post-restart in-memory state: empty, so the prior incarnation + // is recovered through the persister. + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + assert!( + matches!(resolved.resolution, DepartureResolution::Resolved), + "test precondition: with the domain and history lookups primed and the \ + mirror healthy, the classified departure must resolve, got {:?}", + resolved.resolution + ); + assert_eq!( + resolved.summary.status, + Some(DpnsNameSaleStatus::Transferred { to: new_owner }), + "the departure is classified from the replacement's own history" + ); + assert_eq!( + resolved.summary.document_id, + Some(replacement_document_id), + "a classified departure reports the document the identity departed from" + ); + assert_eq!( + resolved + .entry + .as_ref() + .expect("the classified departure must write the historical row") + .document_id, + replacement_document_id, + "the historical row is keyed by the departed (replacement) document" + ); + assert_eq!( + resolved.remove_document_id, + Some(prior_document_id), + "the recovered prior incarnation must be retired alongside the \ + classified entry — the caller's label drop leaves nothing else to \ + ever reconcile it" + ); + } + + /// The companion failure arm of the round-6 regression: the label + /// carries a live document whose history DOES depart this identity, + /// and the persistence read fails non-retryably. Whether a prior + /// incarnation must be retired with the classified entry is + /// unknowable, so resolving anyway could orphan a durable row under + /// a different id. The departure must fail terminally — preserving + /// the label — and complete, entry and retirement together, once the + /// backend is repaired. + #[tokio::test] + async fn resolve_departed_name_fails_terminally_when_a_classified_departure_needs_the_failed_lookup( + ) { + let prior_document_id = Identifier::from([0xD6; 32]); + let identity_id = Identifier::from([0xD7; 32]); + let replacement_document_id = Identifier::from([0xD8; 32]); + let new_owner = Identifier::from([0xD9; 32]); + + let mirror = Arc::new(MirrorPersister::hydrated_but_failing( + vec![mirrored_row(prior_document_id, identity_id)], + PersistenceErrorKind::Fatal, + )); + let mut documents = dash_sdk::query_types::Documents::new(); + documents.insert( + replacement_document_id, + Some(listed_domain_document( + replacement_document_id, + new_owner, + None, + )), + ); + let transfer_id = Identifier::from([0xDA; 32]); + let mut transfers = dash_sdk::query_types::Documents::new(); + transfers.insert( + transfer_id, + Some(transfer_history_document( + transfer_id, + identity_id, + new_owner, + 900, + )), + ); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_with_dpns_domain_history( + DEPARTED_LABEL, + documents, + replacement_document_id, + dash_sdk::query_types::Documents::new(), + transfers, + ) + .await, + ); + + let resolved = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + + match &resolved.resolution { + DepartureResolution::Failed(error) => assert!( + !error.is_transient(), + "the terminal failure must carry the non-retryable error: {error}" + ), + other => panic!( + "an unrecoverable read under a classified departure must be a \ + terminal per-item failure — resolving could orphan an unknown \ + prior incarnation, got {other:?}" + ), + } + assert!( + resolved.entry.is_none(), + "no historical row may be written while the retirement set is unknown" + ); + assert_eq!( + resolved.remove_document_id, None, + "nothing may be removed while the prior incarnation is unknown" + ); + assert_eq!( + resolved.summary.document_id, None, + "the summary must not claim an id the lookup never produced" + ); + + // Backend repaired: the SAME departure — re-detected through the + // label the failure preserved — writes the classified entry AND + // retires the recovered prior incarnation. + mirror.heal(); + let healed = wallet + .resolve_departed_name(&identity_id, DEPARTED_LABEL, &BTreeMap::new(), 1_000) + .await; + assert!( + matches!(healed.resolution, DepartureResolution::Resolved), + "a repaired backend must let the preserved departure resolve" + ); + assert_eq!( + healed.summary.status, + Some(DpnsNameSaleStatus::Transferred { to: new_owner }) + ); + assert_eq!(healed.remove_document_id, Some(prior_document_id)); + assert_eq!( + healed.entry.as_ref().expect("classified entry").document_id, + replacement_document_id + ); + } + + /// The labels `identity_id` currently carries in the wallet manager — + /// the departure trigger the Failed arm must preserve. + async fn dpns_labels(wallet: &IdentityWallet, identity_id: &Identifier) -> Vec { + let wm = wallet.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet.wallet_id).expect("wallet info"); + info.identity_manager + .wallet_identity(&wallet.wallet_id, identity_id) + .expect("managed identity") + .dpns_names + .iter() + .map(|name| name.label.clone()) + .collect() + } + + /// [`DepartureResolution::Failed`] as the SYNC LOOP consumes it — + /// the load-bearing caller branch the resolver-level tests above + /// cannot reach. A managed identity still carries [`DEPARTED_LABEL`], + /// Platform confirms the domain document absent, and the mirror read + /// fails fatally. The pass must surface the failure on + /// `departures_failed` while leaving EVERYTHING else untouched: were + /// the arm to regress to the old degrade-to-`None` behavior, the pass + /// would instead report a successful departure with no document id, + /// drop the label (the only re-detection trigger), and orphan the + /// mirror's durable row for good — every assertion below fails on + /// that regression. The healed second pass then proves the retained + /// label really does let a later pass finish the removal, so the + /// terminal failure neither parks the queue nor loses the departure. + #[tokio::test] + async fn sync_pass_surfaces_a_terminal_departure_failure_and_completes_it_once_healed() { + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + + let document_id = Identifier::from([0xB1; 32]); + let identity_id = Identifier::from([0xB2; 32]); + let mirror = Arc::new(MirrorPersister::hydrated_but_failing( + vec![mirrored_row(document_id, identity_id)], + PersistenceErrorKind::Fatal, + )); + let wallet = mirror_backed_identity_wallet_with_sdk( + Arc::clone(&mirror), + sdk_for_departed_identity_sync(&identity_id, DEPARTED_LABEL).await, + ); + + // The wallet still holds the identity AND its label; Platform + // (the mock) no longer shows the identity owning any document. + { + let mut wm = wallet.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet.wallet_id) + .expect("wallet info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: identity_id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet.wallet_id, + &wallet.persister, + ) + .expect("add identity"); + info.identity_manager + .wallet_identity_mut(&wallet.wallet_id, &identity_id) + .expect("managed identity") + .dpns_names + .push(DpnsNameInfo { + label: DEPARTED_LABEL.to_string(), + acquired_at: Some(500), + }); + } + + let summary = wallet + .sync_dpns_marketplace() + .await + .expect("a terminal PER-ITEM failure must not fail the pass"); + + // Surfaced on the summary, not silently swallowed... + assert_eq!( + summary.departures_failed.len(), + 1, + "the fatal mirror read under a confirmed-absent document must land \ + in departures_failed, got {:?}", + summary.departures_failed + ); + let failure = &summary.departures_failed[0]; + assert_eq!(failure.identity_id, identity_id); + assert_eq!(failure.label, DEPARTED_LABEL); + assert!( + failure.error.contains("simulated mirror read failure"), + "the summary must carry the underlying persistence error, got: {}", + failure.error + ); + + // ...and NOT reported as a successful departure or any other delta. + assert!( + summary.names_departed.is_empty(), + "a failed departure must not appear in names_departed: {:?}", + summary.names_departed + ); + assert!( + summary.is_empty_delta(), + "the failed pass must apply no adds, departures or price changes" + ); + assert_eq!(summary.names_tracked, 0); + + // The label survives — it is the only trigger for re-detection. + assert_eq!( + dpns_labels(&wallet, &identity_id).await, + vec![DEPARTED_LABEL.to_string()], + "the failed departure must leave the identity's label in place" + ); + + // No row delta reached the durable mirror. + assert_eq!( + mirror.stored_dpns_removals(), + Vec::::new(), + "nothing may be removed from the mirror while the document id is unknown" + ); + + // The queue is not parked: the failed item was consumed, not + // requeued, so the identity carries no pending sync progress and + // the next pass starts from a clean scan (which re-detects the + // departure from the retained label). + assert!( + wallet + .dpns_sync_progress + .lock() + .expect("progress lock") + .get(&identity_id) + .is_none(), + "a terminal failure must not park the departure queue" + ); + + // Backend repaired: the SAME departure — re-detected through the + // preserved label — now resolves, drops the label, and finally + // emits the removal delta for the mirror's row. + mirror.heal(); + let healed = wallet + .sync_dpns_marketplace() + .await + .expect("healed pass must succeed"); + assert!( + healed.departures_failed.is_empty(), + "no failure may remain once the backend answers: {:?}", + healed.departures_failed + ); + assert_eq!( + healed.names_departed, + vec![DepartedDpnsName { + identity_id, + label: DEPARTED_LABEL.to_string(), + document_id: Some(document_id), + status: None, + }], + "the healed pass must complete the departure with the document id \ + recovered from the mirror" + ); + assert_eq!( + dpns_labels(&wallet, &identity_id).await, + Vec::::new(), + "the completed departure finally drops the label" + ); + assert_eq!( + mirror.stored_dpns_removals(), + vec![document_id], + "the removal delta must finally reach the durable mirror" + ); + } + + /// A live `IdentityWallet` over a bare mock SDK (no expectations, so + /// every network read fails) whose persister is `mirror`. + fn mirror_backed_identity_wallet(mirror: Arc) -> IdentityWallet { + mirror_backed_identity_wallet_with_sdk( + mirror, + Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")), + ) + } + + /// A mock SDK primed so the departed-name domain lookup for `label` + /// answers "no such document" — Platform CONFIRMING the name is + /// gone, which is the branch that resolves a departure and emits its + /// removal delta. Without this the mock has no expectations, every + /// fetch errors, and resolution can only ever take its retry arm — + /// which would make a "retained on persistence failure" assertion + /// vacuous, since the network failure alone already retains. + async fn sdk_with_absent_dpns_domain(label: &str) -> Arc { + sdk_answering_dpns_domain_query(label, dash_sdk::query_types::Documents::new()).await + } + + /// A mock SDK primed to answer the DPNS contract fetch and the + /// exact-match domain query for `label` with `documents`. + async fn sdk_answering_dpns_domain_query( + label: &str, + documents: dash_sdk::query_types::Documents, + ) -> Arc { + Arc::new(mock_sdk_answering_dpns_domain_query(label, documents).await) + } + + /// [`sdk_answering_dpns_domain_query`] before the `Arc` wrap, for + /// helpers that need to register further expectations. + async fn mock_sdk_answering_dpns_domain_query( + label: &str, + documents: dash_sdk::query_types::Documents, + ) -> dash_sdk::Sdk { + // Pin the protocol version. Expectations are keyed by the ENCODED + // request, and an unpinned SDK seeds at the network minimum and + // ratchets up on the first response it sees — so the contract + // fetch would silently re-encode every later query into a + // different version than the one these expectations were + // registered against, and none of them would match. + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk"); + let contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::DPNS, + dpp::version::PlatformVersion::latest(), + ) + .expect("bundled DPNS contract"); + sdk.mock() + .expect_fetch(dpns_contract_id(), Some(contract.clone())) + .await + .expect("DPNS contract expectation"); + let query = domain_by_normalized_label_query( + Arc::new(contract), + convert_to_homograph_safe_chars(dpns_label(label)), + ); + sdk.mock() + .expect_fetch_many::( + query, + Some(documents), + ) + .await + .expect("domain-document expectation"); + sdk + } + + /// A mock SDK primed like [`sdk_answering_dpns_domain_query`] and + /// additionally answering the Document History contract fetch and the + /// purchase/transfer history lookups for `history_document_id` with + /// the given pages. Without these expectations the history fetch + /// errors and resolution can only take its retry arm, never reaching + /// the branches under test. + async fn sdk_with_dpns_domain_history( + label: &str, + documents: dash_sdk::query_types::Documents, + history_document_id: Identifier, + purchase_documents: dash_sdk::query_types::Documents, + transfer_documents: dash_sdk::query_types::Documents, + ) -> Arc { + let mut sdk = mock_sdk_answering_dpns_domain_query(label, documents).await; + let history_contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::DocumentHistory, + dpp::version::PlatformVersion::latest(), + ) + .expect("bundled Document History contract"); + sdk.mock() + .expect_fetch( + document_history_contract_id(), + Some(history_contract.clone()), + ) + .await + .expect("Document History contract expectation"); + let history_contract = Arc::new(history_contract); + for (doc_type, page) in [ + (HISTORY_TYPE_PURCHASE, purchase_documents), + (HISTORY_TYPE_TRANSFER, transfer_documents), + ] { + let query = history_by_source_document_query( + Arc::clone(&history_contract), + doc_type, + &dpns_contract_id(), + &history_document_id, + None, + ); + sdk.mock() + .expect_fetch_many::( + query, + Some(page), + ) + .await + .expect("history-document expectation"); + } + Arc::new(sdk) + } + + /// [`sdk_with_dpns_domain_history`] with EMPTY history pages — a live + /// domain document whose history never departs any wallet identity, + /// which is exactly what [`IdentityWallet::classify_departure`] sees + /// when a label was deleted and re-registered by an unrelated party. + async fn sdk_with_history_unrelated_dpns_domain( + label: &str, + documents: dash_sdk::query_types::Documents, + history_document_id: Identifier, + ) -> Arc { + sdk_with_dpns_domain_history( + label, + documents, + history_document_id, + dash_sdk::query_types::Documents::new(), + dash_sdk::query_types::Documents::new(), + ) + .await + } + + /// A mock SDK primed for a full [`IdentityWallet::sync_dpns_marketplace`] + /// pass over one identity that has LOST `label`: the DPNS contract + /// fetch, the identity-owned domain page query (answered empty — the + /// identity owns no documents on Platform, so every label it still + /// carries locally is a departure) and the exact-match domain query + /// for `label` (also empty — Platform CONFIRMING the name is gone, + /// the branch whose removal delta needs the persisted document id). + async fn sdk_for_departed_identity_sync( + identity_id: &Identifier, + label: &str, + ) -> Arc { + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk"); + let contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::DPNS, + dpp::version::PlatformVersion::latest(), + ) + .expect("bundled DPNS contract"); + sdk.mock() + .expect_fetch(dpns_contract_id(), Some(contract.clone())) + .await + .expect("DPNS contract expectation"); + let contract = Arc::new(contract); + // The exact first (cursor-less) page query + // `dpns_domain_states_page` issues during a sync pass. If the + // production query drifts from this shape the mock stops + // matching, the page fetch errors, and the test fails on its + // `departures_failed` precondition — loudly, not vacuously. + let page_query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: Arc::clone(&contract), + document_type_name: DPNS_DOCUMENT_TYPE.to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: SYNC_QUERY_LIMIT, + offset: None, + start: None, + }; + sdk.mock() + .expect_fetch_many::( + page_query, + Some(dash_sdk::query_types::Documents::new()), + ) + .await + .expect("identity domain-page expectation"); + let label_query = domain_by_normalized_label_query( + contract, + convert_to_homograph_safe_chars(dpns_label(label)), + ); + sdk.mock() + .expect_fetch_many::( + label_query, + Some(dash_sdk::query_types::Documents::new()), + ) + .await + .expect("domain-document expectation"); + Arc::new(sdk) + } + + /// A live `IdentityWallet` over `sdk` whose persister is `mirror`. + /// Mirrors `PlatformWallet::new`'s wiring; only the persister and the + /// SDK are substituted. + fn mirror_backed_identity_wallet_with_sdk( + mirror: Arc, + sdk: Arc, + ) -> IdentityWallet { + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + use key_wallet_manager::WalletManager; + use tokio::sync::RwLock; + + let mut wm = WalletManager::::new( + Network::Testnet, + ); + let wallet_id = wm + .create_wallet_with_random_mnemonic(WalletAccountCreationOptions::None) + .expect("create wallet"); + let wallet_manager = Arc::new(RwLock::new(wm)); + + let persister = WalletPersister::new(wallet_id, mirror); + let spv = Arc::new(crate::spv::SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(crate::events::PlatformEventManager::new(Vec::new())), + )); + let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)); + let asset_locks = Arc::new(crate::wallet::asset_lock::manager::AssetLockManager::new( + Arc::clone(&sdk), + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(tokio::sync::Notify::new()), + Arc::clone(&broadcaster), + persister.clone(), + )); + IdentityWallet { + sdk: Arc::clone(&sdk), + wallet_manager, + wallet_id, + asset_locks, + persister, + broadcaster, + sdk_writer: Arc::new(super::super::sdk_writer::SdkWriter::new(sdk)), + dpns_operation_gate: Arc::new(tokio::sync::Mutex::new(())), + dpns_sync_progress: Arc::new(std::sync::Mutex::new(BTreeMap::new())), + } + } + + // ----------------------------------------------------------------- + // Zero-price guards (listing side and purchase side) + // ----------------------------------------------------------------- + + /// A DPNS `domain` document for [`DEPARTED_LABEL`] owned by `owner`, + /// carrying `price` as `$price` when listed. Only the fields + /// [`DpnsDomainState::from_document`] reads are populated. + fn listed_domain_document( + document_id: Identifier, + owner: Identifier, + price: Option, + ) -> Document { + let mut properties = BTreeMap::new(); + properties.insert("label".to_string(), Value::Text(DEPARTED_LABEL.to_string())); + properties.insert( + "normalizedLabel".to_string(), + Value::Text(convert_to_homograph_safe_chars(DEPARTED_LABEL)), + ); + properties.insert( + "normalizedParentDomainName".to_string(), + Value::Text(DPNS_PARENT_DOMAIN.to_string()), + ); + if let Some(price) = price { + properties.insert(PRICE.to_string(), Value::U64(price)); + } + Document::V0(dpp::document::DocumentV0 { + id: document_id, + owner_id: owner, + properties, + revision: Some(1), + created_at: Some(1_700_000_000_000), + ..Default::default() + }) + } + + /// A wallet whose Platform answers the domain query for + /// [`DEPARTED_LABEL`] with a single document listed at `price`. + async fn wallet_seeing_listing( + document_id: Identifier, + owner: Identifier, + price: Option, + ) -> IdentityWallet { + let mut documents = dash_sdk::query_types::Documents::new(); + documents.insert( + document_id, + Some(listed_domain_document(document_id, owner, price)), + ); + mirror_backed_identity_wallet_with_sdk( + Arc::new(MirrorPersister::hydrated(Vec::new())), + sdk_answering_dpns_domain_query(DEPARTED_LABEL, documents).await, + ) + } + + /// The purchase pre-flight's rejection ORDER, as a pure decision. + /// `$price` absent outranks everything; a `$price` of 0 is rejected + /// as an invalid listing BEFORE the `expected_price` comparison, so + /// the caller is told the listing is not purchasable rather than + /// that the price moved. + #[test] + fn purchase_preflight_rejects_a_zero_price_ahead_of_the_price_comparison() { + let document_id = Identifier::from([0xB0; 32]); + let owner = Identifier::from([0xB9; 32]); + let state = |price: Option| { + DpnsDomainState::from_document(&listed_domain_document(document_id, owner, price)) + .expect("fixture document must decode") + }; + + assert!(matches!( + preflight_purchase_price(&state(None), DEPARTED_LABEL, 5_000), + Err(PlatformWalletError::DocumentNotForSale { document_id: got }) if got == document_id + )); + + match preflight_purchase_price(&state(Some(0)), DEPARTED_LABEL, 5_000) { + Err(PlatformWalletError::InvalidParameter(message)) => assert!( + message.contains("0 credits"), + "the rejection must name the zero price: {message}" + ), + other => panic!( + "a zero listing must be InvalidParameter, never DocumentPriceChanged: {other:?}" + ), + } + + assert!(matches!( + preflight_purchase_price(&state(Some(7_000)), DEPARTED_LABEL, 5_000), + Err(PlatformWalletError::DocumentPriceChanged { + expected: 5_000, + actual: 7_000, + .. + }) + )); + assert!(preflight_purchase_price(&state(Some(5_000)), DEPARTED_LABEL, 5_000).is_ok()); + } + + /// End-to-end through the real `purchase_dpns_name` against a mock + /// Platform that serves a domain document listed at `$price = 0`. + /// + /// The purchaser is deliberately NOT one of this wallet's identities, + /// so every step after the price pre-flight — the credit check, the + /// signing-key selection, the broadcast — fails with a DIFFERENT, + /// clearly identifiable error. A typed `InvalidParameter` back from + /// the call is therefore proof that the guard fired and that nothing + /// downstream of it ran. + #[tokio::test] + async fn purchase_dpns_name_rejects_a_zero_listed_price_before_signing() { + let document_id = Identifier::from([0xB1; 32]); + let seller = Identifier::from([0xB2; 32]); + let purchaser = Identifier::from([0xB3; 32]); + let wallet = wallet_seeing_listing(document_id, seller, Some(0)).await; + let signer = simple_signer::signer::SimpleSigner::default(); + + // Non-zero expectation: without the zero guard this is a plain + // 0-vs-5000 mismatch and would surface as DocumentPriceChanged, + // inviting a "refresh the price and retry" loop that can never + // succeed. + match wallet + .purchase_dpns_name(&purchaser, DEPARTED_LABEL, 5_000, &signer) + .await + .expect_err("a zero-credit listing must not be purchasable") + { + PlatformWalletError::InvalidParameter(message) => assert!( + message.contains("0 credits"), + "the rejection must name the zero price: {message}" + ), + other => panic!( + "expected the zero-price guard to reject ahead of the price \ + comparison and ahead of signing, got {other:?}" + ), + } + + // Zero expectation: the prices MATCH, so without the guard the + // pre-flight would pass and the call would run on into signing + // and broadcast. Reaching an identity/signing error here instead + // of InvalidParameter is exactly the regression. + match wallet + .purchase_dpns_name(&purchaser, DEPARTED_LABEL, 0, &signer) + .await + .expect_err("a zero-credit listing must not be purchasable at any price") + { + PlatformWalletError::InvalidParameter(message) => assert!( + message.contains("0 credits"), + "the rejection must name the zero price: {message}" + ), + other => { + panic!("a matching zero price must still be refused BEFORE signing, got {other:?}") + } + } + } + + /// The positive control: a non-zero listing that matches + /// `expected_price` passes the price pre-flight and fails at the NEXT + /// step (the buyer is not a wallet identity, so the credit check + /// cannot find its balance). Pins that the guard rejects only zero, + /// and that the pre-flight really does sit ahead of the credit / + /// signing stages rather than replacing them. + #[tokio::test] + async fn purchase_dpns_name_lets_a_matching_non_zero_price_past_the_guard() { + let document_id = Identifier::from([0xB4; 32]); + let seller = Identifier::from([0xB5; 32]); + let purchaser = Identifier::from([0xB6; 32]); + let wallet = wallet_seeing_listing(document_id, seller, Some(5_000)).await; + let signer = simple_signer::signer::SimpleSigner::default(); + + let error = wallet + .purchase_dpns_name(&purchaser, DEPARTED_LABEL, 5_000, &signer) + .await + .expect_err("the buyer is not a wallet identity, so the credit check must fail"); + + assert!( + matches!(error, PlatformWalletError::IdentityNotFound(id) if id == purchaser), + "a matching non-zero price must pass the price pre-flight and fail at the \ + credit check: {error:?}" + ); + } + + /// A zero-credit listing is refused BEFORE any network work, so the + /// mock SDK (which has no expectations and would fail every fetch) + /// never gets a chance to speak. A regression that moved the guard + /// below the domain fetch would surface as the fetch's + /// `InvalidIdentityData` error instead. + #[tokio::test] + async fn set_dpns_name_price_rejects_a_zero_price_before_any_network_work() { + let wallet = mirror_backed_identity_wallet(Arc::new(MirrorPersister::hydrated(Vec::new()))); + // Empty: the guard rejects long before a transition is signed, so + // this signer must never be asked for a key. + let signer = simple_signer::signer::SimpleSigner::default(); + + let error = wallet + .set_dpns_name_price(&Identifier::from([0x01; 32]), DEPARTED_LABEL, 0, &signer) + .await + .expect_err("a zero-credit listing must be refused"); + + match error { + PlatformWalletError::InvalidParameter(message) => { + assert!( + message.contains("0 credits"), + "the rejection must name the zero price: {message}" + ); + } + other => panic!( + "expected a typed InvalidParameter rejection ahead of any network \ + work, got {other:?}" + ), + } + } + + /// A non-zero price passes the guard and proceeds to the (mocked-out, + /// therefore failing) domain fetch. Pins that the guard rejects ONLY + /// zero — a regression that rejected every price would fail here. + #[tokio::test] + async fn set_dpns_name_price_lets_a_non_zero_price_reach_the_network() { + let wallet = mirror_backed_identity_wallet(Arc::new(MirrorPersister::hydrated(Vec::new()))); + let signer = simple_signer::signer::SimpleSigner::default(); + + let error = wallet + .set_dpns_name_price(&Identifier::from([0x01; 32]), DEPARTED_LABEL, 1, &signer) + .await + .expect_err("the mock SDK has no expectations, so the fetch must fail"); + + assert!( + !matches!(error, PlatformWalletError::InvalidParameter(_)), + "a price of 1 credit must pass the zero-price guard and fail later, \ + at the network: {error:?}" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 752fee202ce..433e0a597b4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -75,7 +75,8 @@ pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; pub use dpns_marketplace::{ DepartedDpnsName, DpnsDomainState, DpnsMarketplaceSyncSummary, DpnsNameHistoryEvent, - DpnsNameHistoryEventKind, DpnsPriceChange, DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, + DpnsNameHistoryEventKind, DpnsPriceChange, FailedDpnsDeparture, + DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, }; pub use identity_handle::{ derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index c7b111809df..e6cc78affaa 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -10,10 +10,11 @@ use dashcore::Txid; use key_wallet::managed_account::transaction_record::TransactionRecord; use crate::changeset::{ - ClientStartState, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, - PlatformWalletPersistence, + ClientStartState, DpnsNameStateEntry, PersistenceCapabilities, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, }; use crate::wallet::platform_wallet::WalletId; +use dpp::prelude::Identifier; /// Per-wallet persistence handle. /// @@ -73,6 +74,24 @@ impl WalletPersister { ) -> Result>, PersistenceError> { self.inner.list_wallet_core_txids(self.wallet_id) } + + /// Look up the persisted DPNS marketplace row for + /// `(wallet_identity_id, normalized_label)` within this wallet. + /// + /// The durable fallback the DPNS marketplace sync pass uses to + /// recover a departed name's `document_id` once a process restart + /// has left the session-scoped in-memory map empty — see + /// [`PlatformWalletPersistence::get_dpns_name_state`] for the full + /// contract. `Ok(None)` means the backend does not index DPNS rows + /// by label (or holds no such row); it is not an error. + pub(crate) fn get_dpns_name_state( + &self, + wallet_identity_id: &Identifier, + normalized_label: &str, + ) -> Result, PersistenceError> { + self.inner + .get_dpns_name_state(self.wallet_id, wallet_identity_id, normalized_label) + } } /// No-op platform persistence for standalone wallets.