diff --git a/Cargo.lock b/Cargo.lock index bbbd58b64e0..14ee6074fba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5219,6 +5219,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "simple-signer", "static_assertions", "thiserror 1.0.69", "tokio", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index 89098ff625b..fe919a0d872 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -218,6 +218,7 @@ class AppContainer(private val context: Context) { manager.stopShieldedSync() } manager.stopDashPaySync() + manager.stopDpnsSync() } catch (e: Exception) { android.util.Log.w(TAG, "Failed to stop sync coordinators", e) } @@ -287,6 +288,9 @@ class AppContainer(private val context: Context) { if (!manager.isDashPaySyncRunning()) { manager.startDashPaySync() } + if (!manager.isDpnsSyncRunning()) { + manager.startDpnsSync() + } } catch (e: Exception) { android.util.Log.e(TAG, "Failed to bind wallet-scoped services", e) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt index a0681fef33d..d37ae44dd65 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt @@ -41,6 +41,7 @@ import org.dashfoundation.example.ui.dashpay.HiddenContactsScreen import org.dashfoundation.example.ui.dashpay.IgnoredContactsScreen import org.dashfoundation.example.ui.dashpay.InvitationsScreen import org.dashfoundation.example.ui.identity.DpnsTestScreen +import org.dashfoundation.example.ui.identity.DpnsMarketplaceScreen import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen import org.dashfoundation.example.ui.identity.IdentityDetailScreen import org.dashfoundation.example.ui.identity.KeyDetailScreen @@ -181,6 +182,11 @@ fun AppNavHost( SelectMainNameScreen(route.identityIdHex, navController) } + composable { entry -> + val route = entry.toRoute() + DpnsMarketplaceScreen(route.identityIdHex, navController) + } + composable { entry -> val route = entry.toRoute() KeysListScreen(route.identityIdHex, navController) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt index bc69c19376d..1d0098c1ea9 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt @@ -105,6 +105,9 @@ import kotlinx.serialization.Serializable /** Pick the identity's main DPNS name (← `SelectMainNameView.swift`). */ @Serializable data class SelectMainName(val identityIdHex: String) +/** Browse, buy and manage DPNS marketplace names for one wallet identity. */ +@Serializable data class DpnsMarketplace(val identityIdHex: String) + /** All public keys of an identity (← `KeysListView.swift`). */ @Serializable data class KeysList(val identityIdHex: String) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DpnsMarketplaceScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DpnsMarketplaceScreen.kt new file mode 100644 index 00000000000..bb52da68c00 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DpnsMarketplaceScreen.kt @@ -0,0 +1,398 @@ +package org.dashfoundation.example.ui.identity + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.dpns.DpnsMarketplaceName +import org.dashfoundation.dashsdk.dpns.DpnsNameHistoryEvent +import org.dashfoundation.dashsdk.persistence.entities.DpnsNameEntity +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.ui.components.ErrorAlertDialog +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.hexToBytes +import java.text.DateFormat +import java.util.Date + +private sealed interface MarketplaceAction { + val name: String + data class SetPrice(override val name: String) : MarketplaceAction + data class Delist(override val name: String) : MarketplaceAction + data class Transfer(override val name: String) : MarketplaceAction + data class Purchase( + override val name: String, + val expectedPriceCredits: ULong, + ) : MarketplaceAction +} + +/** End-to-end DPNS marketplace example using only wallet-level SDK APIs. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DpnsMarketplaceScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val identityId = remember(identityIdHex) { identityIdHex.hexToBytes() } + val identity by container.database.identityDao().observeByIdentityId(identityId) + .collectAsStateWithLifecycle(initialValue = null) + val durableNames by container.database.dpnsNameDao().observeMarketplaceByIdentity(identityId) + .collectAsStateWithLifecycle(initialValue = emptyList()) + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val wallet = identity?.walletId?.let { manager?.wallet(it) } + val scope = rememberCoroutineScope() + + var prefix by remember { mutableStateOf("") } + var results by remember { mutableStateOf>(emptyList()) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var lastSyncMs by remember { mutableStateOf(0L) } + var syncMessage by remember { mutableStateOf(null) } + var pendingAction by remember { mutableStateOf(null) } + var actionPrice by remember { mutableStateOf("") } + var actionRecipient by remember { mutableStateOf("") } + val histories = remember { mutableStateMapOf>() } + + LaunchedEffect(manager) { + lastSyncMs = (manager?.dpnsLastSyncUnixSeconds() ?: 0L) * 1_000L + } + + fun launch(block: suspend () -> Unit) { + if (busy) return + busy = true + scope.launch { + try { + block() + } catch (t: Throwable) { + error = t.message ?: "DPNS marketplace operation failed" + } finally { + busy = false + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("DPNS Marketplace") }, + navigationIcon = { + IconButton(onClick = navController::popBackStack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton( + enabled = wallet != null && !busy, + modifier = Modifier.testTag("dpnsMarketplace.refresh"), + onClick = { + val activeManager = manager ?: return@IconButton + val activeWallet = wallet ?: return@IconButton + launch { + val summary = activeManager.dpnsMarketplace.sync(activeWallet.handle) + lastSyncMs = summary.syncUnixMs + syncMessage = "${summary.tracked} tracked, ${summary.added.size} added, " + + "${summary.departed.size} departed, ${summary.pricesChanged.size} repriced" + } + }, + ) { + if (busy) CircularProgressIndicator() else { + Icon(Icons.Filled.Refresh, contentDescription = "Sync marketplace") + } + } + }, + ) + }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FormSection(title = "Browse") { + OutlinedTextField( + value = prefix, + onValueChange = { prefix = it }, + label = { Text("Name prefix (empty browses all)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("dpnsMarketplace.searchField"), + ) + Button( + enabled = wallet != null && !busy, + modifier = Modifier.testTag("dpnsMarketplace.search"), + onClick = { + val activeManager = manager ?: return@Button + val activeWallet = wallet ?: return@Button + launch { + results = activeManager.dpnsMarketplace.search( + activeWallet.handle, + prefix.trim(), + 50, + ) + } + }, + ) { Text("Search") } + if (wallet == null) { + Text("This identity does not have an active local wallet.", color = MaterialTheme.colorScheme.error) + } + results.forEach { row -> + MarketplaceResultCard( + row = row, + isMine = row.ownerId.contentEquals(identityId), + onPurchase = row.priceCredits?.let { price -> + { pendingAction = MarketplaceAction.Purchase(row.label, price) } + }, + ) + } + if (results.isEmpty()) { + Text("Search by prefix or browse all names.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + FormSection(title = "My Names") { + if (durableNames.isEmpty()) { + Text("No marketplace state yet. Tap refresh to sync.") + } + durableNames.forEach { row -> + OwnedNameCard( + row = row, + history = histories[row.normalizedLabel], + onSetPrice = { pendingAction = MarketplaceAction.SetPrice(row.label) }, + onDelist = { pendingAction = MarketplaceAction.Delist(row.label) }, + onTransfer = { pendingAction = MarketplaceAction.Transfer(row.label) }, + onHistory = history@{ + val activeManager = manager ?: return@history + val activeWallet = wallet ?: return@history + launch { + histories[row.normalizedLabel] = + activeManager.dpnsMarketplace.history(activeWallet.handle, row.label) + } + }, + ) + } + } + + FormSection(title = "Sync") { + Text( + if (lastSyncMs == 0L) "Not synced yet" + else "Last synced ${DateFormat.getDateTimeInstance().format(Date(lastSyncMs))}", + modifier = Modifier.testTag("dpnsMarketplace.lastSync"), + ) + syncMessage?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + } + } + + pendingAction?.let { action -> + MarketplaceConfirmationDialog( + action = action, + price = actionPrice, + recipient = actionRecipient, + onPriceChange = { actionPrice = it.filter(Char::isDigit) }, + onRecipientChange = { actionRecipient = it }, + onDismiss = { pendingAction = null }, + onConfirm = { + val activeManager = manager + val activeWallet = wallet + if (activeManager == null || activeWallet == null) { + error = "The wallet manager is unavailable" + pendingAction = null + return@MarketplaceConfirmationDialog + } + launch { + when (action) { + is MarketplaceAction.SetPrice -> { + val credits = actionPrice.toULongOrNull() + ?: throw IllegalArgumentException("Enter a price in credits") + activeManager.dpnsMarketplace.setPrice( + activeWallet.handle, identityId, action.name, credits, + activeManager.signerHandle, + ) + } + is MarketplaceAction.Delist -> activeManager.dpnsMarketplace.delist( + activeWallet.handle, identityId, action.name, activeManager.signerHandle, + ) + is MarketplaceAction.Transfer -> { + val recipient = Base58.decodeIdentifier(actionRecipient) + ?: throw IllegalArgumentException("Enter a valid recipient identity") + activeManager.dpnsMarketplace.transfer( + activeWallet.handle, identityId, action.name, recipient, + activeManager.signerHandle, + ) + } + is MarketplaceAction.Purchase -> activeManager.dpnsMarketplace.purchase( + activeWallet.handle, identityId, action.name, action.expectedPriceCredits, + activeManager.signerHandle, + ) + } + val summary = activeManager.dpnsMarketplace.sync(activeWallet.handle) + lastSyncMs = summary.syncUnixMs + if (results.isNotEmpty()) { + results = activeManager.dpnsMarketplace.search(activeWallet.handle, prefix.trim(), 50) + } + } + pendingAction = null + actionPrice = "" + actionRecipient = "" + }, + ) + } + + ErrorAlertDialog(message = error, onDismiss = { error = null }) +} + +@Composable +private fun MarketplaceResultCard( + row: DpnsMarketplaceName, + isMine: Boolean, + onPurchase: (() -> Unit)?, +) { + Card(Modifier.fillMaxWidth().testTag("dpnsMarketplace.search.${row.normalizedLabel}")) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("${row.label}.dash", style = MaterialTheme.typography.titleMedium) + Text(if (isMine) "Owned by this identity" else "Owner ${Base58.encode(row.ownerId)}") + Text(row.priceCredits?.let(::formatCredits) ?: "Not for sale") + if (!isMine && onPurchase != null) { + TextButton( + onClick = onPurchase, + modifier = Modifier.testTag("dpnsMarketplace.buy.${row.normalizedLabel}"), + ) { Text("Buy") } + } + } + } +} + +@Composable +private fun OwnedNameCard( + row: DpnsNameEntity, + history: List?, + onSetPrice: () -> Unit, + onDelist: () -> Unit, + onTransfer: () -> Unit, + onHistory: () -> Unit, +) { + Card(Modifier.fillMaxWidth().testTag("dpnsMarketplace.owned.${row.normalizedLabel}")) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("${row.label}.${row.parentDomainName}", style = MaterialTheme.typography.titleMedium) + Text( + when (row.saleStatusRaw) { + 0 -> row.priceCredits + ?.let { "Owned · ${formatCredits(it.toULong())}" } + ?: "Owned · not listed" + 1 -> "Sold" + 2 -> "Transferred" + else -> "Unknown status" + }, + ) + row.counterpartyIdentityId?.let { Text("Counterparty ${Base58.encode(it)}") } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (row.isOwned) { + TextButton(onClick = onSetPrice) { Text(if (row.priceCredits == null) "List" else "Reprice") } + if (row.priceCredits != null) TextButton(onClick = onDelist) { Text("Delist") } + TextButton(onClick = onTransfer) { Text("Transfer") } + } + TextButton(onClick = onHistory) { Text("History") } + } + history?.forEach { event -> + Text( + "${event.kind.name.lowercase().replace('_', ' ')} · " + + DateFormat.getDateTimeInstance().format(Date(event.atMs)) + + (event.priceCredits?.let { " · ${formatCredits(it)}" } ?: ""), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun MarketplaceConfirmationDialog( + action: MarketplaceAction, + price: String, + recipient: String, + onPriceChange: (String) -> Unit, + onRecipientChange: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val title = when (action) { + is MarketplaceAction.SetPrice -> "List or reprice ${action.name}.dash" + is MarketplaceAction.Delist -> "Delist ${action.name}.dash" + is MarketplaceAction.Transfer -> "Transfer ${action.name}.dash" + is MarketplaceAction.Purchase -> "Purchase ${action.name}.dash" + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + when (action) { + is MarketplaceAction.SetPrice -> { + OutlinedTextField( + value = price, + onValueChange = onPriceChange, + label = { Text("Price in credits") }, + modifier = Modifier.testTag("dpnsMarketplace.price"), + ) + price.toULongOrNull()?.let { Text(formatCredits(it)) } + } + is MarketplaceAction.Transfer -> OutlinedTextField( + value = recipient, + onValueChange = onRecipientChange, + label = { Text("Recipient identity (Base58 or hex)") }, + modifier = Modifier.testTag("dpnsMarketplace.recipient"), + ) + is MarketplaceAction.Purchase -> Text( + "Confirm the exact listed price: ${formatCredits(action.expectedPriceCredits)}. " + + "If the seller changes it, the purchase fails without executing.", + ) + is MarketplaceAction.Delist -> Text("This removes the sale price but keeps ownership.") + } + Text("Your configured authentication may be requested to sign.") + } + }, + confirmButton = { + TextButton(onClick = onConfirm, modifier = Modifier.testTag("dpnsMarketplace.confirm")) { + Text("Confirm") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private fun formatCredits(credits: ULong): String { + val whole = credits / 1_000u + val fraction = (credits % 1_000u).toString().padStart(3, '0') + return "$credits credits ($whole.$fraction duffs)" +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt index c36c49c9a56..f3ca4e4727a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt @@ -44,6 +44,7 @@ import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.navigation.ContestDetail import org.dashfoundation.example.navigation.DashPayHome +import org.dashfoundation.example.navigation.DpnsMarketplace import org.dashfoundation.example.navigation.KeysList import org.dashfoundation.example.navigation.RegisterName import org.dashfoundation.example.navigation.SelectMainName @@ -353,6 +354,13 @@ fun IdentityDetailScreen(identityIdHex: String, navController: NavHostController } } HorizontalDivider(Modifier.padding(vertical = 8.dp)) + ListItem( + headlineContent = { Text("DPNS Marketplace") }, + supportingContent = { Text("Browse, buy, price, transfer, and view history") }, + modifier = Modifier + .clickable { navController.navigate(DpnsMarketplace(identityIdHex)) } + .testTag("identityDetail.dpnsMarketplace"), + ) ListItem( headlineContent = { Text("Register Name") }, modifier = Modifier diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageModels.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageModels.kt index 4fff2c99f00..016b6b4ba24 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageModels.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageModels.kt @@ -81,7 +81,15 @@ val STORAGE_MODELS: List = listOf( headline = { row -> "${row.text("label") ?: "?"}.${row.text("parentDomainName") ?: "dash"}" }, - subtitle = { row -> row.base58("identityId")?.let { truncateMiddle(it) } }, + subtitle = { row -> + val ownership = if (row.bool("isOwned")) "owned" else when (row.long("saleStatusRaw")) { + 1L -> "sold" + 2L -> "transferred" + else -> "departed" + } + val price = row.long("priceCredits")?.let { " · $it credits" }.orEmpty() + "$ownership$price" + }, ), StorageModel( name = "dashpayProfiles", displayName = "DashPay Profiles", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageRecordDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageRecordDetailScreen.kt index aceefa27ac7..be3c31286ed 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageRecordDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/storage/StorageRecordDetailScreen.kt @@ -118,6 +118,8 @@ private val DATE_COLUMNS = setOf( "createdAt", "updatedAt", "lastUpdated", "lastUpdatedAt", "lastAccessedAt", "lastAccessed", "lastSyncedAt", "eventTimestamp", "localCreatedAt", "localUpdatedAt", "transferredAt", + "marketplaceUpdatedAt", "documentCreatedAtMs", "documentUpdatedAtMs", + "documentTransferredAtMs", ) @Composable diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/10.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/10.json new file mode 100644 index 00000000000..a59399f8de6 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/10.json @@ -0,0 +1,4120 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "8165e473c57aaafa3d2ce95ffaeed515", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8165e473c57aaafa3d2ce95ffaeed515')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index bad082f9c9a..e6ce11bee92 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -358,29 +358,66 @@ class DashDatabaseMigrationTest { db.close() } + /** v9 labels remain owned/unlisted and gain nullable marketplace ids. */ + @Test + fun migrate9To10AddsDpnsMarketplaceState() { + val legacy = helper.createDatabase(dbName, 9) + legacy.execSQL( + "INSERT INTO identities (identityId, networkRaw, balance, revision, identityIndex, " + + "isLocal, identityType, createdAt, lastUpdated) " + + "VALUES (x'01', 1, 0, 0, 0, 1, 'user', 0, 0)", + ) + legacy.execSQL( + "INSERT INTO dpns_names (networkRaw, label, normalizedLabel, parentDomainName, " + + "normalizedParentDomainName, acquiredAt, identityId, createdAt, lastUpdated) " + + "VALUES (1, 'Alice', 'a11ce', 'dash', 'dash', 42, x'01', 0, 0)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 10, true, DashDatabase.MIGRATION_9_10) + db.query( + "SELECT documentId, isOwned, priceCredits, saleStatusRaw, " + + "counterpartyIdentityId, documentCreatedAtMs, documentUpdatedAtMs, " + + "documentTransferredAtMs, marketplaceUpdatedAt FROM dpns_names", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertTrue(cursor.isNull(0)) + assertEquals(1, cursor.getInt(1)) + assertTrue(cursor.isNull(2)) + assertEquals(0, cursor.getInt(3)) + assertTrue(cursor.isNull(4)) + assertEquals(0L, cursor.getLong(5)) + assertEquals(0L, cursor.getLong(6)) + assertEquals(0L, cursor.getLong(7)) + assertEquals(0L, cursor.getLong(8)) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 9, + 10, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, DashDatabase.MIGRATION_6_7, DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, + DashDatabase.MIGRATION_9_10, ).close() } - /** The full chain from v1 must also land on a valid v9 schema. */ + /** The full chain from v1 must also land on a valid v10 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 9, + 10, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -390,6 +427,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_6_7, DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, + DashDatabase.MIGRATION_9_10, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/dpns/DpnsMarketplace.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/dpns/DpnsMarketplace.kt new file mode 100644 index 00000000000..d8eeb3b25ac --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/dpns/DpnsMarketplace.kt @@ -0,0 +1,288 @@ +package org.dashfoundation.dashsdk.dpns + +import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DpnsMarketplaceNative +import org.dashfoundation.dashsdk.wallet.TeardownGate +import org.dashfoundation.dashsdk.wallet.op +import org.json.JSONArray +import org.json.JSONObject + +data class DpnsMarketplaceName( + val documentId: ByteArray, + val ownerId: ByteArray, + val recordsIdentityId: ByteArray?, + val label: String, + val normalizedLabel: String, + val priceCredits: ULong?, + val createdAtMs: Long, + val updatedAtMs: Long, + val transferredAtMs: Long, +) + +enum class DpnsNameSaleStatus(val rawValue: Int) { + OWNED(0), SOLD(1), TRANSFERRED(2); + + companion object { + fun fromRaw(raw: Int): DpnsNameSaleStatus = + entries.firstOrNull { it.rawValue == raw } + ?: throw IllegalArgumentException("unknown DPNS sale status $raw") + } +} + +data class DpnsNameState( + val documentId: ByteArray, + val walletIdentityId: ByteArray, + val label: String, + val normalizedLabel: String, + val priceCredits: ULong?, + val status: DpnsNameSaleStatus, + val counterpartyId: ByteArray?, + val createdAtMs: Long, + val updatedAtMs: Long, + val transferredAtMs: Long, + val lastSyncedAtMs: Long, +) + +enum class DpnsNameHistoryKind(val rawValue: Int) { + REGISTERED(0), PRICE_SET(1), PURCHASED(2), TRANSFERRED(3); + + companion object { + fun fromRaw(raw: Int): DpnsNameHistoryKind = + entries.firstOrNull { it.rawValue == raw } + ?: throw IllegalArgumentException("unknown DPNS history kind $raw") + } +} + +data class DpnsNameHistoryEvent( + val kind: DpnsNameHistoryKind, + val atMs: Long, + val blockHeight: Long?, + val priceCredits: ULong?, + val fromId: ByteArray?, + val toId: ByteArray?, +) + +data class DpnsNameAdded(val identityId: ByteArray, val label: String) +data class DpnsNameDeparted( + val identityId: ByteArray, + val label: String, + val documentId: ByteArray?, + val status: DpnsNameSaleStatus?, + val counterpartyId: ByteArray?, +) +data class DpnsPriceChange( + val documentId: ByteArray, + val label: String, + val previousCredits: ULong?, + val currentCredits: ULong?, +) +data class DpnsMarketplaceSyncSummary( + val tracked: Int, + val added: List, + val departed: List, + val pricesChanged: List, + val syncUnixMs: Long, +) +data class DpnsManagerSyncSummary( + val successCount: Int, + val errorCount: Int, + val syncUnixSeconds: Long, +) + +/** + * Typed Kotlin projection of the wallet-owned DPNS marketplace API. + * Business decisions stay in Rust; this class validates fixed-width ids, + * fences native-handle lifetimes, and decodes copied JNI results. + */ +class DpnsMarketplace internal constructor( + private val gate: TeardownGate? = null, +) { + suspend fun search( + walletHandle: Long, + prefix: String = "", + limit: Int = 0, + startAfter: ByteArray? = null, + ): List = gate.op { + require(limit >= 0) { "limit must be non-negative" } + require(startAfter == null || startAfter.size == 32) { "startAfter must be 32 bytes" } + decodeNames(mapNativeErrors { + DpnsMarketplaceNative.search(walletHandle, prefix, limit, startAfter) + }) + } + + suspend fun nameState(walletHandle: Long, name: String): DpnsMarketplaceName? = gate.op { + mapNativeErrors { DpnsMarketplaceNative.nameState(walletHandle, name) }?.let(::decodeName) + } + + suspend fun myNames(walletHandle: Long, identityId: ByteArray? = null): List = gate.op { + require(identityId == null || identityId.size == 32) { "identityId must be 32 bytes" } + decodeStates(mapNativeErrors { DpnsMarketplaceNative.myNames(walletHandle, identityId) }) + } + + suspend fun history(walletHandle: Long, name: String): List = gate.op { + decodeHistory(mapNativeErrors { DpnsMarketplaceNative.history(walletHandle, name) }) + } + + suspend fun setPrice( + walletHandle: Long, + ownerIdentityId: ByteArray, + name: String, + priceCredits: ULong, + signerHandle: Long, + ): DpnsMarketplaceName = trade(ownerIdentityId, signerHandle) { + DpnsMarketplaceNative.setPrice( + walletHandle, ownerIdentityId, name, priceCredits.toLong(), signerHandle, + ) + } + + suspend fun delist( + walletHandle: Long, + ownerIdentityId: ByteArray, + name: String, + signerHandle: Long, + ): DpnsMarketplaceName = trade(ownerIdentityId, signerHandle) { + DpnsMarketplaceNative.delist(walletHandle, ownerIdentityId, name, signerHandle) + } + + suspend fun transfer( + walletHandle: Long, + ownerIdentityId: ByteArray, + name: String, + recipientIdentityId: ByteArray, + signerHandle: Long, + ): DpnsMarketplaceName = trade(ownerIdentityId, signerHandle) { + require(recipientIdentityId.size == 32) { "recipientIdentityId must be 32 bytes" } + DpnsMarketplaceNative.transfer( + walletHandle, ownerIdentityId, name, recipientIdentityId, signerHandle, + ) + } + + suspend fun purchase( + walletHandle: Long, + purchaserIdentityId: ByteArray, + name: String, + expectedPriceCredits: ULong, + signerHandle: Long, + ): DpnsMarketplaceName = trade(purchaserIdentityId, signerHandle) { + DpnsMarketplaceNative.purchase( + walletHandle, purchaserIdentityId, name, expectedPriceCredits.toLong(), signerHandle, + ) + } + + suspend fun sync(walletHandle: Long): DpnsMarketplaceSyncSummary = gate.op { + decodeSyncSummary(mapNativeErrors { DpnsMarketplaceNative.sync(walletHandle) }) + } + + private suspend fun trade( + identityId: ByteArray, + signerHandle: Long, + native: () -> String, + ): DpnsMarketplaceName = gate.op { + require(identityId.size == 32) { "identityId must be 32 bytes" } + require(signerHandle != 0L) { "signerHandle must not be 0" } + decodeName(mapNativeErrors(native)) + } + + internal companion object { + fun decodeName(json: String): DpnsMarketplaceName = JSONObject(json).toMarketplaceName() + fun decodeNames(json: String): List { + val values = JSONArray(json) + return List(values.length()) { values.getJSONObject(it).toMarketplaceName() } + } + fun decodeStates(json: String): List { + val values = JSONArray(json) + return List(values.length()) { values.getJSONObject(it).toState() } + } + fun decodeHistory(json: String): List { + val values = JSONArray(json) + return List(values.length()) { values.getJSONObject(it).toHistory() } + } + fun decodeSyncSummary(json: String): DpnsMarketplaceSyncSummary = + JSONObject(json).toSyncSummary() + } +} + +private fun JSONObject.optionalLong(name: String): Long? = + if (isNull(name)) null else getLong(name) + +/** Prices cross JSON as decimal strings so the full protocol u64 range is lossless. */ +private fun JSONObject.optionalULong(name: String): ULong? = + if (isNull(name)) null else get(name).toString().toULong() + +private fun JSONObject.optionalId(name: String): ByteArray? = + if (isNull(name)) null else getString(name).decodeHex32(name) + +private fun JSONObject.toMarketplaceName() = DpnsMarketplaceName( + documentId = getString("documentId").decodeHex32("documentId"), + ownerId = getString("ownerId").decodeHex32("ownerId"), + recordsIdentityId = optionalId("recordsIdentityId"), + label = getString("label"), + normalizedLabel = getString("normalizedLabel"), + priceCredits = optionalULong("priceCredits"), + createdAtMs = getLong("createdAtMs"), + updatedAtMs = getLong("updatedAtMs"), + transferredAtMs = getLong("transferredAtMs"), +) + +private fun JSONObject.toState() = DpnsNameState( + documentId = getString("documentId").decodeHex32("documentId"), + walletIdentityId = getString("walletIdentityId").decodeHex32("walletIdentityId"), + label = getString("label"), + normalizedLabel = getString("normalizedLabel"), + priceCredits = optionalULong("priceCredits"), + status = DpnsNameSaleStatus.fromRaw(getInt("status")), + counterpartyId = optionalId("counterpartyId"), + createdAtMs = getLong("createdAtMs"), + updatedAtMs = getLong("updatedAtMs"), + transferredAtMs = getLong("transferredAtMs"), + lastSyncedAtMs = getLong("lastSyncedAtMs"), +) + +private fun JSONObject.toHistory() = DpnsNameHistoryEvent( + kind = DpnsNameHistoryKind.fromRaw(getInt("kind")), + atMs = getLong("atMs"), + blockHeight = optionalLong("blockHeight"), + priceCredits = optionalULong("priceCredits"), + fromId = optionalId("fromId"), + toId = optionalId("toId"), +) + +private fun JSONObject.toSyncSummary(): DpnsMarketplaceSyncSummary { + val addedJson = getJSONArray("added") + val departedJson = getJSONArray("departed") + val pricesJson = getJSONArray("pricesChanged") + return DpnsMarketplaceSyncSummary( + tracked = getInt("tracked"), + added = List(addedJson.length()) { i -> addedJson.getJSONObject(i).let { + DpnsNameAdded(it.getString("identityId").decodeHex32("identityId"), it.getString("label")) + } }, + departed = List(departedJson.length()) { i -> departedJson.getJSONObject(i).let { + DpnsNameDeparted( + identityId = it.getString("identityId").decodeHex32("identityId"), + label = it.getString("label"), + documentId = it.optionalId("documentId"), + status = if (it.isNull("status")) null else DpnsNameSaleStatus.fromRaw(it.getInt("status")), + counterpartyId = it.optionalId("counterpartyId"), + ) + } }, + pricesChanged = List(pricesJson.length()) { i -> pricesJson.getJSONObject(i).let { + DpnsPriceChange( + documentId = it.getString("documentId").decodeHex32("documentId"), + label = it.getString("label"), + previousCredits = it.optionalULong("previousCredits"), + currentCredits = it.optionalULong("currentCredits"), + ) + } }, + syncUnixMs = getLong("syncUnixMs"), + ) +} + +private fun String.decodeHex32(field: String): ByteArray { + require(length == 64) { "$field must contain 32 bytes" } + return ByteArray(32) { index -> + val hi = Character.digit(this[index * 2], 16) + val lo = Character.digit(this[index * 2 + 1], 16) + require(hi >= 0 && lo >= 0) { "$field is not hexadecimal" } + ((hi shl 4) or lo).toByte() + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index f62d85eeced..b6c555a3add 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -1,6 +1,7 @@ package org.dashfoundation.dashsdk.errors import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.json.JSONObject /** * Public error hierarchy of the Kotlin SDK — the Android analog of the @@ -307,6 +308,47 @@ sealed class DashSdkError( class ReservationWalletMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** The DPNS name is not currently listed for sale. */ + class DocumentNotForSale(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** The listing changed after the user confirmed [expectedCredits]. */ + class DocumentPriceChanged( + val documentId: String, + val expectedCredits: ULong, + val actualCredits: ULong, + cause: Throwable? = null, + ) : PlatformWallet( + "The DPNS price changed from $expectedCredits to $actualCredits credits. " + + "Nothing was purchased.", + cause, + ) + + /** The purchasing identity cannot cover price plus the fee reserve. */ + class InsufficientIdentityCredits( + val identityId: String, + val requiredCredits: ULong, + val availableCredits: ULong, + cause: Throwable? = null, + ) : PlatformWallet( + "Identity $identityId has $availableCredits credits but $requiredCredits are required.", + cause, + ) + + /** The name is still in an active contested-name vote. */ + class ContestedNameNotTradable( + val label: String, + val endsAtMs: Long, + cause: Throwable? = null, + ) : PlatformWallet( + if (endsAtMs == 0L) { + "\"$label\" cannot be traded until its contested-name vote resolves." + } else { + "\"$label\" cannot be traded until its contested-name vote ends at $endsAtMs ms." + }, + cause, + ) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -437,6 +479,36 @@ sealed class DashSdkError( 34 -> PlatformWallet.StaleReservationToken(message, cause) // ErrorStaleReservationToken 35 -> PlatformWallet.ReservationTokenConsumed(message, cause) // ErrorReservationTokenConsumed 36 -> PlatformWallet.ReservationWalletMismatch(message, cause) // ErrorReservationWalletMismatch + 37 -> PlatformWallet.DocumentNotForSale(message, cause) + 38 -> parseMarketplaceDetail(message)?.let { detail -> + runCatching { + PlatformWallet.DocumentPriceChanged( + documentId = detail.getString("documentId"), + expectedCredits = detail.requiredULong("expected"), + actualCredits = detail.requiredULong("actual"), + cause = cause, + ) + }.getOrNull() + } ?: PlatformWallet.Generic(code, message, cause) + 39 -> parseMarketplaceDetail(message)?.let { detail -> + runCatching { + PlatformWallet.InsufficientIdentityCredits( + identityId = detail.getString("identityId"), + requiredCredits = detail.requiredULong("required"), + availableCredits = detail.requiredULong("available"), + cause = cause, + ) + }.getOrNull() + } ?: PlatformWallet.Generic(code, message, cause) + 40 -> parseMarketplaceDetail(message)?.let { detail -> + runCatching { + PlatformWallet.ContestedNameNotTradable( + label = detail.getString("label"), + endsAtMs = detail.getLong("endsAtMs"), + cause = cause, + ) + }.getOrNull() + } ?: PlatformWallet.Generic(code, message, cause) // ErrorSigningKeyUnavailable — the STRUCTURED signer // discriminator (dashpay/platform#4060 finding 7): the typed // completion code rides the whole Rust round-trip, no message @@ -458,6 +530,12 @@ sealed class DashSdkError( private fun isSigningKeyUnavailable(message: String): Boolean = message.contains(PlatformWallet.SigningKeyUnavailable.MESSAGE_MARKER) + + private fun parseMarketplaceDetail(message: String): JSONObject? = + runCatching { JSONObject(message) }.getOrNull() + + private fun JSONObject.requiredULong(name: String): ULong = + get(name).toString().toULong() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DpnsMarketplaceNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DpnsMarketplaceNative.kt new file mode 100644 index 00000000000..319d7c84fa6 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DpnsMarketplaceNative.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.dashsdk.ffi + +/** Thin JNI surface over `platform-wallet-ffi`'s DPNS marketplace APIs. */ +internal object DpnsMarketplaceNative { + init { NativeLoader.ensureLoaded() } + + external fun search( + walletHandle: Long, + prefix: String, + limit: Int, + startAfter: ByteArray?, + ): String + + external fun nameState(walletHandle: Long, name: String): String? + external fun myNames(walletHandle: Long, identityId: ByteArray?): String + external fun history(walletHandle: Long, name: String): String + + external fun setPrice( + walletHandle: Long, + ownerIdentityId: ByteArray, + name: String, + priceCredits: Long, + signerHandle: Long, + ): String + + external fun delist( + walletHandle: Long, + ownerIdentityId: ByteArray, + name: String, + signerHandle: Long, + ): String + + external fun transfer( + walletHandle: Long, + ownerIdentityId: ByteArray, + name: String, + recipientIdentityId: ByteArray, + signerHandle: Long, + ): String + + external fun purchase( + walletHandle: Long, + purchaserIdentityId: ByteArray, + name: String, + expectedPriceCredits: Long, + signerHandle: Long, + ): String + + external fun sync(walletHandle: Long): String + external fun syncStart(managerHandle: Long): Boolean + external fun syncStop(managerHandle: Long): Boolean + external fun syncIsRunning(managerHandle: Long): Boolean + external fun syncIsSyncing(managerHandle: Long): Boolean + external fun syncLastUnixSeconds(managerHandle: Long): Long + external fun syncSetInterval(managerHandle: Long, seconds: Long): Boolean + external fun syncNow(managerHandle: Long): LongArray +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 99cba6ec409..65c25e423d0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -328,6 +328,35 @@ abstract class NativePersistenceBridge { /** One identity-id removal. Descriptor `([B[B)I`. */ open fun onPersistIdentityRemoval(walletId: ByteArray, identityId: ByteArray): Int = 0 + // ── DPNS marketplace state extension ───────────────────────────── + + /** + * `PersistenceCallbacksExtension.on_persist_dpns_name_states_fn`, one + * call per upsert row. Descriptor + * `([B[B[BZ[BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJBJJJJ)I`. + */ + @Suppress("LongParameterList") + open fun onPersistDpnsNameState( + walletId: ByteArray, + documentId: ByteArray, + walletIdentityId: ByteArray, + hasCounterparty: Boolean, + counterpartyId: ByteArray, + label: String, + normalizedLabel: String, + normalizedParentDomainName: String, + hasPrice: Boolean, + priceCredits: Long, + status: Byte, + createdAtMs: Long, + updatedAtMs: Long, + transferredAtMs: Long, + lastSyncedAtMs: Long, + ): Int = 0 + + /** DPNS marketplace removal; descriptor `([B[B)I`. */ + open fun onRemoveDpnsNameState(walletId: ByteArray, documentId: ByteArray): Int = 0 + // ── Identity keys ───────────────────────────────────────────────── /** One `IdentityKeyEntryFFI` upsert. Descriptor `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;)I`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativeWalletEventBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativeWalletEventBridge.kt index 425d36d3757..4d7a69993e1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativeWalletEventBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativeWalletEventBridge.kt @@ -7,9 +7,10 @@ import android.util.Log * vtable, reached from Rust via the trampolines in * `rs-unified-sdk-jni/src/events.rs`. * - * Every slot of the vtable is wired: the two ABI-simple slots - * ([onWalletEvent] / [onError]) plus the platform-address and shielded - * completion / progress slots. The completion callbacks fan the Rust-owned + * Every slot of the legacy vtable is wired, and the versioned extension + * carries DPNS marketplace completion: the two ABI-simple slots + * ([onWalletEvent] / [onError]) plus the platform-address, DPNS, and shielded + * completion / progress events. The completion callbacks fan the Rust-owned * `results` arrays out into one flat per-entry call, with a trailing * `…PassCompleted` boundary call carrying the pass's unix timestamp and * entry count. This mirrors `PlatformWalletManager.swift`'s @@ -75,6 +76,26 @@ abstract class NativeWalletEventBridge { open fun onPlatformAddressSyncPassCompleted(syncUnixSeconds: Long, walletCount: Int) { } + /** + * Versioned DPNS marketplace completion extension, once per wallet — + * descriptor `([BZIIIILjava/lang/String;)V`. Values are copied before + * the native callback returns, so no native ownership escapes here. + */ + open fun onDpnsMarketplaceSyncCompleted( + walletId: ByteArray, + success: Boolean, + namesTracked: Int, + namesAdded: Int, + namesDeparted: Int, + pricesChanged: Int, + errorMessage: String?, + ) { + } + + /** DPNS marketplace pass boundary — descriptor `(JI)V`. */ + open fun onDpnsMarketplaceSyncPassCompleted(syncUnixSeconds: Long, walletCount: Int) { + } + /** * `on_shielded_sync_completed_fn`, once per wallet result — descriptor * `([BZZZIJIJLjava/lang/String;)V`. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 70251953420..13e78e16471 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -114,9 +114,14 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * the Swift `PersistentInvitation` model — push-persisted by the * `on_persist_invitations_fn` callback, no Rust rehydrate, no secret * column; the "Sent invitations" list reads it via a Room `Flow`). + * + * Version 10 (DPNS marketplace): enriches `dpns_names` with the stable + * document id, ownership/sale state, counterparty, document timestamps and + * marketplace reconciliation watermark. Defaults keep every legacy label an + * owned, unlisted row until the first native marketplace sync refreshes it. */ @Database( - version = 9, + version = 10, exportSchema = true, entities = [ WalletEntity::class, @@ -514,6 +519,43 @@ abstract class DashDatabase : RoomDatabase() { } } + /** v9 → v10: additive DPNS marketplace state on legacy label rows. */ + val MIGRATION_9_10: Migration = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `dpns_names` ADD COLUMN `documentId` BLOB") + db.execSQL( + "ALTER TABLE `dpns_names` ADD COLUMN `isOwned` " + + "INTEGER NOT NULL DEFAULT 1", + ) + db.execSQL("ALTER TABLE `dpns_names` ADD COLUMN `priceCredits` INTEGER") + db.execSQL( + "ALTER TABLE `dpns_names` ADD COLUMN `saleStatusRaw` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL("ALTER TABLE `dpns_names` ADD COLUMN `counterpartyIdentityId` BLOB") + db.execSQL( + "ALTER TABLE `dpns_names` ADD COLUMN `documentCreatedAtMs` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `dpns_names` ADD COLUMN `documentUpdatedAtMs` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `dpns_names` ADD COLUMN `documentTransferredAtMs` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `dpns_names` ADD COLUMN `marketplaceUpdatedAt` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` " + + "ON `dpns_names` (`documentId`)", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -531,6 +573,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ) .build() 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 abc115a0726..806ab0d62ac 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 @@ -133,7 +133,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_SHIELDED_VIEWING_KEYS or CAPABILITY_PROVIDER_TRANSACTIONS or CAPABILITY_UNSIGNED_TOKEN_STORAGE or - CAPABILITY_WALLET_RESTORE + CAPABILITY_WALLET_RESTORE or + CAPABILITY_DPNS_NAME_STATES /** * The single-thread executor created when no [dispatcher] is injected. @@ -1027,7 +1028,22 @@ class PlatformWalletPersistenceHandler( ) db.identityDao().upsert(row) - // DPNS labels (append-only; upsert by the unique triple). + // IdentityEntryFFI carries the complete canonical label set. Drop + // owned labels that are no longer present; a marketplace state + // callback in the same changeset re-inserts departed rows with + // their sold/transferred status and counterparty. + val canonicalLabels = dpnsNames + .asSequence() + .filter { it.isNotEmpty() } + .map(::normalizeDpnsLabel) + .toSet() + for (persisted in db.dpnsNameDao().getAllByIdentity(identityId)) { + if (persisted.isOwned && persisted.normalizedLabel !in canonicalLabels) { + db.dpnsNameDao().delete(persisted) + } + } + + // DPNS labels (last-write-wins; upsert by the unique triple). for (i in dpnsNames.indices) { val label = dpnsNames[i] if (label.isEmpty()) continue @@ -1042,6 +1058,15 @@ class PlatformWalletPersistenceHandler( acquiredAt = if (acquiredAt != 0L) acquiredAt else existingName?.acquiredAt ?: 0L, identityId = identityId, + documentId = existingName?.documentId, + isOwned = true, + priceCredits = existingName?.priceCredits, + saleStatusRaw = 0, + counterpartyIdentityId = null, + documentCreatedAtMs = existingName?.documentCreatedAtMs ?: 0L, + documentUpdatedAtMs = existingName?.documentUpdatedAtMs ?: 0L, + documentTransferredAtMs = existingName?.documentTransferredAtMs ?: 0L, + marketplaceUpdatedAt = existingName?.marketplaceUpdatedAt ?: 0L, createdAt = existingName?.createdAt ?: java.util.Date(), lastUpdated = now(), ), @@ -1088,6 +1113,72 @@ class PlatformWalletPersistenceHandler( 0 } + @Suppress("LongParameterList") + override fun onPersistDpnsNameState( + walletId: ByteArray, + documentId: ByteArray, + walletIdentityId: ByteArray, + hasCounterparty: Boolean, + counterpartyId: ByteArray, + label: String, + normalizedLabel: String, + normalizedParentDomainName: String, + hasPrice: Boolean, + priceCredits: Long, + status: Byte, + createdAtMs: Long, + updatedAtMs: Long, + transferredAtMs: Long, + lastSyncedAtMs: Long, + ): Int = guarded { + require(status.toInt() in 0..2) { "unknown DPNS sale status $status" } + stage(walletId) { db -> + // The relationship is non-optional. A marketplace sweep can race + // the first identity snapshot, so skip this row and let the next + // sync re-emit it instead of rolling back the complete changeset. + if (db.identityDao().getByIdentityId(walletIdentityId) == null) { + return@stage + } + val networkRaw = db.walletDao().getByWalletId(walletId)?.networkRaw ?: NETWORK_TESTNET + val existing = db.dpnsNameDao().getByDocumentId(documentId) + ?: db.dpnsNameDao().getByUniqueKey( + networkRaw, + normalizedParentDomainName, + normalizedLabel, + ) + db.dpnsNameDao().upsert( + DpnsNameEntity( + networkRaw = networkRaw, + label = label, + normalizedLabel = normalizedLabel, + parentDomainName = normalizedParentDomainName, + normalizedParentDomainName = normalizedParentDomainName, + acquiredAt = existing?.acquiredAt ?: createdAtMs, + identityId = walletIdentityId, + documentId = documentId, + isOwned = status.toInt() == 0, + priceCredits = if (hasPrice) priceCredits else null, + saleStatusRaw = status.toInt(), + counterpartyIdentityId = if (hasCounterparty) counterpartyId else null, + documentCreatedAtMs = createdAtMs, + documentUpdatedAtMs = updatedAtMs, + documentTransferredAtMs = transferredAtMs, + marketplaceUpdatedAt = lastSyncedAtMs, + createdAt = existing?.createdAt ?: now(), + lastUpdated = now(), + ), + ) + } + 0 + } + + override fun onRemoveDpnsNameState(walletId: ByteArray, documentId: ByteArray): Int = guarded { + stage(walletId) { db -> + db.dpnsNameDao().clearMarketplaceByDocumentId(documentId, now()) + } + 0 + } + // ── Identity keys ───────────────────────────────────────────────── override fun onPersistIdentityKeyUpsert( @@ -3084,6 +3175,7 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_PROVIDER_TRANSACTIONS: Long = 0x10 internal const val CAPABILITY_UNSIGNED_TOKEN_STORAGE: Long = 0x20 internal const val CAPABILITY_WALLET_RESTORE: Long = 0x80 + internal const val CAPABILITY_DPNS_NAME_STATES: Long = 0x100 private const val TAG = "DashPersistence" diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DpnsNameDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DpnsNameDao.kt index bdab561ce59..e358fa03a3a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DpnsNameDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DpnsNameDao.kt @@ -16,9 +16,32 @@ import org.dashfoundation.dashsdk.persistence.entities.DpnsNameEntity interface DpnsNameDao { /** Mirror of `predicate(identityId:)` — labels owned by one identity. */ - @Query("SELECT * FROM dpns_names WHERE identityId = :identityId") + @Query("SELECT * FROM dpns_names WHERE identityId = :identityId AND isOwned = 1") fun observeByIdentity(identityId: ByteArray): Flow> + /** Marketplace rows include retained sold/transferred history. */ + @Query("SELECT * FROM dpns_names WHERE identityId = :identityId ORDER BY normalizedLabel") + fun observeMarketplaceByIdentity(identityId: ByteArray): Flow> + + @Query("SELECT * FROM dpns_names WHERE identityId = :identityId") + suspend fun getAllByIdentity(identityId: ByteArray): List + + @Query("SELECT * FROM dpns_names WHERE documentId = :documentId LIMIT 1") + suspend fun getByDocumentId(documentId: ByteArray): DpnsNameEntity? + + /** + * Clear fields owned by marketplace reconciliation without deleting the + * identity snapshot's label-cache row. + */ + @Query( + "UPDATE dpns_names SET documentId = NULL, priceCredits = NULL, " + + "saleStatusRaw = 0, counterpartyIdentityId = NULL, " + + "documentCreatedAtMs = 0, documentUpdatedAtMs = 0, " + + "documentTransferredAtMs = 0, marketplaceUpdatedAt = 0, " + + "lastUpdated = :lastUpdated WHERE documentId = :documentId" + ) + suspend fun clearMarketplaceByDocumentId(documentId: ByteArray, lastUpdated: java.util.Date) + /** Persister upsert key (the Swift `#Unique` triple). */ @Query( "SELECT * FROM dpns_names WHERE networkRaw = :networkRaw " + diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DpnsNameEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DpnsNameEntity.kt index 110a6b4609e..de33515e308 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DpnsNameEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DpnsNameEntity.kt @@ -19,7 +19,7 @@ import java.util.Date @Entity( tableName = "dpns_names", primaryKeys = ["networkRaw", "normalizedParentDomainName", "normalizedLabel"], - indices = [Index(value = ["identityId"])], + indices = [Index(value = ["identityId"]), Index(value = ["documentId"])], foreignKeys = [ ForeignKey( entity = IdentityEntity::class, @@ -43,6 +43,21 @@ data class DpnsNameEntity( val acquiredAt: Long = 0, /** Owning identity (32 bytes) — non-optional in Swift. */ val identityId: ByteArray, + /** Stable DPNS domain-document id; null for legacy label-only rows. */ + val documentId: ByteArray? = null, + /** Ownership relative to [identityId]. False retains sale/transfer history. */ + val isOwned: Boolean = true, + /** Listed price in Platform credits, or null when not for sale. */ + val priceCredits: Long? = null, + /** 0 owned, 1 sold, 2 transferred. */ + val saleStatusRaw: Int = 0, + /** Buyer/recipient for departed names. */ + val counterpartyIdentityId: ByteArray? = null, + val documentCreatedAtMs: Long = 0, + val documentUpdatedAtMs: Long = 0, + val documentTransferredAtMs: Long = 0, + /** Wall-clock time of the marketplace reconciliation that wrote this row. */ + val marketplaceUpdatedAt: Long = 0, val createdAt: Date = Date(), val lastUpdated: Date = Date(), ) 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 76c5f4901e6..1d216a594bb 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 @@ -23,6 +23,7 @@ import org.dashfoundation.dashsdk.Sdk import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative +import org.dashfoundation.dashsdk.ffi.DpnsMarketplaceNative import org.dashfoundation.dashsdk.ffi.FundingNative import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge import org.dashfoundation.dashsdk.ffi.WalletManagerNative @@ -57,6 +58,7 @@ data class PlatformWalletPersistenceCapabilities( const val UNSIGNED_TOKEN_STORAGE: Long = 1L shl 5 const val PENDING_CONTACT_CRYPTO: Long = 1L shl 6 const val WALLET_RESTORE: Long = 1L shl 7 + const val DPNS_NAME_STATES: Long = 1L shl 8 } } @@ -295,6 +297,37 @@ class PlatformWalletManager( ) } + override fun onDpnsMarketplaceSyncCompleted( + walletId: ByteArray, + success: Boolean, + namesTracked: Int, + namesAdded: Int, + namesDeparted: Int, + pricesChanged: Int, + errorMessage: String?, + ) { + _syncEvents.tryEmit( + WalletSyncEvent.DpnsMarketplaceResult( + walletId = walletId, + success = success, + namesTracked = namesTracked, + namesAdded = namesAdded, + namesDeparted = namesDeparted, + pricesChanged = pricesChanged, + errorMessage = errorMessage, + ), + ) + } + + override fun onDpnsMarketplaceSyncPassCompleted( + syncUnixSeconds: Long, + walletCount: Int, + ) { + _syncEvents.tryEmit( + WalletSyncEvent.DpnsMarketplacePassCompleted(syncUnixSeconds, walletCount), + ) + } + override fun onShieldedSyncCompleted( walletId: ByteArray, success: Boolean, @@ -632,6 +665,10 @@ class PlatformWalletManager( val documentTransactions: org.dashfoundation.dashsdk.documents.DocumentTransactions = org.dashfoundation.dashsdk.documents.DocumentTransactions(teardownGate) + /** DPNS marketplace queries, trades, history and per-wallet sync. */ + val dpnsMarketplace: org.dashfoundation.dashsdk.dpns.DpnsMarketplace = + org.dashfoundation.dashsdk.dpns.DpnsMarketplace(teardownGate) + /** * Masternode contested-resource vote bridge — port of * `SDK.castContestedResourceVote` (driven by Swift `ContestDetailView`). @@ -1963,6 +2000,47 @@ class PlatformWalletManager( ) } + // ── DPNS marketplace sync ───────────────────────────────────────── + + /** Start the recurring cross-wallet DPNS marketplace sweep. */ + suspend fun startDpnsSync() = withContext(Dispatchers.IO) { + mapNativeErrors { DpnsMarketplaceNative.syncStart(managerHandle) } + } + + /** Stop the recurring DPNS marketplace sweep; it may be started again. */ + suspend fun stopDpnsSync() = withContext(Dispatchers.IO) { + mapNativeErrors { DpnsMarketplaceNative.syncStop(managerHandle) } + } + + suspend fun isDpnsSyncRunning(): Boolean = withContext(Dispatchers.IO) { + mapNativeErrors { DpnsMarketplaceNative.syncIsRunning(managerHandle) } + } + + suspend fun isDpnsSyncing(): Boolean = withContext(Dispatchers.IO) { + mapNativeErrors { DpnsMarketplaceNative.syncIsSyncing(managerHandle) } + } + + suspend fun dpnsLastSyncUnixSeconds(): Long = withContext(Dispatchers.IO) { + mapNativeErrors { DpnsMarketplaceNative.syncLastUnixSeconds(managerHandle) } + } + + suspend fun setDpnsSyncInterval(seconds: Long) = withContext(Dispatchers.IO) { + require(seconds > 0) { "seconds must be positive" } + mapNativeErrors { DpnsMarketplaceNative.syncSetInterval(managerHandle, seconds) } + } + + /** Run one DPNS marketplace sweep across every registered wallet now. */ + suspend fun dpnsSyncNow(): org.dashfoundation.dashsdk.dpns.DpnsManagerSyncSummary = + withContext(Dispatchers.IO) { + val values = mapNativeErrors { DpnsMarketplaceNative.syncNow(managerHandle) } + check(values.size == 3) { "DPNS sync result must contain three values" } + org.dashfoundation.dashsdk.dpns.DpnsManagerSyncSummary( + successCount = values[0].toInt(), + errorCount = values[1].toInt(), + syncUnixSeconds = values[2], + ) + } + /** Deferred contact-crypto entries queued on [walletId]'s wallet. */ suspend fun contactCryptoPendingCount(walletId: ByteArray): Int = withContext(Dispatchers.IO) { @@ -2199,6 +2277,7 @@ class PlatformWalletManager( withContext(Dispatchers.IO) { // Best-effort stop; ignore failures (destroy shuts it all down). runCatching { DashpayNative.dashPaySyncStop(managerHandle) } + runCatching { DpnsMarketplaceNative.syncStop(managerHandle) } runCatching { WalletManagerNative.platformAddressSyncStop(managerHandle) } runCatching { WalletManagerNative.identitySyncStop(managerHandle) } runCatching { WalletManagerNative.shieldedSyncStop(managerHandle) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletSyncEvent.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletSyncEvent.kt index d53cbe81aae..41e82ce83f2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletSyncEvent.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/WalletSyncEvent.kt @@ -62,6 +62,41 @@ sealed interface WalletSyncEvent { val walletCount: Int, ) : WalletSyncEvent + /** One wallet's bounded DPNS marketplace reconciliation result. */ + data class DpnsMarketplaceResult( + val walletId: ByteArray, + val success: Boolean, + val namesTracked: Int, + val namesAdded: Int, + val namesDeparted: Int, + val pricesChanged: Int, + val errorMessage: String?, + ) : WalletSyncEvent { + override fun equals(other: Any?): Boolean = + other is DpnsMarketplaceResult && + walletId.contentEquals(other.walletId) && + success == other.success && + namesTracked == other.namesTracked && + namesAdded == other.namesAdded && + namesDeparted == other.namesDeparted && + pricesChanged == other.pricesChanged && + errorMessage == other.errorMessage + + override fun hashCode(): Int { + var result = walletId.contentHashCode() + result = 31 * result + success.hashCode() + result = 31 * result + namesDeparted + result = 31 * result + pricesChanged + return result + } + } + + /** DPNS marketplace pass boundary (all wallets done). */ + data class DpnsMarketplacePassCompleted( + val syncUnixSeconds: Long, + val walletCount: Int, + ) : WalletSyncEvent + /** * One wallet's shielded sync result (`on_shielded_sync_completed_fn`, * per entry). diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/dpns/DpnsMarketplaceTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/dpns/DpnsMarketplaceTest.kt new file mode 100644 index 00000000000..1c8527f2acd --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/dpns/DpnsMarketplaceTest.kt @@ -0,0 +1,53 @@ +package org.dashfoundation.dashsdk.dpns + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DpnsMarketplaceTest { + @Test + fun decodesMarketplaceNameWithOptionalFields() { + val name = DpnsMarketplace.decodeName( + """{"documentId":"${"01".repeat(32)}","ownerId":"${"02".repeat(32)}","recordsIdentityId":null,"label":"Alice","normalizedLabel":"a11ce","priceCredits":5000,"createdAtMs":1,"updatedAtMs":2,"transferredAtMs":0}""", + ) + + assertArrayEquals(ByteArray(32) { 1 }, name.documentId) + assertArrayEquals(ByteArray(32) { 2 }, name.ownerId) + assertNull(name.recordsIdentityId) + assertEquals("Alice", name.label) + assertEquals(5_000uL, name.priceCredits) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsUnknownSaleStatus() { + DpnsMarketplace.decodeStates( + """[{"documentId":"${"01".repeat(32)}","walletIdentityId":"${"02".repeat(32)}","label":"Alice","normalizedLabel":"a11ce","priceCredits":null,"status":99,"counterpartyId":null,"createdAtMs":0,"updatedAtMs":0,"transferredAtMs":0,"lastSyncedAtMs":0}]""", + ) + } + + @Test + fun decodesFullUnsignedPriceRangeLosslessly() { + val name = DpnsMarketplace.decodeName( + """{"documentId":"${"01".repeat(32)}","ownerId":"${"02".repeat(32)}","recordsIdentityId":null,"label":"Alice","normalizedLabel":"a11ce","priceCredits":"18446744073709551615","createdAtMs":1,"updatedAtMs":2,"transferredAtMs":0}""", + ) + assertEquals(ULong.MAX_VALUE, name.priceCredits) + + val summary = DpnsMarketplace.decodeSyncSummary( + """{"tracked":1,"added":[],"departed":[],"pricesChanged":[{"documentId":"${"03".repeat(32)}","label":"Alice","previousCredits":null,"currentCredits":"18446744073709551615"}],"syncUnixMs":4}""", + ) + assertNull(summary.pricesChanged.single().previousCredits) + assertEquals(ULong.MAX_VALUE, summary.pricesChanged.single().currentCredits) + assertEquals(4L, summary.syncUnixMs) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsUnknownHistoryKind() { + DpnsMarketplace.decodeHistory( + """[{"kind":99,"atMs":0,"blockHeight":null,"priceCredits":null,"fromId":null,"toId":null}]""", + ) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DpnsMarketplaceErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DpnsMarketplaceErrorTest.kt new file mode 100644 index 00000000000..fccc8cc8dcc --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DpnsMarketplaceErrorTest.kt @@ -0,0 +1,36 @@ +package org.dashfoundation.dashsdk.errors + +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DpnsMarketplaceErrorTest { + @Test + fun mapsPriceChangedDetail() { + val error = DashSdkError.fromNative( + DashSDKException( + DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 38, + """{"documentId":"abc","expected":1000,"actual":2000}""", + ), + ) + + assertTrue(error is DashSdkError.PlatformWallet.DocumentPriceChanged) + error as DashSdkError.PlatformWallet.DocumentPriceChanged + assertEquals("abc", error.documentId) + assertEquals(1_000uL, error.expectedCredits) + assertEquals(2_000uL, error.actualCredits) + } + + @Test + fun malformedTypedDetailFailsClosed() { + val error = DashSdkError.fromNative( + DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 39, "not-json"), + ) + assertTrue(error is DashSdkError.PlatformWallet.Generic) + assertEquals(39, (error as DashSdkError.PlatformWallet.Generic).nativeCode) + } +} 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 c3d16f7c511..1e2f0daa34b 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 @@ -65,7 +65,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0xbfL, handler.persistenceCapabilitiesBits()) + assertEquals(0x1bfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -76,6 +76,7 @@ class PlatformWalletPersistenceHandlerTest { ) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.ATOMIC_CHANGESETS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) } // ── Standalone (non-bracketed) writes ───────────────────────────── @@ -735,6 +736,122 @@ class PlatformWalletPersistenceHandlerTest { assertNull(profile.avatarFingerprint) } + @Test + fun identityDpnsSnapshotRemovesStaleOwnedLabels() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 12 } + + fun persistSnapshot(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) + } + + persistSnapshot("Alice", "Bob") + assertEquals(2, db.dpnsNameDao().observeByIdentity(identityId).first().size) + + persistSnapshot("Alice") + val current = db.dpnsNameDao().observeByIdentity(identityId).first() + assertEquals(listOf("Alice"), current.map { it.label }) + assertEquals(1, db.dpnsNameDao().observeMarketplaceByIdentity(identityId).first().size) + } + + @Test + fun marketplaceStateRetainsDepartedNameAndCanClearIt() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val identityId = ByteArray(32) { 13 } + val documentId = ByteArray(32) { 14 } + val buyerId = ByteArray(32) { 15 } + + handler.onChangesetBegin(walletId) + handler.onPersistIdentityUpsert( + walletId, identityId, 1, 0, false, 0, 0, true, walletId, + emptyArray(), longArrayOf(), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onPersistDpnsNameState( + walletId = walletId, + documentId = documentId, + walletIdentityId = identityId, + hasCounterparty = true, + counterpartyId = buyerId, + label = "Alice", + normalizedLabel = "a11ce", + normalizedParentDomainName = "dash", + hasPrice = false, + priceCredits = 0, + status = 1, + createdAtMs = 100, + updatedAtMs = 200, + transferredAtMs = 300, + lastSyncedAtMs = 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertTrue(db.dpnsNameDao().observeByIdentity(identityId).first().isEmpty()) + val retained = db.dpnsNameDao().observeMarketplaceByIdentity(identityId).first().single() + assertTrue(documentId.contentEquals(retained.documentId!!)) + assertFalse(retained.isOwned) + assertEquals(1, retained.saleStatusRaw) + assertTrue(buyerId.contentEquals(retained.counterpartyIdentityId!!)) + assertEquals(100L, retained.documentCreatedAtMs) + assertEquals(200L, retained.documentUpdatedAtMs) + assertEquals(300L, retained.documentTransferredAtMs) + assertEquals(400L, retained.marketplaceUpdatedAt) + + handler.onChangesetBegin(walletId) + handler.onRemoveDpnsNameState(walletId, documentId) + handler.onChangesetEnd(walletId, success = true) + assertNull(db.dpnsNameDao().getByDocumentId(documentId)) + val labelCache = db.dpnsNameDao().observeMarketplaceByIdentity(identityId).first().single() + assertEquals("Alice", labelCache.label) + assertFalse(labelCache.isOwned) + assertNull(labelCache.documentId) + assertNull(labelCache.priceCredits) + assertEquals(0, labelCache.saleStatusRaw) + assertNull(labelCache.counterpartyIdentityId) + assertEquals(0L, labelCache.documentCreatedAtMs) + assertEquals(0L, labelCache.documentUpdatedAtMs) + assertEquals(0L, labelCache.documentTransferredAtMs) + assertEquals(0L, labelCache.marketplaceUpdatedAt) + } + + @Test + fun marketplaceStateSkipsUnknownIdentityWithoutRollingBackRound() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val missingIdentityId = ByteArray(32) { 16 } + val documentId = ByteArray(32) { 17 } + + handler.onChangesetBegin(walletId) + assertEquals( + 0, + handler.onPersistDpnsNameState( + walletId = walletId, + documentId = documentId, + walletIdentityId = missingIdentityId, + hasCounterparty = false, + counterpartyId = ByteArray(32), + label = "Orphan", + normalizedLabel = "0rphan", + normalizedParentDomainName = "dash", + hasPrice = false, + priceCredits = 0, + status = 0, + createdAtMs = 100, + updatedAtMs = 200, + transferredAtMs = 0, + lastSyncedAtMs = 300, + ), + ) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + assertNull(db.dpnsNameDao().getByDocumentId(documentId)) + } + @Test fun identityRemovalDeletesTheRow() = runTest { handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletEventFanOutTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletEventFanOutTest.kt index 3b193e914cc..e11f58621f5 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletEventFanOutTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/WalletEventFanOutTest.kt @@ -54,6 +54,30 @@ class WalletEventFanOutTest { sink.tryEmit(WalletSyncEvent.PlatformAddressPassCompleted(syncUnixSeconds, walletCount)) } + override fun onDpnsMarketplaceSyncCompleted( + walletId: ByteArray, + success: Boolean, + namesTracked: Int, + namesAdded: Int, + namesDeparted: Int, + pricesChanged: Int, + errorMessage: String?, + ) { + sink.tryEmit( + WalletSyncEvent.DpnsMarketplaceResult( + walletId, success, namesTracked, namesAdded, + namesDeparted, pricesChanged, errorMessage, + ), + ) + } + + override fun onDpnsMarketplaceSyncPassCompleted( + syncUnixSeconds: Long, + walletCount: Int, + ) { + sink.tryEmit(WalletSyncEvent.DpnsMarketplacePassCompleted(syncUnixSeconds, walletCount)) + } + override fun onShieldedSyncCompleted( walletId: ByteArray, success: Boolean, @@ -104,6 +128,8 @@ class WalletEventFanOutTest { bridge.onError("err") bridge.onPlatformAddressSyncCompleted(walletId, true, 1, 2, 3, 4, 5, 6, null) bridge.onPlatformAddressSyncPassCompleted(1_000, 1) + bridge.onDpnsMarketplaceSyncCompleted(walletId, true, 7, 8, 9, 10, null) + bridge.onDpnsMarketplaceSyncPassCompleted(1_500, 1) bridge.onShieldedSyncCompleted(walletId, true, false, false, 7, 8, 9, 10, null) bridge.onShieldedSyncPassCompleted(2_000, 1) bridge.onShieldedSyncProgress(11, 12) @@ -113,7 +139,7 @@ class WalletEventFanOutTest { kotlinx.coroutines.yield() collector.cancel() - assertEquals(8, received.size) + assertEquals(10, received.size) assertTrue(received[0] is WalletSyncEvent.Generic) assertEquals("evt", (received[0] as WalletSyncEvent.Generic).debug) assertTrue(received[1] is WalletSyncEvent.Error) @@ -126,15 +152,20 @@ class WalletEventFanOutTest { assertTrue(received[3] is WalletSyncEvent.PlatformAddressPassCompleted) - val sr = received[4] as WalletSyncEvent.ShieldedResult + val dpns = received[4] as WalletSyncEvent.DpnsMarketplaceResult + assertEquals(7, dpns.namesTracked) + assertEquals(9, dpns.namesDeparted) + assertTrue(received[5] is WalletSyncEvent.DpnsMarketplacePassCompleted) + + val sr = received[6] as WalletSyncEvent.ShieldedResult assertEquals(7, sr.newNotes) assertEquals(8L, sr.totalScanned) assertEquals(10L, sr.balance) - assertTrue(received[5] is WalletSyncEvent.ShieldedPassCompleted) - val sp = received[6] as WalletSyncEvent.ShieldedProgress + assertTrue(received[7] is WalletSyncEvent.ShieldedPassCompleted) + val sp = received[8] as WalletSyncEvent.ShieldedProgress assertEquals(11L, sp.cumulativeScanned) - val tp = received[7] as WalletSyncEvent.ShieldedTreeProgress + val tp = received[9] as WalletSyncEvent.ShieldedTreeProgress assertEquals(13L, tp.leavesCommitted) } } diff --git a/packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs b/packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs new file mode 100644 index 00000000000..80a1dcbd1b4 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dpns_marketplace.rs @@ -0,0 +1,1421 @@ +//! FFI bindings for the DPNS username marketplace on the platform-wallet +//! [`IdentityWallet`](platform_wallet::IdentityWallet): search with sale +//! state, the local name-state rows, the four trade ops (list / delist / +//! transfer / purchase), the per-name trade history, and the on-demand +//! marketplace sync pass. +//! +//! Wallet-layer design record: +//! `platform-wallet`. The typed rejections +//! these entry points can return (`ErrorDocumentNotForSale`, +//! `ErrorDocumentPriceChanged`, `ErrorInsufficientIdentityCredits`, +//! `ErrorContestedNameNotTradable`) are documented on +//! [`PlatformWalletFFIResultCode`](crate::error::PlatformWalletFFIResultCode); +//! three of them carry a stable JSON detail object in the result message. +//! +//! Prices are **credits** everywhere on this boundary (1 duff = 1000 +//! credits). The duffs↔credits conversion is a host concern. +//! +//! Memory contract, matching the rest of the crate: every returned +//! pointer is Rust-owned and released by the paired `*_free` function in +//! this module — single values with +//! [`dpns_marketplace_name_free`], arrays with +//! [`dpns_marketplace_names_free`] / [`dpns_name_state_rows_free`] / +//! [`dpns_name_history_events_free`]. A single value and an array are +//! DIFFERENT allocations (a `Box` vs a `Box<[T]>`), so the free +//! functions are not interchangeable. Empty results are reported as +//! `null` + count `0` with a success code, never as an error. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +use dpp::prelude::Identifier; +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateEntry}; +use platform_wallet::wallet::identity::network::{ + DpnsDomainState, DpnsNameHistoryEvent, DpnsNameHistoryEventKind, +}; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::check_ptr; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::read_identifier; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +// --------------------------------------------------------------------------- +// Flat result structs +// --------------------------------------------------------------------------- + +/// A DPNS `domain` document read off Platform, with the marketplace +/// fields the plain name lookup drops: the document id every trade +/// transition needs, and `$price` (the sale state). +/// +/// `label` / `normalized_label` are heap-allocated NUL-terminated UTF-8 +/// owned by this struct. Release a single value with +/// [`dpns_marketplace_name_free`], an array with +/// [`dpns_marketplace_names_free`]. +/// +/// Optional fields travel as a `has_*` flag plus the value, never as a +/// sentinel: `has_price == false` means "not listed for sale", which is +/// a different fact from "listed at 0 credits". +#[repr(C)] +pub struct DpnsMarketplaceNameFFI { + /// The domain document id — stable across transfers and purchases. + pub document_id: [u8; 32], + /// The document's `$ownerId`: the identity that owns (and may sell) + /// the name. + pub owner_id: [u8; 32], + /// Whether `records_identity_id` is populated. + pub has_records_identity: bool, + /// `records.identity` — the identity the name resolves to. The + /// protocol rewrites it to the new owner on purchase/transfer. + /// Ignore unless `has_records_identity`. + pub records_identity_id: [u8; 32], + /// Display label, e.g. "Alice". + pub label: *mut c_char, + /// Homograph-normalized label, e.g. "a11ce". + pub normalized_label: *mut c_char, + /// Whether `price` is populated. `false` = the name is NOT for sale. + pub has_price: bool, + /// Listed sale price in credits (`$price`). Ignore unless `has_price`. + pub price: u64, + /// Document `$createdAt` in ms. `0` = unknown (the existing + /// convention on this boundary for absent document timestamps). + pub created_at_ms: u64, + /// Document `$updatedAt` in ms — bumps on price changes. `0` = unknown. + pub updated_at_ms: u64, + /// Document `$transferredAt` in ms — set on purchase/transfer. + /// `0` = unknown. + pub transferred_at_ms: u64, +} + +/// One locally persisted marketplace row: a name tracked for a wallet +/// identity, with its sale state and — for names that already left — +/// the counterparty. +/// +/// Distinct from [`DpnsMarketplaceNameFFI`]: this is the wallet's own +/// bookkeeping (no network read), so it carries `wallet_identity_id` / +/// `status` / `counterparty_id` instead of the live document's +/// `$ownerId` and `records.identity`. For a `Sold`/`Transferred` row +/// the current owner IS the counterparty; for an `Owned` row it is +/// `wallet_identity_id`. Release with [`dpns_name_state_rows_free`]. +#[repr(C)] +pub struct DpnsNameStateRowFFI { + /// The domain document id — this row's key. + pub document_id: [u8; 32], + /// The wallet identity this row is tracked for. For `Owned` rows the + /// document's `$ownerId`; for `Sold`/`Transferred` rows the previous + /// owner (ours). + pub wallet_identity_id: [u8; 32], + /// Display label, e.g. "Alice". + pub label: *mut c_char, + /// Homograph-normalized label, e.g. "a11ce". + pub normalized_label: *mut c_char, + /// Whether `price` is populated. `false` = not listed for sale. + pub has_price: bool, + /// Last-known listed sale price in credits. Ignore unless `has_price`. + pub price: u64, + /// Ownership status relative to `wallet_identity_id`: + /// `0` = owned, `1` = sold, `2` = transferred. + pub status: u8, + /// Whether `counterparty_id` is populated — true exactly when + /// `status != 0`. + pub has_counterparty: bool, + /// The buyer (`status == 1`) or recipient (`status == 2`). Ignore + /// unless `has_counterparty`. + pub counterparty_id: [u8; 32], + /// Document `$createdAt` in ms. `0` = unknown. + pub created_at_ms: u64, + /// Document `$updatedAt` in ms. `0` = unknown. + pub updated_at_ms: u64, + /// Document `$transferredAt` in ms. `0` = unknown. + pub transferred_at_ms: u64, + /// Wall-clock ms of the sync pass / confirmed transition that wrote + /// this row. + pub last_synced_at_ms: u64, +} + +/// One event in a name's trade timeline. All-POD (no owned strings), but +/// the array is still Rust-allocated — release it with +/// [`dpns_name_history_events_free`]. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct DpnsNameHistoryEventFFI { + /// What happened: `0` = registered, `1` = price set, `2` = purchased, + /// `3` = transferred. A transfer whose `from_id == to_id` is a + /// transfer-to-self delist. + pub kind: u8, + /// Block time of the event in ms. + pub at_ms: u64, + /// Whether `block_height` is populated. + pub has_block_height: bool, + /// Block height of the event. Ignore unless `has_block_height`. + pub block_height: u64, + /// Whether `price` is populated — true for `kind` 1 and 2. + pub has_price: bool, + /// Price in credits. Ignore unless `has_price`. + pub price: u64, + /// Whether `from_id` is populated — true for `kind` 2 and 3. + pub has_from: bool, + /// The seller (`kind == 2`) or sender (`kind == 3`). Ignore unless + /// `has_from`. + pub from_id: [u8; 32], + /// Whether `to_id` is populated — true for `kind` 2 and 3. + pub has_to: bool, + /// The buyer (`kind == 2`) or recipient (`kind == 3`). Ignore unless + /// `has_to`. + pub to_id: [u8; 32], +} + +/// One label newly observed on a wallet identity during marketplace sync. +#[repr(C)] +pub struct DpnsNameAddedFFI { + pub identity_id: [u8; 32], + pub label: *mut c_char, +} + +/// One name that left a wallet identity during marketplace sync. +#[repr(C)] +pub struct DpnsNameDepartedFFI { + pub identity_id: [u8; 32], + pub label: *mut c_char, + pub has_document_id: bool, + pub document_id: [u8; 32], + /// Whether the departure could be classified. When true, `status` is + /// `1` for sold or `2` for transferred and `counterparty_id` is present. + pub has_status: bool, + pub status: u8, + pub counterparty_id: [u8; 32], +} + +/// One listing-price change observed during marketplace sync. +#[repr(C)] +pub struct DpnsPriceChangeFFI { + pub document_id: [u8; 32], + pub label: *mut c_char, + pub has_previous: bool, + pub previous: u64, + pub has_current: bool, + pub current: u64, +} + +/// Lossless result of one per-wallet marketplace sync pass. +/// +/// Release all owned arrays and strings with +/// [`dpns_marketplace_sync_summary_free`]. The older counts-only API remains +/// available for source and ABI compatibility. +#[repr(C)] +pub struct DpnsMarketplaceSyncSummaryFFI { + pub names_tracked: u32, + pub names_added: *mut DpnsNameAddedFFI, + pub names_added_count: usize, + pub names_departed: *mut DpnsNameDepartedFFI, + pub names_departed_count: usize, + pub prices_changed: *mut DpnsPriceChangeFFI, + pub prices_changed_count: usize, + pub sync_unix_ms: u64, +} + +impl Default for DpnsMarketplaceSyncSummaryFFI { + fn default() -> Self { + Self { + names_tracked: 0, + names_added: ptr::null_mut(), + names_added_count: 0, + names_departed: ptr::null_mut(), + names_departed_count: 0, + prices_changed: ptr::null_mut(), + prices_changed_count: 0, + sync_unix_ms: 0, + } + } +} + +// --------------------------------------------------------------------------- +// Conversions +// --------------------------------------------------------------------------- + +/// Heap-allocate `s` as an owned C string, or `null` if it contains an +/// interior NUL. Same fallback the DPNS label arrays use in +/// [`crate::dpns`] — the host reads a null label as an empty one rather +/// than losing the whole row. +fn owned_c_string(s: &str) -> *mut c_char { + CString::new(s) + .map(|c| c.into_raw()) + .unwrap_or(ptr::null_mut()) +} + +/// Release a C string produced by [`owned_c_string`] and null the slot, +/// so a second free is a no-op. +/// +/// # Safety +/// `slot` must be null or point at a `CString::into_raw` allocation. +unsafe fn free_owned_c_string(slot: &mut *mut c_char) { + if !slot.is_null() { + let _ = unsafe { CString::from_raw(*slot) }; + *slot = ptr::null_mut(); + } +} + +impl DpnsMarketplaceNameFFI { + /// Flatten a live domain state. Allocates both label strings. + fn from_state(state: &DpnsDomainState) -> Self { + let (has_records_identity, records_identity_id) = match state.records_identity_id { + Some(id) => (true, id.to_buffer()), + None => (false, [0u8; 32]), + }; + let (has_price, price) = match state.price { + Some(p) => (true, p), + None => (false, 0), + }; + Self { + document_id: state.document_id.to_buffer(), + owner_id: state.owner_id.to_buffer(), + has_records_identity, + records_identity_id, + label: owned_c_string(&state.label), + normalized_label: owned_c_string(&state.normalized_label), + has_price, + price, + created_at_ms: state.created_at_ms.unwrap_or(0), + updated_at_ms: state.updated_at_ms.unwrap_or(0), + transferred_at_ms: state.transferred_at_ms.unwrap_or(0), + } + } +} + +impl DpnsNameStateRowFFI { + /// Flatten a persisted marketplace row. Allocates both label strings. + fn from_entry(entry: &DpnsNameStateEntry) -> Self { + // Wildcard-free so a new status variant is a compile error rather + // than a silent mis-map (same discipline as `status_to_u8` in + // `invitation_persistence`). + let (status, has_counterparty, counterparty_id) = match entry.status { + DpnsNameSaleStatus::Owned => (0u8, false, [0u8; 32]), + DpnsNameSaleStatus::Sold { to } => (1u8, true, to.to_buffer()), + DpnsNameSaleStatus::Transferred { to } => (2u8, true, to.to_buffer()), + }; + let (has_price, price) = match entry.price { + Some(p) => (true, p), + None => (false, 0), + }; + Self { + document_id: entry.document_id.to_buffer(), + wallet_identity_id: entry.wallet_identity_id.to_buffer(), + label: owned_c_string(&entry.label), + normalized_label: owned_c_string(&entry.normalized_label), + has_price, + price, + status, + has_counterparty, + counterparty_id, + created_at_ms: entry.created_at_ms.unwrap_or(0), + updated_at_ms: entry.updated_at_ms.unwrap_or(0), + transferred_at_ms: entry.transferred_at_ms.unwrap_or(0), + last_synced_at_ms: entry.last_synced_at_ms, + } + } +} + +impl DpnsNameHistoryEventFFI { + /// Flatten one timeline event. All-POD — nothing to allocate. + fn from_event(event: &DpnsNameHistoryEvent) -> Self { + let mut out = Self { + kind: 0, + at_ms: event.at_ms, + has_block_height: event.block_height.is_some(), + block_height: event.block_height.unwrap_or(0), + has_price: false, + price: 0, + has_from: false, + from_id: [0u8; 32], + has_to: false, + to_id: [0u8; 32], + }; + // Wildcard-free: a new event kind must be mapped explicitly, not + // silently reported as a registration. + match event.kind { + DpnsNameHistoryEventKind::Registered => { + out.kind = 0; + } + DpnsNameHistoryEventKind::PriceSet { price } => { + out.kind = 1; + out.has_price = true; + out.price = price; + } + DpnsNameHistoryEventKind::Purchased { + price, + seller, + buyer, + } => { + out.kind = 2; + out.has_price = true; + out.price = price; + out.has_from = true; + out.from_id = seller.to_buffer(); + out.has_to = true; + out.to_id = buyer.to_buffer(); + } + DpnsNameHistoryEventKind::Transferred { from, to } => { + out.kind = 3; + out.has_from = true; + out.from_id = from.to_buffer(); + out.has_to = true; + out.to_id = to.to_buffer(); + } + } + out + } +} + +/// Move `values` into a heap array and publish it through the out-params. +/// An empty input writes `null` + `0` — an expected outcome, not an +/// error, and one the paired `*_free` tolerates. +/// +/// # Safety +/// `out_ptr` / `out_count` must be valid, writable, non-null. +unsafe fn publish_array(values: Vec, out_ptr: *mut *mut T, out_count: *mut usize) { + if values.is_empty() { + unsafe { + *out_ptr = ptr::null_mut(); + *out_count = 0; + } + return; + } + let count = values.len(); + let boxed = values.into_boxed_slice(); + unsafe { + *out_ptr = Box::into_raw(boxed) as *mut T; + *out_count = count; + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/// Search DPNS names by prefix, returning full domain state (document +/// id, owner, `$price`, timestamps) ordered by normalized label. +/// +/// An empty `prefix` is a valid alphabetical browse. `limit == 0` uses +/// the wallet's default page size. `start_after` is an optional 32-byte +/// cursor — pass the previous page's last `document_id` to continue, or +/// `null` for the first page. +/// +/// There is no server-side price filter or ordering: `$price` is not an +/// indexable system property, so the marketplace is +/// search-driven. Release the array with [`dpns_marketplace_names_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_search( + wallet_handle: Handle, + prefix: *const c_char, + limit: u32, + start_after: *const u8, + out_results: *mut *mut DpnsMarketplaceNameFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(prefix); + check_ptr!(out_results); + check_ptr!(out_count); + // Define the out-slots before any fallible work so an error return + // never leaves the caller holding stack garbage to free. + unsafe { + *out_results = ptr::null_mut(); + *out_count = 0; + } + + let prefix_str = + unwrap_result_or_return!(unsafe { CStr::from_ptr(prefix) }.to_str()).to_string(); + let limit_opt = if limit == 0 { None } else { Some(limit) }; + let start_after_id = if start_after.is_null() { + None + } else { + Some(unwrap_result_or_return!(unsafe { + read_identifier(start_after) + })) + }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + identity + .search_dpns_names_with_state(&prefix_str, limit_opt, start_after_id) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let states = unwrap_result_or_return!(result); + + let rows: Vec = states + .iter() + .map(DpnsMarketplaceNameFFI::from_state) + .collect(); + unsafe { publish_array(rows, out_results, out_count) }; + PlatformWalletFFIResult::ok() +} + +/// Fetch the authoritative marketplace state of a single DPNS name +/// (`"alice"` or `"alice.dash"`). +/// +/// A name that is not registered is an expected outcome, NOT an error: +/// the call succeeds with `*out_result == null`. Release a non-null +/// result with [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_name_state( + wallet_handle: Handle, + name: *const c_char, + out_result: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_result); + unsafe { *out_result = ptr::null_mut() }; + + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dpns_name_state(&name_str).await }) + }); + let result = unwrap_option_or_return!(option); + let state_opt = unwrap_result_or_return!(result); + if let Some(state) = state_opt { + let boxed = Box::new(DpnsMarketplaceNameFFI::from_state(&state)); + unsafe { *out_result = Box::into_raw(boxed) }; + } + PlatformWalletFFIResult::ok() +} + +/// Read this wallet's locally persisted marketplace rows — owned names +/// with their sale state, plus retained `Sold`/`Transferred` rows. +/// +/// Pass a 32-byte `identity_id` to filter to one wallet identity, or +/// `null` for every identity in the wallet. Reads the in-memory working +/// set: no network round-trip, so this is the cheap read behind a +/// "my names" screen. Release with [`dpns_name_state_rows_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_my_names( + wallet_handle: Handle, + identity_id: *const u8, + out_rows: *mut *mut DpnsNameStateRowFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_rows); + check_ptr!(out_count); + unsafe { + *out_rows = ptr::null_mut(); + *out_count = 0; + } + + let filter: Option = if identity_id.is_null() { + None + } else { + Some(unwrap_result_or_return!(unsafe { + read_identifier(identity_id) + })) + }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.local_dpns_name_states(filter.as_ref()).await }) + }); + let result = unwrap_option_or_return!(option); + let entries = unwrap_result_or_return!(result); + + let rows: Vec = entries + .iter() + .map(DpnsNameStateRowFFI::from_entry) + .collect(); + unsafe { publish_array(rows, out_rows, out_count) }; + PlatformWalletFFIResult::ok() +} + +/// The trade timeline of `name`: registration, price changes, purchases +/// (with price and counterparties), and transfers — merged and ordered +/// by block time ascending. +/// +/// Works for names that already left the wallet (the document id is then +/// taken from the local marketplace rows). An empty timeline writes +/// `null` + `0` with a success code. Release with +/// [`dpns_name_history_events_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_name_history( + wallet_handle: Handle, + name: *const c_char, + out_events: *mut *mut DpnsNameHistoryEventFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_events); + check_ptr!(out_count); + unsafe { + *out_events = ptr::null_mut(); + *out_count = 0; + } + + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dpns_name_history(&name_str).await }) + }); + let result = unwrap_option_or_return!(option); + let events = unwrap_result_or_return!(result); + + let rows: Vec = events + .iter() + .map(DpnsNameHistoryEventFFI::from_event) + .collect(); + unsafe { publish_array(rows, out_events, out_count) }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Trade operations +// --------------------------------------------------------------------------- + +/// List (or re-price) `name` for sale at `price_credits`. +/// +/// Goes through `IdentityWallet::set_dpns_name_price`: authoritative +/// name resolution (typed contested / not-found errors), ownership +/// check, automatic AUTHENTICATION + ECDSA signing-key selection on the +/// owner, broadcast, and a local sale-state write from the CONFIRMED +/// document. `out_state` receives that confirmed state — release it with +/// [`dpns_marketplace_name_free`]. +/// +/// `signer_handle` must be a valid, non-destroyed handle produced by +/// `dash_sdk_signer_create_with_ctx` (typically `KeychainSigner.handle`); +/// the caller retains ownership. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_set_name_price( + wallet_handle: Handle, + owner_identity_id: *const u8, + name: *const c_char, + price_credits: u64, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let owner_id = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + // Launder the signer handle across the `Send + 'static` future bound: + // the raw pointer is not `Send`, but the address is, and the signer is + // guaranteed alive for the whole synchronous call by the caller's + // ownership contract. Same idiom as `document.rs`. + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .set_dpns_name_price(&owner_id, &name_str, price_credits, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +/// Delist `name` — remove its `$price` while keeping ownership. +/// +/// Goes through `IdentityWallet::delist_dpns_name`, which broadcasts a +/// transfer to the owner's OWN identity: consensus strips `$price` on +/// transfer, and DPNS has no dedicated remove-price transition. The Rust +/// side verifies the confirmed document actually carries no `$price` +/// before recording the delist locally, so a consensus-semantics change +/// fails loudly rather than persisting a delist that didn't happen. +/// +/// `out_state` receives the confirmed state — release it with +/// [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_delist_name( + wallet_handle: Handle, + owner_identity_id: *const u8, + name: *const c_char, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let owner_id = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .delist_dpns_name(&owner_id, &name_str, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +/// Transfer `name` to `recipient_id` without payment (a gift or +/// off-market handover). Consensus strips any `$price` on transfer, so +/// this also delists. +/// +/// Goes through `IdentityWallet::transfer_dpns_name`, which reconciles +/// both sides locally when they belong to this wallet. Use +/// [`platform_wallet_dpns_delist_name`] for a transfer to self — this +/// entry point rejects `recipient_id == owner_identity_id` with an +/// invalid-parameter error. +/// +/// `out_state` receives the confirmed state — release it with +/// [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_transfer_name( + wallet_handle: Handle, + owner_identity_id: *const u8, + name: *const c_char, + recipient_id: *const u8, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let owner_id = unwrap_result_or_return!(unsafe { read_identifier(owner_identity_id) }); + let recipient = unwrap_result_or_return!(unsafe { read_identifier(recipient_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .transfer_dpns_name(&owner_id, &name_str, &recipient, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +/// Purchase `name` for `purchaser_identity_id` at exactly +/// `expected_price_credits` — the price the user confirmed. +/// +/// Goes through `IdentityWallet::purchase_dpns_name`, whose pre-flight +/// is fully typed: name resolution (contested-aware), a self-purchase +/// guard, `ErrorDocumentNotForSale` (37), `ErrorDocumentPriceChanged` +/// (38) when the listing no longer matches, and +/// `ErrorInsufficientIdentityCredits` (39) when the buyer's balance +/// can't cover the price plus the fee reserve. +/// +/// The broadcast transition carries `expected_price_credits`, NEVER a +/// re-read price, so a listing change between pre-flight and broadcast +/// is rejected by consensus and surfaces as the same typed code 38 — the +/// purchase does not execute at an unconfirmed price. +/// +/// `out_state` receives the confirmed state (now owned by the +/// purchaser) — release it with [`dpns_marketplace_name_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_purchase_name( + wallet_handle: Handle, + purchaser_identity_id: *const u8, + name: *const c_char, + expected_price_credits: u64, + signer_handle: *mut SignerHandle, + out_state: *mut *mut DpnsMarketplaceNameFFI, +) -> PlatformWalletFFIResult { + check_ptr!(name); + check_ptr!(out_state); + unsafe { *out_state = ptr::null_mut() }; + check_ptr!(signer_handle); + + let purchaser_id = unwrap_result_or_return!(unsafe { read_identifier(purchaser_identity_id) }); + let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; + identity + .purchase_dpns_name(&purchaser_id, &name_str, expected_price_credits, signer) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let state = unwrap_result_or_return!(result); + unsafe { *out_state = Box::into_raw(Box::new(DpnsMarketplaceNameFFI::from_state(&state))) }; + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// On-demand sync +// --------------------------------------------------------------------------- + +/// Run one marketplace sync pass on THIS wallet and report its delta. +/// +/// Refreshes owned-name rows (price / sale state), adds newly observed +/// names to the identity label lists, detects names that LEFT an +/// identity (sold or transferred away), and refreshes the balances of +/// identities that sold a name. All four out-params are optional — pass +/// `null` to ignore any of them: +/// +/// * `out_names_tracked`: owned-name rows written this pass. +/// * `out_names_added`: labels newly observed on a wallet identity. +/// * `out_names_departed`: names that left a wallet identity. +/// * `out_prices_changed`: listed-price changes since the last pass. +/// +/// This is the per-wallet, on-demand entry point (pull-to-refresh). The +/// recurring cross-wallet sweep is the manager-level coordinator in +/// [`crate::dpns_sync`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_sync( + wallet_handle: Handle, + out_names_tracked: *mut u32, + out_names_added: *mut u32, + out_names_departed: *mut u32, + out_prices_changed: *mut u32, +) -> PlatformWalletFFIResult { + // Optional out-params: define every non-null slot before the fallible + // work so an error return leaves well-defined zeros, not garbage. + unsafe { + for slot in [ + out_names_tracked, + out_names_added, + out_names_departed, + out_prices_changed, + ] { + if !slot.is_null() { + *slot = 0; + } + } + } + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.sync_dpns_marketplace().await }) + }); + let result = unwrap_option_or_return!(option); + let summary = unwrap_result_or_return!(result); + + unsafe { + if !out_names_tracked.is_null() { + *out_names_tracked = summary.names_tracked; + } + if !out_names_added.is_null() { + *out_names_added = summary.names_added.len() as u32; + } + if !out_names_departed.is_null() { + *out_names_departed = summary.names_departed.len() as u32; + } + if !out_prices_changed.is_null() { + *out_prices_changed = summary.prices_changed.len() as u32; + } + } + PlatformWalletFFIResult::ok() +} + +/// Run one marketplace sync pass and retain the complete delta and completion +/// timestamp. This is the lossless companion to the original counts-only +/// [`platform_wallet_dpns_marketplace_sync`] entry point. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_dpns_marketplace_sync_detailed( + wallet_handle: Handle, + out_summary: *mut DpnsMarketplaceSyncSummaryFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_summary); + unsafe { *out_summary = DpnsMarketplaceSyncSummaryFFI::default() }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.sync_dpns_marketplace().await }) + }); + let result = unwrap_option_or_return!(option); + let summary = unwrap_result_or_return!(result); + + let added: Vec = summary + .names_added + .into_iter() + .map(|(identity_id, label)| DpnsNameAddedFFI { + identity_id: identity_id.to_buffer(), + label: owned_c_string(&label), + }) + .collect(); + let departed: Vec = summary + .names_departed + .into_iter() + .map(|row| { + let (has_status, status, counterparty_id) = match row.status { + Some(DpnsNameSaleStatus::Sold { to }) => (true, 1, to.to_buffer()), + Some(DpnsNameSaleStatus::Transferred { to }) => (true, 2, to.to_buffer()), + Some(DpnsNameSaleStatus::Owned) | None => (false, 0, [0; 32]), + }; + DpnsNameDepartedFFI { + identity_id: row.identity_id.to_buffer(), + label: owned_c_string(&row.label), + has_document_id: row.document_id.is_some(), + document_id: row.document_id.map_or([0; 32], |id| id.to_buffer()), + has_status, + status, + counterparty_id, + } + }) + .collect(); + let prices: Vec = summary + .prices_changed + .into_iter() + .map(|row| DpnsPriceChangeFFI { + document_id: row.document_id.to_buffer(), + label: owned_c_string(&row.label), + has_previous: row.previous.is_some(), + previous: row.previous.unwrap_or(0), + has_current: row.current.is_some(), + current: row.current.unwrap_or(0), + }) + .collect(); + + let mut ffi = DpnsMarketplaceSyncSummaryFFI { + names_tracked: summary.names_tracked, + sync_unix_ms: summary.sync_unix_ms, + ..Default::default() + }; + unsafe { + publish_array(added, &mut ffi.names_added, &mut ffi.names_added_count); + publish_array( + departed, + &mut ffi.names_departed, + &mut ffi.names_departed_count, + ); + publish_array( + prices, + &mut ffi.prices_changed, + &mut ffi.prices_changed_count, + ); + *out_summary = ffi; + } + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Destructors +// --------------------------------------------------------------------------- + +/// Release a SINGLE [`DpnsMarketplaceNameFFI`] returned through an +/// `out_state` / `out_result` pointer — its two label strings, then the +/// value itself. No-op on `null`. +/// +/// Not interchangeable with [`dpns_marketplace_names_free`]: a single +/// value is a `Box`, an array is a `Box<[T]>`. +/// +/// # Safety +/// `name` must be null or a pointer this module returned through a +/// single-value out-param, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_marketplace_name_free(name: *mut DpnsMarketplaceNameFFI) { + if name.is_null() { + return; + } + let mut boxed = unsafe { Box::from_raw(name) }; + unsafe { + free_owned_c_string(&mut boxed.label); + free_owned_c_string(&mut boxed.normalized_label); + } +} + +/// Release the owned arrays and strings inside a detailed sync summary and +/// reset it to the empty value. Safe to call on an already-empty summary. +#[no_mangle] +pub unsafe extern "C" fn dpns_marketplace_sync_summary_free( + summary: *mut DpnsMarketplaceSyncSummaryFFI, +) { + if summary.is_null() { + return; + } + let summary = unsafe { &mut *summary }; + if !summary.names_added.is_null() && summary.names_added_count > 0 { + let rows = unsafe { + std::slice::from_raw_parts_mut(summary.names_added, summary.names_added_count) + }; + for row in rows.iter_mut() { + unsafe { free_owned_c_string(&mut row.label) }; + } + let _ = unsafe { Box::from_raw(rows as *mut [DpnsNameAddedFFI]) }; + } + if !summary.names_departed.is_null() && summary.names_departed_count > 0 { + let rows = unsafe { + std::slice::from_raw_parts_mut(summary.names_departed, summary.names_departed_count) + }; + for row in rows.iter_mut() { + unsafe { free_owned_c_string(&mut row.label) }; + } + let _ = unsafe { Box::from_raw(rows as *mut [DpnsNameDepartedFFI]) }; + } + if !summary.prices_changed.is_null() && summary.prices_changed_count > 0 { + let rows = unsafe { + std::slice::from_raw_parts_mut(summary.prices_changed, summary.prices_changed_count) + }; + for row in rows.iter_mut() { + unsafe { free_owned_c_string(&mut row.label) }; + } + let _ = unsafe { Box::from_raw(rows as *mut [DpnsPriceChangeFFI]) }; + } + *summary = DpnsMarketplaceSyncSummaryFFI::default(); +} + +/// Release an array of [`DpnsMarketplaceNameFFI`] — every row's two +/// label strings, then the array. No-op on `null` / `count == 0` (the +/// empty-result shape). +/// +/// # Safety +/// `names` must be null or an array this module returned with exactly +/// `count` elements, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_marketplace_names_free( + names: *mut DpnsMarketplaceNameFFI, + count: usize, +) { + if names.is_null() || count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(names, count) }; + for row in slice.iter_mut() { + unsafe { + free_owned_c_string(&mut row.label); + free_owned_c_string(&mut row.normalized_label); + } + } + let _ = unsafe { Box::from_raw(slice as *mut [DpnsMarketplaceNameFFI]) }; +} + +/// Release an array of [`DpnsNameStateRowFFI`] — every row's two label +/// strings, then the array. No-op on `null` / `count == 0`. +/// +/// # Safety +/// `rows` must be null or an array this module returned with exactly +/// `count` elements, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_name_state_rows_free(rows: *mut DpnsNameStateRowFFI, count: usize) { + if rows.is_null() || count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(rows, count) }; + for row in slice.iter_mut() { + unsafe { + free_owned_c_string(&mut row.label); + free_owned_c_string(&mut row.normalized_label); + } + } + let _ = unsafe { Box::from_raw(slice as *mut [DpnsNameStateRowFFI]) }; +} + +/// Release an array of [`DpnsNameHistoryEventFFI`]. The rows are all-POD +/// (no owned strings), so this only reclaims the array allocation. +/// No-op on `null` / `count == 0`. +/// +/// # Safety +/// `events` must be null or an array this module returned with exactly +/// `count` elements, not previously freed. +#[no_mangle] +pub unsafe extern "C" fn dpns_name_history_events_free( + events: *mut DpnsNameHistoryEventFFI, + count: usize, +) { + if events.is_null() || count == 0 { + return; + } + let slice = unsafe { std::slice::from_raw_parts_mut(events, count) }; + let _ = unsafe { Box::from_raw(slice as *mut [DpnsNameHistoryEventFFI]) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn domain_state(price: Option, records_identity: Option) -> DpnsDomainState { + DpnsDomainState { + document_id: Identifier::from([1u8; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + owner_id: Identifier::from([2u8; 32]), + records_identity_id: records_identity, + price, + created_at_ms: Some(10), + updated_at_ms: None, + transferred_at_ms: Some(30), + } + } + + fn state_entry(status: DpnsNameSaleStatus, price: Option) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([1u8; 32]), + wallet_identity_id: Identifier::from([2u8; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + price, + status, + created_at_ms: Some(10), + updated_at_ms: Some(20), + transferred_at_ms: None, + last_synced_at_ms: 99, + } + } + + /// Absent optionals must cross as `has_* == false`, never as a + /// fabricated value: "not for sale" and "listed at 0 credits" are + /// different facts, and so are "no `$updatedAt`" and "updated at + /// epoch". + #[test] + fn marketplace_name_absent_optionals_are_flagged_not_fabricated() { + let mut ffi = DpnsMarketplaceNameFFI::from_state(&domain_state(None, None)); + assert!(!ffi.has_price); + assert_eq!(ffi.price, 0); + assert!(!ffi.has_records_identity); + assert_eq!(ffi.records_identity_id, [0u8; 32]); + assert_eq!(ffi.updated_at_ms, 0); + assert_eq!(ffi.created_at_ms, 10); + assert_eq!(ffi.transferred_at_ms, 30); + unsafe { + free_owned_c_string(&mut ffi.label); + free_owned_c_string(&mut ffi.normalized_label); + } + } + + #[test] + fn marketplace_name_round_trips_present_fields() { + let records = Identifier::from([3u8; 32]); + let ffi = DpnsMarketplaceNameFFI::from_state(&domain_state(Some(5_000), Some(records))); + assert_eq!(ffi.document_id, [1u8; 32]); + assert_eq!(ffi.owner_id, [2u8; 32]); + assert!(ffi.has_records_identity); + assert_eq!(ffi.records_identity_id, [3u8; 32]); + assert!(ffi.has_price); + assert_eq!(ffi.price, 5_000); + let label = unsafe { CStr::from_ptr(ffi.label) } + .to_string_lossy() + .into_owned(); + let normalized = unsafe { CStr::from_ptr(ffi.normalized_label) } + .to_string_lossy() + .into_owned(); + assert_eq!(label, "Alice"); + assert_eq!(normalized, "a11ce"); + // Free through the public single-value destructor, which is what + // the host calls. + unsafe { dpns_marketplace_name_free(Box::into_raw(Box::new(ffi))) }; + } + + /// The status discriminants are the ABI contract with the host's + /// `DpnsNameSaleStatus` mirror; pin all three plus their + /// counterparty flags. + #[test] + fn name_state_row_pins_status_discriminants() { + let buyer = Identifier::from([7u8; 32]); + let cases = [ + (DpnsNameSaleStatus::Owned, 0u8, false, [0u8; 32]), + (DpnsNameSaleStatus::Sold { to: buyer }, 1u8, true, [7u8; 32]), + ( + DpnsNameSaleStatus::Transferred { to: buyer }, + 2u8, + true, + [7u8; 32], + ), + ]; + for (status, expected_status, expected_has_cp, expected_cp) in cases { + let row = DpnsNameStateRowFFI::from_entry(&state_entry(status, Some(1))); + assert_eq!(row.status, expected_status); + assert_eq!(row.has_counterparty, expected_has_cp); + assert_eq!(row.counterparty_id, expected_cp); + assert_eq!(row.last_synced_at_ms, 99); + free_rows(vec![row]); + } + } + + #[test] + fn name_state_row_unlisted_price_is_flagged() { + let row = DpnsNameStateRowFFI::from_entry(&state_entry(DpnsNameSaleStatus::Owned, None)); + assert!(!row.has_price); + assert_eq!(row.price, 0); + assert_eq!(row.transferred_at_ms, 0); + free_rows(vec![row]); + } + + /// Publish `rows` exactly as an entry point would, then release them + /// through the public destructor — so the tests exercise the real + /// allocation shape (`Box<[T]>`) rather than a hand-rolled one. + fn free_rows(rows: Vec) { + let mut out: *mut DpnsNameStateRowFFI = ptr::null_mut(); + let mut count: usize = 0; + unsafe { + publish_array(rows, &mut out, &mut count); + dpns_name_state_rows_free(out, count); + } + } + + /// The event-kind discriminants and which optional payloads each kind + /// carries are both ABI contracts — a host reading `price` on a + /// registration event, or mistaking a purchase for a transfer, shows + /// the user a wrong trade history. + #[test] + fn history_event_kinds_and_payloads_are_pinned() { + let seller = Identifier::from([4u8; 32]); + let buyer = Identifier::from([5u8; 32]); + + let registered = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Registered, + at_ms: 1, + block_height: None, + }); + assert_eq!(registered.kind, 0); + assert!(!registered.has_price); + assert!(!registered.has_from); + assert!(!registered.has_to); + assert!(!registered.has_block_height); + assert_eq!(registered.block_height, 0); + + let priced = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::PriceSet { price: 42 }, + at_ms: 2, + block_height: Some(1_000), + }); + assert_eq!(priced.kind, 1); + assert!(priced.has_price); + assert_eq!(priced.price, 42); + assert!(!priced.has_from); + assert!(priced.has_block_height); + assert_eq!(priced.block_height, 1_000); + + let purchased = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Purchased { + price: 7, + seller, + buyer, + }, + at_ms: 3, + block_height: None, + }); + assert_eq!(purchased.kind, 2); + assert!(purchased.has_price); + assert_eq!(purchased.price, 7); + // Purchase: from = seller, to = buyer. + assert_eq!(purchased.from_id, [4u8; 32]); + assert_eq!(purchased.to_id, [5u8; 32]); + + let transferred = DpnsNameHistoryEventFFI::from_event(&DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Transferred { + from: seller, + to: buyer, + }, + at_ms: 4, + block_height: None, + }); + assert_eq!(transferred.kind, 3); + assert!(!transferred.has_price); + assert_eq!(transferred.from_id, [4u8; 32]); + assert_eq!(transferred.to_id, [5u8; 32]); + } + + /// Every destructor must tolerate the empty-result shape (`null` + + /// `0`) and a bare `null`, since that is exactly what a + /// no-results-but-successful call publishes. + #[test] + fn destructors_are_null_and_empty_tolerant() { + unsafe { + dpns_marketplace_name_free(ptr::null_mut()); + dpns_marketplace_names_free(ptr::null_mut(), 0); + dpns_marketplace_names_free(ptr::null_mut(), 3); + dpns_name_state_rows_free(ptr::null_mut(), 0); + dpns_name_history_events_free(ptr::null_mut(), 0); + dpns_marketplace_sync_summary_free(ptr::null_mut()); + } + } + + #[test] + fn detailed_sync_summary_frees_every_nested_array() { + let mut summary = DpnsMarketplaceSyncSummaryFFI { + names_tracked: 3, + sync_unix_ms: 99, + ..Default::default() + }; + unsafe { + publish_array( + vec![DpnsNameAddedFFI { + identity_id: [1; 32], + label: owned_c_string("Alice"), + }], + &mut summary.names_added, + &mut summary.names_added_count, + ); + publish_array( + vec![DpnsNameDepartedFFI { + identity_id: [2; 32], + label: owned_c_string("Bob"), + has_document_id: true, + document_id: [3; 32], + has_status: true, + status: 1, + counterparty_id: [4; 32], + }], + &mut summary.names_departed, + &mut summary.names_departed_count, + ); + publish_array( + vec![DpnsPriceChangeFFI { + document_id: [5; 32], + label: owned_c_string("Carol"), + has_previous: false, + previous: 0, + has_current: true, + current: 7, + }], + &mut summary.prices_changed, + &mut summary.prices_changed_count, + ); + dpns_marketplace_sync_summary_free(&mut summary); + } + assert_eq!(summary.names_tracked, 0); + assert_eq!(summary.sync_unix_ms, 0); + assert!(summary.names_added.is_null()); + assert!(summary.names_departed.is_null()); + assert!(summary.prices_changed.is_null()); + } + + /// `publish_array` on an empty Vec must write the documented + /// `null` + `0` pair rather than a dangling one-past-the-end pointer, + /// and the paired free must accept it. + #[test] + fn publish_array_writes_the_empty_shape() { + // Seed the out-slots with garbage a caller could mistake for a + // real result, so the assertions below prove they were OVERWRITTEN + // rather than merely left alone. + let mut out: *mut DpnsMarketplaceNameFFI = std::ptr::dangling_mut(); + let mut count: usize = 7; + unsafe { publish_array(Vec::new(), &mut out, &mut count) }; + assert!(out.is_null()); + assert_eq!(count, 0); + unsafe { dpns_marketplace_names_free(out, count) }; + } + + /// A populated array round-trips through `publish_array` and its + /// destructor without leaking the per-row label strings (verified + /// under the test harness's allocator; a double free would abort). + #[test] + fn publish_array_round_trips_and_frees_rows() { + let states = [ + domain_state(Some(1), None), + domain_state(None, Some(Identifier::from([6u8; 32]))), + ]; + let rows: Vec = states + .iter() + .map(DpnsMarketplaceNameFFI::from_state) + .collect(); + let mut out: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let mut count: usize = 0; + unsafe { publish_array(rows, &mut out, &mut count) }; + assert!(!out.is_null()); + assert_eq!(count, 2); + let first_label = unsafe { CStr::from_ptr((*out).label) } + .to_string_lossy() + .into_owned(); + assert_eq!(first_label, "Alice"); + unsafe { dpns_marketplace_names_free(out, count) }; + } + + /// Unknown handles must surface as `NotFound` through + /// `unwrap_option_or_return!` rather than dereferencing a stale slot, + /// and required out-pointers must be rejected first with + /// `ErrorNullPointer`. Covers the pointer-discipline half of every + /// entry point without needing a live wallet. + #[test] + fn unknown_handle_and_null_out_pointers_are_rejected() { + let bogus: Handle = 0xDEAD_BEEF; + + let mut names: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let mut count: usize = 0; + let prefix = CString::new("a").unwrap(); + let r = unsafe { + platform_wallet_dpns_marketplace_search( + bogus, + prefix.as_ptr(), + 0, + ptr::null(), + &mut names, + &mut count, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut detailed = DpnsMarketplaceSyncSummaryFFI::default(); + let r = unsafe { platform_wallet_dpns_marketplace_sync_detailed(bogus, &mut detailed) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!(detailed.names_added.is_null()); + assert_eq!(detailed.sync_unix_ms, 0); + assert!(names.is_null()); + assert_eq!(count, 0); + + let mut rows: *mut DpnsNameStateRowFFI = ptr::null_mut(); + let mut rows_count: usize = 0; + let r = unsafe { + platform_wallet_dpns_marketplace_my_names( + bogus, + ptr::null(), + &mut rows, + &mut rows_count, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + // All four sync out-params are optional — a null-only call must + // still reach the handle lookup. + let r = unsafe { + platform_wallet_dpns_marketplace_sync( + bogus, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + // Required out-pointer missing: rejected before the handle lookup. + let c = CString::new("alice").unwrap(); + let r = unsafe { + platform_wallet_dpns_marketplace_name_state(bogus, c.as_ptr(), ptr::null_mut()) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + // Missing signer handle: rejected before the handle lookup too. + let mut state: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let r = unsafe { + platform_wallet_dpns_set_name_price( + bogus, + [0u8; 32].as_ptr(), + c.as_ptr(), + 1, + ptr::null_mut(), + &mut state, + ) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!(state.is_null()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs b/packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs new file mode 100644 index 00000000000..8a0c2f48fb2 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dpns_name_state_persistence.rs @@ -0,0 +1,270 @@ +//! FFI types for forwarding +//! [`DpnsNameStateChangeSet`](platform_wallet::changeset::DpnsNameStateChangeSet) +//! — the DPNS username-marketplace rows — out of +//! [`FFIPersister`](crate::persistence::FFIPersister) to the host. +//! +//! Shaped like [`crate::invitation_persistence`], with one difference: +//! [`DpnsNameStateEntry`] carries three owned strings (the display label, +//! its homograph-normalized form, and the normalized parent domain), so +//! each row owns `CString` allocations that MUST be released with +//! [`free_dpns_name_state_entries`] after the callback returns — exactly +//! the allocate/free discipline `IdentityEntryFFI`'s DPNS label arrays +//! use in [`crate::identity_persistence`]. +//! +//! The strings are Rust-owned and valid only for the callback window; +//! the host must copy anything it keeps before returning. + +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; + +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateEntry}; + +/// C mirror of one [`DpnsNameStateEntry`]: a DPNS `domain` document +/// tracked for a wallet identity, with its sale state. +/// +/// The three `*const c_char` fields are NUL-terminated UTF-8 owned by +/// this struct for the duration of the persistence callback. Optional +/// values travel as a `has_*` flag plus the value — never as a sentinel, +/// so "not for sale" stays distinguishable from "listed at 0 credits" +/// and "no `$updatedAt`" from "updated at the epoch". +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct DpnsNameStateFFI { + /// The DPNS `domain` document id — this row's key, stable across + /// transfers and purchases. + pub document_id: [u8; 32], + /// The wallet identity this row is tracked for. For `Owned` rows the + /// document's `$ownerId`; for `Sold`/`Transferred` rows the previous + /// owner (ours). + pub wallet_identity_id: [u8; 32], + /// Whether `counterparty_id` is populated — true exactly when + /// `status != 0`. + pub has_counterparty: bool, + /// The buyer (`status == 1`) or recipient (`status == 2`). Ignore + /// unless `has_counterparty`. + pub counterparty_id: [u8; 32], + /// Display label, e.g. "Alice". + pub label: *const c_char, + /// Homograph-normalized label, e.g. "a11ce". + pub normalized_label: *const c_char, + /// Normalized parent domain — "dash" today. Carried (rather than + /// defaulted host-side) because it is part of the host's row + /// uniqueness key alongside the normalized label. + pub normalized_parent_domain_name: *const c_char, + /// Whether `price` is populated. `false` = not listed for sale. + pub has_price: bool, + /// Listed sale price in credits (`$price`). Ignore unless `has_price`. + pub price: u64, + /// Ownership status relative to `wallet_identity_id`: + /// `0` = owned, `1` = sold, `2` = transferred. + pub status: u8, + /// Document `$createdAt` in ms. `0` = unknown. + pub created_at_ms: u64, + /// Document `$updatedAt` in ms. `0` = unknown. + pub updated_at_ms: u64, + /// Document `$transferredAt` in ms. `0` = unknown. + pub transferred_at_ms: u64, + /// Wall-clock ms of the sync pass / confirmed transition that wrote + /// this row. + pub last_synced_at_ms: u64, +} + +// Pin the ABI size so a future field reorder/add that changes the layout +// is a compile error rather than a silent desync (the layout-assert +// convention every other `*EntryFFI` follows). +// [u8;32]@0, [u8;32]@32, bool@64, [u8;32]@65..97, 3 ptrs@104..128, +// bool@128, u64@136, u8@144, 4 u64@152..184 → align 8 → size 184. +const _: [u8; 184] = [0u8; std::mem::size_of::()]; + +/// Discriminant mapping for [`DpnsNameSaleStatus`], plus the +/// counterparty it carries. Wildcard-free so adding a variant is a +/// compile error rather than a silent mis-map. Pinned by a test. +fn status_and_counterparty(status: &DpnsNameSaleStatus) -> (u8, bool, [u8; 32]) { + match status { + DpnsNameSaleStatus::Owned => (0, false, [0u8; 32]), + DpnsNameSaleStatus::Sold { to } => (1, true, to.to_buffer()), + DpnsNameSaleStatus::Transferred { to } => (2, true, to.to_buffer()), + } +} + +/// Heap-allocate `s` as an owned C string, or `null` if it contains an +/// interior NUL (unreachable for DPNS-validated labels, but a null is +/// far better than a panic across the boundary). Released by +/// [`free_dpns_name_state_entries`]. +fn owned_c_string(s: &str) -> *const c_char { + match CString::new(s) { + Ok(c) => c.into_raw() as *const c_char, + Err(_) => ptr::null(), + } +} + +/// Build the flat FFI rows from the changeset entries. +/// +/// Every returned row owns three `CString` allocations — the caller MUST +/// pass the Vec to [`free_dpns_name_state_entries`] once the persistence +/// callback has returned. +pub fn build_dpns_name_state_entries(entries: &[&DpnsNameStateEntry]) -> Vec { + entries + .iter() + .map(|entry| { + let (status, has_counterparty, counterparty_id) = + status_and_counterparty(&entry.status); + let (has_price, price) = match entry.price { + Some(p) => (true, p), + None => (false, 0), + }; + DpnsNameStateFFI { + document_id: entry.document_id.to_buffer(), + wallet_identity_id: entry.wallet_identity_id.to_buffer(), + has_counterparty, + counterparty_id, + label: owned_c_string(&entry.label), + normalized_label: owned_c_string(&entry.normalized_label), + normalized_parent_domain_name: owned_c_string(&entry.normalized_parent_domain_name), + has_price, + price, + status, + created_at_ms: entry.created_at_ms.unwrap_or(0), + updated_at_ms: entry.updated_at_ms.unwrap_or(0), + transferred_at_ms: entry.transferred_at_ms.unwrap_or(0), + last_synced_at_ms: entry.last_synced_at_ms, + } + }) + .collect() +} + +/// Release the three owned C strings on every row and null the slots. +/// Idempotent — a second call is a no-op. +/// +/// # Safety +/// +/// Every row must have been produced by [`build_dpns_name_state_entries`] +/// and not previously freed; the pointers must reference allocations +/// owned by these rows. +pub unsafe fn free_dpns_name_state_entries(entries: &mut [DpnsNameStateFFI]) { + for entry in entries.iter_mut() { + unsafe { + free_owned_c_string(&mut entry.label); + free_owned_c_string(&mut entry.normalized_label); + free_owned_c_string(&mut entry.normalized_parent_domain_name); + } + } +} + +/// Release one C string produced by [`owned_c_string`] and null the slot +/// in place, so repeated frees no-op. +/// +/// # Safety +/// The pointer must be null or a `CString::into_raw` allocation. +unsafe fn free_owned_c_string(slot: &mut *const c_char) { + if !slot.is_null() { + let _ = unsafe { CString::from_raw(*slot as *mut c_char) }; + *slot = ptr::null(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use std::ffi::CStr; + + fn entry(status: DpnsNameSaleStatus, price: Option) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([1u8; 32]), + wallet_identity_id: Identifier::from([2u8; 32]), + label: "Alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: "dash".to_string(), + price, + status, + created_at_ms: Some(10), + updated_at_ms: None, + transferred_at_ms: Some(30), + last_synced_at_ms: 99, + } + } + + /// The status discriminants are the ABI contract with the host's + /// mirror; pin all three plus the counterparty they carry. + #[test] + fn status_discriminants_are_pinned() { + let to = Identifier::from([7u8; 32]); + assert_eq!( + status_and_counterparty(&DpnsNameSaleStatus::Owned), + (0, false, [0u8; 32]) + ); + assert_eq!( + status_and_counterparty(&DpnsNameSaleStatus::Sold { to }), + (1, true, [7u8; 32]) + ); + assert_eq!( + status_and_counterparty(&DpnsNameSaleStatus::Transferred { to }), + (2, true, [7u8; 32]) + ); + } + + #[test] + fn build_entries_round_trips_every_field() { + let owned = entry(DpnsNameSaleStatus::Owned, Some(5_000)); + let sold = entry( + DpnsNameSaleStatus::Sold { + to: Identifier::from([7u8; 32]), + }, + None, + ); + let refs = [&owned, &sold]; + let mut ffi = build_dpns_name_state_entries(&refs); + assert_eq!(ffi.len(), 2); + + assert_eq!(ffi[0].document_id, [1u8; 32]); + assert_eq!(ffi[0].wallet_identity_id, [2u8; 32]); + assert_eq!(ffi[0].status, 0); + assert!(!ffi[0].has_counterparty); + assert!(ffi[0].has_price); + assert_eq!(ffi[0].price, 5_000); + assert_eq!(ffi[0].created_at_ms, 10); + // Absent `$updatedAt` must arrive as 0-and-unknown, not fabricated. + assert_eq!(ffi[0].updated_at_ms, 0); + assert_eq!(ffi[0].transferred_at_ms, 30); + assert_eq!(ffi[0].last_synced_at_ms, 99); + let label = unsafe { CStr::from_ptr(ffi[0].label) } + .to_string_lossy() + .into_owned(); + let normalized = unsafe { CStr::from_ptr(ffi[0].normalized_label) } + .to_string_lossy() + .into_owned(); + let parent = unsafe { CStr::from_ptr(ffi[0].normalized_parent_domain_name) } + .to_string_lossy() + .into_owned(); + assert_eq!(label, "Alice"); + assert_eq!(normalized, "a11ce"); + assert_eq!(parent, "dash"); + + assert_eq!(ffi[1].status, 1); + assert!(ffi[1].has_counterparty); + assert_eq!(ffi[1].counterparty_id, [7u8; 32]); + // Not listed: flagged, never rendered as a 0-credit listing. + assert!(!ffi[1].has_price); + assert_eq!(ffi[1].price, 0); + + unsafe { free_dpns_name_state_entries(&mut ffi) }; + assert!(ffi[0].label.is_null()); + assert!(ffi[0].normalized_label.is_null()); + assert!(ffi[0].normalized_parent_domain_name.is_null()); + // Idempotent — the dispatcher frees on every path, including the + // one where the callback returned an error. + unsafe { free_dpns_name_state_entries(&mut ffi) }; + } + + /// An empty changeset produces an empty Vec, and freeing it is a + /// no-op — the shape `store()` hits when a round carries only + /// tombstones. + #[test] + fn empty_entries_build_and_free_cleanly() { + let mut ffi = build_dpns_name_state_entries(&[]); + assert!(ffi.is_empty()); + unsafe { free_dpns_name_state_entries(&mut ffi) }; + } +} diff --git a/packages/rs-platform-wallet-ffi/src/dpns_sync.rs b/packages/rs-platform-wallet-ffi/src/dpns_sync.rs new file mode 100644 index 00000000000..2b31cf6aabb --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/dpns_sync.rs @@ -0,0 +1,249 @@ +//! FFI bindings for `PlatformWalletManager`'s recurring DPNS +//! username-marketplace sync coordinator. +//! +//! Sibling of [`crate::dashpay_sync`] and shaped identically: lifecycle +//! controls (`start` / `stop` / `is_running` / `is_syncing` / +//! `last_sync_unix_seconds` / `set_interval` / `sync_now`). The sweep is +//! **wallet-driven, not registry-driven** (see +//! [`DpnsSyncManager`](platform_wallet::manager::dpns_sync::DpnsSyncManager)), +//! so there is no per-identity registry surface here — every registered +//! wallet is swept on every pass. It is a separate coordinator from the +//! DashPay one because marketplace state changes are rare: this loop +//! defaults to 60s against DashPay's 15s. +//! +//! `sync_now` surfaces the per-pass success / error counts and +//! completion timestamp through out-params; all three are optional — +//! pass null to ignore any of them. For a single wallet's delta (names +//! tracked / added / departed / re-priced) use the per-wallet +//! [`platform_wallet_dpns_marketplace_sync`](crate::dpns_marketplace::platform_wallet_dpns_marketplace_sync) +//! instead. +//! +//! Not auto-started. The host lifecycle calls +//! [`platform_wallet_manager_dpns_sync_start`] once the wallets are +//! registered and the SDK is connected; the on-demand `sync_now` entry +//! point stays available for pull-to-refresh. + +use std::time::Duration; + +use crate::error::*; +use crate::handle::*; +use crate::runtime::{block_on_worker, runtime}; +use crate::{check_ptr, unwrap_option_or_return}; + +/// Start the recurring DPNS marketplace sync loop in the background. +/// Idempotent — calling while already running is a no-op. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_start( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let _entered = runtime().enter(); + manager.dpns_sync_arc().start(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Stop the recurring DPNS marketplace sync loop if it is running. +/// +/// Cancel-only: a pass already inside `sync_now` keeps running to +/// completion. Manager shutdown uses the Rust-side `quiesce` barrier; +/// the host does not need to. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_stop( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.dpns_sync().stop(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Whether the recurring DPNS marketplace sync background loop is +/// running. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_is_running( + handle: Handle, + out_running: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_running); + // Define the out-slot before the stale-handle early return below can + // fire, so the caller never reads uninitialized stack contents. + *out_running = false; + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.dpns_sync().is_running()); + let running = unwrap_option_or_return!(option); + *out_running = running; + PlatformWalletFFIResult::ok() +} + +/// Whether a DPNS marketplace sync pass is currently in flight. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_is_syncing( + handle: Handle, + out_syncing: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_syncing); + *out_syncing = false; + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.dpns_sync().is_syncing()); + let syncing = unwrap_option_or_return!(option); + *out_syncing = syncing; + PlatformWalletFFIResult::ok() +} + +/// Unix seconds of the last completed DPNS marketplace sync pass, or 0 +/// if no pass has ever completed. +/// +/// The watermark is global (one last-sync per manager, not per-wallet), +/// matching the wallet-driven sweep. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_last_sync_unix_seconds( + handle: Handle, + out_last_sync_unix: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_last_sync_unix); + *out_last_sync_unix = 0; + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.dpns_sync().last_sync_unix_seconds() + }); + let value = unwrap_option_or_return!(option); + *out_last_sync_unix = value.unwrap_or(0); + PlatformWalletFFIResult::ok() +} + +/// Set the background DPNS marketplace sync interval in seconds. +/// +/// Clamped to a minimum of 1s on the Rust side; the running loop picks +/// up the new interval on its next sleep. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_set_interval( + handle: Handle, + interval_seconds: u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager + .dpns_sync() + .set_interval(Duration::from_secs(interval_seconds)); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Run one DPNS marketplace sync pass across every registered wallet. +/// +/// Synchronous from the FFI caller's point of view — blocks the calling +/// thread until the pass completes. If a pass is already in flight (e.g. +/// fired by the background loop), the underlying manager skips and +/// returns an empty summary immediately; this function then reports +/// `*out_success_count == 0`, `*out_error_count == 0`, and +/// `*out_sync_unix_seconds == 0` (the "no pass ran" sentinel). Check +/// `is_syncing` if the caller needs to distinguish "skipped" from +/// "swept zero wallets". +/// +/// All three out-params are optional — pass null to ignore any of them: +/// * `out_success_count`: wallets whose marketplace sync succeeded. +/// * `out_error_count`: wallets whose marketplace sync failed (logged +/// Rust-side, non-fatal to the rest of the pass). +/// * `out_sync_unix_seconds`: Unix seconds the pass completed, or `0` +/// if no pass ran. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_dpns_sync_sync_now( + handle: Handle, + out_success_count: *mut usize, + out_error_count: *mut usize, + out_sync_unix_seconds: *mut u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let mgr = manager.dpns_sync_arc(); + // `block_on_worker`, NOT `runtime().block_on`: the pass verifies + // GroveDB document-query proofs whose recursion blows the ~512 KB + // stack of the iOS calling thread. The worker dispatch moves the + // compute onto the runtime's 8 MB-stack threads (see runtime.rs). + block_on_worker(async move { mgr.sync_now().await }) + }); + let summary = unwrap_option_or_return!(option); + + if !out_success_count.is_null() { + *out_success_count = summary.success_count(); + } + if !out_error_count.is_null() { + *out_error_count = summary.error_count(); + } + if !out_sync_unix_seconds.is_null() { + *out_sync_unix_seconds = summary.sync_unix_seconds; + } + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every DPNS-sync entry point must reject an unknown `Handle` with + /// `NotFound` rather than dereferencing a stale slot — the + /// `unwrap_option_or_return!` contract every other coordinator's FFI + /// upholds. Pins the stale-handle path for all seven calls. + #[test] + fn unknown_handle_returns_not_found() { + let bogus: Handle = 0xDEAD_BEEF; + + let r = unsafe { platform_wallet_manager_dpns_sync_start(bogus) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let r = unsafe { platform_wallet_manager_dpns_sync_stop(bogus) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut running = true; + let r = unsafe { platform_wallet_manager_dpns_sync_is_running(bogus, &mut running) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!(!running); + + let mut syncing = true; + let r = unsafe { platform_wallet_manager_dpns_sync_is_syncing(bogus, &mut syncing) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert!(!syncing); + + let mut last = 123u64; + let r = + unsafe { platform_wallet_manager_dpns_sync_last_sync_unix_seconds(bogus, &mut last) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(last, 0); + + let r = unsafe { platform_wallet_manager_dpns_sync_set_interval(bogus, 30) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + + let mut ok = 7usize; + let mut err = 7usize; + let mut ts = 7u64; + let r = unsafe { + platform_wallet_manager_dpns_sync_sync_now(bogus, &mut ok, &mut err, &mut ts) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::NotFound); + } + + /// Null out-pointers on the reader entry points must be rejected with + /// `ErrorNullPointer` (the `check_ptr!` contract) before the handle is + /// even looked up. + #[test] + fn null_required_out_pointers_are_rejected() { + let bogus: Handle = 1; + + let r = + unsafe { platform_wallet_manager_dpns_sync_is_running(bogus, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let r = + unsafe { platform_wallet_manager_dpns_sync_is_syncing(bogus, std::ptr::null_mut()) }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let r = unsafe { + platform_wallet_manager_dpns_sync_last_sync_unix_seconds(bogus, std::ptr::null_mut()) + }; + assert_eq!(r.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 99477d23677..cb560d46f31 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1,3 +1,4 @@ +use dpp::platform_value::string_encoding::Encoding; use platform_wallet::PlatformWalletError; use std::ffi::CString; use std::os::raw::c_char; @@ -256,6 +257,18 @@ pub enum PlatformWalletFFIResultCode { // This trio previously sat at 26-28, then 27/28/30. It moved to 34-36 after // #4268 merged `ErrorShutdownIncomplete = 27` into the v4.2-dev ABI; the // contiguous block above every current claim ends the renumbering churn. + // + // Claimed after the trio, same rule (fresh block above every claim): + // + // 37 ErrorDocumentNotForSale DPNS username marketplace + // 38 ErrorDocumentPriceChanged DPNS username marketplace + // 39 ErrorInsufficientIdentityCredits DPNS username marketplace + // 40 ErrorContestedNameNotTradable DPNS username marketplace + // + // 38/39/40 carry a STABLE JSON detail object in the result `message` + // instead of the typed `Display` rendering — see each variant's doc for + // the exact object. `PlatformWalletFFIResult` is ABI-frozen (code + + // message only), so structured values ride the message or not at all. /// Maps `SignedPaymentError::StaleReservationToken` from the deferred /// build → broadcast/release core-send lifecycle (`core_wallet_signed_payment_*`): /// the token has outlived the registry's `RESERVATION_MAX_AGE_BLOCKS` bound @@ -288,6 +301,64 @@ pub enum PlatformWalletFFIResultCode { /// ErrorReservationWalletMismatch = 36, + // ----------------------------------------------------------------- + // DPNS username-marketplace trade rejections (37-40). + // + // A fresh contiguous block ABOVE every current claim, for the same + // reason the 34-36 trio moved there: 28 and 30 are nominally free but + // reusing a vacated slot re-opens the renumbering churn the registry + // note above exists to end. + // ----------------------------------------------------------------- + /// Maps `PlatformWalletError::DocumentNotForSale`. The document + /// carries no `$price`, so it cannot be purchased (and a DPNS delist + /// has nothing to clear). Raised by the wallet's pre-flight read and + /// by the downcast of the consensus `DocumentNotForSaleError` (DPP + /// code 40108). The transition did NOT execute. + /// + /// Message: the typed `Display` rendering (no structured detail — + /// the only value is the document id, which the caller already has). + ErrorDocumentNotForSale = 37, + + /// Maps `PlatformWalletError::DocumentPriceChanged`. The listing no + /// longer matches the price the user confirmed — either the wallet's + /// pre-flight read disagreed, or consensus rejected the broadcast + /// with `DocumentIncorrectPurchasePriceError` (DPP code 40109) + /// because the listing changed between read and broadcast. The + /// purchase did NOT execute in either case; re-confirm at the new + /// price and retry. + /// + /// Message: a STABLE JSON detail object so hosts recover the typed + /// values without parsing prose — + /// `{"documentId":"","expected":,"actual":}` + /// (credits). Swift mirror: `PlatformWalletError.priceChanged`. + ErrorDocumentPriceChanged = 38, + + /// Maps `PlatformWalletError::InsufficientIdentityCredits`. The + /// identity's credit balance cannot cover the operation — the + /// wallet's purchase pre-flight (price + fee reserve against the + /// local balance snapshot) or the downcast of the consensus + /// `IdentityInsufficientBalanceError`. Nothing executed; top the + /// identity up and retry. + /// + /// Message: a STABLE JSON detail object — + /// `{"identityId":"","required":,"available":}` + /// (credits). Swift mirror: + /// `PlatformWalletError.insufficientIdentityCredits`. + ErrorInsufficientIdentityCredits = 39, + + /// Maps `PlatformWalletError::ContestedNameNotTradable`. The DPNS + /// name is inside an active contested-name vote, so its domain + /// document is not in the documents tree and no trade transition can + /// reference it. Without this typed code the network's bare + /// `DocumentNotFoundError` (40101) would read as "no such name". + /// Retry after the contest resolves. + /// + /// Message: a STABLE JSON detail object — + /// `{"label":"","endsAtMs":}`, where `endsAtMs == 0` + /// means the vote's end time was unavailable. Swift mirror: + /// `PlatformWalletError.contestedNameNotTradable`. + ErrorContestedNameNotTradable = 40, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -400,8 +471,69 @@ impl From> for PlatformWalletFFIResult { } } +/// The value-carrying DPNS-marketplace rejections, rendered as +/// `(code, JSON detail)` instead of `(code, Display)`. +/// +/// `PlatformWalletFFIResult` is ABI-frozen at `{ code, message }`, so a +/// host that needs the *values* — not prose naming them — can only get +/// them through the message. These three therefore put a stable JSON +/// object there; the exact shape is documented on each +/// [`PlatformWalletFFIResultCode`] variant and parsed back by the Swift +/// mirror. Returns `None` for every other error, leaving the `Display` +/// rendering in charge. +/// +/// `DocumentNotForSale` (37) is deliberately absent: its only value is +/// the document id the caller supplied, so its `Display` is enough. +fn trade_error_json_detail( + error: &PlatformWalletError, +) -> Option<(PlatformWalletFFIResultCode, String)> { + match error { + PlatformWalletError::DocumentPriceChanged { + document_id, + expected, + actual, + } => Some(( + PlatformWalletFFIResultCode::ErrorDocumentPriceChanged, + serde_json::json!({ + "documentId": document_id.to_string(Encoding::Base58), + "expected": expected, + "actual": actual, + }) + .to_string(), + )), + PlatformWalletError::InsufficientIdentityCredits { + identity_id, + required, + available, + } => Some(( + PlatformWalletFFIResultCode::ErrorInsufficientIdentityCredits, + serde_json::json!({ + "identityId": identity_id.to_string(Encoding::Base58), + "required": required, + "available": available, + }) + .to_string(), + )), + PlatformWalletError::ContestedNameNotTradable { label, ends_at_ms } => Some(( + PlatformWalletFFIResultCode::ErrorContestedNameNotTradable, + serde_json::json!({ + "label": label, + "endsAtMs": ends_at_ms, + }) + .to_string(), + )), + _ => None, + } +} + impl From for PlatformWalletFFIResult { fn from(error: PlatformWalletError) -> Self { + // The three value-carrying marketplace rejections replace the + // Display rendering with a stable JSON detail object; everything + // else keeps Display as the message. + if let Some((code, detail)) = trade_error_json_detail(&error) { + return PlatformWalletFFIResult::err(code, detail); + } // Map the typed wallet error variants explicitly so they // don't flatten to ErrorUnknown at the FFI boundary. The // catch-all ErrorUnknown remains for variants the FFI hasn't @@ -531,6 +663,17 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::MessageSigningKeyUnavailable { .. } => { PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable } + // DPNS marketplace: the one trade rejection whose Display is + // sufficient (the other three are handled by + // `trade_error_json_detail` above and never reach this match). + PlatformWalletError::DocumentNotForSale { .. } => { + PlatformWalletFFIResultCode::ErrorDocumentNotForSale + } + // An exact-label DPNS lookup that came back empty IS the + // "does not exist" case this code has always covered, so it + // rides `NotFound` rather than spending a fifth marketplace + // code hosts would handle identically. + PlatformWalletError::DpnsNameNotFound { .. } => PlatformWalletFFIResultCode::NotFound, // NOTE: `MessageSigningFailed` is deliberately NOT matched, so it // falls to the `ErrorUnknown` catch-all below. Its causes are // internal invariant breaks (a public key that does not own the @@ -1241,6 +1384,151 @@ mod tests { ); } + /// The four DPNS-marketplace trade rejections each map to their own + /// dedicated code rather than flattening to `ErrorUnknown`, and the + /// not-found case rides the existing `NotFound`. Hosts branch on these + /// to distinguish "re-confirm the price" from "top up credits" from + /// "wait for the contest". + #[test] + fn dpns_marketplace_errors_map_to_dedicated_codes() { + let document_id = dpp::prelude::Identifier::from([9u8; 32]); + let identity_id = dpp::prelude::Identifier::from([8u8; 32]); + let cases: Vec<(PlatformWalletError, PlatformWalletFFIResultCode)> = vec![ + ( + PlatformWalletError::DocumentNotForSale { document_id }, + PlatformWalletFFIResultCode::ErrorDocumentNotForSale, + ), + ( + PlatformWalletError::DocumentPriceChanged { + document_id, + expected: 1_000, + actual: 2_000, + }, + PlatformWalletFFIResultCode::ErrorDocumentPriceChanged, + ), + ( + PlatformWalletError::InsufficientIdentityCredits { + identity_id, + required: 100_001_000, + available: 7, + }, + PlatformWalletFFIResultCode::ErrorInsufficientIdentityCredits, + ), + ( + PlatformWalletError::ContestedNameNotTradable { + label: "alice".to_string(), + ends_at_ms: 1_800_000_000_000, + }, + PlatformWalletFFIResultCode::ErrorContestedNameNotTradable, + ), + ( + PlatformWalletError::DpnsNameNotFound { + name: "nobody".to_string(), + }, + PlatformWalletFFIResultCode::NotFound, + ), + ]; + for (error, expected_code) in cases { + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + assert_eq!( + result.code, expected_code, + "variant should map to {expected_code:?} (rendered: {rendered})" + ); + } + } + + /// Code 37 keeps the typed `Display` rendering as its message — it + /// carries no value the caller doesn't already have, so it is NOT in + /// the JSON-detail set. + #[test] + fn document_not_for_sale_message_is_the_display_rendering() { + let err = PlatformWalletError::DocumentNotForSale { + document_id: dpp::prelude::Identifier::from([9u8; 32]), + }; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!(message_of(&result), rendered); + } + + /// Codes 38/39/40 put a STABLE JSON detail object in the message so + /// the Swift mirror can rebuild typed cases. Pin the exact keys and + /// values — a rename or a transposed pair silently degrades every host + /// to `.unknown`, which no compiler catches across the ABI. + #[test] + fn price_changed_message_is_the_documented_json_detail() { + let document_id = dpp::prelude::Identifier::from([9u8; 32]); + let result: PlatformWalletFFIResult = PlatformWalletError::DocumentPriceChanged { + document_id, + expected: 1_000, + actual: 2_000, + } + .into(); + let parsed: serde_json::Value = + serde_json::from_str(&message_of(&result)).expect("code 38 message must parse as JSON"); + assert_eq!( + parsed["documentId"], + document_id.to_string(Encoding::Base58) + ); + assert_eq!(parsed["expected"], 1_000u64); + assert_eq!(parsed["actual"], 2_000u64); + } + + #[test] + fn insufficient_credits_message_is_the_documented_json_detail() { + let identity_id = dpp::prelude::Identifier::from([8u8; 32]); + let result: PlatformWalletFFIResult = PlatformWalletError::InsufficientIdentityCredits { + identity_id, + required: 100_001_000, + available: 7, + } + .into(); + let parsed: serde_json::Value = + serde_json::from_str(&message_of(&result)).expect("code 39 message must parse as JSON"); + assert_eq!( + parsed["identityId"], + identity_id.to_string(Encoding::Base58) + ); + assert_eq!(parsed["required"], 100_001_000u64); + assert_eq!(parsed["available"], 7u64); + } + + #[test] + fn contested_name_message_is_the_documented_json_detail() { + let result: PlatformWalletFFIResult = PlatformWalletError::ContestedNameNotTradable { + label: "alice".to_string(), + ends_at_ms: 1_800_000_000_000, + } + .into(); + let parsed: serde_json::Value = + serde_json::from_str(&message_of(&result)).expect("code 40 message must parse as JSON"); + assert_eq!(parsed["label"], "alice"); + assert_eq!(parsed["endsAtMs"], 1_800_000_000_000u64); + } + + /// The numeric values are the ABI contract with the Swift/Kotlin + /// mirrors (there is no compile-time check across the boundary), so + /// pin them explicitly rather than trusting declaration order. + #[test] + fn dpns_marketplace_codes_are_pinned_at_37_through_40() { + assert_eq!( + PlatformWalletFFIResultCode::ErrorDocumentNotForSale as i32, + 37 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorDocumentPriceChanged as i32, + 38 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorInsufficientIdentityCredits as i32, + 39 + ); + assert_eq!( + PlatformWalletFFIResultCode::ErrorContestedNameNotTradable as i32, + 40 + ); + } + /// `MessageSigningFailed` is intentionally unmapped: its causes are /// internal invariant breaks, which should read as a bug rather than as a /// key-repair prompt, so it falls through to ErrorUnknown carrying the @@ -1268,4 +1556,14 @@ mod tests { let result: PlatformWalletFFIResult = internal.into(); assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); } + + /// Read a result's message back as an owned `String`. Every + /// marketplace assertion below inspects the message, and the raw + /// `CStr::from_ptr` dance is noise at each site. + fn message_of(result: &PlatformWalletFFIResult) -> String { + assert!(!result.message.is_null(), "result carries no message"); + unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + } } diff --git a/packages/rs-platform-wallet-ffi/src/event_handler.rs b/packages/rs-platform-wallet-ffi/src/event_handler.rs index 79830b91635..34f50721da3 100644 --- a/packages/rs-platform-wallet-ffi/src/event_handler.rs +++ b/packages/rs-platform-wallet-ffi/src/event_handler.rs @@ -5,11 +5,84 @@ use crate::platform_address_sync::{ }; use crate::shielded_types::ShieldedSyncWalletResultFFI; use platform_wallet::events::{EventHandler, PlatformEventHandler, WalletEvent}; +use platform_wallet::manager::dpns_sync::{DpnsSyncPassSummary, WalletDpnsSyncOutcome}; #[cfg(feature = "shielded")] use platform_wallet::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; use platform_wallet::{PlatformAddressSyncSummary, WalletSyncOutcome}; use std::os::raw::{c_char, c_void}; +/// Current layout version of [`EventHandlerCallbacksExtension`]. +pub const PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION: u32 = 1; + +/// One wallet's owned DPNS marketplace sync result. All pointers are valid +/// only for the callback duration; managed-language bridges must copy them. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct DpnsSyncWalletResultFFI { + pub wallet_id: [u8; 32], + pub success: bool, + pub names_tracked: u32, + pub names_added: u32, + pub names_departed: u32, + pub prices_changed: u32, + pub error_message: *const c_char, +} + +impl Default for DpnsSyncWalletResultFFI { + fn default() -> Self { + Self { + wallet_id: [0; 32], + success: false, + names_tracked: 0, + names_added: 0, + names_departed: 0, + prices_changed: 0, + error_message: std::ptr::null(), + } + } +} + +pub type DpnsMarketplaceSyncCompletedFn = unsafe extern "C" fn( + context: *mut c_void, + results: *const DpnsSyncWalletResultFFI, + count: usize, + sync_unix_seconds: u64, +); + +/// Size/version-tagged event extension. It shares the legacy event +/// vtable's context and destructor; only callback pointers are copied. +/// This avoids growing [`EventHandlerCallbacks`] and over-reading callers +/// compiled against an older generated header. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct EventHandlerCallbacksExtension { + pub struct_size: usize, + pub version: u32, + pub reserved: u32, + /// Declared inline because cbindgen does not expand a named function- + /// pointer alias inside `Option`; using the alias here emits an opaque + /// `Option_*` field by value and produces an invalid C header. + pub on_dpns_marketplace_sync_completed_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + results: *const DpnsSyncWalletResultFFI, + count: usize, + sync_unix_seconds: u64, + ), + >, +} + +impl Default for EventHandlerCallbacksExtension { + fn default() -> Self { + Self { + struct_size: std::mem::size_of::(), + version: PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION, + reserved: 0, + on_dpns_marketplace_sync_completed_fn: None, + } + } +} + /// C callback vtable for event handling. /// /// All callbacks are optional (`Option`) — pass null for events you don't @@ -26,13 +99,9 @@ use std::os::raw::{c_char, c_void}; /// copy of the generated header allocates a smaller struct, so each appended /// slot makes `ptr::read` over-read past that allocation. /// -/// This is accepted for now because the only consumer is the in-tree Swift -/// SDK, which is regenerated (cbindgen) and rebuilt in lockstep with this -/// crate — there is no out-of-tree consumer pinned to a stale header. A -/// proper fix (a leading `size`/`version` discriminator passed to -/// `platform_wallet_manager_create`, or per-callback registration -/// entrypoints) is tracked as a follow-up and is out of scope for the -/// sync-progress work that added these slots. +/// Existing slots remain frozen for compatibility. New event kinds belong +/// in the size/version-tagged [`EventHandlerCallbacksExtension`] (or a later +/// extension version), never at the end of this legacy by-value vtable. #[repr(C)] pub struct EventHandlerCallbacks { /// Opaque context pointer passed to all callbacks. @@ -119,11 +188,18 @@ unsafe impl Sync for EventHandlerCallbacks {} /// Wrapper that implements `PlatformEventHandler` via FFI callbacks. pub(crate) struct FFIEventHandler { callbacks: EventHandlerCallbacks, + dpns_sync_callback: Option, } impl FFIEventHandler { - pub fn new(callbacks: EventHandlerCallbacks) -> Self { - Self { callbacks } + pub fn new( + callbacks: EventHandlerCallbacks, + dpns_sync_callback: Option, + ) -> Self { + Self { + callbacks, + dpns_sync_callback, + } } } @@ -174,6 +250,63 @@ impl EventHandler for FFIEventHandler { } impl PlatformEventHandler for FFIEventHandler { + fn on_dpns_marketplace_sync_completed(&self, summary: &DpnsSyncPassSummary) { + let Some(callback) = self.dpns_sync_callback else { + return; + }; + if summary.wallet_results.is_empty() { + unsafe { + callback( + self.callbacks.context, + std::ptr::null(), + 0, + summary.sync_unix_seconds, + ); + } + return; + } + + let mut owned_errors = Vec::new(); + let mut results = Vec::with_capacity(summary.wallet_results.len()); + for (&wallet_id, outcome) in &summary.wallet_results { + match outcome { + WalletDpnsSyncOutcome::Ok(wallet_summary) => { + results.push(DpnsSyncWalletResultFFI { + wallet_id, + success: true, + names_tracked: wallet_summary.names_tracked, + names_added: wallet_summary.names_added.len() as u32, + names_departed: wallet_summary.names_departed.len() as u32, + prices_changed: wallet_summary.prices_changed.len() as u32, + error_message: std::ptr::null(), + }); + } + WalletDpnsSyncOutcome::Err(error) => { + let error_message = std::ffi::CString::new(error.as_str()).ok(); + let error_ptr = error_message + .as_ref() + .map_or(std::ptr::null(), |message| message.as_ptr()); + if let Some(error_message) = error_message { + owned_errors.push(error_message); + } + results.push(DpnsSyncWalletResultFFI { + wallet_id, + error_message: error_ptr, + ..DpnsSyncWalletResultFFI::default() + }); + } + } + } + unsafe { + callback( + self.callbacks.context, + results.as_ptr(), + results.len(), + summary.sync_unix_seconds, + ); + } + } + fn on_platform_address_sync_completed(&self, summary: &PlatformAddressSyncSummary) { let Some(cb) = self.callbacks.on_platform_address_sync_completed_fn else { return; @@ -331,8 +464,8 @@ mod release_tests { } let releases = Box::leak(Box::new(AtomicUsize::new(0))); - let handler: Arc = - Arc::new(FFIEventHandler::new(EventHandlerCallbacks { + let handler: Arc = Arc::new(FFIEventHandler::new( + EventHandlerCallbacks { context: releases as *const AtomicUsize as *mut c_void, on_wallet_event_fn: None, on_error_fn: None, @@ -341,7 +474,9 @@ mod release_tests { on_shielded_sync_progress_fn: None, on_shielded_tree_progress_fn: None, release_fn: Some(count_release), - })); + }, + None, + )); let straggler = Arc::clone(&handler); drop(handler); diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 5d80c33ded5..a6df9e830d6 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -29,6 +29,9 @@ pub mod derive_and_persist_callbacks; pub mod derive_identity_key_at_slot; pub mod document; pub mod dpns; +pub mod dpns_marketplace; +pub mod dpns_name_state_persistence; +pub mod dpns_sync; pub mod error; pub mod established_contact; pub mod event_handler; @@ -101,6 +104,9 @@ pub use derive_and_persist_callbacks::*; pub use derive_identity_key_at_slot::*; pub use document::*; pub use dpns::*; +pub use dpns_marketplace::*; +pub use dpns_name_state_persistence::*; +pub use dpns_sync::*; pub use error::*; pub use established_contact::*; pub use event_handler::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 9f2739bbff4..3cfcebb4957 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -2,9 +2,15 @@ use crate::check_ptr; use crate::error::*; -use crate::event_handler::{EventHandlerCallbacks, FFIEventHandler}; +use crate::event_handler::{ + DpnsMarketplaceSyncCompletedFn, EventHandlerCallbacks, EventHandlerCallbacksExtension, + FFIEventHandler, PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION, +}; use crate::handle::*; -use crate::persistence::{FFIPersister, PersistenceCallbacks, PersistenceCapabilitiesFFI}; +use crate::persistence::{ + FFIPersister, PersistDpnsNameStatesFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, +}; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; use crate::{unwrap_option_or_return, unwrap_result_or_return}; @@ -68,6 +74,8 @@ pub unsafe extern "C" fn platform_wallet_manager_create( persistence, event_handler, PersistenceCapabilities::NONE, + None, + None, out_handle, ) } @@ -92,15 +100,129 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_capabil persistence, event_handler, declaration, + None, + None, + out_handle, + ) +} + +/// Create a manager with an explicit capability declaration and the current +/// size/version-tagged additive callback extension. +/// +/// `persistence_extension` must point to at least its leading `struct_size` +/// field. Fields beyond that are read only when `struct_size` proves they are +/// present and the version is recognized. Unknown versions and short +/// extensions fail closed to no additive callbacks. The extension shares the +/// legacy vtable's `context` and `release_fn`; Rust copies the callback pointer +/// during this call and never retains the extension pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_extensions( + sdk_ptr: *const c_void, + persistence: *const PersistenceCallbacks, + event_handler: *const EventHandlerCallbacks, + persistence_capabilities: *const PersistenceCapabilitiesFFI, + persistence_extension: *const PersistenceCallbacksExtension, + out_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(persistence_capabilities); + check_ptr!(persistence_extension); + let declaration = persistence_capabilities_declaration(&*persistence_capabilities); + let dpns_callback = persistence_extension_dpns_callback(persistence_extension); + platform_wallet_manager_create_impl( + sdk_ptr, + persistence, + event_handler, + declaration, + dpns_callback, + None, + out_handle, + ) +} + +/// Create a manager with both size/version-tagged persistence and event +/// extensions. Additive event callbacks share the legacy event vtable's +/// context and release function; Rust copies supported slots and never +/// retains either extension pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( + sdk_ptr: *const c_void, + persistence: *const PersistenceCallbacks, + event_handler: *const EventHandlerCallbacks, + persistence_capabilities: *const PersistenceCapabilitiesFFI, + persistence_extension: *const PersistenceCallbacksExtension, + event_extension: *const EventHandlerCallbacksExtension, + out_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(persistence_capabilities); + check_ptr!(persistence_extension); + check_ptr!(event_extension); + let declaration = persistence_capabilities_declaration(&*persistence_capabilities); + let dpns_persistence_callback = persistence_extension_dpns_callback(persistence_extension); + let dpns_event_callback = event_extension_dpns_callback(event_extension); + platform_wallet_manager_create_impl( + sdk_ptr, + persistence, + event_handler, + declaration, + dpns_persistence_callback, + dpns_event_callback, out_handle, ) } +unsafe fn persistence_extension_dpns_callback( + extension: *const PersistenceCallbacksExtension, +) -> Option { + let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); + let version_end = + std::mem::offset_of!(PersistenceCallbacksExtension, version) + std::mem::size_of::(); + if supplied_size < version_end { + return None; + } + let version = std::ptr::addr_of!((*extension).version).read(); + if version != PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION { + return None; + } + let callback_end = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_dpns_name_states_fn + ) + std::mem::size_of::>(); + if supplied_size < callback_end { + return None; + } + std::ptr::addr_of!((*extension).on_persist_dpns_name_states_fn).read() +} + +unsafe fn event_extension_dpns_callback( + extension: *const EventHandlerCallbacksExtension, +) -> Option { + let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); + let version_end = + std::mem::offset_of!(EventHandlerCallbacksExtension, version) + std::mem::size_of::(); + if supplied_size < version_end { + return None; + } + let version = std::ptr::addr_of!((*extension).version).read(); + if version != PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION { + return None; + } + let callback_end = std::mem::offset_of!( + EventHandlerCallbacksExtension, + on_dpns_marketplace_sync_completed_fn + ) + std::mem::size_of::>(); + if supplied_size < callback_end { + return None; + } + std::ptr::addr_of!((*extension).on_dpns_marketplace_sync_completed_fn).read() +} + unsafe fn platform_wallet_manager_create_impl( sdk_ptr: *const c_void, persistence: *const PersistenceCallbacks, event_handler: *const EventHandlerCallbacks, declared_capabilities: PersistenceCapabilities, + dpns_name_states_callback: Option, + dpns_event_callback: Option, out_handle: *mut Handle, ) -> PlatformWalletFFIResult { check_ptr!(sdk_ptr); @@ -136,12 +258,17 @@ unsafe fn platform_wallet_manager_create_impl( } let sdk = Arc::new((*(sdk_ptr as *const Sdk)).clone()); - let persister = Arc::new(FFIPersister::new_with_persistence_capabilities( - std::ptr::read(persistence), - declared_capabilities, + let persister = Arc::new( + FFIPersister::new_with_persistence_capabilities_and_dpns_callback( + std::ptr::read(persistence), + declared_capabilities, + dpns_name_states_callback, + ), + ); + let handler: Arc = Arc::new(FFIEventHandler::new( + std::ptr::read(event_handler), + dpns_event_callback, )); - let handler: Arc = - Arc::new(FFIEventHandler::new(std::ptr::read(event_handler))); // `PlatformWalletManager::new` spawns the wallet-event adapter // task on construction (the subscriber that translates upstream @@ -637,6 +764,17 @@ mod tests { 0 } + unsafe extern "C" fn persist_dpns_name_states( + _context: *mut c_void, + _wallet_id: *const u8, + _rows: *const crate::dpns_name_state_persistence::DpnsNameStateFFI, + _rows_count: usize, + _removed_ptr: *const [u8; 32], + _removed_count: usize, + ) -> i32 { + 0 + } + fn persistence_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_changeset_begin_fn: Some(begin_changeset), @@ -645,6 +783,19 @@ mod tests { } } + fn assert_legacy_persistence_callbacks_layout() { + #[cfg(not(feature = "shielded"))] + assert_eq!( + std::mem::size_of::(), + 25 * std::mem::size_of::() + ); + #[cfg(feature = "shielded")] + assert_eq!( + std::mem::size_of::(), + 41 * std::mem::size_of::() + ); + } + fn event_callbacks() -> EventHandlerCallbacks { EventHandlerCallbacks { context: std::ptr::null_mut(), @@ -834,6 +985,7 @@ mod tests { #[test] fn legacy_create_is_abi_stable_and_fail_closed() { + assert_legacy_persistence_callbacks_layout(); let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); let callbacks = persistence_callbacks(); let event_callbacks = event_callbacks(); @@ -858,6 +1010,7 @@ mod tests { #[test] fn additive_create_versions_and_intersects_capabilities() { + assert_legacy_persistence_callbacks_layout(); let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); let callbacks = persistence_callbacks(); let event_callbacks = event_callbacks(); @@ -866,6 +1019,7 @@ mod tests { reserved: 0, bits: PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) + .union(PersistenceCapabilities::DPNS_NAME_STATES) .bits(), }; let mut handle = 0; @@ -889,6 +1043,60 @@ mod tests { let result = unsafe { platform_wallet_manager_destroy(handle) }; assert_eq!(result.code, PlatformWalletFFIResultCode::Success); } + + #[test] + fn extension_create_versions_and_intersects_dpns_callback() { + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let callbacks = persistence_callbacks(); + let event_callbacks = event_callbacks(); + let declaration = PersistenceCapabilitiesFFI { + version: PERSISTENCE_CAPABILITIES_VERSION, + reserved: 0, + bits: PersistenceCapabilities::DPNS_NAME_STATES.bits(), + }; + let extension = PersistenceCallbacksExtension { + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + ..Default::default() + }; + let mut handle = 0; + let result = unsafe { + platform_wallet_manager_create_with_persistence_extensions( + &sdk as *const Sdk as *const c_void, + &callbacks, + &event_callbacks, + &declaration, + &extension, + &mut handle, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + query(handle).bits, + PersistenceCapabilities::DPNS_NAME_STATES.bits() + ); + + let result = unsafe { platform_wallet_manager_destroy(handle) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + } + + #[test] + fn short_and_unknown_persistence_extensions_fail_closed() { + let short = PersistenceCallbacksExtension { + struct_size: std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_dpns_name_states_fn + ), + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + ..Default::default() + }; + let unknown = PersistenceCallbacksExtension { + version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION + 1, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + ..Default::default() + }; + assert!(unsafe { persistence_extension_dpns_callback(&short) }.is_none()); + assert!(unsafe { persistence_extension_dpns_callback(&unknown) }.is_none()); + } } /// Wallet-generation teardown vs. the deferred-payment registry diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 72026484c3a..a367896437a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -45,6 +45,9 @@ use crate::contact_persistence::{ use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; +use crate::dpns_name_state_persistence::{ + build_dpns_name_state_entries, free_dpns_name_state_entries, DpnsNameStateFFI, +}; use crate::identity_persistence::{ free_identity_entry_ffi, free_identity_key_entry_ffi, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, @@ -110,6 +113,66 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PENDING_CONTACT_CRYPTO: u64 = 1 pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DEFERRED_CONTACT_CRYPTO: u64 = PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PENDING_CONTACT_CRYPTO; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE: u64 = 1 << 7; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES: u64 = 1 << 8; + +/// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately +/// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by +/// pointer without a size field, so growing it would make Rust read beyond an +/// older allocation. +pub const PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION: u32 = 1; + +pub type PersistDpnsNameStatesFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const DpnsNameStateFFI, + rows_count: usize, + removed_ptr: *const [u8; 32], + removed_count: usize, +) -> i32; + +/// Size- and version-tagged additive persistence callbacks. +/// +/// `context` is the context in the accompanying [`PersistenceCallbacks`] +/// vtable and has the same lifetime. The extension owns no additional context, +/// so the legacy vtable's `release_fn` remains the single release hook. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PersistenceCallbacksExtension { + /// Total bytes supplied by the caller, including this header. Rust reads a + /// callback only when its complete field fits within this size. + pub struct_size: usize, + pub version: u32, + pub reserved: u32, + /// Declared INLINE rather than as `Option`, + /// even though that alias exists and is ABI-identical: cbindgen does + /// not expand a named fn-pointer alias inside `Option`, emitting an + /// opaque `struct Option_PersistDpnsNameStatesFn` forward + /// declaration and then using it by value — an incomplete type that + /// makes the generated header unbuildable as a clang module. Every + /// sibling callback on [`PersistenceCallbacks`] is inline for the + /// same reason; keep new ones that way. + pub on_persist_dpns_name_states_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const DpnsNameStateFFI, + rows_count: usize, + removed_ptr: *const [u8; 32], + removed_count: usize, + ) -> i32, + >, +} + +impl Default for PersistenceCallbacksExtension { + fn default() -> Self { + Self { + struct_size: std::mem::size_of::(), + version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + reserved: 0, + on_persist_dpns_name_states_fn: None, + } + } +} /// C callback vtable for wallet persistence. /// @@ -882,6 +945,8 @@ impl RoundGuardState { /// In-memory persister that accumulates changesets and notifies via callbacks. pub struct FFIPersister { callbacks: PersistenceCallbacks, + /// Additive callbacks negotiated outside the legacy unsized vtable. + dpns_name_states_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -933,9 +998,22 @@ impl FFIPersister { pub fn new_with_persistence_capabilities( callbacks: PersistenceCallbacks, declared_capabilities: PersistenceCapabilities, + ) -> Self { + Self::new_with_persistence_capabilities_and_dpns_callback( + callbacks, + declared_capabilities, + None, + ) + } + + pub fn new_with_persistence_capabilities_and_dpns_callback( + callbacks: PersistenceCallbacks, + declared_capabilities: PersistenceCapabilities, + dpns_name_states_callback: Option, ) -> Self { Self { callbacks, + dpns_name_states_callback, declared_capabilities, pending: RwLock::new(BTreeMap::new()), round_lock: Mutex::new(RoundGuardState::default()), @@ -956,6 +1034,9 @@ impl FFIPersister { if self.callbacks.on_persist_invitations_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::INVITATIONS); } + if self.dpns_name_states_callback.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DPNS_NAME_STATES); + } let wallet_restore = self.callbacks.on_load_wallet_list_fn.is_some() && self.callbacks.on_load_wallet_list_free_fn.is_some(); if self.callbacks.on_persist_account_registrations_fn.is_some() @@ -1563,6 +1644,56 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Send the DPNS username-marketplace changeset — one upsert row + // per tracked `domain` document (keyed by document id) plus + // document-id tombstones. Maps onto the host's DPNS-name rows, + // whose marketplace columns (price, sale status, counterparty) + // these rows own. + // + // Fires AFTER the identities callback so a brand-new identity's + // row is already staged in the same round when the host resolves + // a marketplace row's owning identity. + if let Some(ref dpns_cs) = changeset.dpns_name_states { + if let Some(cb) = self.dpns_name_states_callback { + let upsert_refs: Vec<&platform_wallet::changeset::DpnsNameStateEntry> = + dpns_cs.names.values().collect(); + let mut upserts = build_dpns_name_state_entries(&upsert_refs); + let removed: Vec<[u8; 32]> = + dpns_cs.removed.iter().map(|id| id.to_buffer()).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Release the per-row label strings on EVERY path, + // including the callback-reported-failure one, before + // the Vec drops its storage. + unsafe { free_dpns_name_state_entries(&mut upserts) }; + if result != 0 { + eprintln!( + "DPNS name state persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Send DashPay contact-request changeset. // // The flat upsert array is built by walking every source @@ -6059,13 +6190,10 @@ mod tests { assert_eq!(ffi.reserved, 0); assert_eq!(ffi.bits, 0x81); assert_eq!(std::mem::size_of::(), 16); - // Capability negotiation is deliberately NOT appended to the legacy - // callback vtable. Pin the vtable size so a new slot has to be a - // deliberate, reviewed act, and prove the last-appended field really is - // terminal — growth is only safe while it happens at the end, where no - // previously-defined slot changes offset. The count moves with each - // append (invitations, then the `release_fn` context destructor, the - // txid enumeration pair, now the DashPay payment persist slot). + // Additive capabilities and callbacks are deliberately NOT appended to + // the legacy unsized callback vtable. Pin its established size and + // terminal DashPay slot: reading even a single later word would overrun + // a host compiled against this ABI. #[cfg(not(feature = "shielded"))] assert_eq!( std::mem::size_of::(), @@ -6081,6 +6209,14 @@ mod tests { + std::mem::size_of::(), std::mem::size_of::() ); + assert_eq!(PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, 1); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_dpns_name_states_fn + ) + std::mem::size_of::>(), + std::mem::size_of::() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITIES_VERSION, PERSISTENCE_CAPABILITIES_VERSION @@ -6117,6 +6253,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_WALLET_RESTORE, PersistenceCapabilities::WALLET_RESTORE.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DPNS_NAME_STATES, + PersistenceCapabilities::DPNS_NAME_STATES.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs b/packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs new file mode 100644 index 00000000000..8aa3d378f7f --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V005__dpns_name_states.rs @@ -0,0 +1,35 @@ +//! Add the `dpns_name_states` table (DPNS username marketplace). +//! +//! One row per tracked DPNS `domain` document belonging to (or recently +//! departed from) a wallet identity, carrying the sale state (`$price`) +//! the label-only `dpns_names` list on the identity blob cannot: document +//! id, listed price, ownership status, and the document's own timestamps. +//! Written by the marketplace sync pass and the set-price / delist / +//! purchase / transfer orchestration ops. +//! +//! All fields map to explicit columns (the entry is all-primitive), so no +//! opaque blob is needed — the row reconstructs directly. `counterparty_id` +//! carries the buyer/recipient for `sold` / `transferred` rows and is NULL +//! for `owned` rows (the status enum's payload flattened into a column). + +pub fn migration() -> String { + "CREATE TABLE dpns_name_states ( + wallet_id BLOB NOT NULL, + document_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + label TEXT NOT NULL, + normalized_label TEXT NOT NULL, + normalized_parent_domain TEXT NOT NULL, + price INTEGER CHECK (price IS NULL OR price >= 0), + status TEXT NOT NULL CHECK (status IN ('owned', 'sold', 'transferred')), + counterparty_id BLOB, + created_at_ms INTEGER, + updated_at_ms INTEGER, + transferred_at_ms INTEGER, + last_synced_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, document_id), + CHECK ((status = 'owned') = (counterparty_id IS NULL)), + FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + );" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 884319cab24..ef0890f4e8b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -829,6 +829,7 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) + .union(PersistenceCapabilities::DPNS_NAME_STATES) } /// Merge `changeset` into the per-wallet buffer. @@ -1105,6 +1106,9 @@ fn apply_changeset_to_tx( if let Some(invitations) = cs.invitations.as_ref() { schema::invitations::apply(tx, wallet_id, invitations)?; } + if let Some(dpns_name_states) = cs.dpns_name_states.as_ref() { + schema::dpns_name_states::apply(tx, wallet_id, dpns_name_states)?; + } if let Some(balances) = cs.token_balances.as_ref() { schema::token_balances::apply(tx, wallet_id, balances)?; } 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 new file mode 100644 index 00000000000..cada877a297 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dpns_name_states.rs @@ -0,0 +1,307 @@ +//! `dpns_name_states` table writer + reader (DPNS username marketplace). +//! +//! Every field maps to an explicit column (the entry is all-primitive), so a +//! row reconstructs a [`DpnsNameStateEntry`] directly — no blob. The status +//! enum's `Sold { to } / Transferred { to }` payload is flattened into the +//! `counterparty_id` column (NULL for `owned`), with the pairing enforced by +//! a table CHECK. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{DpnsNameSaleStatus, DpnsNameStateChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +// Imports used only by the test-gated reader below. +#[cfg(any(test, feature = "__test-helpers"))] +use { + dpp::prelude::Identifier, platform_wallet::changeset::DpnsNameStateEntry, rusqlite::Connection, + std::collections::BTreeMap, +}; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &DpnsNameStateChangeSet, +) -> Result<(), WalletStorageError> { + if !cs.names.is_empty() { + let mut stmt = tx.prepare_cached( + "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, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \ + ON CONFLICT(wallet_id, document_id) DO UPDATE SET \ + identity_id = excluded.identity_id, \ + label = excluded.label, \ + normalized_label = excluded.normalized_label, \ + normalized_parent_domain = excluded.normalized_parent_domain, \ + price = excluded.price, \ + status = excluded.status, \ + counterparty_id = excluded.counterparty_id, \ + created_at_ms = excluded.created_at_ms, \ + updated_at_ms = excluded.updated_at_ms, \ + transferred_at_ms = excluded.transferred_at_ms, \ + last_synced_at_ms = excluded.last_synced_at_ms", + )?; + for (document_id, entry) in &cs.names { + let (status, counterparty) = status_columns(&entry.status); + let price = entry + .price + .map(|p| crate::sqlite::util::safe_cast::u64_to_i64("dpns_name_states.price", p)) + .transpose()?; + stmt.execute(params![ + wallet_id.as_slice(), + document_id.as_slice(), + entry.wallet_identity_id.as_slice(), + entry.label, + entry.normalized_label, + entry.normalized_parent_domain_name, + price, + status, + counterparty.map(|c| c.to_vec()), + entry + .created_at_ms + .map(|v| crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.created_at_ms", + v + )) + .transpose()?, + entry + .updated_at_ms + .map(|v| crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.updated_at_ms", + v + )) + .transpose()?, + entry + .transferred_at_ms + .map(|v| crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.transferred_at_ms", + v + )) + .transpose()?, + crate::sqlite::util::safe_cast::u64_to_i64( + "dpns_name_states.last_synced_at_ms", + entry.last_synced_at_ms + )?, + ])?; + } + } + if !cs.removed.is_empty() { + let mut stmt = tx.prepare_cached( + "DELETE FROM dpns_name_states WHERE wallet_id = ?1 AND document_id = ?2", + )?; + for document_id in &cs.removed { + stmt.execute(params![wallet_id.as_slice(), document_id.as_slice()])?; + } + } + Ok(()) +} + +/// Single source of truth for the `dpns_name_states.status` TEXT-column +/// domain + counterparty flattening. The `CHECK (status IN …)` in +/// `migrations/V005__dpns_name_states.rs` must list exactly these values. +pub(crate) fn status_columns(s: &DpnsNameSaleStatus) -> (&'static str, Option<[u8; 32]>) { + match s { + DpnsNameSaleStatus::Owned => ("owned", None), + DpnsNameSaleStatus::Sold { to } => ("sold", Some(to.to_buffer())), + DpnsNameSaleStatus::Transferred { to } => ("transferred", Some(to.to_buffer())), + } +} + +#[cfg(any(test, feature = "__test-helpers"))] +fn status_from_columns( + status: &str, + counterparty: Option>, +) -> Result { + let to = || -> Result { + let bytes = counterparty + .as_deref() + .ok_or_else(|| WalletStorageError::blob_decode("missing counterparty_id for row"))?; + Identifier::from_bytes(bytes) + .map_err(|_| WalletStorageError::blob_decode("counterparty_id is not 32 bytes")) + }; + match status { + "owned" => Ok(DpnsNameSaleStatus::Owned), + "sold" => Ok(DpnsNameSaleStatus::Sold { to: to()? }), + "transferred" => Ok(DpnsNameSaleStatus::Transferred { to: to()? }), + _ => Err(WalletStorageError::blob_decode( + "unknown dpns_name_states.status value in row", + )), + } +} + +/// 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 +/// source). +#[cfg(any(test, feature = "__test-helpers"))] +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 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, + }, + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(tag: u8, status: DpnsNameSaleStatus, price: Option) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: Identifier::from([tag; 32]), + wallet_identity_id: Identifier::from([0xAA; 32]), + label: format!("Alice{tag}"), + normalized_label: format!("a11ce{tag}"), + normalized_parent_domain_name: "dash".to_string(), + price, + 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, + } + } + + #[test] + fn apply_then_read_round_trips_and_upserts_and_removes() { + let wallet_id: WalletId = [0x22; 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(); + + // Insert two: one listed, one unlisted. + let e0 = entry(0, DpnsNameSaleStatus::Owned, Some(5_000_000_000)); + let e1 = entry(1, DpnsNameSaleStatus::Owned, None); + let mut cs = DpnsNameStateChangeSet::default(); + cs.names.insert(e0.document_id, e0.clone()); + cs.names.insert(e1.document_id, e1.clone()); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs).unwrap(); + tx.commit().unwrap(); + } + let got = read_all(&conn, &wallet_id).unwrap(); + assert_eq!(got.len(), 2); + assert_eq!(got[&e0.document_id], e0); + assert_eq!(got[&e1.document_id], e1); + + // Upsert e0 → sold (price cleared by consensus), remove e1. + let buyer = Identifier::from([0xBB; 32]); + let mut e0b = e0.clone(); + e0b.price = None; + e0b.status = DpnsNameSaleStatus::Sold { to: buyer }; + e0b.transferred_at_ms = Some(1_800_000_100_000); + let mut cs2 = DpnsNameStateChangeSet::default(); + cs2.names.insert(e0b.document_id, e0b.clone()); + cs2.removed.insert(e1.document_id); + { + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &cs2).unwrap(); + tx.commit().unwrap(); + } + let got = read_all(&conn, &wallet_id).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[&e0.document_id], e0b); + } + + /// `price` is `Credits` (u64) in Rust and the writer routes it through + /// `u64_to_i64`, so a negative price cannot originate from this crate. + /// The column CHECK is the backstop that stops a hand-edited or + /// corrupted row from reading back as valid marketplace state. + #[test] + fn negative_price_is_rejected_by_the_schema() { + let wallet_id: WalletId = [0x33; 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 insert_with_price = |conn: &Connection, doc: u8, price: i64| { + conn.execute( + "INSERT INTO dpns_name_states \ + (wallet_id, document_id, identity_id, label, normalized_label, \ + normalized_parent_domain, price, status, counterparty_id, \ + last_synced_at_ms) \ + VALUES (?1, ?2, ?3, 'Alice', 'a11ce', 'dash', ?4, 'owned', NULL, 1)", + params![&wallet_id[..], &[doc; 32][..], &[0xAAu8; 32][..], price], + ) + }; + assert!( + insert_with_price(&conn, 0x01, -1).is_err(), + "negative price must violate the column CHECK" + ); + assert!( + insert_with_price(&conn, 0x02, 0).is_ok(), + "a zero-credit listing is valid" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 41b4d82c271..5335bde9943 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -19,6 +19,7 @@ pub mod blob; pub mod contacts; pub mod core_state; pub mod dashpay; +pub mod dpns_name_states; pub mod identities; pub mod identity_keys; pub mod invitations; 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 12c5cb863f5..69c92fb7718 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -76,6 +76,10 @@ 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/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index dacd11b0ef7..7d404f6985a 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -100,6 +100,9 @@ name = "shielded_chunk_timing_bench" required-features = ["shielded"] [dev-dependencies] +# In-process Signer for the manual testnet +# verification example (`examples/dpns_marketplace_testnet.rs`). +simple-signer = { path = "../simple-signer", features = ["state-transitions"] } # Used by `examples/shielded_chunk_timing_bench.rs` and # `tests/shielded_decrypt_bench.rs` to assemble per-chunk wire # fixtures and decode the `ShieldedEncryptedNote` wire type. diff --git a/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs new file mode 100644 index 00000000000..9db1d6a312a --- /dev/null +++ b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs @@ -0,0 +1,616 @@ +//! Manual TESTNET verification harness for the DPNS username-marketplace +//! wallet layer (`wallet/identity/network/dpns_marketplace.rs`). +//! +//! Exercises the real wallet-level flow end to end against testnet DAPI: +//! register (uncontested) name → list → verify sale state → re-price → +//! typed stale-price rejection → purchase by a second identity → +//! ownership/records/label reconciliation → history timeline (priceSet ×2 + +//! purchased) → re-list → delist via transfer-to-self → `$price` cleared. +//! Use the printed results to validate the active testnet contract and +//! transition behavior before shipping SDK changes. +//! +//! Environment (secrets stay in env, never printed): +//! DPNS_MNEMONIC required — wallet recovery phrase +//! DPNS_PHASE "discover" (default) or "run" +//! DPNS_SELLER_INDEX HD identity index of the seller (default 0) +//! DPNS_BUYER_INDEX HD identity index of the buyer (default 1) +//! DPNS_IDENTITY_ID optional base58 id: discover also reports which HD +//! index (0..=9) derives this identity's keys, or that +//! none does (out-of-wallet key layout) +//! DPNS_PRIVATE_KEY optional single signing key (hex or WIF) fallback +//! when the identity's keys are not HD-derived; used +//! with DPNS_IDENTITY_ID +//! DPNS_DAPI_ADDRESSES optional comma-separated https://host:port list +//! +//! Run: +//! DPNS_MNEMONIC="…" cargo run -p platform-wallet --example dpns_marketplace_testnet +//! DPNS_PHASE=run DPNS_MNEMONIC="…" cargo run -p platform-wallet --example dpns_marketplace_testnet + +use std::sync::Arc; + +use dash_sdk::sdk::{Address, AddressList}; +use dash_sdk::SdkBuilder; +use dashcore::hashes::{hash160, Hash}; +use dashcore::Network; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, KeyType}; +use dpp::prelude::Identifier; +use key_wallet::bip32::ExtendedPrivKey; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use platform_wallet::changeset::{ + ClientStartState, DpnsNameSaleStatus, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::wallet::identity::network::{ + derive_ecdsa_identity_auth_keypair_from_master, DpnsNameHistoryEventKind, IdentityWallet, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::PlatformWalletManager; +use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; +use simple_signer::signer::SimpleSigner; + +/// Testnet DAPI evonodes (same set as `tests/spv_sync.rs`); override +/// with `DPNS_DAPI_ADDRESSES`. +const TESTNET_DAPI_ADDRESSES: &[&str] = &[ + "https://68.67.122.1:1443", + "https://68.67.122.2:1443", + "https://68.67.122.3:1443", +]; + +/// Listing prices for the flow (credits). Small on purpose — the point +/// is the protocol semantics, not the amounts. +const PRICE_INITIAL: Credits = 1_000_000; +const PRICE_FINAL: Credits = 2_000_000; +const PRICE_RELIST: Credits = 3_000_000; +/// Buyer top-up floor: purchase price + the wallet's fee reserve with +/// headroom for the buyer's own later transitions (re-list + delist). +const BUYER_MIN_CREDITS: Credits = 500_000_000; +const BUYER_TOP_UP: Credits = 1_000_000_000; + +struct NoopPersister; +impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), platform_wallet::changeset::PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + fn flush( + &self, + _wallet_id: WalletId, + ) -> Result<(), platform_wallet::changeset::PersistenceError> { + Ok(()) + } +} + +struct NoopEventHandler; +impl EventHandler for NoopEventHandler {} +impl PlatformEventHandler for NoopEventHandler {} + +fn dapi_addresses() -> AddressList { + let raw = std::env::var("DPNS_DAPI_ADDRESSES").unwrap_or_default(); + let addrs: Vec
= if raw.trim().is_empty() { + TESTNET_DAPI_ADDRESSES + .iter() + .filter_map(|s| s.parse().ok()) + .collect() + } else { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse().ok()) + .collect() + }; + assert!(!addrs.is_empty(), "no DAPI addresses configured"); + AddressList::from_iter(addrs) +} + +/// Whether `sk_bytes` is the private key for `key` (33-byte pubkey for +/// ECDSA_SECP256K1, hash160 for ECDSA_HASH160). +fn private_key_matches(key: &dpp::identity::IdentityPublicKey, sk_bytes: &[u8; 32]) -> bool { + let secp = dashcore::secp256k1::Secp256k1::new(); + let Ok(sk) = dashcore::secp256k1::SecretKey::from_byte_array(sk_bytes) else { + return false; + }; + let pubkey = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &sk).serialize(); + match key.key_type() { + KeyType::ECDSA_SECP256K1 => key.data().as_slice() == pubkey.as_slice(), + KeyType::ECDSA_HASH160 => { + key.data().as_slice() == hash160::Hash::hash(&pubkey).as_byte_array().as_slice() + } + _ => false, + } +} + +/// Load every ECDSA key of `identity` (HD index `identity_index`, +/// derivation convention `key_index == key_id`) into `signer`, verifying +/// each derived pubkey against the on-chain key before insertion. +/// Returns how many keys matched. +fn load_hd_keys_into_signer( + signer: &mut SimpleSigner, + identity: &Identity, + identity_index: u32, + master: &ExtendedPrivKey, +) -> u32 { + let mut matched = 0; + for (key_id, ipk) in identity.public_keys() { + if !matches!( + ipk.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ) { + continue; + } + let Ok(kp) = derive_ecdsa_identity_auth_keypair_from_master( + master, + key_wallet::Network::Testnet, + identity_index, + *key_id, + ) else { + continue; + }; + if private_key_matches(ipk, &kp.private_key) { + signer.add_identity_public_key(ipk.clone(), *kp.private_key); + matched += 1; + } + } + matched +} + +fn parse_private_key(raw: &str) -> Option<[u8; 32]> { + let trimmed = raw.trim(); + if let Ok(bytes) = hex::decode(trimmed) { + if bytes.len() == 32 { + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + return Some(out); + } + } + dashcore::PrivateKey::from_wif(trimmed) + .ok() + .map(|pk| pk.inner.secret_bytes()) +} + +async fn discover( + idw: &IdentityWallet, + master: &ExtendedPrivKey, + sdk: &Arc, +) -> Result<(), Box> { + println!("== discover: HD identities (index 0..=9) =="); + for index in 0..10u32 { + match idw.load_identity_by_index_from_master(index, master).await { + Ok(Some(identity)) => { + let key_summary: Vec = identity + .public_keys() + .iter() + .map(|(id, k)| { + format!( + "#{id}:{:?}/{:?}/{:?}", + k.purpose(), + k.security_level(), + k.key_type() + ) + }) + .collect(); + println!( + "index {index}: {} balance={} keys=[{}]", + identity.id(), + identity.balance(), + key_summary.join(", ") + ); + } + Ok(None) => println!("index {index}: (none)"), + Err(e) => println!("index {index}: lookup error: {e}"), + } + } + + if let Ok(raw_id) = std::env::var("DPNS_IDENTITY_ID") { + use dash_sdk::platform::Fetch; + let id = Identifier::from_string( + raw_id.trim(), + dpp::platform_value::string_encoding::Encoding::Base58, + )?; + println!("== discover: key layout of {id} =="); + let Some(identity) = Identity::fetch(sdk.as_ref(), id).await? else { + println!("identity not found on testnet"); + return Ok(()); + }; + println!( + "balance={} keys={}", + identity.balance(), + identity.public_keys().len() + ); + let mut any = false; + for index in 0..10u32 { + let mut probe = SimpleSigner::default(); + let matched = load_hd_keys_into_signer(&mut probe, &identity, index, master); + if matched > 0 { + println!("HD index {index}: {matched} key(s) derive from this mnemonic"); + any = true; + } + } + if !any { + println!("no key on this identity derives from the mnemonic (indexes 0..=9)"); + } + if let Ok(raw_sk) = std::env::var("DPNS_PRIVATE_KEY") { + match parse_private_key(&raw_sk) { + Some(sk) => { + let matches: Vec = identity + .public_keys() + .iter() + .filter(|(_, k)| private_key_matches(k, &sk)) + .map(|(kid, k)| { + format!("#{kid} ({:?}/{:?})", k.purpose(), k.security_level()) + }) + .collect(); + println!( + "DPNS_PRIVATE_KEY matches keys: [{}]", + if matches.is_empty() { + "none".to_string() + } else { + matches.join(", ") + } + ); + } + None => println!("DPNS_PRIVATE_KEY did not parse as hex or WIF"), + } + } + } + Ok(()) +} + +/// Re-read `label`'s on-chain state until `predicate` holds or attempts +/// run out. Fresh reads race lagging replicas — a query right after a +/// broadcast can land on a node one block behind, briefly serving the +/// pre-transition document. The proof-verified CONFIRMED document from +/// the transition is the authoritative check; these visibility re-reads +/// are the "and other clients can see it" bonus, so they tolerate +/// replica lag with a bounded retry. +async fn wait_for_visible_state( + idw: &IdentityWallet, + label: &str, + predicate: impl Fn(&platform_wallet::wallet::identity::network::DpnsDomainState) -> bool, +) -> Result> +{ + let mut last = None; + for _ in 0..6 { + if let Some(state) = idw.dpns_name_state(label).await? { + if predicate(&state) { + return Ok(state); + } + last = Some(state); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + Ok(last.ok_or("name never became visible")?) +} + +/// Assert helper that prints a PASS line (the run transcript is the +/// verification artifact). +fn check(name: &str, ok: bool, detail: impl std::fmt::Display) { + if ok { + println!("PASS {name}: {detail}"); + } else { + println!("FAIL {name}: {detail}"); + panic!("verification step failed: {name}"); + } +} + +#[allow(clippy::too_many_lines)] +async fn run_flow( + idw: &IdentityWallet, + master: &ExtendedPrivKey, +) -> Result<(), Box> { + let seller_index: u32 = std::env::var("DPNS_SELLER_INDEX") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let buyer_index: u32 = std::env::var("DPNS_BUYER_INDEX") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + + // `_from_master`: the manager-created wallet is external-signable + // (no seed retained Rust-side), so the plain by-index loader cannot + // derive the lookup key hash; the master-xpriv variant exists for + // exactly this shape. + let seller = idw + .load_identity_by_index_from_master(seller_index, master) + .await? + .ok_or_else(|| format!("no identity at seller index {seller_index}"))?; + let buyer = idw + .load_identity_by_index_from_master(buyer_index, master) + .await? + .ok_or_else(|| format!("no identity at buyer index {buyer_index}"))?; + let seller_id = seller.id(); + let buyer_id = buyer.id(); + println!( + "seller (index {seller_index}): {seller_id} balance={}", + seller.balance() + ); + println!( + "buyer (index {buyer_index}): {buyer_id} balance={}", + buyer.balance() + ); + + let mut signer = SimpleSigner::default(); + let seller_keys = load_hd_keys_into_signer(&mut signer, &seller, seller_index, master); + let buyer_keys = load_hd_keys_into_signer(&mut signer, &buyer, buyer_index, master); + check( + "signer-keys", + seller_keys > 0 && buyer_keys > 0, + format!("seller {seller_keys} key(s), buyer {buyer_keys} key(s) HD-derived"), + ); + + // Buyer must afford price + fee reserve (plus its own later + // transitions); top up from the seller when short. + if buyer.balance() < BUYER_MIN_CREDITS { + println!( + "buyer balance {} < {BUYER_MIN_CREDITS}, transferring {BUYER_TOP_UP} credits from seller", + buyer.balance() + ); + idw.transfer_credits_with_external_signer( + &seller_id, + &buyer_id, + BUYER_TOP_UP, + &signer, + None, + ) + .await?; + idw.refresh_identity(&buyer_id).await?; + } + + // Fresh uncontested label per run: contains digits (timestamp) so it + // never enters a masternode vote. + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + let label = format!("mktp{unix}test"); + println!("== registering test name {label:?} on seller =="); + let full_name = idw + .register_name_with_external_signer(&seller_id, &label, &signer) + .await?; + check("register", full_name.ends_with(".dash"), &full_name); + + // 1. List. + let listed = idw + .set_dpns_name_price(&seller_id, &label, PRICE_INITIAL, &signer) + .await?; + check( + "list", + listed.price == Some(PRICE_INITIAL), + format!("confirmed $price={:?}", listed.price), + ); + let fresh = wait_for_visible_state(idw, &label, |s| { + s.price == Some(PRICE_INITIAL) && s.owner_id == seller_id + }) + .await?; + check( + "list-visible", + fresh.price == Some(PRICE_INITIAL) && fresh.owner_id == seller_id, + format!("on-chain $price={:?} owner={}", fresh.price, fresh.owner_id), + ); + + // 2. Re-price. + let repriced = idw + .set_dpns_name_price(&seller_id, &label, PRICE_FINAL, &signer) + .await?; + check( + "re-price", + repriced.price == Some(PRICE_FINAL), + format!("confirmed $price={:?}", repriced.price), + ); + + // 3. Typed stale-price rejection (pre-flight, before any broadcast). + let stale = idw + .purchase_dpns_name(&buyer_id, &label, PRICE_INITIAL, &signer) + .await; + check( + "stale-price-typed", + matches!( + stale, + Err(PlatformWalletError::DocumentPriceChanged { expected, actual, .. }) + if expected == PRICE_INITIAL && actual == PRICE_FINAL + ), + format!("{stale:?}"), + ); + + // 4. Purchase at the confirmed price. + idw.refresh_identity(&buyer_id).await?; + let bought = idw + .purchase_dpns_name(&buyer_id, &label, PRICE_FINAL, &signer) + .await?; + check( + "purchase-owner", + bought.owner_id == buyer_id, + format!("owner={}", bought.owner_id), + ); + check( + "purchase-clears-price", + bought.price.is_none(), + format!("$price={:?}", bought.price), + ); + check( + "purchase-rewrites-records", + bought.records_identity_id == Some(buyer_id), + format!("records.identity={:?}", bought.records_identity_id), + ); + + // 5. Local reconciliation: label moved seller → buyer; marketplace row + // tracks the buyer as Owned. + let rows = idw.local_dpns_name_states(None).await?; + let row = rows + .iter() + .find(|r| r.normalized_label == bought.normalized_label) + .expect("marketplace row for purchased name"); + check( + "local-row", + row.wallet_identity_id == buyer_id && row.status == DpnsNameSaleStatus::Owned, + format!( + "row identity={} status={:?}", + row.wallet_identity_id, row.status + ), + ); + + // 6. History: Registered + PriceSet(1M) + PriceSet(2M) + Purchased(2M). + let history = idw.dpns_name_history(&label).await?; + println!("history ({} events):", history.len()); + for event in &history { + println!(" {:?}", event); + } + let price_sets: Vec = history + .iter() + .filter_map(|e| match e.kind { + DpnsNameHistoryEventKind::PriceSet { price } => Some(price), + _ => None, + }) + .collect(); + let purchased = history.iter().any(|e| { + matches!( + e.kind, + DpnsNameHistoryEventKind::Purchased { price, seller, buyer } + if price == PRICE_FINAL && seller == seller_id && buyer == buyer_id + ) + }); + check( + "history-price-sets", + price_sets == vec![PRICE_INITIAL, PRICE_FINAL], + format!("{price_sets:?}"), + ); + check( + "history-purchase", + purchased, + "purchase event with price+parties", + ); + check( + "history-registered", + matches!( + history.first().map(|e| &e.kind), + Some(DpnsNameHistoryEventKind::Registered) + ), + "timeline starts at registration", + ); + + // 7. Typed not-for-sale rejection now that the purchase cleared $price. + let not_for_sale = idw + .purchase_dpns_name(&seller_id, &label, PRICE_FINAL, &signer) + .await; + check( + "not-for-sale-typed", + matches!( + not_for_sale, + Err(PlatformWalletError::DocumentNotForSale { .. }) + ), + format!("{not_for_sale:?}"), + ); + + // 8. Delist: buyer re-lists, then delists via transfer-to-self; the + // method itself verifies the confirmed document cleared $price. + idw.set_dpns_name_price(&buyer_id, &label, PRICE_RELIST, &signer) + .await?; + let delisted = idw.delist_dpns_name(&buyer_id, &label, &signer).await?; + check( + "delist-clears-price", + delisted.price.is_none() && delisted.owner_id == buyer_id, + format!("$price={:?} owner={}", delisted.price, delisted.owner_id), + ); + let fresh = + wait_for_visible_state(idw, &label, |s| s.price.is_none() && s.owner_id == buyer_id) + .await?; + check( + "delist-visible", + fresh.price.is_none() && fresh.owner_id == buyer_id, + format!("on-chain $price={:?} owner={}", fresh.price, fresh.owner_id), + ); + + // 9. Search + sync passes for completeness. + let results = idw + .search_dpns_names_with_state("mktp", Some(50), None) + .await?; + check( + "search", + results + .iter() + .any(|s| s.normalized_label == fresh.normalized_label), + format!("{} result(s) for prefix", results.len()), + ); + let summary = idw.sync_dpns_marketplace().await?; + println!( + "sync summary: tracked={} added={:?} departed={} prices_changed={}", + summary.names_tracked, + summary.names_added.len(), + summary.names_departed.len(), + summary.prices_changed.len() + ); + + println!("== ALL CHECKS PASSED =="); + Ok(()) +} + +async fn run() -> Result<(), Box> { + let phrase = std::env::var("DPNS_MNEMONIC") + .map_err(|_| "DPNS_MNEMONIC env var is required (never printed)")?; + + let addresses = dapi_addresses(); + let provider = TrustedHttpContextProvider::new( + Network::Testnet, + None, + std::num::NonZeroUsize::new(100).unwrap(), + )?; + let sdk = Arc::new( + SdkBuilder::new(addresses) + .with_network(Network::Testnet) + .with_context_provider(provider) + .build()?, + ); + + let manager = Arc::new(PlatformWalletManager::new( + Arc::clone(&sdk), + Arc::new(NoopPersister), + Arc::new(NoopEventHandler), + )); + let wallet = manager + .create_wallet_from_mnemonic( + &phrase, + Network::Testnet, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await?; + let idw = wallet.identity(); + + let mnemonic: key_wallet::Mnemonic = phrase.parse()?; + let master = ExtendedPrivKey::new_master(key_wallet::Network::Testnet, &mnemonic.to_seed(""))?; + + match std::env::var("DPNS_PHASE").as_deref() { + Ok("run") => run_flow(idw, &master).await, + _ => discover(idw, &master, &sdk).await, + } +} + +fn main() { + let _ = tracing_subscriber::FmtSubscriber::builder() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .try_init(); + + // 8 MiB worker stacks: every marketplace op verifies GroveDB + // document-query proofs, whose recursion overflows the 2 MiB tokio + // default (same rationale as DASHPAY_SYNC_STACK_BYTES). + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(8 * 1024 * 1024) + .enable_all() + .build() + .expect("build runtime") + .block_on(run()) + .expect("verification run failed"); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c19aff6ad6d..fa425fbde56 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -604,12 +604,13 @@ impl Merge for IdentityChangeSet { // profile via `from_managed`, so LWW converges // correctly within a single wallet. existing.dashpay_profile = entry.dashpay_profile.clone(); - // Append new DPNS names (by label). - for name in &entry.dpns_names { - if !existing.dpns_names.iter().any(|n| n.label == name.label) { - existing.dpns_names.push(name.clone()); - } - } + // DPNS names: last-write-wins wholesale, same policy + // as `contested_dpns_names` below. Every emitter + // snapshots the complete current list via + // `from_managed`, and a sold/transferred name must be + // able to LEAVE the list — the previous append-only- + // by-label merge made departure impossible. + existing.dpns_names = entry.dpns_names.clone(); // The contested-name sync emits the complete canonical // snapshot. Last-write-wins is therefore required so // resolved contests disappear, including when the latest @@ -1075,6 +1076,112 @@ impl Merge for InvitationChangeSet { } } +// --------------------------------------------------------------------------- +// DPNS name states (username marketplace) +// --------------------------------------------------------------------------- + +/// Where a tracked DPNS name currently stands relative to the wallet +/// identity that owned it. +/// +/// `Sold` / `Transferred` rows are retained (not deleted) so the host can +/// surface "your name was sold" affordances; hard removal goes through +/// [`DpnsNameStateChangeSet::removed`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DpnsNameSaleStatus { + /// The wallet identity is the document's `$ownerId`. + Owned, + /// The name left the identity through a purchase; `to` is the buyer. + Sold { to: Identifier }, + /// The name left the identity through a plain transfer (gift / + /// off-market handover); `to` is the recipient. + Transferred { to: Identifier }, +} + +/// One tracked DPNS `domain` document belonging to (or recently departed +/// from) a wallet identity, **with sale state** — the marketplace-facing +/// superset of the label-only `DpnsNameInfo` list. +/// +/// Deliberately a separate store rather than new fields on +/// [`IdentityEntry`]: the identity `entry_blob` is unversioned positional +/// bincode, so growing `DpnsNameInfo` would break decoding of existing +/// rows. Keyed by the domain document id, which is stable across ownership +/// changes. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DpnsNameStateEntry { + /// The DPNS `domain` document id (this record's identity; stable + /// across transfers and purchases). + pub document_id: Identifier, + /// The wallet identity this row is tracked for. For `Owned` rows this + /// equals the document's `$ownerId`; for `Sold`/`Transferred` rows it + /// is the previous owner (ours). + pub wallet_identity_id: Identifier, + /// Display label (e.g. "Alice"). + pub label: String, + /// Homograph-normalized label (e.g. "a11ce"). + pub normalized_label: String, + /// Normalized parent domain (today always "dash"). + pub normalized_parent_domain_name: String, + /// Listed sale price in credits (`$price`). `None` = not for sale. + pub price: Option, + /// Ownership status relative to `wallet_identity_id`. + pub status: DpnsNameSaleStatus, + /// Document `$createdAt` (ms since epoch) when the document carries it. + pub created_at_ms: Option, + /// Document `$updatedAt` (ms) — bumps on price changes. + pub updated_at_ms: Option, + /// Document `$transferredAt` (ms) — set on purchase/transfer. + pub transferred_at_ms: Option, + /// Wall-clock ms of the sync pass / confirmed transition that wrote + /// this row. + pub last_synced_at_ms: u64, +} + +/// DPNS name-state records emitted by the marketplace sync pass and by the +/// set-price / delist / purchase / transfer orchestration ops. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DpnsNameStateChangeSet { + /// Name states keyed by domain document id. Last write wins on merge — + /// every emitter writes a complete row read from Platform or from a + /// confirmed transition, so later rows are strictly fresher. + pub names: BTreeMap, + /// Document ids removed from tracking entirely. + pub removed: BTreeSet, +} + +impl Merge for DpnsNameStateChangeSet { + fn merge(&mut self, other: Self) { + // Last OPERATION wins per document id, not merely last write. + // + // The sqlite writer applies inserts before deletes, so a key + // landing in both sets resolves to "removed" no matter which + // operation came first — a stale tombstone would silently + // swallow a newer upsert. Each side therefore evicts the key + // from the other as it merges, so the operation that arrived + // later is the one that survives. + // + // Deliberately stricter than `InvitationChangeSet`'s + // insert-XOR-tombstone convention: a marketplace row can + // legitimately come back after removal (a name re-acquired + // later), so the ordering hazard is reachable here rather than + // latent. + for document_id in other.names.keys() { + self.removed.remove(document_id); + } + for document_id in &other.removed { + self.names.remove(document_id); + } + self.names.extend(other.names); + self.removed.extend(other.removed); + } + + fn is_empty(&self) -> bool { + self.names.is_empty() && self.removed.is_empty() + } +} + // --------------------------------------------------------------------------- // Token Balances // --------------------------------------------------------------------------- @@ -1480,6 +1587,9 @@ pub struct PlatformWalletChangeSet { pub asset_locks: Option, /// DashPay invitation (DIP-13) records — inviter-side create/reclaim. pub invitations: Option, + /// DPNS name states with sale price (username marketplace) — emitted + /// by the marketplace sync pass and the trade orchestration ops. + pub dpns_name_states: Option, /// Platform token balance / watch changes. pub token_balances: Option, /// DashPay profile overlays keyed by identity ID. Applied AFTER @@ -1588,6 +1698,15 @@ impl From for PlatformWalletChangeSet { } } +impl From for PlatformWalletChangeSet { + fn from(cs: DpnsNameStateChangeSet) -> Self { + Self { + dpns_name_states: Some(cs), + ..Default::default() + } + } +} + impl Merge for PlatformWalletChangeSet { fn merge(&mut self, other: Self) { // `CoreChangeSet` implements `Merge`; delegate via the @@ -1599,6 +1718,7 @@ impl Merge for PlatformWalletChangeSet { self.platform_addresses.merge(other.platform_addresses); self.asset_locks.merge(other.asset_locks); self.invitations.merge(other.invitations); + self.dpns_name_states.merge(other.dpns_name_states); self.token_balances.merge(other.token_balances); // DashPay overlays: LWW per identity_id. if let Some(other_profiles) = other.dashpay_profiles { @@ -1651,6 +1771,7 @@ impl Merge for PlatformWalletChangeSet { && self.platform_addresses.is_empty() && self.asset_locks.is_empty() && self.invitations.is_empty() + && self.dpns_name_states.is_empty() && self.token_balances.is_empty() && self.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()) && self @@ -1825,6 +1946,126 @@ mod tests { assert!(changes.identities[&id].contested_dpns_names.is_empty()); } + fn identity_entry_with_names(id: Identifier, labels: &[&str]) -> IdentityEntry { + let mut entry = identity_entry_with_contested(id, &[]); + entry.dpns_names = labels + .iter() + .map(|label| DpnsNameInfo { + label: (*label).to_owned(), + acquired_at: None, + }) + .collect(); + entry + } + + /// DPNS names merge last-write-wins wholesale (same policy as + /// contested names): a sold/transferred name must be able to LEAVE + /// the list, including via an empty snapshot. Guards the 2026-08 + /// change away from append-only-by-label, which made departure + /// impossible. + #[test] + fn dpns_names_merge_replaces_canonical_snapshot_and_allows_empty() { + let id = Identifier::from([0x52; 32]); + let mut changes = IdentityChangeSet::default(); + changes + .identities + .insert(id, identity_entry_with_names(id, &["sold", "kept"])); + + let mut refreshed = IdentityChangeSet::default(); + refreshed + .identities + .insert(id, identity_entry_with_names(id, &["kept", "bought"])); + changes.merge(refreshed); + let labels: Vec<&str> = changes.identities[&id] + .dpns_names + .iter() + .map(|n| n.label.as_str()) + .collect(); + assert_eq!(labels, ["kept", "bought"]); + + let mut emptied = IdentityChangeSet::default(); + emptied + .identities + .insert(id, identity_entry_with_names(id, &[])); + changes.merge(emptied); + assert!(changes.identities[&id].dpns_names.is_empty()); + } + + /// Marketplace name-state rows merge LWW per document id, with + /// tombstones accumulating independently (insert-XOR-tombstone per + /// mutation round, applied inserts-then-deletes downstream). + #[test] + fn dpns_name_state_merge_is_lww_per_document_with_tombstones() { + let doc = Identifier::from([0x61; 32]); + let other_doc = Identifier::from([0x62; 32]); + let identity = Identifier::from([0x63; 32]); + let buyer = Identifier::from([0x64; 32]); + let entry = |price: Option, status: DpnsNameSaleStatus| DpnsNameStateEntry { + document_id: doc, + wallet_identity_id: identity, + label: "Alice".into(), + normalized_label: "a11ce".into(), + normalized_parent_domain_name: "dash".into(), + price, + status, + created_at_ms: Some(1), + updated_at_ms: None, + transferred_at_ms: None, + last_synced_at_ms: 2, + }; + + let mut cs = DpnsNameStateChangeSet::default(); + assert!(cs.is_empty()); + cs.names + .insert(doc, entry(Some(5_000), DpnsNameSaleStatus::Owned)); + + let mut sold = DpnsNameStateChangeSet::default(); + sold.names + .insert(doc, entry(None, DpnsNameSaleStatus::Sold { to: buyer })); + sold.removed.insert(other_doc); + cs.merge(sold); + + assert_eq!(cs.names[&doc].price, None); + assert_eq!( + cs.names[&doc].status, + DpnsNameSaleStatus::Sold { to: buyer } + ); + assert!(cs.removed.contains(&other_doc)); + assert!(!cs.is_empty()); + + // Tombstone AFTER an upsert: the later remove wins and the + // superseded upsert does not linger in `names`. + let mut upsert_then_remove = DpnsNameStateChangeSet::default(); + upsert_then_remove + .names + .insert(doc, entry(Some(1), DpnsNameSaleStatus::Owned)); + let mut tombstone = DpnsNameStateChangeSet::default(); + tombstone.removed.insert(doc); + upsert_then_remove.merge(tombstone); + assert!(!upsert_then_remove.names.contains_key(&doc)); + assert!(upsert_then_remove.removed.contains(&doc)); + + // Upsert AFTER a tombstone (a name re-acquired later): the newer + // upsert wins and the stale tombstone is dropped. Without the + // eviction this row would be silently deleted, because the + // sqlite writer applies inserts before deletes. + let mut remove_then_upsert = DpnsNameStateChangeSet::default(); + remove_then_upsert.removed.insert(doc); + let mut reacquired = DpnsNameStateChangeSet::default(); + reacquired + .names + .insert(doc, entry(Some(7), DpnsNameSaleStatus::Owned)); + remove_then_upsert.merge(reacquired); + assert!(!remove_then_upsert.removed.contains(&doc)); + assert_eq!(remove_then_upsert.names[&doc].price, Some(7)); + + // Replaying the same round is idempotent. + let mut replayed = remove_then_upsert.clone(); + replayed.merge(remove_then_upsert.clone()); + assert_eq!(replayed.names, remove_then_upsert.names); + assert_eq!(replayed.removed, remove_then_upsert.removed); + } + /// The deferred contact-crypto queue rides the changeset as add/clear /// deltas: a pending enqueue OR a pending clear must mark the changeset /// non-empty (so the persist round isn't skipped and the queue survives a diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 913ea54d51b..e87cc14aeec 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -29,13 +29,14 @@ pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, - HighestUsedIndexes, IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, - IdentityKeyEntry, IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, - InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, - PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, - PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, - ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, - ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, HighestUsedIndexes, + IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, + IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, + KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, + PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, + PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, + ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey, + SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index b477ad27dd9..61c4afa50f8 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -46,6 +46,8 @@ impl PersistenceCapabilities { pub const DEFERRED_CONTACT_CRYPTO: Self = Self::PENDING_CONTACT_CRYPTO; /// A persisted core wallet snapshot can be loaded after process restart. pub const WALLET_RESTORE: Self = Self(1 << 7); + /// DPNS name-state (username marketplace) rows can be persisted. + pub const DPNS_NAME_STATES: Self = Self(1 << 8); /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -113,6 +115,10 @@ impl PersistenceCapabilities { "pending_contact_crypto", ), (PersistenceCapabilities::WALLET_RESTORE, "wallet_restore"), + ( + PersistenceCapabilities::DPNS_NAME_STATES, + "dpns_name_states", + ), ]; KNOWN @@ -140,6 +146,7 @@ mod tests { assert_eq!(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE.bits(), 0x20); assert_eq!(PersistenceCapabilities::PENDING_CONTACT_CRYPTO.bits(), 0x40); assert_eq!(PersistenceCapabilities::WALLET_RESTORE.bits(), 0x80); + assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); } #[test] diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 9d6ce8a0a4e..f018bc37b87 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -255,6 +255,61 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), + /// No DPNS `domain` document exists for the requested name (exact + /// normalized-label lookup came back empty). Distinct from + /// [`Self::InvalidParameter`]: the input was well-formed, the name + /// just isn't registered (or is hidden inside an unresolved contest — + /// see [`Self::ContestedNameNotTradable`] for the pre-checked case). + #[error("DPNS name not found: {name:?}")] + DpnsNameNotFound { name: String }, + + /// The DPNS domain document carries no `$price` — it is not listed + /// for sale. Raised by the wallet's pre-flight check and by the + /// consensus downcast of `DocumentNotForSaleError` (DPP code 40108). + #[error("document {document_id} is not for sale")] + DocumentNotForSale { document_id: Identifier }, + + /// The listed price no longer equals the price the user confirmed. + /// Raised pre-flight (fresh read ≠ confirmed price) and by the + /// consensus downcast of `DocumentIncorrectPurchasePriceError` (DPP + /// code 40109) when the listing changed between the pre-flight read + /// and broadcast — the purchase did NOT execute in either case. + #[error( + "document {document_id} price changed: purchase was confirmed at \ + {expected} credits but the listing is now {actual} credits" + )] + DocumentPriceChanged { + document_id: Identifier, + expected: Credits, + actual: Credits, + }, + + /// The identity's credit balance cannot cover the operation + /// (principal + fee margin for pre-flight checks; Platform's own + /// arithmetic for the consensus downcast of + /// `IdentityInsufficientBalanceError`). + #[error( + "identity {identity_id} has insufficient credits: {required} required, \ + {available} available" + )] + InsufficientIdentityCredits { + identity_id: Identifier, + required: Credits, + available: Credits, + }, + + /// The name is inside an active contested-name vote, so its domain + /// document is not yet in the documents tree and cannot be listed, + /// transferred, or purchased. Without this guard the network returns + /// a bare `DocumentNotFoundError` (40101), which reads as "no such + /// name" — this typed error says what is actually going on. + /// `ends_at_ms == 0` means the vote's end time was unavailable. + #[error( + "DPNS name {label:?} is in an active contested-name vote \ + (ends at {ends_at_ms} ms) and cannot be traded until the contest resolves" + )] + ContestedNameNotTradable { label: String, ends_at_ms: u64 }, + /// Platform rejected an address-funds transition because a spent address's /// provided nonce did not equal its expected next value (DPP consensus code /// 40603, `AddressInvalidNonceError`) — an optimistic `fetched + 1` nonce @@ -641,6 +696,80 @@ pub fn promote_address_nonce_error_or_sdk(error: dash_sdk::Error) -> PlatformWal promote_address_nonce_error(&error).unwrap_or(PlatformWalletError::Sdk(error)) } +/// Extract the consensus verdict from the `dash_sdk::Error` shapes that can +/// carry one — `StateTransitionBroadcastError` (wait-stream), +/// `Protocol(ConsensusError)` (CheckTx), and the dapi-client's +/// exhausted-retry envelope it recurses into. Shared by the typed-promotion +/// matchers below; the same coverage caveat as +/// [`as_asset_lock_proof_cl_height_too_low`] applies (re-audit when +/// `dash_sdk::Error` gains consensus-carrying variants). +fn consensus_error_of(error: &dash_sdk::Error) -> Option<&dpp::consensus::ConsensusError> { + match error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => consensus_error_of(inner), + _ => None, + } +} + +/// Promote a document-trade consensus rejection to its typed +/// [`PlatformWalletError`] so callers get structured data instead of a +/// stringified verdict: +/// +/// - `DocumentNotForSaleError` (40108) → [`PlatformWalletError::DocumentNotForSale`] +/// - `DocumentIncorrectPurchasePriceError` (40109) → +/// [`PlatformWalletError::DocumentPriceChanged`] (carries both prices — +/// the race-lost purchase case; the transition did NOT execute) +/// - `IdentityInsufficientBalanceError` → +/// [`PlatformWalletError::InsufficientIdentityCredits`] +/// +/// Returns `None` for anything else, leaving the caller's fallback mapping +/// in charge. +pub fn promote_document_trade_error(error: &dash_sdk::Error) -> Option { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + + match consensus_error_of(error)? { + ConsensusError::StateError(StateError::DocumentNotForSaleError(e)) => { + Some(PlatformWalletError::DocumentNotForSale { + document_id: *e.document_id(), + }) + } + ConsensusError::StateError(StateError::DocumentIncorrectPurchasePriceError(e)) => { + Some(PlatformWalletError::DocumentPriceChanged { + document_id: *e.document_id(), + expected: e.trying_to_purchase_at_price(), + actual: e.actual_price(), + }) + } + ConsensusError::StateError(StateError::IdentityInsufficientBalanceError(e)) => { + Some(PlatformWalletError::InsufficientIdentityCredits { + identity_id: *e.identity_id(), + required: e.required_balance(), + available: e.balance(), + }) + } + _ => None, + } +} + +/// Map a document-trade transition's SDK error to a [`PlatformWalletError`]: +/// typed trade rejections first ([`promote_document_trade_error`]), then the +/// structured signer-key-unavailable preservation, then the caller's `wrap` +/// fallback. Owned-error `.map_err(...)?` analogue for the set-price / +/// purchase / transfer call sites. +pub fn promote_document_trade_error_or( + error: dash_sdk::Error, + wrap: impl FnOnce(dash_sdk::Error) -> PlatformWalletError, +) -> PlatformWalletError { + if let Some(promoted) = promote_document_trade_error(&error) { + return promoted; + } + preserve_signer_key_unavailable_or(error, wrap) +} + /// The reserved machine prefix that a typed `SigningKeyUnavailable` signer /// completion stamps at the **start** of its `ProtocolError::Generic` payload. /// Also stamped at position 0 of `MnemonicResolverCoreSigner::NotFound`'s diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index 9ac256e8730..c329c5a32f5 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -16,6 +16,7 @@ use arc_swap::ArcSwap; pub use dash_spv::EventHandler; pub use key_wallet_manager::WalletEvent; +use crate::manager::dpns_sync::DpnsSyncPassSummary; use crate::manager::platform_address_sync::PlatformAddressSyncSummary; #[cfg(feature = "shielded")] use crate::manager::shielded_sync::ShieldedSyncPassSummary; @@ -33,6 +34,17 @@ pub trait PlatformEventHandler: EventHandler { /// [`PlatformAddressSyncManager`]: crate::manager::platform_address_sync::PlatformAddressSyncManager fn on_platform_address_sync_completed(&self, _summary: &PlatformAddressSyncSummary) {} + /// Fired after each [`DpnsSyncManager`] marketplace pass completes, + /// including passes that produced no delta. Hosts refresh + /// marketplace UI from the mirrored rows and — when the summary + /// reports a name departing an identity — re-run their + /// main-username selection / profile display for that identity. + /// + /// Default impl is a no-op so existing handlers don't have to care. + /// + /// [`DpnsSyncManager`]: crate::manager::dpns_sync::DpnsSyncManager + fn on_dpns_marketplace_sync_completed(&self, _summary: &DpnsSyncPassSummary) {} + /// Fired after each [`ShieldedSyncManager`] pass completes, /// including passes that produced no updates or skipped every /// wallet because none had a bound shielded sub-wallet yet. @@ -130,6 +142,17 @@ impl PlatformEventManager { } } + /// Dispatch a DPNS marketplace sync completion to every handler. + /// + /// Not on the SPV hot path — called once per DPNS sync pass + /// (~60s by default). + pub fn on_dpns_marketplace_sync_completed(&self, summary: &DpnsSyncPassSummary) { + let handlers = self.handlers.load(); + for h in handlers.iter() { + h.on_dpns_marketplace_sync_completed(summary); + } + } + /// Dispatch a shielded sync completion to every handler. /// /// Not on the SPV hot path — called once per shielded sync pass diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index e0daf2bd638..4905ba3b377 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -14,6 +14,7 @@ use key_wallet::WalletCoreBalance; use crate::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; use crate::manager::dashpay_sync::DashPaySyncManager; +use crate::manager::dpns_sync::DpnsSyncManager; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] @@ -340,6 +341,17 @@ impl PlatformWalletManager

{ Arc::clone(&self.dashpay_sync_manager) } + /// Access the recurring DPNS username-marketplace sync coordinator. + pub fn dpns_sync(&self) -> &DpnsSyncManager { + &self.dpns_sync_manager + } + + /// Clone the `Arc` so callers (e.g. FFI) can invoke + /// [`DpnsSyncManager::start`] which takes `&Arc`. + pub fn dpns_sync_arc(&self) -> Arc { + Arc::clone(&self.dpns_sync_manager) + } + /// Access the shielded sync coordinator. #[cfg(feature = "shielded")] pub fn shielded_sync(&self) -> &ShieldedSyncManager { diff --git a/packages/rs-platform-wallet/src/manager/dpns_sync.rs b/packages/rs-platform-wallet/src/manager/dpns_sync.rs new file mode 100644 index 00000000000..ef376031921 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/dpns_sync.rs @@ -0,0 +1,323 @@ +//! Periodic DPNS username-marketplace sync coordinator. +//! +//! Folds the marketplace refresh — owned-name sale state (`$price`), +//! newly acquired names, and names that LEFT an identity (sold / +//! transferred away) — into the recurring background loop, alongside the +//! platform-address, identity-token, DashPay, and shielded coordinators. +//! Before this, DPNS state only refreshed when the host explicitly +//! called an FFI sync entry point. +//! +//! **Wallet-driven, not registry-driven — by design.** A sibling of +//! [`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager): it +//! holds the same `wallets` map, snapshots the wallet `Arc`s under a +//! read guard each sweep, and refreshes **every** wallet. It is a +//! separate coordinator (not a seventh DashPay step) because the DashPay +//! pass is contact/profile-scoped and runs at a 15s cadence, while +//! marketplace state changes are rare — this loop defaults to 60s. +//! +//! The per-wallet refresh is one `IdentityWallet` domain operation, +//! [`sync_dpns_marketplace`](crate::wallet::identity::IdentityWallet::sync_dpns_marketplace) +//! (which also has a standalone on-demand FFI caller); the coordinator +//! owns only the sweep, the log-and-continue policy, and the completion +//! event dispatch. +//! +//! Each pass: +//! 1. Snapshots the wallet map (short read lock, no await while held). +//! 2. Runs `sync_dpns_marketplace()` per wallet (log-and-continue). +//! 3. Stores the pass timestamp and dispatches +//! [`PlatformEventManager::on_dpns_marketplace_sync_completed`]. +//! +//! `sync_now` is re-entrant-safe (an in-flight pass makes it return an +//! empty summary immediately) and shutdown drains an in-flight pass via +//! [`quiesce`](DpnsSyncManager::quiesce), exactly like the sibling +//! coordinators. +//! +//! Not auto-started. Call [`DpnsSyncManager::start`] once the wallets +//! are registered and the SDK is connected. + +use std::collections::BTreeMap; +use std::num::NonZeroUsize; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tokio::sync::RwLock; + +use dash_async::{ThreadRegistry, WorkerConfig}; + +use crate::events::PlatformEventManager; +use crate::manager::{ + coordinator_worker_config, drain_pass, QuiesceGate, QuiesceGuard, SyncSlotGuard, WalletWorker, + COORDINATOR_DRAIN_BUDGET, +}; +use crate::wallet::identity::network::DpnsMarketplaceSyncSummary; +use crate::wallet::platform_wallet::WalletId; +use crate::wallet::PlatformWallet; + +/// Default cadence for the DPNS marketplace sync loop. +/// +/// Marketplace state (listings, sales) changes far less often than +/// DashPay contact state, and each pass costs one indexed document query +/// per identity — 60s keeps sale/departure detection timely without +/// multiplying DAPI traffic. Tunable at runtime via +/// [`DpnsSyncManager::set_interval`]. +pub const DEFAULT_SYNC_INTERVAL_SECS: u64 = 60; + +/// Stack size for the DPNS sync loop's OS thread. +/// +/// The pass verifies GroveDB document-query proofs (domain-document and +/// history-contract fetches), whose recursive descent overflows the +/// platform default thread stack — same rationale and size as the +/// DashPay coordinator and the FFI worker convention (`runtime.rs`). +const DPNS_SYNC_STACK_BYTES: usize = 8 * 1024 * 1024; + +/// Outcome of syncing a single wallet's marketplace state in a pass. +#[derive(Debug)] +pub enum WalletDpnsSyncOutcome { + /// `sync_dpns_marketplace()` completed; carries its per-wallet delta. + Ok(DpnsMarketplaceSyncSummary), + /// `sync_dpns_marketplace()` returned an error message (logged, + /// non-fatal to the rest of the pass). + Err(String), +} + +impl WalletDpnsSyncOutcome { + pub fn is_ok(&self) -> bool { + matches!(self, WalletDpnsSyncOutcome::Ok(_)) + } +} + +/// Summary of one full DPNS marketplace sync pass across every +/// registered wallet. +#[derive(Debug, Default)] +pub struct DpnsSyncPassSummary { + /// Per-wallet outcomes keyed by `WalletId`. + pub wallet_results: BTreeMap, + /// Unix seconds at which the pass completed. `0` means "no pass ran" + /// (a concurrent pass was already in flight and we skipped). + pub sync_unix_seconds: u64, +} + +impl DpnsSyncPassSummary { + pub fn is_empty(&self) -> bool { + self.wallet_results.is_empty() + } + + pub fn success_count(&self) -> usize { + self.wallet_results.values().filter(|o| o.is_ok()).count() + } + + pub fn error_count(&self) -> usize { + self.wallet_results.len() - self.success_count() + } + + /// Whether any wallet reported a marketplace delta (names added, + /// departed, or re-priced) this pass. + pub fn has_delta(&self) -> bool { + self.wallet_results.values().any(|o| match o { + WalletDpnsSyncOutcome::Ok(s) => !s.is_empty_delta(), + WalletDpnsSyncOutcome::Err(_) => false, + }) + } +} + +/// Periodic DPNS username-marketplace sync coordinator. See the module +/// docs for the design; the lifecycle (start / stop / quiesce semantics, +/// registry-owned thread, deep stack) mirrors +/// [`DashPaySyncManager`](super::dashpay_sync::DashPaySyncManager) +/// verbatim. +pub struct DpnsSyncManager { + wallets: Arc>>>, + registry: Arc>, + /// Dispatches `on_dpns_marketplace_sync_completed` after each pass. + events: Arc, + interval_secs: AtomicU64, + is_syncing: AtomicBool, + /// Gates new passes while a [`quiesce`](Self::quiesce) drains an + /// in-flight one — same barrier contract as the sibling coordinators. + quiescing: QuiesceGate, + /// Unix seconds of the last completed pass. `0` = never. + last_sync_unix: AtomicU64, +} + +impl DpnsSyncManager { + pub fn new( + wallets: Arc>>>, + registry: Arc>, + events: Arc, + ) -> Self { + Self { + wallets, + registry, + events, + interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), + is_syncing: AtomicBool::new(false), + quiescing: QuiesceGate::default(), + last_sync_unix: AtomicU64::new(0), + } + } + + /// Set the polling interval. Clamped to a minimum of 1s. The running + /// loop picks this up on its next sleep. + pub fn set_interval(&self, interval: Duration) { + let secs = interval.as_secs().max(1); + self.interval_secs.store(secs, Ordering::Release); + } + + /// Current polling interval. + pub fn interval(&self) -> Duration { + Duration::from_secs(self.interval_secs.load(Ordering::Acquire)) + } + + /// Whether the background loop is currently running. + pub fn is_running(&self) -> bool { + self.registry.is_running(WalletWorker::DpnsSync) + } + + /// Whether a sync pass is in flight right now. + pub fn is_syncing(&self) -> bool { + self.is_syncing.load(Ordering::Acquire) + } + + /// Unix seconds of the last completed pass, or `None` if no pass has + /// ever completed. + pub fn last_sync_unix_seconds(&self) -> Option { + match self.last_sync_unix.load(Ordering::Acquire) { + 0 => None, + n => Some(n), + } + } + + /// Start the background sync loop. Idempotent — calling while + /// already running is a no-op. Runs on a dedicated registry-owned OS + /// thread with a deep stack, driving the (`!Send`) SDK futures via + /// `Handle::block_on` — same mechanism and rationale as + /// `DashPaySyncManager::start`. The first pass runs immediately. + pub fn start(self: Arc) { + let handle = tokio::runtime::Handle::current(); + let registry = Arc::clone(&self.registry); + let this = self; + let cfg = WorkerConfig { + stack_size: NonZeroUsize::new(DPNS_SYNC_STACK_BYTES), + ..coordinator_worker_config() + }; + registry.start_thread(WalletWorker::DpnsSync, cfg, move |cancel| { + handle.block_on(async move { + loop { + if cancel.is_cancelled() { + break; + } + + this.sync_now().await; + + let interval = this.interval(); + tokio::select! { + _ = tokio::time::sleep(interval) => {} + _ = cancel.cancelled() => break, + } + } + }); + }); + } + + /// Stop the background sync loop. Cancel-only — a pass already + /// inside `sync_now` keeps running to completion; use + /// [`quiesce`](Self::quiesce) for a real drain barrier. + pub fn stop(&self) { + self.registry.cancel(WalletWorker::DpnsSync); + } + + /// Cancel the loop and wait for any in-flight pass to fully drain — + /// same contract as `DashPaySyncManager::quiesce`. + #[must_use = "a false return means the pass did NOT drain; the caller must fail closed"] + pub async fn quiesce(&self) -> bool { + self.quiesce_within(COORDINATOR_DRAIN_BUDGET).await + } + + /// [`quiesce`](Self::quiesce) with an explicit drain budget. On + /// timeout the admission gate is left closed and the caller must + /// fail closed. + pub(crate) async fn quiesce_within(&self, budget: Duration) -> bool { + self.quiesce_held_within(budget).await.is_some() + } + + /// Drain variant that keeps sync admission shut until the returned + /// guard drops. + #[must_use = "None means the pass did NOT drain; the caller must fail closed"] + pub(crate) async fn quiesce_held_within(&self, budget: Duration) -> Option> { + drain_pass(&self.quiescing, &self.is_syncing, || self.stop(), budget).await + } + + /// Drain variant that **seals** admission permanently — used by + /// manager shutdown so a mid-flight host-thread `sync_now` cannot + /// start a fresh pass (and fire persister/event callbacks) after the + /// host freed its context. + pub(crate) async fn quiesce_sealed_within(&self, budget: Duration) -> bool { + let guard = self.quiesce_held_within(budget).await; + let drained = guard.is_some(); + // Seal before the guard drops so its Drop cannot reopen. + self.quiescing.seal(); + drop(guard); + drained + } + + /// Run one marketplace sync pass across every registered wallet. + /// + /// If a pass is already in flight, returns an empty summary and + /// skips — the caller can inspect [`Self::is_syncing`] to + /// distinguish. Per-wallet errors are logged and recorded in the + /// summary but never abort the sweep. Dispatches the completion + /// event before returning. + pub async fn sync_now(&self) -> DpnsSyncPassSummary { + if self + .is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return DpnsSyncPassSummary::default(); + } + // Clears `is_syncing` on every exit path — including panic + // unwind — so a failed pass can never wedge `quiesce()`'s drain. + let _slot = SyncSlotGuard(&self.is_syncing); + + // A `quiesce()` may have raised the gate between our CAS and + // here; bail so the drain gets a true barrier. + if self.quiescing.is_closed() { + return DpnsSyncPassSummary::default(); + } + + let snapshot: Vec<(WalletId, Arc)> = { + let wallets = self.wallets.read().await; + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + }; + + let mut summary = DpnsSyncPassSummary::default(); + for (wallet_id, wallet) in snapshot { + let outcome = match wallet.identity().sync_dpns_marketplace().await { + Ok(wallet_summary) => WalletDpnsSyncOutcome::Ok(wallet_summary), + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "DPNS marketplace sync failed for wallet; continuing with the rest" + ); + WalletDpnsSyncOutcome::Err(e.to_string()) + } + }; + summary.wallet_results.insert(wallet_id, outcome); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + summary.sync_unix_seconds = now; + self.last_sync_unix.store(now, Ordering::Release); + + self.events.on_dpns_marketplace_sync_completed(&summary); + + summary + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 65f410f3395..4a4d8a9d9ce 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -99,6 +99,7 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, + dpns_name_states: std::collections::BTreeMap::new(), }; // Canonical id recomputed from the wallet's own key material. diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index e3257045a5c..fc4e47b15fe 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -2,6 +2,7 @@ pub mod accessors; pub mod dashpay_sync; +pub mod dpns_sync; pub mod identity_sync; mod load; pub mod platform_address_sync; @@ -22,6 +23,7 @@ use key_wallet_manager::WalletManager; use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; use crate::events::{PlatformEventHandler, PlatformEventManager}; use crate::manager::dashpay_sync::DashPaySyncManager; +use crate::manager::dpns_sync::DpnsSyncManager; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] @@ -49,6 +51,8 @@ pub enum WalletWorker { IdentitySync, /// DashPay (contact requests + profiles) sync coordinator. DashPaySync, + /// DPNS username-marketplace sync coordinator. + DpnsSync, /// Shielded (Orchard) note sync coordinator. ShieldedSync, /// SPV runtime — the network event source feeding every persister- @@ -347,6 +351,12 @@ pub struct PlatformWalletManager { /// auto-started — call `start` after wallets are registered. See /// [`DashPaySyncManager`]. pub(super) dashpay_sync_manager: Arc, + /// Periodic DPNS username-marketplace sync coordinator. Drives + /// `sync_dpns_marketplace()` (owned-name sale state + departure + /// detection) on **every** registered wallet each sweep; shares the + /// same `wallets` map as [`DashPaySyncManager`]. Not auto-started — + /// call `start` after wallets are registered. See [`DpnsSyncManager`]. + pub(super) dpns_sync_manager: Arc, /// Tracks asynchronous payment hooks so manager shutdown can close /// admission and drain every task before host callback contexts are freed. pub(super) dashpay_payment_handler: Arc, @@ -492,6 +502,13 @@ impl PlatformWalletManager

{ Arc::clone(&wallets), Arc::clone(®istry), )); + // DPNS marketplace sync also sweeps the `wallets` map; it takes + // the event manager to dispatch its pass-completion event. + let dpns_sync = Arc::new(DpnsSyncManager::new( + Arc::clone(&wallets), + Arc::clone(®istry), + Arc::clone(&event_manager), + )); #[cfg(feature = "shielded")] let shielded_coordinator: Arc< RwLock>>, @@ -511,6 +528,7 @@ impl PlatformWalletManager

{ platform_address_sync_manager: platform_address_sync, identity_sync_manager: identity_sync, dashpay_sync_manager: dashpay_sync, + dpns_sync_manager: dpns_sync, dashpay_payment_handler, #[cfg(feature = "shielded")] shielded_sync_manager: shielded_sync, @@ -855,24 +873,28 @@ impl PlatformWalletManager

{ // run a full pass — and fire persister / completion callbacks — // after `destroy` returned and the host freed those contexts. #[cfg(feature = "shielded")] - let (pa_drained, id_drained, dp_drained, sh_drained) = tokio::join!( + let (pa_drained, id_drained, dp_drained, dpns_drained, sh_drained) = tokio::join!( self.platform_address_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.identity_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.dashpay_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), + self.dpns_sync_manager + .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.shielded_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), ); #[cfg(not(feature = "shielded"))] - let (pa_drained, id_drained, dp_drained) = tokio::join!( + let (pa_drained, id_drained, dp_drained, dpns_drained) = tokio::join!( self.platform_address_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.identity_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), self.dashpay_sync_manager .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), + self.dpns_sync_manager + .quiesce_sealed_within(COORDINATOR_DRAIN_BUDGET), ); // Hard-join the coordinator loop threads now that every in-flight @@ -889,6 +911,7 @@ impl PlatformWalletManager

{ (WalletWorker::PlatformAddressSync, pa_drained), (WalletWorker::IdentitySync, id_drained), (WalletWorker::DashPaySync, dp_drained), + (WalletWorker::DpnsSync, dpns_drained), #[cfg(feature = "shielded")] (WalletWorker::ShieldedSync, sh_drained), ]; diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 8a99e65a644..c9eafee286b 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -363,6 +363,7 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + dpns_name_states: std::collections::BTreeMap::new(), }; wallet.downgrade_to_external_signable(); diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 19f64f0d534..31c7abdf446 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -254,6 +254,7 @@ pub(crate) async fn funded_wallet_manager_with_outputs( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -323,6 +324,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -424,6 +426,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -499,6 +502,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -670,6 +674,7 @@ pub(crate) async fn mnemonic_wallet_manager( generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let mut wm = WalletManager::::new(Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 993ccff4134..4390740640c 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -105,6 +105,7 @@ impl PlatformWalletInfo { // is future). Drop explicitly so future readers don't expect a // replay hook. invitations: _, + dpns_name_states, // Registration-round metadata / per-account specs / // per-pool snapshots are persistence-only — the // canonical in-memory wallet state is built up at @@ -161,6 +162,18 @@ impl PlatformWalletInfo { } } + // 2a. DPNS name states (username marketplace): upserts land + // first, then tombstones, into the in-memory working set — + // same LWW-then-remove discipline as the rest of this + // function. + if let Some(dpns_cs) = dpns_name_states { + let crate::changeset::DpnsNameStateChangeSet { names, removed } = dpns_cs; + self.dpns_name_states.extend(names); + for document_id in &removed { + self.dpns_name_states.remove(document_id); + } + } + // 2b. Identity keys. Runs after the scalar identity pass so // the owning ManagedIdentity is guaranteed to exist before // we layer keys into it. Upserts land first, then removals, @@ -413,6 +426,7 @@ mod tests { generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index c43ab869244..bce7e73c9e8 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -787,6 +787,7 @@ mod tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), }; let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 5df388e7bae..ac821c2e3b7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3365,6 +3365,7 @@ mod sweep_tests { generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index 49be83f313a..be3a76caa1f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -116,7 +116,7 @@ where /// flow correct for *any* document type — e.g. DPNS `preorder` requires /// `HIGH`, so both `CRITICAL` and `HIGH` keys qualify, but `MEDIUM` does /// not. -fn allowed_signing_security_levels(requirement: SecurityLevel) -> Vec { +pub(super) fn allowed_signing_security_levels(requirement: SecurityLevel) -> Vec { if requirement == SecurityLevel::MASTER { return vec![SecurityLevel::MASTER]; } @@ -137,7 +137,7 @@ impl IdentityWallet { /// returns `None` for the contract and proof verification fails with /// "unknown contract ... in document verification", even though the /// write landed on-chain. - fn register_contract_for_proof_verification(&self, contract: &DataContract) { + pub(super) fn register_contract_for_proof_verification(&self, contract: &DataContract) { if let Some(provider) = self.sdk.context_provider() { provider.register_data_contract(Arc::new(contract.clone())); } @@ -330,7 +330,7 @@ impl IdentityWallet { /// transfer / set-price / purchase) — each needs the contract as an /// `Arc` for both the single-document fetch query and /// the transition builder. - async fn fetch_contract_arc_for_document_op( + pub(super) async fn fetch_contract_arc_for_document_op( &self, contract_id: &Identifier, document_type_name: &str, @@ -652,11 +652,12 @@ impl IdentityWallet { .document_transfer(builder, &signing_key, &SignerRef(signer)) .await .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData` + // Typed trade rejections (not-for-sale / price-changed / + // insufficient credits) and the structured key-unavailable + // signer failure survive; only genuine operation failures + // get stringified into `InvalidIdentityData` // (dashpay/platform#4183 review). - crate::error::preserve_signer_key_unavailable_or(e, |e| { + crate::error::promote_document_trade_error_or(e, |e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to transfer document: {e}" )) @@ -715,11 +716,11 @@ impl IdentityWallet { .document_set_price(builder, &signing_key, &SignerRef(signer)) .await .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData` + // Typed trade rejections and the structured key-unavailable + // signer failure survive; only genuine operation failures + // get stringified into `InvalidIdentityData` // (dashpay/platform#4183 review). - crate::error::preserve_signer_key_unavailable_or(e, |e| { + crate::error::promote_document_trade_error_or(e, |e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to set document price: {e}" )) @@ -783,11 +784,13 @@ impl IdentityWallet { .document_purchase(builder, &signing_key, &SignerRef(signer)) .await .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData` - // (dashpay/platform#4183 review). - crate::error::preserve_signer_key_unavailable_or(e, |e| { + // Typed trade rejections — crucially the price-changed race + // (40109), where the consensus equality check is the backstop + // behind the wallet's pre-flight — and the structured + // key-unavailable signer failure survive; only genuine + // operation failures get stringified into + // `InvalidIdentityData` (dashpay/platform#4183 review). + crate::error::promote_document_trade_error_or(e, |e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to purchase document: {e}" )) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index e0dc877bf5d..5e6476c1af3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -184,6 +184,16 @@ impl IdentityWallet { { use dash_sdk::platform::dpns_usernames::RegisterDpnsNameInput; + // Ensure the on-chain DPNS contract is fetched and registered + // with the SDK's context provider BEFORE broadcasting: the + // post-broadcast proof of the preorder/domain documents needs it, + // `Sdk::register_dpns_name` never registers it back, and a host + // that doesn't pre-seed known contracts (e.g. a headless + // consumer) would otherwise fail proof verification with + // "unknown contract ... in document verification" even though + // the registration landed on-chain. + self.dpns_contract().await?; + let (identity, auth_key) = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { 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 new file mode 100644 index 00000000000..68ff854db93 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -0,0 +1,1829 @@ +//! DPNS username marketplace: wallet-level search / sell / delist / +//! purchase / transfer orchestration, per-name trade history, and the +//! local name-state bookkeeping behind them. +//! +//! The generic document-trade transitions live in `document.rs` +//! (`set_document_price_with_signer` / `purchase_document_with_signer` / +//! `transfer_document_with_signer`); this module composes them with the +//! DPNS specifics the app layer should not own: +//! +//! - resolving a name to its `domain` document (with `$price` and the +//! document id, which the SDK's `DpnsUsername` drops), +//! - automatic signing-key selection (AUTHENTICATION / ECDSA at the +//! document type's required security level — no hardcoded key ids), +//! - typed pre-flight checks (not-found / contested / not-for-sale / +//! price-changed / insufficient credits), +//! - local persistence of sale state through the changeset pipeline +//! ([`DpnsNameStateEntry`] rows + the legacy `dpns_names` label list), +//! - the trade-history timeline from the Document History system +//! contract. +//! +//! Consensus facts this module relies on (verified against rs-drive): +//! purchase and transfer both REMOVE `$price` +//! (transfer-to-self is therefore the delist primitive); purchase +//! requires the transition price to equal the listed price; +//! `records.identity` is rewritten to the new owner by the protocol on +//! purchase/transfer; a name inside an active contested-name vote is not +//! in the documents tree at all. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::sync::Arc; + +use dpp::document::property_names::PRICE; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; + +use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; +use dash_sdk::drive::query::{OrderClause, SelectProjection, WhereClause, WhereOperator}; +use dash_sdk::platform::dpns_usernames::{convert_to_homograph_safe_chars, is_contested_username}; +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::error::PlatformWalletError; +use crate::wallet::identity::types::key_storage::DpnsNameInfo; + +use super::document::allowed_signing_security_levels; +use super::*; + +/// DPNS document type carrying registered names. +const DPNS_DOCUMENT_TYPE: &str = "domain"; +/// The only DPNS parent domain in production. +const DPNS_PARENT_DOMAIN: &str = "dash"; + +/// Document History system contract document types (see +/// `packages/document-history-contract/schema/v1/...`). All three carry +/// `dataContractId` / `documentId` and a `byDocument` +/// (dataContractId, documentId, $createdAt) index. +const HISTORY_TYPE_TRANSFER: &str = "transfer"; +const HISTORY_TYPE_PURCHASE: &str = "purchase"; +const HISTORY_TYPE_PRICE_UPDATE: &str = "priceUpdate"; + +/// Conservative fee reserve (credits) required ON TOP of the purchase +/// price before a purchase is attempted: Platform deducts the purchase +/// amount as principal first and the processing fee must fit in the +/// remainder (`validate_fees_of_event`). The observed document-batch +/// transition fee is well under 0.0005 DASH; 0.001 DASH (1 duff = 1000 +/// credits) keeps a ~2x margin. The actual fee is metered at execution +/// from the buyer identity's credits; this constant only gates the +/// pre-flight, it is never broadcast. +pub const DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS: Credits = 100_000_000; + +/// Default page size for marketplace search queries. +const DEFAULT_SEARCH_LIMIT: u32 = 25; +/// Page size for the per-identity domain-document sync query. +const SYNC_QUERY_LIMIT: u32 = 100; +/// Maximum number of departed names whose history is resolved in one +/// sync pass. Combined with [`SYNC_QUERY_LIMIT`], this keeps every pass +/// bounded even when an identity has accumulated a large name set. +const SYNC_DEPARTURE_LIMIT: usize = 25; +/// Page size for per-name history queries (per event type). +const HISTORY_QUERY_LIMIT: u32 = 100; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// A DPNS `domain` document read straight off Platform, keeping the +/// marketplace-relevant system fields the SDK's `DpnsUsername` drops: +/// the document id (the handle every trade transition needs) and +/// `$price` (the sale state). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsDomainState { + /// The domain document id — stable across transfers and purchases. + pub document_id: Identifier, + /// Display label (e.g. "Alice"). + pub label: String, + /// Homograph-normalized label (e.g. "a11ce"). + pub normalized_label: String, + /// Normalized parent domain ("dash"). + pub normalized_parent_domain_name: String, + /// The document's `$ownerId` — the identity that owns (and may sell) + /// the name. + pub owner_id: Identifier, + /// `records.identity` — the identity the name points at. The + /// protocol rewrites this to the new owner on purchase/transfer. + pub records_identity_id: Option, + /// Listed sale price in credits (`$price`). `None` = not for sale. + pub price: Option, + /// Document `$createdAt` in ms, when carried. + pub created_at_ms: Option, + /// Document `$updatedAt` in ms — bumps on price changes. + pub updated_at_ms: Option, + /// Document `$transferredAt` in ms — set on purchase/transfer. + pub transferred_at_ms: Option, +} + +impl DpnsDomainState { + /// Read the marketplace-relevant fields off a DPNS `domain` document. + /// + /// Errors (rather than fabricating defaults) when required schema + /// fields are missing or mistyped — a malformed `$price` must not + /// silently read as "not for sale". + fn from_document(doc: &Document) -> Result { + let properties = doc.properties(); + let text = |key: &str| -> Result { + properties + .get(key) + .and_then(|v| v.as_text()) + .map(str::to_string) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS domain document {} is missing required text field {key:?}", + doc.id() + )) + }) + }; + let price = properties + .get_optional_integer::(PRICE) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS domain document {} carries a malformed $price: {e}", + doc.id() + )) + })?; + // `records.identity` — same manual map walk as the SDK's + // `document_to_dpns_username` (the value is an identifier). + let records_identity_id = if let Some(Value::Map(records)) = properties.get("records") { + records + .iter() + .find(|(k, _)| k.as_text() == Some("identity")) + .and_then(|(_, v)| v.to_identifier().ok()) + } else { + None + }; + Ok(Self { + document_id: doc.id(), + label: text("label")?, + normalized_label: text("normalizedLabel")?, + normalized_parent_domain_name: text("normalizedParentDomainName")?, + owner_id: doc.owner_id(), + records_identity_id, + price, + created_at_ms: doc.created_at(), + updated_at_ms: doc.updated_at(), + transferred_at_ms: doc.transferred_at(), + }) + } + + /// Build the local persisted row for this state, tracked for + /// `wallet_identity_id` with `status`. + fn to_entry( + &self, + wallet_identity_id: Identifier, + status: DpnsNameSaleStatus, + now_ms: u64, + ) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id: self.document_id, + wallet_identity_id, + label: self.label.clone(), + normalized_label: self.normalized_label.clone(), + normalized_parent_domain_name: self.normalized_parent_domain_name.clone(), + price: self.price, + status, + created_at_ms: self.created_at_ms, + updated_at_ms: self.updated_at_ms, + transferred_at_ms: self.transferred_at_ms, + last_synced_at_ms: now_ms, + } + } +} + +/// One event in a name's trade timeline, assembled from the Document +/// History system contract plus the domain document's own `$createdAt`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsNameHistoryEvent { + pub kind: DpnsNameHistoryEventKind, + /// Block time of the event in ms (`$createdAt` of the history + /// document; registration uses the domain document's `$createdAt`). + pub at_ms: u64, + /// Block height of the event, when carried. + pub block_height: Option, +} + +/// What happened at a point in a name's trade timeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsNameHistoryEventKind { + /// The domain document was registered (its `$createdAt`). + Registered, + /// The owner listed / re-priced the name (`priceUpdate` history doc). + PriceSet { price: Credits }, + /// The name was purchased: `seller` received `price` credits from + /// `buyer`, who became the owner (`purchase` history doc). + Purchased { + price: Credits, + seller: Identifier, + buyer: Identifier, + }, + /// The name was transferred without payment — a gift/handover, or a + /// transfer-to-self delist when `from == to` (`transfer` history doc). + Transferred { from: Identifier, to: Identifier }, +} + +/// One name that left a wallet identity, observed by +/// [`IdentityWallet::sync_dpns_marketplace`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepartedDpnsName { + pub identity_id: Identifier, + pub label: String, + pub document_id: Option, + /// `Some(Sold { to })` or `Some(Transferred { to })` only when a + /// direct history event names this identity as the departing party. + /// `None` means the document was deleted or no direct event could be + /// resolved — unknown is never reported with a fabricated counterparty. + pub status: Option, +} + +/// A listed-price change observed between two sync passes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsPriceChange { + pub document_id: Identifier, + pub label: String, + pub previous: Option, + pub current: Option, +} + +/// Summary of one [`IdentityWallet::sync_dpns_marketplace`] pass. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DpnsMarketplaceSyncSummary { + /// Name-state rows written this pass (owned names refreshed). + pub names_tracked: u32, + /// Labels newly observed on a wallet identity: `(identity, label)`. + pub names_added: Vec<(Identifier, String)>, + /// Names that left a wallet identity since the local snapshot. + pub names_departed: Vec, + /// Listed-price changes since the local snapshot. + pub prices_changed: Vec, + /// Wall-clock ms at which the pass completed. + pub sync_unix_ms: u64, +} + +impl DpnsMarketplaceSyncSummary { + pub fn is_empty_delta(&self) -> bool { + self.names_added.is_empty() + && self.names_departed.is_empty() + && self.prices_changed.is_empty() + } +} + +/// Incremental scan state shared by every clone of one wallet handle. +/// +/// `seen_normalized_labels` spans all pages in the current ownership +/// scan. Departures are considered only after the final page, so a name +/// on a later page is never misclassified as having left the wallet. +#[derive(Debug, Clone, Default)] +pub(crate) struct DpnsMarketplaceSyncProgress { + pub(crate) cursor: Option, + pub(crate) seen_normalized_labels: BTreeSet, + pub(crate) pending_departures: VecDeque, +} + +// --------------------------------------------------------------------------- +// System contracts +// --------------------------------------------------------------------------- + +/// The DPNS system contract id (fixed across contract versions). +fn dpns_contract_id() -> Identifier { + dpp::data_contracts::SystemDataContract::DPNS.id() +} + +/// The Document History system contract id. +fn document_history_contract_id() -> Identifier { + dpp::data_contracts::SystemDataContract::DocumentHistory.id() +} + +impl IdentityWallet { + /// Fetch a system contract through this wallet's active SDK/provider. + /// + /// Goes through `fetch_contract_arc_for_document_op`, which also + /// registers the contract with the SDK's context provider so + /// document-query and post-broadcast proof verification can resolve + /// it. Fetching (rather than loading the bundled system contract) + /// guarantees the schema matches the network's ACTIVE contract + /// version, and makes the marketplace self-sufficient on hosts that + /// never seed the trusted provider's known-contracts list. + /// + /// Do not put these contracts in a process-global cache: two SDKs on + /// the same network can use different providers/devnets or observe a + /// different active protocol version. The SDK context owns whatever + /// caching is safe for its own lifetime. + async fn system_contract( + &self, + contract_id: Identifier, + document_type_name: &str, + ) -> Result, PlatformWalletError> { + self.fetch_contract_arc_for_document_op(&contract_id, document_type_name) + .await + } + + /// The DPNS data contract for this wallet's active SDK context. + pub(crate) async fn dpns_contract(&self) -> Result, PlatformWalletError> { + self.system_contract(dpns_contract_id(), DPNS_DOCUMENT_TYPE) + .await + } + + /// The Document History system contract for this wallet's network — + /// the event log DPNS v2's `keeps*History` flags write `transfer` / + /// `purchase` / `priceUpdate` documents into. NOT the GroveDB + /// `documentsKeepHistory` mechanism (`getDocumentHistory` returns + /// empty for DPNS). + pub(crate) async fn document_history_contract( + &self, + ) -> Result, PlatformWalletError> { + self.system_contract(document_history_contract_id(), HISTORY_TYPE_TRANSFER) + .await + } +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +/// Best-effort wall-clock ms (same shape as the `acquired_at` stamps in +/// `dpns.rs`). `0` only if the system clock is before the epoch. +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Strip an optional ".dash" suffix so callers can pass either "alice" +/// or "alice.dash". +fn dpns_label(name: &str) -> &str { + name.strip_suffix(".dash").unwrap_or(name) +} + +/// Insert one row produced by a marketplace sync pass. +/// +/// A domain document can appear twice when it moves between two identities +/// managed by the same wallet: once as the new owner's authoritative `Owned` +/// row and once as the old owner's departure. The persistent store has one row +/// per document id, so current ownership must win independently of the +/// identity-manager's deliberately unspecified iteration order. +fn insert_sync_row(rows: &mut BTreeMap, entry: DpnsNameStateEntry) { + let should_replace = rows + .get(&entry.document_id) + .map(|existing| { + matches!(entry.status, DpnsNameSaleStatus::Owned) + || !matches!(existing.status, DpnsNameSaleStatus::Owned) + }) + .unwrap_or(true); + if should_replace { + rows.insert(entry.document_id, entry); + } +} + +fn direct_departure_candidate( + event: DpnsNameHistoryEvent, + departing_identity: &Identifier, +) -> Option<(u64, DpnsNameSaleStatus)> { + match event.kind { + DpnsNameHistoryEventKind::Purchased { seller, buyer, .. } + if seller == *departing_identity => + { + Some((event.at_ms, DpnsNameSaleStatus::Sold { to: buyer })) + } + DpnsNameHistoryEventKind::Transferred { from, to } if from == *departing_identity => { + Some((event.at_ms, DpnsNameSaleStatus::Transferred { to })) + } + _ => None, + } +} + +struct ResolvedDepartedName { + summary: DepartedDpnsName, + entry: Option, + remove_document_id: Option, + retry: bool, +} + +impl IdentityWallet { + // ----------------------------------------------------------------- + // Queries (network reads, sale state included) + // ----------------------------------------------------------------- + + /// Search DPNS names by prefix, returning full domain state (document + /// id, owner, `$price`, timestamps) ordered by normalized label. + /// + /// An empty prefix is a valid alphabetical browse (equality on the + /// parent domain + orderBy label). `start_after` is the cursor: pass + /// the last row's `document_id` to fetch the next page. There is NO + /// server-side price filter or ordering — `$price` is not indexable, + /// so the marketplace is search-driven. + pub async fn search_dpns_names_with_state( + &self, + prefix: &str, + limit: Option, + start_after: Option, + ) -> Result, PlatformWalletError> { + let contract = self.dpns_contract().await?; + let normalized_prefix = convert_to_homograph_safe_chars(dpns_label(prefix)); + let mut where_clauses = vec![WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(DPNS_PARENT_DOMAIN.to_string()), + }]; + if !normalized_prefix.is_empty() { + where_clauses.push(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text(normalized_prefix), + }); + } + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: DPNS_DOCUMENT_TYPE.to_string(), + where_clauses, + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }], + limit: limit.unwrap_or(DEFAULT_SEARCH_LIMIT), + offset: None, + start: start_after.map(|id| Start::StartAfter(id.to_vec())), + }; + self.fetch_domain_states(query).await + } + + /// Fetch the single DPNS domain document for `name` ("alice" or + /// "alice.dash"), or `None` when no such document is in the tree. + pub async fn dpns_name_state( + &self, + name: &str, + ) -> Result, PlatformWalletError> { + let contract = self.dpns_contract().await?; + let normalized = convert_to_homograph_safe_chars(dpns_label(name)); + if normalized.is_empty() { + return Err(PlatformWalletError::InvalidParameter( + "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()) + } + + /// Fetch the domain documents associated with `identity_id` via the + /// `records.identity` index (the only identity-keyed index; the + /// protocol rewrites `records.identity` to the new owner on + /// purchase/transfer, so this stays authoritative across sales). + /// `None` drains every server page; `Some(n)` returns at most `n` + /// documents while still respecting the server's per-page limit. + pub async fn dpns_domain_states_for_identity( + &self, + identity_id: &Identifier, + limit: Option, + ) -> Result, PlatformWalletError> { + if limit == Some(0) { + return Ok(Vec::new()); + } + let contract = self.dpns_contract().await?; + let maximum = limit.map(|value| value as usize); + let mut states = Vec::new(); + let mut cursor: Option = None; + + loop { + let remaining = maximum.map(|value| value.saturating_sub(states.len())); + let page_limit = remaining + .map(|value| value.min(SYNC_QUERY_LIMIT as usize)) + .unwrap_or(SYNC_QUERY_LIMIT as usize); + if page_limit == 0 { + break; + } + + let (page, next_cursor, complete) = self + .dpns_domain_states_page( + Arc::clone(&contract), + identity_id, + cursor, + page_limit as u32, + ) + .await?; + states.extend(page); + + if complete || maximum.is_some_and(|value| states.len() >= value) { + break; + } + if cursor == next_cursor { + return Err(PlatformWalletError::InvalidIdentityData( + "DPNS identity query pagination cursor did not advance".to_string(), + )); + } + cursor = next_cursor; + } + + Ok(states) + } + + /// Fetch exactly one identity-owned DPNS page. The returned cursor is + /// retained by marketplace sync so one pass never drains an unbounded + /// document set. + async fn dpns_domain_states_page( + &self, + contract: Arc, + identity_id: &Identifier, + start_after: Option, + page_limit: u32, + ) -> Result<(Vec, Option, bool), PlatformWalletError> { + let query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: 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: page_limit, + offset: None, + start: start_after.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 DPNS domain documents: {e}" + )) + })?; + let page_len = documents.len(); + let next_cursor = documents.keys().last().copied(); + let states = documents + .into_iter() + .filter_map(|(_, document)| document) + .map(|document| DpnsDomainState::from_document(&document)) + .collect::, _>>()?; + let complete = page_len < page_limit as usize; + if !complete && next_cursor.is_none() { + return Err(PlatformWalletError::InvalidIdentityData( + "full DPNS identity query page did not provide a pagination cursor".to_string(), + )); + } + Ok((states, next_cursor, complete)) + } + + /// The tracked marketplace rows (owned names with sale state, plus + /// retained `Sold`/`Transferred` rows), optionally filtered to one + /// wallet identity. Reads the in-memory working set — no network. + /// + /// **Session-scoped.** This map starts EMPTY on every process start + /// and is repopulated by the first + /// [`sync_dpns_marketplace`](Self::sync_dpns_marketplace) pass; the + /// wallet load path does not rehydrate it. That mirrors the + /// invitations store — `SqlitePersister` does not attest + /// `WALLET_RESTORE` and `load()` still reports + /// `ClientStartState::wallets` in `LOAD_UNIMPLEMENTED`. The durable + /// copy a host should render after a restart is the persister mirror + /// (Swift `PersistentDPNSName`), which the changeset feeds; treat an + /// empty return here as "not synced yet", never as "no names". + pub async fn local_dpns_name_states( + &self, + identity_id: Option<&Identifier>, + ) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + Ok(info + .dpns_name_states + .values() + .filter(|entry| identity_id.is_none_or(|id| entry.wallet_identity_id == *id)) + .cloned() + .collect()) + } + + /// Run `query` and convert the returned documents, preserving server + /// order (the result map is an `IndexMap`). + async fn fetch_domain_states( + &self, + query: DocumentQuery, + ) -> Result, PlatformWalletError> { + let documents = Document::fetch_many(&self.sdk, query).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch DPNS domain documents: {e}" + )) + })?; + documents + .into_iter() + .filter_map(|(_, doc)| doc) + .map(|doc| DpnsDomainState::from_document(&doc)) + .collect() + } + + /// Resolve `name` to its domain state or fail typed: a name hidden + /// inside an active contested-name vote is NOT in the documents tree + /// (the network would answer any trade with a bare + /// `DocumentNotFoundError`), so the miss is classified before it is + /// reported — [`PlatformWalletError::ContestedNameNotTradable`] when + /// an active contest holds the label, + /// [`PlatformWalletError::DpnsNameNotFound`] otherwise. + async fn fetch_dpns_domain_state_required( + &self, + name: &str, + ) -> Result { + if let Some(state) = self.dpns_name_state(name).await? { + return Ok(state); + } + let label = dpns_label(name); + if is_contested_username(label) { + let normalized = convert_to_homograph_safe_chars(label); + let contests = self + .sdk + .get_current_dpns_contests(None, None, None) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to check contested-name votes for {label:?}: {e}" + )) + })?; + if let Some(end_time_ms) = contests.get(&normalized) { + return Err(PlatformWalletError::ContestedNameNotTradable { + label: label.to_string(), + ends_at_ms: *end_time_ms, + }); + } + } + Err(PlatformWalletError::DpnsNameNotFound { + name: name.to_string(), + }) + } + + // ----------------------------------------------------------------- + // Signing-key selection + // ----------------------------------------------------------------- + + /// Auto-select the signing key for a DPNS `domain` state transition + /// on `identity_id`: the identity's first AUTHENTICATION-purpose + /// ECDSA_SECP256K1 key whose security level satisfies the document + /// type's requirement (the same consensus rule + /// [`allowed_signing_security_levels`] encodes). Replaces the app + /// layer's hardcoded "key id 1". + async fn select_dpns_signing_key( + &self, + identity_id: &Identifier, + ) -> Result { + let contract = self.dpns_contract().await?; + let required_level = contract + .document_type_for_name(DPNS_DOCUMENT_TYPE) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS contract has no {DPNS_DOCUMENT_TYPE:?} document type: {e}" + )) + })? + .security_level_requirement(); + let allowed_levels = allowed_signing_security_levels(required_level); + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let identity = info + .identity_manager + .wallet_identity(&self.wallet_id, identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + allowed_levels.iter().copied().collect(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "No ECDSA authentication key at a security level satisfying \ + {required_level} found on identity {identity_id} \ + (required to sign a DPNS domain state transition)" + )) + }) + } + + // ----------------------------------------------------------------- + // Local bookkeeping + // ----------------------------------------------------------------- + + /// Upsert marketplace rows (and optional removals) into the in-memory + /// working set and emit the changeset so the host mirror persists it. + async fn record_dpns_name_states( + &self, + entries: Vec, + removed: Vec, + ) { + if entries.is_empty() && removed.is_empty() { + return; + } + let mut cs = DpnsNameStateChangeSet::default(); + for entry in entries { + cs.names.insert(entry.document_id, entry); + } + cs.removed.extend(removed); + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + info.dpns_name_states.extend(cs.names.clone()); + for document_id in &cs.removed { + info.dpns_name_states.remove(document_id); + } + // Same best-effort discipline as `add_dpns_name`: the in-memory + // mutation stands for this session; a failed store is logged and + // the next sync pass re-emits the same rows (self-healing). + if let Err(e) = self.persister.store(cs.into()) { + tracing::error!("Failed to persist DPNS name states: {e}"); + } + } + + /// Add `label` to `identity_id`'s legacy label list if absent + /// (persisting the identity snapshot). No-op when already present. + async fn add_dpns_label_if_missing( + &self, + identity_id: &Identifier, + label: &str, + acquired_at: Option, + ) { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + let Some(managed) = info + .identity_manager + .wallet_identity_mut(&self.wallet_id, identity_id) + else { + return; + }; + if managed.dpns_names.iter().any(|n| n.label == label) { + return; + } + managed.add_dpns_name( + DpnsNameInfo { + label: label.to_string(), + acquired_at, + }, + &self.persister, + ); + } + + /// Remove `label` from `identity_id`'s legacy label list (persisting + /// the identity snapshot). No-op when absent or the identity isn't + /// in this wallet. + async fn remove_dpns_label(&self, identity_id: &Identifier, label: &str) { + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + let Some(managed) = info + .identity_manager + .wallet_identity_mut(&self.wallet_id, identity_id) + else { + return; + }; + managed.remove_dpns_name(label, &self.persister); + } + + /// Whether `identity_id` is one of this wallet's identities. + async fn is_wallet_identity(&self, identity_id: &Identifier) -> bool { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .map(|info| { + info.identity_manager + .wallet_identity(&self.wallet_id, identity_id) + .is_some() + }) + .unwrap_or(false) + } + + /// Best-effort identity refresh after a trade moved credits or + /// ownership: failures are logged, never propagated — the trade + /// already executed on Platform and must be reported as such. + async fn refresh_identity_after_trade(&self, identity_id: &Identifier, context: &str) { + if let Err(e) = self.refresh_identity(identity_id).await { + tracing::warn!( + identity = %identity_id, + "post-{context} identity refresh failed (will self-heal on next sync): {e}" + ); + } + } + + // ----------------------------------------------------------------- + // Sell / delist / transfer / purchase orchestration + // ----------------------------------------------------------------- + + /// 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. + pub async fn set_dpns_name_price( + &self, + owner_identity_id: &Identifier, + name: &str, + price: Credits, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + 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 { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} is owned by {}, not by {owner_identity_id}", + state.owner_id + ))); + } + let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let contract_id = dpns_contract_id(); + let confirmed = self + .set_document_price_with_signer( + owner_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + price, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*owner_identity_id, DpnsNameSaleStatus::Owned, now_ms())], + vec![], + ) + .await; + Ok(confirmed_state) + } + + /// Delist `name` — a transfer to the owner's own identity, which + /// consensus strips `$price` from while leaving ownership unchanged + /// (DPNS has no dedicated remove-price transition and + /// `documentsMutable=false` rules out a replace). + /// + /// The confirmed document is verified to actually carry no `$price` + /// and the same owner; if consensus semantics ever change, this + /// fails loudly instead of persisting a delist that didn't happen. + pub async fn delist_dpns_name( + &self, + owner_identity_id: &Identifier, + name: &str, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + 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 { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} is owned by {}, not by {owner_identity_id}", + state.owner_id + ))); + } + if state.price.is_none() { + return Err(PlatformWalletError::DocumentNotForSale { + document_id: state.document_id, + }); + } + let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let contract_id = dpns_contract_id(); + let confirmed = self + .transfer_document_with_signer( + owner_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + owner_identity_id, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + if confirmed_state.price.is_some() || confirmed_state.owner_id != *owner_identity_id { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "delist of {name:?} broadcast a self-transfer but the confirmed document \ + still carries price={:?} owner={} — transfer-to-self no longer clears \ + $price; do not trust the local delist state", + confirmed_state.price, confirmed_state.owner_id + ))); + } + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*owner_identity_id, DpnsNameSaleStatus::Owned, now_ms())], + vec![], + ) + .await; + Ok(confirmed_state) + } + + /// Transfer `name` to `recipient_id` (gift / off-market handover). + /// Consensus strips any `$price` on transfer, so this also delists. + /// + /// Both sides are reconciled locally when they belong to this wallet: + /// the sender loses the label (row → `Transferred`), a wallet-owned + /// recipient gains it (row → `Owned`). + pub async fn transfer_dpns_name( + &self, + owner_identity_id: &Identifier, + name: &str, + recipient_id: &Identifier, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let _operation = self.dpns_operation_gate.lock().await; + if recipient_id == owner_identity_id { + return Err(PlatformWalletError::InvalidParameter( + "transfer recipient is the current owner — use delist_dpns_name for a \ + transfer-to-self delist" + .to_string(), + )); + } + let state = self.fetch_dpns_domain_state_required(name).await?; + if state.owner_id != *owner_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "DPNS name {name:?} is owned by {}, not by {owner_identity_id}", + state.owner_id + ))); + } + let signing_key = self.select_dpns_signing_key(owner_identity_id).await?; + let contract_id = dpns_contract_id(); + let confirmed = self + .transfer_document_with_signer( + owner_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + recipient_id, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + let now = now_ms(); + self.remove_dpns_label(owner_identity_id, &confirmed_state.label) + .await; + if self.is_wallet_identity(recipient_id).await { + // Both sides ours: the single per-document row tracks the new + // owner; the departure is visible through the label removal. + self.add_dpns_label_if_missing( + recipient_id, + &confirmed_state.label, + confirmed_state.transferred_at_ms.or(Some(now)), + ) + .await; + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*recipient_id, DpnsNameSaleStatus::Owned, now)], + vec![], + ) + .await; + } else { + self.record_dpns_name_states( + vec![confirmed_state.to_entry( + *owner_identity_id, + DpnsNameSaleStatus::Transferred { to: *recipient_id }, + now, + )], + vec![], + ) + .await; + } + Ok(confirmed_state) + } + + /// Purchase `name` at exactly `expected_price` credits (the price the + /// user confirmed) for `purchaser_identity_id`. + /// + /// Pre-flight, all typed: name resolution (contested-aware), a + /// self-purchase guard, [`PlatformWalletError::DocumentNotForSale`], + /// [`PlatformWalletError::DocumentPriceChanged`] when the listing no + /// longer matches `expected_price`, and + /// [`PlatformWalletError::InsufficientIdentityCredits`] when the + /// buyer's local balance can't cover + /// `expected_price + `[`DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS`]. + /// + /// The broadcast transition carries `expected_price` — NEVER the + /// re-read price — so a listing change between pre-flight and + /// broadcast is rejected by consensus (code 40109) and surfaces as + /// the same typed `DocumentPriceChanged`. + /// + /// On success both sides are reconciled locally: the buyer gains the + /// label and the name-state row (`Owned`), and a wallet-owned seller + /// loses the label. Both identities' balances are refreshed + /// best-effort. Note the seller does NOT get a `Sold` row when both + /// parties live in this wallet — rows are keyed by `document_id` + /// alone, and the buyer's `Owned` row already occupies that key; the + /// seller's departure is represented by the label removal. + /// `Sold` rows are written by the sync pass, which sees a name leave + /// an identity it still tracks (same keying constraint as + /// [`Self::transfer_dpns_name`]). + pub async fn purchase_dpns_name( + &self, + purchaser_identity_id: &Identifier, + name: &str, + expected_price: Credits, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let _operation = self.dpns_operation_gate.lock().await; + let state = self.fetch_dpns_domain_state_required(name).await?; + if state.owner_id == *purchaser_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "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, + }); + } + // 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 + // `IdentityInsufficientBalanceError` (typed through + // `promote_document_trade_error`) is the backstop for a stale + // local balance. + let available = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.identity_manager + .wallet_identity(&self.wallet_id, purchaser_identity_id) + .map(|m| m.balance()) + .ok_or(PlatformWalletError::IdentityNotFound( + *purchaser_identity_id, + ))? + }; + let required = required_purchase_credits(expected_price)?; + if available < required { + return Err(PlatformWalletError::InsufficientIdentityCredits { + identity_id: *purchaser_identity_id, + required, + available, + }); + } + let signing_key = self.select_dpns_signing_key(purchaser_identity_id).await?; + let contract_id = dpns_contract_id(); + let confirmed = self + .purchase_document_with_signer( + purchaser_identity_id, + &contract_id, + DPNS_DOCUMENT_TYPE, + &state.document_id, + expected_price, + signing_key.id(), + signer, + ) + .await?; + let confirmed_state = DpnsDomainState::from_document(&confirmed)?; + let now = now_ms(); + let seller_id = state.owner_id; + + // Buyer side: label + row + balance. + self.add_dpns_label_if_missing( + purchaser_identity_id, + &confirmed_state.label, + confirmed_state.transferred_at_ms.or(Some(now)), + ) + .await; + self.record_dpns_name_states( + vec![confirmed_state.to_entry(*purchaser_identity_id, DpnsNameSaleStatus::Owned, now)], + vec![], + ) + .await; + self.refresh_identity_after_trade(purchaser_identity_id, "purchase (buyer)") + .await; + + // Seller side, when the seller is also one of this wallet's + // identities: the sold name leaves the label list (the host's + // main-username selection falls back to the remaining labels off + // the mirrored identity row) and the seller's balance — which + // just received the sale price — is refreshed. + if self.is_wallet_identity(&seller_id).await { + self.remove_dpns_label(&seller_id, &confirmed_state.label) + .await; + self.refresh_identity_after_trade(&seller_id, "purchase (seller)") + .await; + } + Ok(confirmed_state) + } + + // ----------------------------------------------------------------- + // History + // ----------------------------------------------------------------- + + /// The trade timeline of `name`: registration, price changes, + /// purchases (with price + counterparties), and transfers — read + /// from the Document History system contract's `priceUpdate` / + /// `purchase` / `transfer` documents (`byDocument` index), merged + /// and ordered by block time ascending. + /// + /// Works for names that already left the wallet: when the live + /// domain document can't be resolved, the document id is taken from + /// the local marketplace rows. + pub async fn dpns_name_history( + &self, + name: &str, + ) -> Result, PlatformWalletError> { + // Resolve the domain document id (live first, local rows for + // departed names) and the registration timestamp when known. + let live = self.dpns_name_state(name).await?; + let (document_id, registered_at_ms) = match &live { + Some(state) => (state.document_id, state.created_at_ms), + None => { + let normalized = convert_to_homograph_safe_chars(dpns_label(name)); + let local = self + .local_dpns_name_states(None) + .await? + .into_iter() + .find(|entry| entry.normalized_label == normalized); + match local { + Some(entry) => (entry.document_id, entry.created_at_ms), + None => { + // Reuse the contested-aware classification for the + // typed error. If the name appeared between the two + // reads (registration race), just use it. + match self.fetch_dpns_domain_state_required(name).await { + Ok(state) => (state.document_id, state.created_at_ms), + Err(e) => return Err(e), + } + } + } + } + }; + self.dpns_document_history(&document_id, registered_at_ms) + .await + } + + /// History timeline for a known domain `document_id`. See + /// [`Self::dpns_name_history`]. + pub async fn dpns_document_history( + &self, + document_id: &Identifier, + registered_at_ms: Option, + ) -> Result, PlatformWalletError> { + let dpns_contract_id = dpns_contract_id(); + let mut events: Vec = Vec::new(); + if let Some(at_ms) = registered_at_ms { + events.push(DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Registered, + at_ms, + block_height: None, + }); + } + for doc_type in [ + HISTORY_TYPE_PRICE_UPDATE, + HISTORY_TYPE_PURCHASE, + HISTORY_TYPE_TRANSFER, + ] { + let docs = self + .fetch_history_documents(&dpns_contract_id, document_id, doc_type) + .await?; + for doc in docs { + events.push(history_event_from_document(doc_type, &doc)?); + } + } + events.sort_by_key(|e| e.at_ms); + Ok(events) + } + + /// Fetch every history document of one type for a source document via + /// the `byDocument` (dataContractId, documentId, $createdAt) index, + /// draining its server pages in ascending creation order. + async fn fetch_history_documents( + &self, + source_contract_id: &Identifier, + source_document_id: &Identifier, + history_doc_type: &str, + ) -> Result, PlatformWalletError> { + let contract = self.document_history_contract().await?; + let mut all_documents = Vec::new(); + 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 documents = Document::fetch_many(&self.sdk, query).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch {history_doc_type} history documents: {e}" + )) + })?; + let page_len = documents.len(); + let last_id = documents.keys().last().copied(); + all_documents.extend(documents.into_iter().filter_map(|(_, document)| document)); + + if page_len < HISTORY_QUERY_LIMIT as usize { + break; + } + let Some(last_id) = last_id else { + break; + }; + if cursor == Some(last_id) { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "{history_doc_type} history pagination cursor did not advance" + ))); + } + cursor = Some(last_id); + } + + Ok(all_documents) + } + + // ----------------------------------------------------------------- + // Sync + // ----------------------------------------------------------------- + + /// One marketplace sync pass over every identity in this wallet: + /// refreshes owned-name rows (price/sale state), adds newly observed + /// names to the legacy label list, detects names that LEFT an + /// identity (sold or transferred away — classified through the + /// history contract), removes their labels, and refreshes the + /// balances of identities that sold a name. + /// + /// All network reads happen before the wallet-manager write lock is + /// taken; per-identity failures are logged and skipped, never + /// aborting the pass. + pub async fn sync_dpns_marketplace( + &self, + ) -> Result { + let _operation = self.dpns_operation_gate.lock().await; + // Snapshot identity ids, their label lists, and the current rows. + let (identity_ids, labels_by_identity, previous_rows) = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let ids = info.identity_manager.wallet_identity_ids(&self.wallet_id); + let labels: BTreeMap> = info + .identity_manager + .wallet_managed_identities(&self.wallet_id) + .map(|managed| (managed.identity.id(), managed.dpns_names.clone())) + .collect(); + (ids, labels, info.dpns_name_states.clone()) + }; + + let mut summary = DpnsMarketplaceSyncSummary::default(); + let mut rows_to_write: BTreeMap = BTreeMap::new(); + let mut rows_to_remove: BTreeSet = BTreeSet::new(); + let mut sellers_to_refresh: Vec = Vec::new(); + let now = now_ms(); + let contract = self.dpns_contract().await?; + + for identity_id in identity_ids { + let previous_labels = labels_by_identity + .get(&identity_id) + .cloned() + .unwrap_or_default(); + + let mut progress = self + .dpns_sync_progress + .lock() + .map_err(|_| { + PlatformWalletError::InvalidIdentityData( + "DPNS marketplace sync progress lock was poisoned".to_string(), + ) + })? + .get(&identity_id) + .cloned() + .unwrap_or_default(); + + if progress.pending_departures.is_empty() { + let (states, next_cursor, complete) = match self + .dpns_domain_states_page( + Arc::clone(&contract), + &identity_id, + progress.cursor, + SYNC_QUERY_LIMIT, + ) + .await + { + Ok(page) => page, + Err(e) => { + tracing::warn!( + identity = %identity_id, + "DPNS marketplace sync: domain-state page failed, retaining cursor: {e}" + ); + continue; + } + }; + // `records.identity` follows ownership on-chain, but filter on + // `$ownerId` anyway so a protocol edge (or pre-rewrite record) + // can't count someone else's document as ours. + let owned: Vec<&DpnsDomainState> = states + .iter() + .filter(|state| state.owner_id == identity_id) + .collect(); + + progress + .seen_normalized_labels + .extend(owned.iter().map(|state| state.normalized_label.clone())); + + // Owned rows: upsert, tracking price changes vs the previous row. + for state in &owned { + if let Some(prev) = previous_rows.get(&state.document_id) { + if prev.wallet_identity_id == identity_id && prev.price != state.price { + summary.prices_changed.push(DpnsPriceChange { + document_id: state.document_id, + label: state.label.clone(), + previous: prev.price, + current: state.price, + }); + } + } + insert_sync_row( + &mut rows_to_write, + state.to_entry(identity_id, DpnsNameSaleStatus::Owned, now), + ); + summary.names_tracked += 1; + } + + // Newly observed labels → legacy list additions. + for state in &owned { + let known = previous_labels.iter().any(|name| { + convert_to_homograph_safe_chars(&name.label) == state.normalized_label + }); + if !known { + self.add_dpns_label_if_missing( + &identity_id, + &state.label, + state + .transferred_at_ms + .or(state.created_at_ms) + .or(Some(now)), + ) + .await; + summary.names_added.push((identity_id, state.label.clone())); + } + } + + if complete { + progress.cursor = None; + progress.pending_departures = previous_labels + .iter() + .filter(|name| { + !progress + .seen_normalized_labels + .contains(&convert_to_homograph_safe_chars(&name.label)) + }) + .cloned() + .collect(); + progress.seen_normalized_labels.clear(); + } else { + progress.cursor = next_cursor; + } + } + + // Resolve only a fixed number of departed names per pass. A + // transient domain fetch error keeps the item queued and its + // label/row intact, so the next pass retries without data loss. + let mut departures_processed = 0; + while departures_processed < SYNC_DEPARTURE_LIMIT { + let Some(previous_name) = progress.pending_departures.pop_front() else { + break; + }; + 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; + } + departures_processed += 1; + self.remove_dpns_label(&identity_id, &previous_name.label) + .await; + if let Some(entry) = resolved.entry { + insert_sync_row(&mut rows_to_write, entry); + } + if let Some(document_id) = resolved.remove_document_id { + rows_to_remove.insert(document_id); + } + if matches!( + resolved.summary.status, + Some(DpnsNameSaleStatus::Sold { .. }) + ) { + sellers_to_refresh.push(identity_id); + } + summary.names_departed.push(resolved.summary); + } + + let mut sync_progress = self.dpns_sync_progress.lock().map_err(|_| { + PlatformWalletError::InvalidIdentityData( + "DPNS marketplace sync progress lock was poisoned".to_string(), + ) + })?; + if progress.cursor.is_none() + && progress.seen_normalized_labels.is_empty() + && progress.pending_departures.is_empty() + { + sync_progress.remove(&identity_id); + } else { + sync_progress.insert(identity_id, progress); + } + } + + // A current owned row wins when one document moves between two + // identities in this wallet during the same pass. + rows_to_remove.retain(|document_id| !rows_to_write.contains_key(document_id)); + self.record_dpns_name_states( + rows_to_write.into_values().collect(), + rows_to_remove.into_iter().collect(), + ) + .await; + sellers_to_refresh.sort(); + sellers_to_refresh.dedup(); + for seller in sellers_to_refresh { + self.refresh_identity_after_trade(&seller, "marketplace sync (sold name)") + .await; + } + summary.sync_unix_ms = now_ms(); + Ok(summary) + } + + /// Work out what happened to a name that left `identity_id`: fetch + /// the domain document by label to learn the new owner, then + /// classify the departure through the history contract. + /// + /// 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. + async fn resolve_departed_name( + &self, + identity_id: &Identifier, + label: &str, + 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 state = match self.dpns_name_state(label).await { + Ok(Some(state)) => state, + Ok(None) => { + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: previous_document_id, + status: None, + }, + entry: None, + remove_document_id: previous_document_id, + retry: false, + }; + } + Err(error) => { + tracing::warn!( + identity = %identity_id, + name = label, + "DPNS departed-name lookup failed; retaining local state for retry: {error}" + ); + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: previous_document_id, + status: None, + }, + entry: None, + remove_document_id: None, + retry: true, + }; + } + }; + let status = match self + .classify_departure(&state.document_id, identity_id) + .await + { + Ok(status) => status, + Err(error) => { + tracing::warn!( + identity = %identity_id, + name = label, + document = %state.document_id, + "DPNS departure-history classification failed; retaining local state for retry: {error}" + ); + return ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: Some(state.document_id), + status: None, + }, + entry: None, + remove_document_id: None, + retry: true, + }; + } + }; + ResolvedDepartedName { + summary: DepartedDpnsName { + identity_id: *identity_id, + label: label.to_string(), + document_id: Some(state.document_id), + 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, + } + } + + /// Find the latest history event whose *departing side* is the wallet + /// identity. This is intentionally independent of the live domain's + /// current owner: after S→A→B, S's departure must remain S→A. + async fn classify_departure( + &self, + document_id: &Identifier, + departing_identity: &Identifier, + ) -> Result, PlatformWalletError> { + let mut candidates: Vec<(u64, DpnsNameSaleStatus)> = Vec::new(); + for document_type in [HISTORY_TYPE_PURCHASE, HISTORY_TYPE_TRANSFER] { + let documents = self + .fetch_history_documents(&dpns_contract_id(), document_id, document_type) + .await?; + for document in documents { + match history_event_from_document(document_type, &document) { + Ok(event) => { + if let Some(candidate) = + direct_departure_candidate(event, departing_identity) + { + candidates.push(candidate); + } + } + Err(error) => tracing::warn!( + document = %document.id(), + history_type = document_type, + "ignoring malformed DPNS departure-history document: {error}" + ), + } + } + } + Ok(candidates + .into_iter() + .max_by_key(|(at_ms, _)| *at_ms) + .map(|(_, status)| status)) + } +} + +// --------------------------------------------------------------------------- +// History document decoding +// --------------------------------------------------------------------------- + +/// Decode one Document History contract document into a timeline event. +/// Errors on missing/mistyped required fields rather than fabricating +/// values (`priceUpdate`/`purchase` must carry `price`, `transfer` must +/// carry `toIdentityId`, all must carry `$createdAt`). +fn history_event_from_document( + doc_type: &str, + doc: &Document, +) -> Result { + let properties = doc.properties(); + let at_ms = doc.created_at().ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "history document {} ({doc_type}) is missing $createdAt", + doc.id() + )) + })?; + let price = || -> Result { + properties + .get_optional_integer::("price") + .ok() + .flatten() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "history document {} ({doc_type}) is missing its price field", + doc.id() + )) + }) + }; + let identifier = |key: &str| -> Result { + properties + .get(key) + .and_then(|v| v.to_identifier().ok()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "history document {} ({doc_type}) is missing identifier field {key:?}", + doc.id() + )) + }) + }; + let kind = match doc_type { + HISTORY_TYPE_PRICE_UPDATE => DpnsNameHistoryEventKind::PriceSet { price: price()? }, + HISTORY_TYPE_PURCHASE => DpnsNameHistoryEventKind::Purchased { + price: price()?, + seller: identifier("sellerId")?, + buyer: doc.owner_id(), + }, + HISTORY_TYPE_TRANSFER => DpnsNameHistoryEventKind::Transferred { + from: doc.owner_id(), + to: identifier("toIdentityId")?, + }, + other => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "unknown history document type {other:?}" + ))) + } + }; + Ok(DpnsNameHistoryEvent { + kind, + at_ms, + block_height: doc.created_at_block_height(), + }) +} + +fn required_purchase_credits(expected_price: Credits) -> Result { + expected_price + .checked_add(DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS) + .ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "DPNS purchase price is too large to reserve the document transition fee" + .to_string(), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name_state_entry( + document_id: Identifier, + wallet_identity_id: Identifier, + status: DpnsNameSaleStatus, + ) -> DpnsNameStateEntry { + DpnsNameStateEntry { + document_id, + wallet_identity_id, + label: "alice".to_string(), + normalized_label: "a11ce".to_string(), + normalized_parent_domain_name: DPNS_PARENT_DOMAIN.to_string(), + price: None, + status, + created_at_ms: None, + updated_at_ms: None, + transferred_at_ms: None, + last_synced_at_ms: 1, + } + } + + #[test] + fn dpns_label_strips_only_the_dash_suffix() { + assert_eq!(dpns_label("alice"), "alice"); + assert_eq!(dpns_label("alice.dash"), "alice"); + assert_eq!(dpns_label("alice.dash.dash"), "alice.dash"); + } + + #[test] + fn fee_reserve_is_one_millidash() { + // 0.001 DASH = 100_000 duffs? No: 1 DASH = 100_000_000 duffs, so + // 0.001 DASH = 100_000 duffs = 100_000_000 credits (1 duff = + // 1000 credits). Pin the constant against unit drift. + assert_eq!(DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS, 100_000 * 1_000); + } + + #[test] + fn purchase_credit_requirement_rejects_overflow() { + assert_eq!( + required_purchase_credits(1).expect("small price should fit"), + DOCUMENT_TRANSITION_FEE_RESERVE_CREDITS + 1 + ); + + assert!(matches!( + required_purchase_credits(u64::MAX), + Err(PlatformWalletError::InvalidParameter(message)) + if message.contains("too large") + )); + } + + #[test] + fn owned_sync_row_wins_regardless_of_identity_iteration_order() { + let document_id = Identifier::from([1; 32]); + let seller_id = Identifier::from([2; 32]); + let buyer_id = Identifier::from([3; 32]); + let sold = name_state_entry( + document_id, + seller_id, + DpnsNameSaleStatus::Sold { to: buyer_id }, + ); + let owned = name_state_entry(document_id, buyer_id, DpnsNameSaleStatus::Owned); + + for entries in [[sold.clone(), owned.clone()], [owned.clone(), sold.clone()]] { + let mut rows = BTreeMap::new(); + for entry in entries { + insert_sync_row(&mut rows, entry); + } + let row = rows.get(&document_id).expect("document row"); + assert_eq!(row.wallet_identity_id, buyer_id); + assert_eq!(row.status, DpnsNameSaleStatus::Owned); + } + } + + #[test] + fn departure_attribution_ignores_later_multi_hop_owner() { + let seller = Identifier::from([1; 32]); + let first_buyer = Identifier::from([2; 32]); + let later_buyer = Identifier::from([3; 32]); + let seller_departure = DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Purchased { + price: 10, + seller, + buyer: first_buyer, + }, + at_ms: 100, + block_height: None, + }; + let later_transfer = DpnsNameHistoryEvent { + kind: DpnsNameHistoryEventKind::Transferred { + from: first_buyer, + to: later_buyer, + }, + at_ms: 200, + block_height: None, + }; + + assert_eq!( + direct_departure_candidate(seller_departure, &seller), + Some((100, DpnsNameSaleStatus::Sold { to: first_buyer })) + ); + assert_eq!(direct_departure_candidate(later_transfer, &seller), None); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 883e4bae99b..4f0a3a51c1e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -20,11 +20,13 @@ //! `SpvBroadcaster` because the [`AssetLockManager`] itself is pinned; that //! invariant lives in `PlatformWallet::new`. -use std::sync::Arc; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex as StdMutex}; use dashcore::secp256k1::PublicKey; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyType}; +use dpp::prelude::Identifier; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType}; use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, @@ -322,6 +324,14 @@ pub struct IdentityWallet { /// signer-generic `PutDocument` trait) behind two by-value methods /// so the call sites stay simple. pub(crate) sdk_writer: Arc, + /// Serializes DPNS marketplace mutations and sync reconciliation for + /// this wallet. Every cloned handle shares the same gate. + pub(crate) dpns_operation_gate: Arc>, + /// Bounded ownership-scan cursors, one per wallet identity. This is a + /// short-lived in-memory optimization; durable marketplace rows remain + /// the source rendered after process restart. + pub(crate) dpns_sync_progress: + Arc>>, } // Manual `Debug`: the derive would require `B: Debug`, which is not part @@ -345,6 +355,8 @@ impl Clone for IdentityWallet { persister: self.persister.clone(), broadcaster: Arc::clone(&self.broadcaster), sdk_writer: Arc::clone(&self.sdk_writer), + dpns_operation_gate: Arc::clone(&self.dpns_operation_gate), + dpns_sync_progress: Arc::clone(&self.dpns_sync_progress), } } } 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 bbcc27c09e4..3ac7e8cacfe 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -24,6 +24,7 @@ mod contract; mod discovery; mod document; mod dpns; +mod dpns_marketplace; mod identity_handle; mod loading; mod register_from_addresses; @@ -70,6 +71,10 @@ pub use contact_requests::{ pub use dashpay_view::DashPayView; 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, +}; pub use identity_handle::{ derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 5ed5a805262..d447f282222 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -5862,6 +5862,8 @@ mod tests { persister: real.persister.clone(), broadcaster: Arc::new(AcceptingBroadcaster), sdk_writer: Arc::clone(&real.sdk_writer), + dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), + dpns_sync_progress: Arc::clone(&real.dpns_sync_progress), } } @@ -5895,6 +5897,8 @@ mod tests { persister: real.persister.clone(), broadcaster: Arc::new(RejectingBroadcaster), sdk_writer: Arc::clone(&real.sdk_writer), + dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), + dpns_sync_progress: Arc::clone(&real.dpns_sync_progress), } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 933f3aad5c6..8e4b98bc635 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -240,6 +240,37 @@ impl ManagedIdentity { } } + /// Replace the DPNS-name list wholesale. + /// + /// Use this when a sync round (or a confirmed sale/transfer) has the + /// canonical set of names owned by this identity. `IdentityChangeSet::merge` + /// and replay both treat this field as a complete last-write-wins + /// snapshot, so names that left the identity (sold / transferred + /// away) are removed, including by an empty snapshot — the same + /// policy as [`Self::set_contested_dpns_names`]. + pub fn set_dpns_names(&mut self, names: Vec, persister: &WalletPersister) { + self.dpns_names = names; + let cs = self.snapshot_changeset(); + if let Err(e) = persister.store(cs.into()) { + tracing::error!("Failed to persist changeset: {}", e); + } + } + + /// Remove one DPNS name by label (the sold / transferred-away case). + /// + /// No-op (no changeset emitted) when the label isn't present. + pub fn remove_dpns_name(&mut self, label: &str, persister: &WalletPersister) { + let before = self.dpns_names.len(); + self.dpns_names.retain(|n| n.label != label); + if self.dpns_names.len() == before { + return; + } + let cs = self.snapshot_changeset(); + if let Err(e) = persister.store(cs.into()) { + tracing::error!("Failed to persist changeset: {}", e); + } + } + /// Append a contested DPNS label this identity is contending for. /// /// Dedup is enforced — the same label isn't added twice. When a diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs index ba47d0c67a1..ea80ea19ae0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/accessors.rs @@ -12,6 +12,68 @@ use dpp::identity::Identity; use dpp::prelude::Identifier; impl IdentityManager { + /// Look up an identity only when it is signing-capable and belongs to + /// `wallet_id`. Observed identities deliberately do not match. + pub fn wallet_identity( + &self, + wallet_id: &WalletId, + identity_id: &Identifier, + ) -> Option<&ManagedIdentity> { + let IdentityLocation::InWallet { + wallet_id: located_wallet, + registration_index, + } = self.location_index().get(identity_id).copied()? + else { + return None; + }; + if located_wallet != *wallet_id { + return None; + } + self.wallet_identities + .get(wallet_id)? + .get(®istration_index) + } + + /// Mutable counterpart to [`Self::wallet_identity`]. + pub fn wallet_identity_mut( + &mut self, + wallet_id: &WalletId, + identity_id: &Identifier, + ) -> Option<&mut ManagedIdentity> { + let IdentityLocation::InWallet { + wallet_id: located_wallet, + registration_index, + } = self.location_index().get(identity_id).copied()? + else { + return None; + }; + if located_wallet != *wallet_id { + return None; + } + self.wallet_identities + .get_mut(wallet_id)? + .get_mut(®istration_index) + } + + /// Iterate only identities owned by `wallet_id`. Unlike + /// [`Self::managed_identities`], this never includes observed contacts. + pub fn wallet_managed_identities( + &self, + wallet_id: &WalletId, + ) -> impl Iterator { + self.wallet_identities + .get(wallet_id) + .into_iter() + .flat_map(|identities| identities.values()) + } + + /// Snapshot the identifiers owned by `wallet_id`. + pub fn wallet_identity_ids(&self, wallet_id: &WalletId) -> Vec { + self.wallet_managed_identities(wallet_id) + .map(|managed| managed.identity.id()) + .collect() + } + /// Look up a managed identity by id across both buckets. /// /// O(log n): hits the side-index for the bucket discriminant + diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 398356cf218..0080effaa3c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -38,8 +38,9 @@ impl IdentityManager { /// as applying it once. If the identity already exists in either /// bucket, the scalar fields are updated in place; balance/revision /// are gated on `entry.revision >= existing.identity.revision()` - /// matching the merge policy on `IdentityChangeSet`. Contested DPNS - /// labels are a complete canonical snapshot and are assigned wholesale. + /// matching the merge policy on `IdentityChangeSet`. DPNS labels and + /// contested DPNS labels are complete canonical snapshots and are + /// assigned wholesale. pub(crate) fn apply_identity_entry(&mut self, entry: IdentityEntry) { use dpp::identity::accessors::IdentitySettersV0; @@ -55,11 +56,11 @@ impl IdentityManager { existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; *existing.dashpay_profile_mut() = entry.dashpay_profile; - for name in entry.dpns_names { - if !existing.dpns_names.iter().any(|n| n.label == name.label) { - existing.dpns_names.push(name); - } - } + // DPNS names: wholesale assign, matching the changeset's + // last-write-wins merge — entries carry the complete list + // (snapshotted via `from_managed`), and a sold/transferred + // name must be able to leave it. + existing.dpns_names = entry.dpns_names; existing.contested_dpns_names = entry.contested_dpns_names; existing .dashpay_payments_mut() diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs index 663e58f0639..4b13ae5c4c4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs @@ -505,6 +505,15 @@ mod tests { assert!(observed_managed.wallet_id.is_none()); assert_eq!(observed_managed.identity.id(), observed); + // Wallet-scoped accessors are the signing/ownership boundary: they + // include the owned identity and categorically exclude an observed + // contact or an identity queried under another wallet id. + assert!(manager.wallet_identity(&wallet_id, &owned).is_some()); + assert!(manager.wallet_identity(&wallet_id, &observed).is_none()); + assert!(manager.wallet_identity(&[43u8; 32], &owned).is_none()); + assert_eq!(manager.wallet_identity_ids(&wallet_id), vec![owned]); + assert!(manager.wallet_identity_mut(&wallet_id, &observed).is_none()); + // Unknown ids miss cleanly. let unknown = Identifier::from([0xFFu8; 32]); assert!(manager.identity(&unknown).is_none()); diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index bf28a640c2d..ccb27d2bc60 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -64,6 +64,12 @@ pub struct PlatformWalletInfo { pub(crate) generation: Arc, pub identity_manager: IdentityManager, pub tracked_asset_locks: BTreeMap, + /// DPNS name states with sale price (username marketplace), keyed by + /// domain document id. Session-lifetime working set for the + /// marketplace sync/orchestration ops; the durable copy is the + /// host-side persister mirror fed by + /// [`DpnsNameStateChangeSet`](crate::changeset::DpnsNameStateChangeSet). + pub dpns_name_states: BTreeMap, } /// A platform wallet that combines core UTXO functionality with identity management. @@ -476,6 +482,8 @@ impl PlatformWallet { sdk_writer: Arc::new( crate::wallet::identity::network::sdk_writer::SdkWriter::new(Arc::clone(&sdk)), ), + dpns_operation_gate: Arc::new(tokio::sync::Mutex::new(())), + dpns_sync_progress: Arc::new(std::sync::Mutex::new(BTreeMap::new())), }; let platform = PlatformAddressWallet::new( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 62b1cef00ea..b4a2f7d05b0 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -40,6 +40,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + dpns_name_states: std::collections::BTreeMap::new(), } } @@ -52,6 +53,7 @@ impl WalletInfoInterface for PlatformWalletInfo { generation: std::sync::Arc::new(super::core::WalletGeneration::new()), identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), + dpns_name_states: std::collections::BTreeMap::new(), } } diff --git a/packages/rs-unified-sdk-jni/src/dpns_marketplace.rs b/packages/rs-unified-sdk-jni/src/dpns_marketplace.rs new file mode 100644 index 00000000000..3257b9ca9d6 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/dpns_marketplace.rs @@ -0,0 +1,734 @@ +//! JNI bridge for the platform-wallet DPNS marketplace surface. +//! +//! All policy and transition construction remains in `platform-wallet`. +//! This module only validates JVM values, calls the C FFI entry points, +//! copies Rust-owned rows into compact JSON, and releases every allocation. + +#![allow(clippy::missing_safety_doc)] + +use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; +use jni::objects::{JByteArray, JClass, JString}; +use jni::sys::{jboolean, jint, jlong, jlongArray, jstring, JNI_FALSE, JNI_TRUE}; +use jni::JNIEnv; +use platform_wallet_ffi::dpns_marketplace::{ + DpnsMarketplaceNameFFI, DpnsMarketplaceSyncSummaryFFI, DpnsNameHistoryEventFFI, + DpnsNameStateRowFFI, +}; +use platform_wallet_ffi::handle::Handle; +use rs_sdk_ffi::SignerHandle; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + if arr.is_null() { + throw_sdk_exception(env, 1, &format!("{field} must not be null")); + return None; + } + let len = env.get_array_length(arr).ok()? as usize; + if len != 32 { + throw_sdk_exception(env, 1, &format!("{field} must be 32 bytes, got {len}")); + return None; + } + let mut bytes = [0i8; 32]; + env.get_byte_array_region(arr, 0, &mut bytes).ok()?; + Some(bytes.map(|b| b as u8)) +} + +fn read_optional_id32( + env: &mut JNIEnv, + arr: &JByteArray, + field: &str, +) -> Result, ()> { + if arr.is_null() { + return Ok(None); + } + read_id32(env, arr, field).map(Some).ok_or(()) +} + +fn read_cstring(env: &mut JNIEnv, value: &JString, field: &str) -> Option { + if value.is_null() { + throw_sdk_exception(env, 1, &format!("{field} must not be null")); + return None; + } + let value: String = env.get_string(value).ok()?.into(); + match CString::new(value) { + Ok(value) => Some(value), + Err(_) => { + throw_sdk_exception(env, 1, &format!("{field} must not contain NUL")); + None + } + } +} + +fn nonnegative_u64(env: &mut JNIEnv, value: jlong, field: &str) -> Option { + if value < 0 { + throw_sdk_exception(env, 1, &format!("{field} must be non-negative")); + None + } else { + Some(value as u64) + } +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn hex32(value: &[u8; 32]) -> String { + value.iter().map(|b| format!("{b:02x}")).collect() +} + +unsafe fn cstr(value: *const c_char) -> String { + if value.is_null() { + String::new() + } else { + CStr::from_ptr(value).to_string_lossy().into_owned() + } +} + +fn new_jstring(env: &mut JNIEnv, value: String) -> jstring { + env.new_string(value) + .map(|value| value.into_raw()) + .unwrap_or(ptr::null_mut()) +} + +fn name_json(row: &DpnsMarketplaceNameFFI) -> String { + format!( + "{{\"documentId\":\"{}\",\"ownerId\":\"{}\",\"recordsIdentityId\":{},\"label\":{},\"normalizedLabel\":{},\"priceCredits\":{},\"createdAtMs\":{},\"updatedAtMs\":{},\"transferredAtMs\":{}}}", + hex32(&row.document_id), + hex32(&row.owner_id), + if row.has_records_identity { + format!("\"{}\"", hex32(&row.records_identity_id)) + } else { + "null".into() + }, + json_string(&unsafe { cstr(row.label) }), + json_string(&unsafe { cstr(row.normalized_label) }), + if row.has_price { + format!("\"{}\"", row.price) + } else { + "null".into() + }, + row.created_at_ms, + row.updated_at_ms, + row.transferred_at_ms, + ) +} + +fn state_json(row: &DpnsNameStateRowFFI) -> String { + format!( + "{{\"documentId\":\"{}\",\"walletIdentityId\":\"{}\",\"label\":{},\"normalizedLabel\":{},\"priceCredits\":{},\"status\":{},\"counterpartyId\":{},\"createdAtMs\":{},\"updatedAtMs\":{},\"transferredAtMs\":{},\"lastSyncedAtMs\":{}}}", + hex32(&row.document_id), + hex32(&row.wallet_identity_id), + json_string(&unsafe { cstr(row.label) }), + json_string(&unsafe { cstr(row.normalized_label) }), + if row.has_price { + format!("\"{}\"", row.price) + } else { + "null".into() + }, + row.status, + if row.has_counterparty { + format!("\"{}\"", hex32(&row.counterparty_id)) + } else { + "null".into() + }, + row.created_at_ms, + row.updated_at_ms, + row.transferred_at_ms, + row.last_synced_at_ms, + ) +} + +fn history_json(row: &DpnsNameHistoryEventFFI) -> String { + format!( + "{{\"kind\":{},\"atMs\":{},\"blockHeight\":{},\"priceCredits\":{},\"fromId\":{},\"toId\":{}}}", + row.kind, + row.at_ms, + if row.has_block_height { + row.block_height.to_string() + } else { + "null".into() + }, + if row.has_price { + format!("\"{}\"", row.price) + } else { + "null".into() + }, + if row.has_from { + format!("\"{}\"", hex32(&row.from_id)) + } else { + "null".into() + }, + if row.has_to { + format!("\"{}\"", hex32(&row.to_id)) + } else { + "null".into() + }, + ) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_search( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + prefix: JString, + limit: jint, + start_after: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + if limit < 0 { + throw_sdk_exception(env, 1, "limit must be non-negative"); + return ptr::null_mut(); + } + let Some(prefix) = read_cstring(env, &prefix, "prefix") else { + return ptr::null_mut(); + }; + let start_after = match read_optional_id32(env, &start_after, "startAfter") { + Ok(value) => value, + Err(()) => return ptr::null_mut(), + }; + let mut rows: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let mut count = 0usize; + let result = unsafe { + platform_wallet_ffi::platform_wallet_dpns_marketplace_search( + wallet_handle as Handle, + prefix.as_ptr(), + limit as u32, + start_after.as_ref().map_or(ptr::null(), |id| id.as_ptr()), + &mut rows, + &mut count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let json = if rows.is_null() || count == 0 { + "[]".to_string() + } else { + let values = unsafe { std::slice::from_raw_parts(rows, count) }; + format!( + "[{}]", + values.iter().map(name_json).collect::>().join(",") + ) + }; + unsafe { platform_wallet_ffi::dpns_marketplace_names_free(rows, count) }; + new_jstring(env, json) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_nameState( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + name: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(name) = read_cstring(env, &name, "name") else { + return ptr::null_mut(); + }; + let mut row: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_dpns_marketplace_name_state( + wallet_handle as Handle, + name.as_ptr(), + &mut row, + ) + }; + if take_pwffi_error(env, result) || row.is_null() { + return ptr::null_mut(); + } + let json = name_json(unsafe { &*row }); + unsafe { platform_wallet_ffi::dpns_marketplace_name_free(row) }; + new_jstring(env, json) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_myNames( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let identity_id = match read_optional_id32(env, &identity_id, "identityId") { + Ok(value) => value, + Err(()) => return ptr::null_mut(), + }; + let mut rows: *mut DpnsNameStateRowFFI = ptr::null_mut(); + let mut count = 0usize; + let result = unsafe { + platform_wallet_ffi::platform_wallet_dpns_marketplace_my_names( + wallet_handle as Handle, + identity_id.as_ref().map_or(ptr::null(), |id| id.as_ptr()), + &mut rows, + &mut count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let json = if rows.is_null() || count == 0 { + "[]".to_string() + } else { + let values = unsafe { std::slice::from_raw_parts(rows, count) }; + format!( + "[{}]", + values.iter().map(state_json).collect::>().join(",") + ) + }; + unsafe { platform_wallet_ffi::dpns_name_state_rows_free(rows, count) }; + new_jstring(env, json) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_history( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + name: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(name) = read_cstring(env, &name, "name") else { + return ptr::null_mut(); + }; + let mut rows: *mut DpnsNameHistoryEventFFI = ptr::null_mut(); + let mut count = 0usize; + let result = unsafe { + platform_wallet_ffi::platform_wallet_dpns_name_history( + wallet_handle as Handle, + name.as_ptr(), + &mut rows, + &mut count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let json = if rows.is_null() || count == 0 { + "[]".to_string() + } else { + let values = unsafe { std::slice::from_raw_parts(rows, count) }; + format!( + "[{}]", + values + .iter() + .map(history_json) + .collect::>() + .join(",") + ) + }; + unsafe { platform_wallet_ffi::dpns_name_history_events_free(rows, count) }; + new_jstring(env, json) + }) +} + +fn trade( + env: &mut JNIEnv, + wallet_handle: jlong, + identity_id: &JByteArray, + name: &JString, + amount_or_recipient: TradeArgument<'_>, + signer_handle: jlong, +) -> jstring { + let Some(identity_id) = read_id32(env, identity_id, "identityId") else { + return ptr::null_mut(); + }; + let Some(name) = read_cstring(env, name, "name") else { + return ptr::null_mut(); + }; + if signer_handle == 0 { + throw_sdk_exception(env, 1, "signerHandle must not be 0"); + return ptr::null_mut(); + } + let mut out: *mut DpnsMarketplaceNameFFI = ptr::null_mut(); + let result = unsafe { + match amount_or_recipient { + TradeArgument::Price(price) => { + platform_wallet_ffi::platform_wallet_dpns_set_name_price( + wallet_handle as Handle, + identity_id.as_ptr(), + name.as_ptr(), + price, + signer_handle as *mut SignerHandle, + &mut out, + ) + } + TradeArgument::Delist => platform_wallet_ffi::platform_wallet_dpns_delist_name( + wallet_handle as Handle, + identity_id.as_ptr(), + name.as_ptr(), + signer_handle as *mut SignerHandle, + &mut out, + ), + TradeArgument::Transfer(recipient) => { + platform_wallet_ffi::platform_wallet_dpns_transfer_name( + wallet_handle as Handle, + identity_id.as_ptr(), + name.as_ptr(), + recipient.as_ptr(), + signer_handle as *mut SignerHandle, + &mut out, + ) + } + TradeArgument::Purchase(price) => { + platform_wallet_ffi::platform_wallet_dpns_purchase_name( + wallet_handle as Handle, + identity_id.as_ptr(), + name.as_ptr(), + price, + signer_handle as *mut SignerHandle, + &mut out, + ) + } + } + }; + if take_pwffi_error(env, result) || out.is_null() { + return ptr::null_mut(); + } + let json = name_json(unsafe { &*out }); + unsafe { platform_wallet_ffi::dpns_marketplace_name_free(out) }; + new_jstring(env, json) +} + +enum TradeArgument<'a> { + Price(u64), + Delist, + Transfer(&'a [u8; 32]), + Purchase(u64), +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_setPrice( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + identity: JByteArray, + name: JString, + price: jlong, + signer: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + trade( + env, + wallet, + &identity, + &name, + TradeArgument::Price(price as u64), + signer, + ) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_delist( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + identity: JByteArray, + name: JString, + signer: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + trade(env, wallet, &identity, &name, TradeArgument::Delist, signer) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_transfer( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + identity: JByteArray, + name: JString, + recipient: JByteArray, + signer: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(recipient) = read_id32(env, &recipient, "recipientId") else { + return ptr::null_mut(); + }; + trade( + env, + wallet, + &identity, + &name, + TradeArgument::Transfer(&recipient), + signer, + ) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_purchase( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + identity: JByteArray, + name: JString, + price: jlong, + signer: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + trade( + env, + wallet, + &identity, + &name, + TradeArgument::Purchase(price as u64), + signer, + ) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_sync( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut summary = DpnsMarketplaceSyncSummaryFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_dpns_marketplace_sync_detailed( + wallet as Handle, + &mut summary, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let added = if summary.names_added.is_null() { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(summary.names_added, summary.names_added_count) } + }; + let departed = if summary.names_departed.is_null() { + &[][..] + } else { + unsafe { + std::slice::from_raw_parts(summary.names_departed, summary.names_departed_count) + } + }; + let prices = if summary.prices_changed.is_null() { + &[][..] + } else { + unsafe { + std::slice::from_raw_parts(summary.prices_changed, summary.prices_changed_count) + } + }; + let added_json = added + .iter() + .map(|row| { + format!( + "{{\"identityId\":\"{}\",\"label\":{}}}", + hex32(&row.identity_id), + json_string(&unsafe { cstr(row.label) }) + ) + }) + .collect::>() + .join(","); + let departed_json = departed.iter().map(|row| format!( + "{{\"identityId\":\"{}\",\"label\":{},\"documentId\":{},\"status\":{},\"counterpartyId\":{}}}", + hex32(&row.identity_id), json_string(&unsafe { cstr(row.label) }), + if row.has_document_id { format!("\"{}\"", hex32(&row.document_id)) } else { "null".into() }, + if row.has_status { row.status.to_string() } else { "null".into() }, + if row.has_status { format!("\"{}\"", hex32(&row.counterparty_id)) } else { "null".into() }, + )).collect::>().join(","); + let prices_json = prices + .iter() + .map(|row| { + format!( + "{{\"documentId\":\"{}\",\"label\":{},\"previousCredits\":{},\"currentCredits\":{}}}", + hex32(&row.document_id), json_string(&unsafe { cstr(row.label) }), + if row.has_previous { format!("\"{}\"", row.previous) } else { "null".into() }, + if row.has_current { format!("\"{}\"", row.current) } else { "null".into() }, + ) + }) + .collect::>() + .join(","); + let json = format!( + "{{\"tracked\":{},\"added\":[{}],\"departed\":[{}],\"pricesChanged\":[{}],\"syncUnixMs\":{}}}", + summary.names_tracked, added_json, departed_json, prices_json, summary.sync_unix_ms, + ); + unsafe { platform_wallet_ffi::dpns_marketplace_sync_summary_free(&mut summary) }; + new_jstring(env, json) + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncStart( + mut env: JNIEnv, + _class: JClass, + manager: jlong, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_start(manager as Handle) + }; + if take_pwffi_error(env, result) { + JNI_FALSE + } else { + JNI_TRUE + } + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncStop( + mut env: JNIEnv, + _class: JClass, + manager: jlong, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_stop(manager as Handle) + }; + if take_pwffi_error(env, result) { + JNI_FALSE + } else { + JNI_TRUE + } + }) +} + +fn sync_bool(env: &mut JNIEnv, manager: jlong, syncing: bool) -> jboolean { + let mut out = false; + let result = unsafe { + if syncing { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_is_syncing( + manager as Handle, + &mut out, + ) + } else { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_is_running( + manager as Handle, + &mut out, + ) + } + }; + if take_pwffi_error(env, result) { + JNI_FALSE + } else if out { + JNI_TRUE + } else { + JNI_FALSE + } +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncIsRunning( + mut env: JNIEnv, + _class: JClass, + manager: jlong, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| sync_bool(env, manager, false)) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncIsSyncing( + mut env: JNIEnv, + _class: JClass, + manager: jlong, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| sync_bool(env, manager, true)) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncLastUnixSeconds( + mut env: JNIEnv, + _class: JClass, + manager: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + let mut out = 0u64; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_last_sync_unix_seconds( + manager as Handle, + &mut out, + ) + }; + if take_pwffi_error(env, result) { + 0 + } else { + out as jlong + } + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncSetInterval( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + seconds: jlong, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let Some(seconds) = nonnegative_u64(env, seconds, "seconds") else { + return JNI_FALSE; + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_set_interval( + manager as Handle, + seconds, + ) + }; + if take_pwffi_error(env, result) { + JNI_FALSE + } else { + JNI_TRUE + } + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DpnsMarketplaceNative_syncNow( + mut env: JNIEnv, + _class: JClass, + manager: jlong, +) -> jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + let mut success = 0usize; + let mut errors = 0usize; + let mut unix = 0u64; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dpns_sync_sync_now( + manager as Handle, + &mut success, + &mut errors, + &mut unix, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let Ok(out) = env.new_long_array(3) else { + return ptr::null_mut(); + }; + if env + .set_long_array_region(&out, 0, &[success as i64, errors as i64, unix as i64]) + .is_err() + { + return ptr::null_mut(); + } + out.into_raw() + }) +} diff --git a/packages/rs-unified-sdk-jni/src/events.rs b/packages/rs-unified-sdk-jni/src/events.rs index 3be99418b8f..47639f89a60 100644 --- a/packages/rs-unified-sdk-jni/src/events.rs +++ b/packages/rs-unified-sdk-jni/src/events.rs @@ -1,4 +1,5 @@ -//! JNI bridge for the platform-wallet-ffi `EventHandlerCallbacks` vtable. +//! JNI bridge for the platform-wallet-ffi `EventHandlerCallbacks` vtable +//! and its size/version-tagged additive event extension. //! //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge`, //! reached from the manager built in [`crate::wallet_manager`]. @@ -28,7 +29,9 @@ use crate::support::JVM; use jni::objects::{GlobalRef, JByteArray, JObject, JValue}; use jni::JNIEnv; -use platform_wallet_ffi::event_handler::EventHandlerCallbacks; +use platform_wallet_ffi::event_handler::{ + DpnsSyncWalletResultFFI, EventHandlerCallbacks, EventHandlerCallbacksExtension, +}; use platform_wallet_ffi::platform_address_sync::PlatformAddressSyncWalletResultFFI; use platform_wallet_ffi::shielded_types::ShieldedSyncWalletResultFFI; use std::ffi::{c_void, CStr}; @@ -210,6 +213,49 @@ unsafe extern "C" fn tramp_platform_address_sync_completed( }); } +// ── on_dpns_marketplace_sync_completed (versioned extension) ───────── + +unsafe extern "C" fn tramp_dpns_marketplace_sync_completed( + context: *mut c_void, + results: *const DpnsSyncWalletResultFFI, + count: usize, + sync_unix_seconds: u64, +) { + with_bridge(context, |env, bridge| { + for result in slice_or_empty(results, count) { + env.with_local_frame(16, |env| -> Result<(), jni::errors::Error> { + let wallet_id = id32(env, &result.wallet_id)?; + let error = cstr_opt(env, result.error_message)?; + env.call_method( + bridge, + "onDpnsMarketplaceSyncCompleted", + "([BZIIIILjava/lang/String;)V", + &[ + (&wallet_id).into(), + JValue::Bool(result.success as u8), + JValue::Int(result.names_tracked as i32), + JValue::Int(result.names_added as i32), + JValue::Int(result.names_departed as i32), + JValue::Int(result.prices_changed as i32), + (&error).into(), + ], + )?; + Ok(()) + })?; + } + env.call_method( + bridge, + "onDpnsMarketplaceSyncPassCompleted", + "(JI)V", + &[ + JValue::Long(sync_unix_seconds as i64), + JValue::Int(count as i32), + ], + )?; + Ok(()) + }); +} + // ── on_shielded_sync_completed ──────────────────────────────────────── unsafe extern "C" fn tramp_shielded_sync_completed( @@ -301,7 +347,8 @@ unsafe extern "C" fn tramp_shielded_tree_progress( /// [`KotlinEventCtx`]). Every slot is wired: the two ABI-simple event / /// error slots, plus the platform-address + shielded completion / progress /// slots that marshal their payload arrays into per-entry flat calls on the -/// Kotlin bridge. +/// Kotlin bridge. DPNS completion lives in [`build_event_extension`] so the +/// legacy by-value vtable never grows. pub(crate) fn build_event_vtable(context: *mut c_void) -> EventHandlerCallbacks { EventHandlerCallbacks { context, @@ -315,6 +362,16 @@ pub(crate) fn build_event_vtable(context: *mut c_void) -> EventHandlerCallbacks } } +/// Build the size/version-tagged additive event extension. It shares the +/// legacy vtable's context and destructor and is copied during manager +/// creation. +pub(crate) fn build_event_extension() -> EventHandlerCallbacksExtension { + EventHandlerCallbacksExtension { + on_dpns_marketplace_sync_completed_fn: Some(tramp_dpns_marketplace_sync_completed), + ..EventHandlerCallbacksExtension::default() + } +} + /// `release_fn` for the event vtable: frees the boxed [`KotlinEventCtx`] /// when the native manager's last event-handler reference drops. The FFI /// guarantees exactly one call, which may land on any Rust thread — diff --git a/packages/rs-unified-sdk-jni/src/lib.rs b/packages/rs-unified-sdk-jni/src/lib.rs index c7f2a1287dc..3e9caf17b7b 100644 --- a/packages/rs-unified-sdk-jni/src/lib.rs +++ b/packages/rs-unified-sdk-jni/src/lib.rs @@ -16,6 +16,7 @@ mod credits; mod dashpay; +mod dpns_marketplace; mod events; mod funding; mod identity; diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 0d85f012861..917d26094df 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -54,12 +54,13 @@ use jni::JNIEnv; use platform_wallet_ffi::{ AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI, AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, - ContactRequestRemovalFFI, CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, - IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, - PaymentRestoreEntryFFI, PersistenceCallbacks, PlatformAddressFFI, - ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, TokenBalanceRemovalFFI, - TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, - UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, + ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI, IdentityEntryFFI, + IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, + InvitationEntryFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, + PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, + SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, + UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, + WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -191,6 +192,16 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { } } +/// Assemble the additive, size/version-tagged persistence callbacks. It shares +/// the legacy vtable's context and release hook; this value is copied by the +/// native manager during creation and owns nothing itself. +pub(crate) fn build_extension() -> PersistenceCallbacksExtension { + PersistenceCallbacksExtension { + on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), + ..Default::default() + } +} + /// `release_fn` for the persistence vtable: frees the boxed /// [`KotlinPersistenceCtx`] when the native manager's last persister /// reference drops. The FFI guarantees exactly one call, which may land @@ -851,6 +862,73 @@ unsafe extern "C" fn tramp_persist_identities( }) } +// ── DPNS marketplace name state ────────────────────────────────────── + +unsafe extern "C" fn tramp_persist_dpns_name_states( + context: *mut c_void, + wallet_id: *const u8, + rows_ptr: *const DpnsNameStateFFI, + rows_count: usize, + removed_ptr: *const [u8; 32], + removed_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + for row in slice_or_empty(rows_ptr, rows_count) { + let code = env.with_local_frame(32, |env| { + let document_id = env.byte_array_from_slice(&row.document_id)?; + let identity_id = env.byte_array_from_slice(&row.wallet_identity_id)?; + let counterparty_id = env.byte_array_from_slice(&row.counterparty_id)?; + let label = cstr(env, row.label)?; + let normalized_label = cstr(env, row.normalized_label)?; + let parent = cstr(env, row.normalized_parent_domain_name)?; + env.call_method( + bridge, + "onPersistDpnsNameState", + "([B[B[BZ[BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJBJJJJ)I", + &[ + (&wid).into(), + (&document_id).into(), + (&identity_id).into(), + JValue::Bool(row.has_counterparty as u8), + (&counterparty_id).into(), + (&label).into(), + (&normalized_label).into(), + (&parent).into(), + JValue::Bool(row.has_price as u8), + JValue::Long(row.price as i64), + JValue::Byte(row.status as i8), + JValue::Long(row.created_at_ms as i64), + JValue::Long(row.updated_at_ms as i64), + JValue::Long(row.transferred_at_ms as i64), + JValue::Long(row.last_synced_at_ms as i64), + ], + )? + .i() + })?; + if code != 0 { + return Ok(code); + } + } + for document_id in slice_or_empty(removed_ptr, removed_count) { + let code = env.with_local_frame(8, |env| { + let document_id = env.byte_array_from_slice(document_id)?; + env.call_method( + bridge, + "onRemoveDpnsNameState", + "([B[B)I", + &[(&wid).into(), (&document_id).into()], + )? + .i() + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) + }) +} + unsafe fn persist_identity_upsert( env: &mut JNIEnv, bridge: &JObject, diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index bb5d7a1539e..1cc5801db3e 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -40,8 +40,8 @@ #![allow(clippy::missing_safety_doc)] -use crate::events::{build_event_vtable, KotlinEventCtx}; -use crate::persistence::{build_vtable, KotlinPersistenceCtx}; +use crate::events::{build_event_extension, build_event_vtable, KotlinEventCtx}; +use crate::persistence::{build_extension, build_vtable, KotlinPersistenceCtx}; use crate::support::{guard, take_pwffi_error, throw_sdk_exception, PWFFI_CODE_OFFSET}; use jni::objects::{JByteArray, JClass, JObject, JObjectArray, JString, JValue}; use jni::sys::{ @@ -52,7 +52,7 @@ use jni::JNIEnv; use platform_wallet_ffi::error::{ platform_wallet_ffi_result_free, PlatformWalletFFIResult, PlatformWalletFFIResultCode, }; -use platform_wallet_ffi::event_handler::EventHandlerCallbacks; +use platform_wallet_ffi::event_handler::{EventHandlerCallbacks, EventHandlerCallbacksExtension}; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::persistence::{PersistenceCallbacks, PersistenceCapabilitiesFFI}; use platform_wallet_ffi::types::IdentifierArray; @@ -155,6 +155,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n let persistence_ctx = Box::into_raw(Box::new(KotlinPersistenceCtx::new(persistence_global))); let persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); + let persistence_extension = build_extension(); let persistence_capabilities = PersistenceCapabilitiesFFI { version: declared_capabilities_version, reserved: 0, @@ -174,16 +175,19 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n let event_ctx = Box::into_raw(Box::new(KotlinEventCtx::new(event_global))); let mut event_callbacks: EventHandlerCallbacks = build_event_vtable(event_ctx as *mut c_void); + let event_extension: EventHandlerCallbacksExtension = build_event_extension(); let mut manager_handle: Handle = 0; // SAFETY: `inner` is a live Sdk pointer for the duration of this // call; the manager clones the Sdk and reads both vtables by value. let result = unsafe { - platform_wallet_ffi::platform_wallet_manager_create_with_persistence_capabilities( + platform_wallet_ffi::platform_wallet_manager_create_with_extensions( inner, &persistence as *const PersistenceCallbacks, &mut event_callbacks as *const EventHandlerCallbacks, &persistence_capabilities as *const PersistenceCapabilitiesFFI, + &persistence_extension, + &event_extension, &mut manager_handle as *mut Handle, ) }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 75d722c54ab..47d3e29fbe4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -208,6 +208,17 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// - `PersistentTokenBalance.balance` remains the original `Int64` SwiftData /// property and SQLite column. Protocol `u64` values use its raw bits via a /// computed accessor, so full-domain support does not alter this V1 schema. +/// - `PersistentDPNSName` gained the DPNS username-marketplace +/// columns `documentIdBase58`, `priceCredits`, `saleStatusRaw`, +/// `counterpartyIdBase58`, the three optional document timestamps, +/// and `marketplaceUpdatedAt`, written by +/// the new `on_persist_dpns_name_states_fn` persister callback +/// (`DpnsNameStateFFI`). All optional or defaulted, and the +/// `(networkRaw, normalizedParentDomainName, normalizedLabel)` +/// uniqueness is unchanged ⇒ lightweight migration. Existing rows +/// migrate with a nil `documentIdBase58`, which is the documented +/// "no marketplace state tracked" signal — the next marketplace +/// sync pass fills them in. /// Each of those is a destructive change to a unique-attribute /// column or to relationship topology, so any pre-existing dev /// store will fail to open and get rebuilt from scratch on next diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift index 67861cb9c44..14c0b61897a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift @@ -1,8 +1,11 @@ import Foundation import SwiftData -/// SwiftData row for one confirmed DPNS label owned by a -/// `PersistentIdentity`. Mirrors a single +/// SwiftData row for one confirmed DPNS label observed by this wallet. There +/// is one row per `(network, parent, normalized label)`: when a name leaves the +/// wallet, the row stays attached to the departed identity for history; when +/// it moves between two identities in the same wallet, that one row follows +/// the current owner. Mirrors a single /// `platform_wallet::DpnsNameInfo` after it travels across the FFI on /// `IdentityEntryFFI.dpns_names` / `dpns_names_acquired_at`. /// @@ -11,10 +14,10 @@ import SwiftData /// list reactively — `@Query` over a row collection beats a `[String]` /// column that views can only read in bulk on `onAppear`. /// -/// This is purely a label cache. The DPNS document's `normalizedLabel` -/// (homograph-safe form used for the uniqueness lookup) is NOT -/// persisted here — DPNS lookups go through the SDK / platform-wallet, -/// and the local cache only needs to render the display label. +/// The identity-snapshot portion starts as a label cache. Marketplace sync +/// later enriches that same row with the domain document id, listing state, +/// ownership outcome, and document timestamps so SwiftUI can present the +/// last confirmed state without issuing a network lookup for every row. @Model public final class PersistentDPNSName { /// Compound uniqueness on `(networkRaw, normalizedParentDomainName, @@ -70,6 +73,66 @@ public final class PersistentDPNSName { /// `DpnsNameInfo.acquired_at`. `0` when unknown. public var acquiredAt: UInt64 + /// Whether the latest canonical identity snapshot still includes this + /// name. Marketplace callbacks never overwrite this value. A name that + /// leaves the wallet keeps its row on the departed identity with `false`; + /// a same-wallet transfer rebinds the unique row to the current identity + /// with `true`. + public var isOwned: Bool = true + + // MARK: - Username marketplace + // + // Fed by the `on_persist_dpns_name_states_fn` persister callback + // (`DpnsNameStateFFI`), NOT by the identity label snapshot that + // populates the fields above. All of them are optional or defaulted + // so an existing store migrates in place (SwiftData lightweight + // migration). + // + // READ CONTRACT: every field in this section is meaningful only + // while `documentIdBase58` is non-nil. A nil document id means the + // wallet is not tracking this name's marketplace state — it does NOT + // mean the name is owned and unlisted. Gate any marketplace UI on + // `documentIdBase58 != nil` before reading `saleStatus` or + // `priceCredits`. + + /// Base58 id of the DPNS `domain` document behind this label — the + /// handle every trade transition needs, stable across transfers and + /// purchases. `nil` while no marketplace state has been mirrored (or + /// after the row was dropped from marketplace tracking). + public var documentIdBase58: String? + + /// Listed sale price in **credits** (1 duff = 1000 credits), stored + /// as `Int64(bitPattern:)` like `PersistentIdentity.balance` because + /// SwiftData has no unsigned 64-bit column. `nil` = the name is not + /// listed for sale, which is distinct from a 0-credit listing. + public var priceCredits: Int64? + + /// Raw ``DpnsNameSaleStatus`` discriminant: 0 = owned, 1 = sold, + /// 2 = transferred. Defaults to 0 so existing rows migrate, so read + /// it through ``saleStatus`` rather than directly. + public var saleStatusRaw: Int16 = 0 + + /// Base58 id of the counterparty a departed name went to — the buyer + /// when `saleStatusRaw == 1`, the recipient when it is 2. `nil` while + /// the name is still owned (or the counterparty is unknown). + public var counterpartyIdBase58: String? + + /// Domain document `$createdAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + public var documentCreatedAtMs: UInt64? + + /// Domain document `$updatedAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + public var documentUpdatedAtMs: UInt64? + + /// Domain document `$transferredAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + public var documentTransferredAtMs: UInt64? + + /// Unix-millis timestamp of the sync pass / confirmed transition + /// that last wrote the marketplace fields. `0` = never written. + public var marketplaceUpdatedAt: UInt64 = 0 + // MARK: - Relationships /// Owning identity. Cascade-deleted from the parent — losing the @@ -94,7 +157,8 @@ public final class PersistentDPNSName { identity: PersistentIdentity, label: String, parentDomainName: String = "dash", - acquiredAt: UInt64 = 0 + acquiredAt: UInt64 = 0, + isOwned: Bool = true ) { self.identity = identity self.networkRaw = identity.networkRaw @@ -103,11 +167,74 @@ public final class PersistentDPNSName { self.parentDomainName = parentDomainName self.normalizedParentDomainName = Self.normalize(parentDomainName) self.acquiredAt = acquiredAt + self.isOwned = isOwned + // A freshly inserted row carries no marketplace state until the + // marketplace persister callback writes it — hence a nil document + // id, which is the "not tracked" signal the read contract above + // documents. + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 self.createdAt = Date() self.lastUpdated = Date() } } +// MARK: - Marketplace accessors + +extension PersistentDPNSName { + /// Typed view of the marketplace columns as the SDK's + /// ``DpnsNameSaleStatus``, or `nil` when this row carries no + /// trustworthy marketplace state. + /// + /// Prefer this over reading `saleStatusRaw` directly: it enforces the + /// read contract, so an untracked row (`documentIdBase58 == nil`) can + /// never be mistaken for an owned-and-unlisted one. It also returns + /// `nil` for a departed row whose counterparty id is missing or + /// undecodable — the wallet always attaches one for a sale or a + /// transfer, so its absence means the row is unreliable, not that the + /// name went nowhere. + /// + /// An unrecognized discriminant is likewise `nil`, never `.owned`: if + /// Rust's `DpnsNameSaleStatus` gains a variant, an older Swift build + /// must report the row as unreadable rather than claim a departed + /// name is still owned. + public var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + /// The departed name's counterparty as a 32-byte identifier, decoded + /// from `counterpartyIdBase58`. `nil` while the name is still owned, + /// or if the stored string doesn't decode. + public var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + /// Listed sale price in credits, or `nil` when the name is not + /// listed (or carries no mirrored marketplace state at all). + public var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + // MARK: - Normalization extension PersistentDPNSName { @@ -136,15 +263,16 @@ extension PersistentDPNSName { // MARK: - Queries extension PersistentDPNSName { - /// Predicate filtering all DPNS-label rows that belong to a - /// specific identity. Traverses the `identity` relationship to + /// Predicate filtering DPNS labels currently owned by a specific + /// identity. Retained sold/transferred history rows deliberately do not + /// match. Traverses the `identity` relationship to /// match its `identityId` — safe because the relationship is /// non-optional and SwiftData's predicate engine handles /// non-optional one-hop traversal cleanly. public static func predicate(identityId: Data) -> Predicate { let target = identityId return #Predicate { name in - name.identity.identityId == target + name.identity.identityId == target && name.isOwned == true } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift index 9ace0361b53..5611e654b2c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift @@ -84,14 +84,13 @@ public final class PersistentIdentity { @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) public var documents: [PersistentDocument] @Relationship(deleteRule: .nullify) public var tokenBalances: [PersistentTokenBalance] - /// Confirmed DPNS labels owned by this identity. Cascade-deleted - /// from the parent — losing the identity row drops the label - /// cache too. Append-only on the write path: the changeset's - /// merge policy never removes labels (DPNS doesn't expose a - /// user-driven "delete name" today), so the persister callback - /// only inserts new rows, never removes them. Predicates filter - /// by the denormalized `PersistentDPNSName.identityId` column, - /// not through this collection — see + /// Confirmed DPNS labels observed for this identity. Cascade-deleted from + /// the parent — losing the identity row drops the label cache and retained + /// marketplace history too. A name that leaves this wallet remains related + /// to its departed identity for history with + /// `PersistentDPNSName.isOwned == false`. A transfer to another identity in + /// the same wallet instead rebinds the schema's single unique-name row to + /// the current owner. Owned-name surfaces use /// `PersistentDPNSName.predicate(identityId:)`. @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) public var dpnsNames: [PersistentDPNSName] = [] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift new file mode 100644 index 00000000000..83d8ef7a367 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DpnsMarketplace.swift @@ -0,0 +1,819 @@ +import Foundation +import DashSDKFFI + +// MARK: - Value types + +/// A DPNS name read off Platform with its marketplace state: the domain +/// document id every trade transition needs, and the listed `$price`. +/// +/// Prices are **credits** (1 duff = 1000 credits). `priceCredits == nil` +/// means the name is NOT for sale — distinct from a 0-credit listing. +/// Timestamps are Unix milliseconds and `nil` when the document doesn't +/// carry them, so a UI shows "unknown" rather than the epoch. +public struct DpnsMarketplaceName: Sendable, Equatable { + /// The DPNS `domain` document id — stable across transfers and + /// purchases. + public let documentId: Data + /// The document's owner: the identity that owns (and may sell) the + /// name. + public let ownerId: Data + /// `records.identity` — the identity the name resolves to. The + /// protocol rewrites it to the new owner on purchase/transfer. + public let recordsIdentityId: Data? + /// Display label, e.g. "Alice". + public let label: String + /// Homograph-normalized label, e.g. "a11ce". + public let normalizedLabel: String + /// Listed sale price in credits. `nil` = not for sale. + public let priceCredits: UInt64? + /// Document `$createdAt` in Unix ms, when carried. + public let createdAtMs: UInt64? + /// Document `$updatedAt` in Unix ms — bumps on price changes. + public let updatedAtMs: UInt64? + /// Document `$transferredAt` in Unix ms — set on purchase/transfer. + public let transferredAtMs: UInt64? + + public init( + documentId: Data, + ownerId: Data, + recordsIdentityId: Data?, + label: String, + normalizedLabel: String, + priceCredits: UInt64?, + createdAtMs: UInt64?, + updatedAtMs: UInt64?, + transferredAtMs: UInt64? + ) { + self.documentId = documentId + self.ownerId = ownerId + self.recordsIdentityId = recordsIdentityId + self.label = label + self.normalizedLabel = normalizedLabel + self.priceCredits = priceCredits + self.createdAtMs = createdAtMs + self.updatedAtMs = updatedAtMs + self.transferredAtMs = transferredAtMs + } +} + +/// Where a tracked DPNS name stands relative to the wallet identity that +/// owned it. `Sold` / `Transferred` rows are retained (not deleted) so +/// the host can surface "your name was sold" affordances. +public enum DpnsNameSaleStatus: Sendable, Equatable { + /// The wallet identity still owns the name. + case owned + /// The name left through a purchase; the associated value is the + /// buyer. + case sold(to: Data) + /// The name left through a plain transfer (gift / off-market + /// handover); the associated value is the recipient. + case transferred(to: Data) +} + +/// One locally persisted marketplace row: a name tracked for a wallet +/// identity, with its last-known sale state. +/// +/// Unlike ``DpnsMarketplaceName`` this is the wallet's own bookkeeping +/// (no network read), so it names the wallet identity and — for names +/// that already left — the counterparty, rather than the live document's +/// owner. +public struct DpnsNameStateRow: Sendable, Equatable { + /// The DPNS `domain` document id — this row's key. + public let documentId: Data + /// The wallet identity this row is tracked for. For `.owned` rows the + /// current owner; otherwise the previous owner (ours). + public let walletIdentityId: Data + /// Display label, e.g. "Alice". + public let label: String + /// Homograph-normalized label, e.g. "a11ce". + public let normalizedLabel: String + /// Last-known listed price in credits. `nil` = not for sale. + public let priceCredits: UInt64? + /// Ownership status relative to `walletIdentityId`. + public let status: DpnsNameSaleStatus + /// Document `$createdAt` in Unix ms, when carried. + public let createdAtMs: UInt64? + /// Document `$updatedAt` in Unix ms, when carried. + public let updatedAtMs: UInt64? + /// Document `$transferredAt` in Unix ms, when carried. + public let transferredAtMs: UInt64? + /// Unix ms of the sync pass / confirmed transition that wrote this + /// row. + public let lastSyncedAtMs: UInt64 + + public init( + documentId: Data, + walletIdentityId: Data, + label: String, + normalizedLabel: String, + priceCredits: UInt64?, + status: DpnsNameSaleStatus, + createdAtMs: UInt64?, + updatedAtMs: UInt64?, + transferredAtMs: UInt64?, + lastSyncedAtMs: UInt64 + ) { + self.documentId = documentId + self.walletIdentityId = walletIdentityId + self.label = label + self.normalizedLabel = normalizedLabel + self.priceCredits = priceCredits + self.status = status + self.createdAtMs = createdAtMs + self.updatedAtMs = updatedAtMs + self.transferredAtMs = transferredAtMs + self.lastSyncedAtMs = lastSyncedAtMs + } +} + +/// One event in a DPNS name's trade timeline, assembled from the +/// Document History system contract plus the domain document's own +/// creation time. Prices are credits; `atMs` is Unix milliseconds. +public enum DpnsNameHistoryEvent: Sendable, Equatable { + /// The domain document was registered. + case registered(atMs: UInt64) + /// The owner listed or re-priced the name. + case priceSet(price: UInt64, atMs: UInt64, blockHeight: UInt64?) + /// The name was purchased: `seller` received `price` credits from + /// `buyer`, who became the owner. + case purchased(price: UInt64, seller: Data, buyer: Data, atMs: UInt64, blockHeight: UInt64?) + /// The name was transferred without payment — a gift/handover, or a + /// transfer-to-self delist when `from == to`. + case transferred(from: Data, to: Data, atMs: UInt64, blockHeight: UInt64?) + + /// Block time of the event in Unix ms, whatever the case. + public var atMs: UInt64 { + switch self { + case .registered(let atMs): + return atMs + case .priceSet(_, let atMs, _), .transferred(_, _, let atMs, _): + return atMs + case .purchased(_, _, _, let atMs, _): + return atMs + } + } +} + +/// Per-pass delta returned by +/// ``ManagedPlatformWallet/syncDpnsMarketplace()``. +public struct DpnsMarketplaceSyncSummary: Sendable, Equatable { + /// Owned-name rows refreshed this pass. + public let tracked: UInt32 + /// Labels newly observed on a wallet identity. + public let added: UInt32 + /// Names that left a wallet identity (sold or transferred away). + public let departed: UInt32 + /// Listed-price changes since the previous pass. + public let pricesChanged: UInt32 + /// Labels newly observed, with the wallet identity that owns each one. + public let addedNames: [DpnsNameAdded] + /// Names that left a wallet identity, including the classified destination + /// when the wallet could establish it. + public let departedNames: [DpnsNameDeparture] + /// Full before/after values for listings whose price changed. + public let priceChanges: [DpnsPriceChange] + /// Unix milliseconds when this pass completed. + public let syncUnixMs: UInt64 + + public init( + tracked: UInt32, + added: UInt32, + departed: UInt32, + pricesChanged: UInt32, + addedNames: [DpnsNameAdded] = [], + departedNames: [DpnsNameDeparture] = [], + priceChanges: [DpnsPriceChange] = [], + syncUnixMs: UInt64 = 0 + ) { + self.tracked = tracked + self.added = added + self.departed = departed + self.pricesChanged = pricesChanged + self.addedNames = addedNames + self.departedNames = departedNames + self.priceChanges = priceChanges + self.syncUnixMs = syncUnixMs + } +} + +/// One label newly observed by a marketplace sync pass. +public struct DpnsNameAdded: Sendable, Equatable { + public let identityId: Data + public let label: String + + public init(identityId: Data, label: String) { + self.identityId = identityId + self.label = label + } +} + +/// One name that left a wallet identity during marketplace sync. +public struct DpnsNameDeparture: Sendable, Equatable { + public let identityId: Data + public let label: String + public let documentId: Data? + /// `.sold` or `.transferred` when the destination was classifiable; + /// nil when the sweep could only establish that the name departed. + public let status: DpnsNameSaleStatus? + + public init( + identityId: Data, + label: String, + documentId: Data?, + status: DpnsNameSaleStatus? + ) { + self.identityId = identityId + self.label = label + self.documentId = documentId + self.status = status + } +} + +/// One listing-price change observed during marketplace sync. +public struct DpnsPriceChange: Sendable, Equatable { + public let documentId: Data + public let label: String + public let previousPriceCredits: UInt64? + public let currentPriceCredits: UInt64? + + public init( + documentId: Data, + label: String, + previousPriceCredits: UInt64?, + currentPriceCredits: UInt64? + ) { + self.documentId = documentId + self.label = label + self.previousPriceCredits = previousPriceCredits + self.currentPriceCredits = currentPriceCredits + } +} + +// MARK: - FFI decoding + +extension DpnsMarketplaceName { + /// Copy a Rust-owned row into an owned Swift value. Every `has_*` + /// flag gates its field: a `false` flag becomes `nil`, never the + /// zero the FFI struct happens to hold. Zero timestamps mean + /// "unknown" on this boundary and decode to `nil` for the same + /// reason. + /// + /// Must be called while the Rust allocation is still alive — the + /// label strings are copied here, not retained. + init(ffi: DpnsMarketplaceNameFFI) { + var documentTuple = ffi.document_id + var ownerTuple = ffi.owner_id + var recordsTuple = ffi.records_identity_id + self.init( + documentId: Swift.withUnsafeBytes(of: &documentTuple) { Data($0) }, + ownerId: Swift.withUnsafeBytes(of: &ownerTuple) { Data($0) }, + recordsIdentityId: ffi.has_records_identity + ? Swift.withUnsafeBytes(of: &recordsTuple) { Data($0) } + : nil, + label: ffi.label.map { String(cString: $0) } ?? "", + normalizedLabel: ffi.normalized_label.map { String(cString: $0) } ?? "", + priceCredits: ffi.has_price ? ffi.price : nil, + createdAtMs: ffi.created_at_ms == 0 ? nil : ffi.created_at_ms, + updatedAtMs: ffi.updated_at_ms == 0 ? nil : ffi.updated_at_ms, + transferredAtMs: ffi.transferred_at_ms == 0 ? nil : ffi.transferred_at_ms + ) + } +} + +extension DpnsNameStateRow { + /// Copy a Rust-owned persisted row into an owned Swift value. + /// + /// The status/counterparty pair is one invariant at this boundary: + /// owned rows must not carry a counterparty, while sold/transferred + /// rows must carry one. Unknown or inconsistent rows fail closed and + /// are omitted by the collection wrapper rather than being presented + /// as an ownership state this Swift build cannot establish. + init?(ffi: DpnsNameStateRowFFI) { + var documentTuple = ffi.document_id + var walletIdentityTuple = ffi.wallet_identity_id + var counterpartyTuple = ffi.counterparty_id + let counterparty: Data? = ffi.has_counterparty + ? Swift.withUnsafeBytes(of: &counterpartyTuple) { Data($0) } + : nil + let status: DpnsNameSaleStatus + switch (ffi.status, counterparty) { + case (0, .none): + status = .owned + case (1, .some(let to)): + status = .sold(to: to) + case (2, .some(let to)): + status = .transferred(to: to) + default: + return nil + } + self.init( + documentId: Swift.withUnsafeBytes(of: &documentTuple) { Data($0) }, + walletIdentityId: Swift.withUnsafeBytes(of: &walletIdentityTuple) { Data($0) }, + label: ffi.label.map { String(cString: $0) } ?? "", + normalizedLabel: ffi.normalized_label.map { String(cString: $0) } ?? "", + priceCredits: ffi.has_price ? ffi.price : nil, + status: status, + createdAtMs: ffi.created_at_ms == 0 ? nil : ffi.created_at_ms, + updatedAtMs: ffi.updated_at_ms == 0 ? nil : ffi.updated_at_ms, + transferredAtMs: ffi.transferred_at_ms == 0 ? nil : ffi.transferred_at_ms, + lastSyncedAtMs: ffi.last_synced_at_ms + ) + } +} + +extension DpnsNameAdded { + init(ffi: DpnsNameAddedFFI) { + var identityTuple = ffi.identity_id + self.init( + identityId: Swift.withUnsafeBytes(of: &identityTuple) { Data($0) }, + label: ffi.label.map { String(cString: $0) } ?? "" + ) + } +} + +extension DpnsNameDeparture { + init(ffi: DpnsNameDepartedFFI) { + var identityTuple = ffi.identity_id + var documentTuple = ffi.document_id + var counterpartyTuple = ffi.counterparty_id + let status: DpnsNameSaleStatus? + switch (ffi.has_status, ffi.status) { + case (true, 1): + status = .sold(to: Swift.withUnsafeBytes(of: &counterpartyTuple) { Data($0) }) + case (true, 2): + status = .transferred( + to: Swift.withUnsafeBytes(of: &counterpartyTuple) { Data($0) } + ) + default: + status = nil + } + self.init( + identityId: Swift.withUnsafeBytes(of: &identityTuple) { Data($0) }, + label: ffi.label.map { String(cString: $0) } ?? "", + documentId: ffi.has_document_id + ? Swift.withUnsafeBytes(of: &documentTuple) { Data($0) } + : nil, + status: status + ) + } +} + +extension DpnsPriceChange { + init(ffi: DpnsPriceChangeFFI) { + var documentTuple = ffi.document_id + self.init( + documentId: Swift.withUnsafeBytes(of: &documentTuple) { Data($0) }, + label: ffi.label.map { String(cString: $0) } ?? "", + previousPriceCredits: ffi.has_previous ? ffi.previous : nil, + currentPriceCredits: ffi.has_current ? ffi.current : nil + ) + } +} + +extension DpnsNameHistoryEvent { + /// Copy a Rust-owned timeline row into an owned Swift value. + /// Returns `nil` for a `kind` byte this build doesn't know, or for a + /// row missing a payload its kind requires — an unreadable event is + /// dropped from the timeline rather than rendered with invented + /// values. + init?(ffi: DpnsNameHistoryEventFFI) { + var fromTuple = ffi.from_id + var toTuple = ffi.to_id + let from: Data? = ffi.has_from + ? Swift.withUnsafeBytes(of: &fromTuple) { Data($0) } + : nil + let to: Data? = ffi.has_to ? Swift.withUnsafeBytes(of: &toTuple) { Data($0) } : nil + let blockHeight: UInt64? = ffi.has_block_height ? ffi.block_height : nil + let price: UInt64? = ffi.has_price ? ffi.price : nil + + switch ffi.kind { + case 0: + self = .registered(atMs: ffi.at_ms) + case 1: + guard let price else { return nil } + self = .priceSet(price: price, atMs: ffi.at_ms, blockHeight: blockHeight) + case 2: + guard let price, let from, let to else { return nil } + self = .purchased( + price: price, + seller: from, + buyer: to, + atMs: ffi.at_ms, + blockHeight: blockHeight + ) + case 3: + guard let from, let to else { return nil } + self = .transferred(from: from, to: to, atMs: ffi.at_ms, blockHeight: blockHeight) + default: + return nil + } + } +} + +// MARK: - ManagedPlatformWallet operations + +extension ManagedPlatformWallet { + /// Prefix-search DPNS names on Platform, with each hit's full + /// marketplace state (document id, owner, `$price`, timestamps). + /// + /// An empty `prefix` is a valid alphabetical browse. `limit == 0` + /// uses the wallet's default page size. `startAfter` is the cursor: + /// pass the previous page's last `documentId` to fetch the next page. + /// + /// There is no server-side price filter or ordering — `$price` is not + /// an indexable system property on Dash Platform, so a global + /// "everything for sale, cheapest first" query is not buildable at + /// any layer today. The marketplace is search-driven. + public func searchDpnsMarketplace( + prefix: String, + limit: UInt32 = 0, + startAfter: Data? = nil + ) async throws -> [DpnsMarketplaceName] { + let handle = self.handle + let cursorBytes = try Self.validatedOptionalIdentifier( + startAfter, + parameter: "startAfter" + ) + return try await Task.detached(priority: .userInitiated) { () -> [DpnsMarketplaceName] in + var outPtr: UnsafeMutablePointer? = nil + var outCount: UInt = 0 + let result = prefix.withCString { prefixPtr -> PlatformWalletFFIResult in + Self.withOptionalBytes(cursorBytes) { cursorPtr in + platform_wallet_dpns_marketplace_search( + handle, + prefixPtr, + limit, + cursorPtr, + &outPtr, + &outCount + ) + } + } + try result.check() + guard let ptr = outPtr, outCount > 0 else { return [] } + defer { dpns_marketplace_names_free(ptr, outCount) } + return (0..