From 4290e2b902075354e1d82b0af6e186b95bef2b30 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 18:17:24 -0500 Subject: [PATCH 01/15] feat(dashpay)!: support shielded tips with dedicated wallet accounts --- .../schema/v2/dashpay.schema.json | 8 + packages/dashpay-contract/src/v2/mod.rs | 3 +- .../example/ui/dashpay/DashPayJson.kt | 6 + .../ui/dashpay/DashPayProfileScreen.kt | 68 + .../example/ui/dashpay/DashPayTabScreen.kt | 16 + .../example/ui/dashpay/ShieldedTipSheet.kt | 116 + .../SearchWalletsForIdentitiesScreen.kt | 3 + .../ui/wallet/SendTransactionScreen.kt | 4 +- .../example/ui/wallet/WalletDetailScreen.kt | 4 +- .../12.json | 4192 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 58 +- .../dashsdk/ffi/DashpayNative.kt | 6 + .../dashsdk/ffi/FundingNative.kt | 11 + .../dashsdk/ffi/NativePersistenceBridge.kt | 14 +- .../dashsdk/persistence/DashDatabase.kt | 17 +- .../PlatformWalletPersistenceHandler.kt | 26 + .../entities/DashpayContactProfileEntity.kt | 3 + .../entities/DashpayProfileEntity.kt | 3 + .../dashsdk/services/ShieldedService.kt | 4 +- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 13 + .../dashsdk/tokens/PaymentAddressUpdate.kt | 8 + .../dashsdk/tokens/ShieldedTipRecipient.kt | 16 + .../tokens/ShieldedTipRecipientHistory.kt | 40 + .../dashsdk/wallet/PlatformWalletManager.kt | 20 + .../dashsdk/persistence/DashDatabaseTest.kt | 4 +- .../PlatformWalletPersistenceHandlerTest.kt | 21 + .../tokens/ShieldedTipRecipientHistoryTest.kt | 58 + .../src/execution/check_tx/v0/mod.rs | 10 +- .../v0/mod.rs | 59 +- .../batch/tests/document/deletion.rs | 5 +- .../batch/tests/document/replacement.rs | 26 +- .../rs-drive/tests/deterministic_root_hash.rs | 2 +- .../src/dashpay_profile.rs | 190 +- .../src/identity_persistence.rs | 136 +- .../rs-platform-wallet-ffi/src/persistence.rs | 90 +- .../src/shielded_send.rs | 184 + .../src/wallet_restore_types.rs | 11 +- .../V008__profile_address_encoding.rs | 7 + .../src/sqlite/schema/blob.rs | 3 +- .../src/sqlite/schema/dashpay.rs | 6 +- .../src/sqlite/schema/identities.rs | 25 +- .../schema/identity_profile_encoding.rs | 207 + .../src/sqlite/schema/mod.rs | 8 +- .../tests/sqlite_persist_roundtrip.rs | 1 + .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 79 + packages/rs-platform-wallet/src/lib.rs | 14 +- .../rs-platform-wallet/src/wallet/apply.rs | 1 + .../src/wallet/identity/mod.rs | 4 +- .../src/wallet/identity/network/profile.rs | 437 +- .../src/wallet/identity/types/dashpay/mod.rs | 2 +- .../wallet/identity/types/dashpay/profile.rs | 52 + .../src/wallet/identity/types/mod.rs | 3 +- .../src/wallet/platform_wallet.rs | 63 +- .../src/wallet/shielded/mod.rs | 2 + .../shielded/sync/memo_roundtrip_tests.rs | 30 + .../src/wallet/shielded/tips.rs | 185 + .../wallet/shielded/viewing_key_bind_tests.rs | 163 + .../rs-sdk/src/platform/dpns_usernames/mod.rs | 13 +- packages/rs-unified-sdk-jni/src/dashpay.rs | 36 +- packages/rs-unified-sdk-jni/src/funding.rs | 152 + .../rs-unified-sdk-jni/src/persistence.rs | 133 +- packages/rs-unified-sdk-jni/src/tokens.rs | 48 + .../Persistence/DashModelContainer.swift | 24 +- .../PersistentDashpayContactProfile.swift | 15 +- .../PersistentDashpayPaymentAddresses.swift | 60 + .../Models/PersistentDashpayProfile.swift | 13 + .../PlatformWallet/DashPayProfile.swift | 58 +- .../ManagedPlatformWallet.swift | 42 +- .../PlatformWalletManagerShieldedSync.swift | 85 + .../PlatformWalletPersistenceHandler.swift | 135 +- .../SwiftDashSDK/PlatformWallet/README.md | 27 + .../ShieldedTipRecipientHistory.swift | 32 + .../Core/Views/CoreContentView.swift | 2 +- .../Core/Views/SendTransactionView.swift | 2 +- .../Core/Views/WalletDetailView.swift | 2 +- .../Views/CreateIdentityView.swift | 2 +- .../Views/DashPay/DashPayProfileView.swift | 30 + .../Views/DashPay/DashPayTabView.swift | 111 +- .../Views/IdentityDetailView.swift | 47 +- .../SearchWalletsForIdentitiesView.swift | 3 + .../DashModelMigrationTests.swift | 106 +- .../DashPayPersistenceTests.swift | 110 +- 82 files changed, 7773 insertions(+), 262 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt create mode 100644 packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs create mode 100644 packages/rs-platform-wallet/docs/SHIELDED_TIPS.md create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/tips.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipRecipientHistory.swift diff --git a/packages/dashpay-contract/schema/v2/dashpay.schema.json b/packages/dashpay-contract/schema/v2/dashpay.schema.json index db5ce9b4dcd..74ea27bdf9c 100644 --- a/packages/dashpay-contract/schema/v2/dashpay.schema.json +++ b/packages/dashpay-contract/schema/v2/dashpay.schema.json @@ -74,6 +74,14 @@ "maxItems": 21, "description": "Platform address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by the 20-byte HASH160, i.e. RIPEMD160 of SHA256, of the public key or redeem script) for public payments. The type byte is consensus-enforced by a data trigger.", "position": 6 + }, + "shieldedAddress": { + "type": "array", + "byteArray": true, + "minItems": 43, + "maxItems": 43, + "description": "Raw Orchard receiving address: 11-byte diversifier followed by 32-byte diversified transmission key. Clients validate before payment; wallets should use a dedicated tip account.", + "position": 7 } }, "minProperties": 1, diff --git a/packages/dashpay-contract/src/v2/mod.rs b/packages/dashpay-contract/src/v2/mod.rs index 131fecb1769..a9611b407fb 100644 --- a/packages/dashpay-contract/src/v2/mod.rs +++ b/packages/dashpay-contract/src/v2/mod.rs @@ -3,7 +3,8 @@ use serde_json::Value; // Document-type name and property constants live in `crate::v1::document_types`; // v2 does not change any names v1 defined, it only adds the optional -// `corePaymentAddress` / `platformPaymentAddress` properties to `profile`. +// `corePaymentAddress`, `platformPaymentAddress`, and `shieldedAddress` +// properties to `profile`. pub fn load_documents_schemas() -> Result { serde_json::from_str(include_str!("../../schema/v2/dashpay.schema.json")) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt index cab86124046..274cdd57f01 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt @@ -23,6 +23,9 @@ data class DashPayProfile( val displayName: String?, val publicMessage: String?, val avatarUrl: String?, + val corePaymentAddress: String? = null, + val platformPaymentAddress: String? = null, + val shieldedAddress: String? = null, ) /** Parse a `getProfile` / `getContactProfile` JSON object, or null. */ @@ -32,6 +35,9 @@ fun parseDashPayProfile(json: String?): DashPayProfile? { displayName = obj.optStringOrNull("displayName"), publicMessage = obj.optStringOrNull("publicMessage"), avatarUrl = obj.optStringOrNull("avatarUrl"), + corePaymentAddress = obj.optStringOrNull("corePaymentAddress"), + platformPaymentAddress = obj.optStringOrNull("platformPaymentAddress"), + shieldedAddress = obj.optStringOrNull("shieldedAddress"), ) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt index 2f81559246e..6e85c35d62b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,14 +40,20 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import java.math.BigDecimal import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.tokens.PaymentAddressUpdate import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.ui.components.FormSection import org.dashfoundation.example.ui.components.LabeledContent import org.dashfoundation.example.ui.components.SubmitButton import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.DashAddress +import org.dashfoundation.example.util.DashAddressType import org.dashfoundation.example.util.generateQrBitmap import org.dashfoundation.example.util.hexToBytes @@ -72,7 +79,19 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val walletId = identity?.walletId val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + val tipAccount = remember(manager, identity?.identityIndex) { + identity?.identityIndex?.let { index -> runCatching { manager?.shieldedTipAccountIndex(index) }.getOrNull() } + } + val tipBalance by remember(walletId, tipAccount) { + if (walletId == null || tipAccount == null) flowOf(0L) + else container.database.shieldedDao().observeNotesByWalletAccount(walletId, tipAccount) + .map { notes -> notes.filter { !it.isSpent }.sumOf { it.value } } + }.collectAsStateWithLifecycle(initialValue = 0L) + var profile by remember { mutableStateOf(null) } + val publishedTipAddress = profile?.shieldedAddress?.let { raw -> + manager?.let { m -> runCatching { DashAddress.encodeOrchard(raw.hexToBytes(), m.network) }.getOrNull() } + } var profileExists by remember { mutableStateOf(false) } var qrUri by remember { mutableStateOf(null) } var qrError by remember { mutableStateOf(null) } @@ -87,6 +106,7 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController var displayNameField by remember { mutableStateOf("") } var publicMessageField by remember { mutableStateOf("") } var avatarUrlField by remember { mutableStateOf("") } + var shieldedAddressField by remember { mutableStateOf("") } var isSaving by remember { mutableStateOf(false) } var saveError by remember { mutableStateOf(null) } @@ -132,6 +152,7 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController displayNameField = profile?.displayName.orEmpty() publicMessageField = profile?.publicMessage.orEmpty() avatarUrlField = profile?.avatarUrl.orEmpty() + shieldedAddressField = publishedTipAddress.orEmpty() saveError = null } isEditing = !isEditing @@ -172,6 +193,31 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController label = { Text("Avatar URL") }, singleLine = true, ) + TextButton(enabled = !isSaving && container.shieldedService.isAvailable, onClick = { + val m = manager ?: return@TextButton + val wid = walletId ?: return@TextButton + isSaving = true + saveError = null + scope.launch { + try { + shieldedAddressField = requireNotNull(DashAddress.encodeOrchard(m.prepareShieldedTipAddress(wid, idBytes), m.network)) + } catch (e: Exception) { + saveError = e.message ?: "Could not prepare tip account" + } finally { + isSaving = false + } + } + }, modifier = Modifier.testTag("dashpay.profile.useTipAccount")) { + Text("Use this wallet’s dedicated tip account") + } + Text("The address is published only when you save.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = shieldedAddressField, + onValueChange = { shieldedAddressField = it }, + modifier = Modifier.fillMaxWidth().testTag("dashpay.profile.shieldedAddress"), + label = { Text("Shielded tip address") }, + supportingText = { Text("Paste an external receiving address, or leave blank to disable tips. External funds are managed by the receiving wallet.") }, + ) saveError?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } @@ -194,6 +240,15 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController avatarUrl = avatarUrlField.trim().ifEmpty { null }, doCreate = !profileExists, signerHandle = m.signerHandle, + shieldedAddress = when (val address = shieldedAddressField.trim()) { + publishedTipAddress.orEmpty() -> PaymentAddressUpdate.Keep + "" -> PaymentAddressUpdate.Remove + else -> { + val parsed = DashAddress.parse(address, m.network) as? DashAddressType.Orchard + ?: throw IllegalArgumentException("Enter a shielded address for this network") + PaymentAddressUpdate.Set(parsed.raw43) + } + }, ) loadProfile() isEditing = false @@ -224,6 +279,19 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController } } + if (!isEditing) { + FormSection(title = "Shielded tips") { + Text("This wallet’s tip balance: ${BigDecimal.valueOf(tipBalance, 11).stripTrailingZeros().toPlainString()} DASH") + val address = publishedTipAddress + if (address == null) { + Text("Tips are not enabled.") + } else { + SelectionContainer { Text(address, style = MaterialTheme.typography.bodySmall) } + Text("This receiving address is public and associated with your username. Removing it does not revoke previously shared copies.", style = MaterialTheme.typography.bodySmall) + } + } + } + FormSection(title = "Identity") { Text( Base58.encode(idBytes), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 983beec2f0e..fdc1d6e6724 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -159,6 +159,7 @@ fun DashPayTabScreen(navController: NavHostController) { val appUiState = container.appUiState var claimSheetUri by remember { mutableStateOf(null) } var showClaimSheet by remember { mutableStateOf(false) } + var showTipSheet by remember { mutableStateOf(false) } val pendingInvite by appUiState.pendingInviteUri.collectAsStateWithLifecycle() val claimInFlight by appUiState.invitationClaimInFlight.collectAsStateWithLifecycle() // The parked URI is NOT cleared at seeding: it stays in AppUiState (the @@ -320,7 +321,22 @@ fun DashPayTabScreen(navController: NavHostController) { onError = { unlockError = it }, ) + val tipManager = manager + val tipWalletId = identity.walletId + if (showTipSheet && managed != null && tipManager != null && tipWalletId != null) { + ModalBottomSheet(onDismissRequest = { showTipSheet = false }) { + ShieldedTipSheet(tipManager, managed, tipWalletId, tipManager.shieldedTipAccountIndex(identity.identityIndex)) + } + } FormSection(title = "DashPay") { + if (container.shieldedService.isAvailable) { + EntityRow( + icon = Icons.AutoMirrored.Filled.Send, + title = "Send shielded tip", + onClick = { showTipSheet = true }, + modifier = Modifier.testTag("dashpay.sendShieldedTip"), + ) + } EntityRow( icon = Icons.Default.Group, title = "Contacts", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt new file mode 100644 index 00000000000..f693aac66c2 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt @@ -0,0 +1,116 @@ +package org.dashfoundation.example.ui.dashpay + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.LocalContext +import androidx.compose.ui.unit.dp +import java.math.BigDecimal +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient +import org.dashfoundation.dashsdk.tokens.ShieldedTipRecipientHistory +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet +import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.DashAddress + +/** Username tipping uses the same verified recipient and confirmation boundary as Swift. */ +@Composable +fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWallet, walletId: ByteArray, tipAccount: Int) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val history = remember(context) { + ShieldedTipRecipientHistory(context.getSharedPreferences("dashpay.tipRecipients", Context.MODE_PRIVATE)) + } + var changedRecipient by remember { mutableStateOf(null) } + var username by remember { mutableStateOf("") } + var amount by remember { mutableStateOf("") } + var recipient by remember { mutableStateOf(null) } + var confirmedAmount by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var submitted by remember { mutableStateOf(false) } + var spendTips by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + changedRecipient?.let { changed -> + AlertDialog( + onDismissRequest = { changedRecipient = null }, + title = { Text("Tip recipient changed") }, + text = { Text("The identity or shielded address for this username differs from your previous confirmation. Verify the change with the recipient before continuing.") }, + confirmButton = { + TextButton(onClick = { recipient = changed; changedRecipient = null }) { Text("Review new recipient") } + }, + dismissButton = { + TextButton(onClick = { changedRecipient = null }) { Text("Cancel") } + }, + ) + } + + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Send a shielded tip") + OutlinedTextField(username, { username = it; recipient = null }, label = { Text("Username") }, enabled = !busy && !submitted) + OutlinedTextField(amount, { amount = it; recipient = null }, label = { Text("Amount (DASH)") }, enabled = !busy && !submitted) + Row { + Checkbox(checked = spendTips, enabled = !busy && !submitted, + onCheckedChange = { spendTips = it; recipient = null }) + Text("Spend from my dedicated tip account") + } + recipient?.let { + Text("Recipient: ${Base58.encode(it.identityId)}") + DashAddress.encodeOrchard(it.address, manager.network)?.let { address -> + SelectionContainer { Text(address) } + } + Text("Send $amount DASH from your ${if (spendTips) "tip" else "main shielded"} account to $username?") + } + message?.let { Text(it) } + SubmitButton( + text = if (recipient == null) "Review tip" else "Confirm and send", + isLoading = busy, enabled = !busy && !submitted && changedRecipient == null, modifier = Modifier.fillMaxWidth(), + ) { + busy = true + message = null + scope.launch { + try { + val selected = recipient + if (selected == null) { + val credits = BigDecimal(amount.trim()).movePointRight(11).longValueExact() + require(credits > 0) { "Enter a positive amount" } + confirmedAmount = credits + val resolved = wallet.dashpay.resolveShieldedTip(username.trim()) + if (history.hasChanged(manager.network, walletId, username, resolved)) { + changedRecipient = resolved + } else { + recipient = resolved + } + } else { + // No retry button after submission: ambiguous broadcast outcomes require sync. + history.confirm(manager.network, walletId, username, selected) + submitted = true + manager.sendShieldedTip(walletId, username.trim(), selected, requireNotNull(confirmedAmount), account = if (spendTips) tipAccount else 0) + message = "Shielded tip sent." + } + } catch (e: Exception) { + message = e.message ?: "Unable to send tip" + recipient = null + } finally { busy = false } + } + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt index a92013c0bd8..f0bd2c54cd5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt @@ -140,6 +140,9 @@ fun SearchWalletsForIdentitiesScreen(navController: NavHostController) { walletHandle = wallet.handle, mnemonicResolverHandle = mgr.mnemonicResolverHandle, ) + if (container.shieldedService.isAvailable && found.isNotEmpty()) { + mgr.bindShielded(wallet.walletId) + } summary = "Found ${found.size} identity(ies)." if (found.isEmpty()) { previewPaths = mgr.identityRegistration.previewRegistrationKeys( diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 5656d273640..25adcbc5d91 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -197,8 +197,8 @@ fun SendTransactionScreen( val hasShielded = remember { Sdk.hasShielded() } val shieldedBalance by remember(walletIdHex) { if (hasShielded) { - container.database.shieldedDao().observeUnspentNotesByWallet(walletId) - .map { notes -> notes.sumOf { it.value } } + container.database.shieldedDao().observeNotesByWalletAccount(walletId, 0) + .map { notes -> notes.filter { !it.isSpent }.sumOf { it.value } } } else { MutableStateFlow(0L) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletDetailScreen.kt index 50b42461858..1ea3555b1b0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/WalletDetailScreen.kt @@ -129,8 +129,8 @@ fun WalletDetailScreen( val hasShielded = remember { Sdk.hasShielded() } val shieldedBalance by remember(walletIdHex) { if (hasShielded) { - container.database.shieldedDao().observeUnspentNotesByWallet(walletId) - .map { notes -> notes.sumOf { it.value } } + container.database.shieldedDao().observeNotesByWalletAccount(walletId, 0) + .map { notes -> notes.filter { !it.isSpent }.sumOf { it.value } } } else { MutableStateFlow(0L) } diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json new file mode 100644 index 00000000000..b00de9abc58 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json @@ -0,0 +1,4192 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "099a0638297ed00c932013944578313f", + "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, `lastAppliedChainLockHeight` INTEGER, `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": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "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, `supersededByTxid` BLOB, 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" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "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, `corePaymentAddress` BLOB, `platformPaymentAddress` BLOB, `shieldedAddress` 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": "corePaymentAddress", + "columnName": "corePaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "platformPaymentAddress", + "columnName": "platformPaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "shieldedAddress", + "columnName": "shieldedAddress", + "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, `corePaymentAddress` BLOB, `platformPaymentAddress` BLOB, `shieldedAddress` 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": "corePaymentAddress", + "columnName": "corePaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "platformPaymentAddress", + "columnName": "platformPaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "shieldedAddress", + "columnName": "shieldedAddress", + "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, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, 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 + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "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`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "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, '099a0638297ed00c932013944578313f')" + ] + } +} \ 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 ef90b3804a1..bcf28f8455b 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 @@ -28,6 +28,56 @@ class DashDatabaseMigrationTest { private val dbName = "migration-test.db" + @Test + fun migrate11To12PreservesProfilesAndSweepState() { + migrateProfilesAndSweepState(11) + } + + @Test + fun migrate10To12PreservesProfilesThroughBothMigrations() { + migrateProfilesAndSweepState(10) + } + + private fun migrateProfilesAndSweepState(fromVersion: Int) { + helper.createDatabase(dbName, fromVersion).apply { + execSQL("INSERT INTO identities (identityId, balance, revision, isLocal, identityType, createdAt, lastUpdated, networkRaw, identityIndex) VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0)") + execSQL("INSERT INTO dashpay_profiles (networkRaw, identityId, displayName, createdAt, lastUpdated) VALUES (1, x'0A', 'Alice', 0, 0)") + execSQL("INSERT INTO dashpay_contact_profiles (networkRaw, ownerIdentityId, contactIdentityId, displayName, checkedAtMs, createdAt, lastUpdated) VALUES (1, x'0A', x'0B', 'Bob', 123, 0, 0)") + execSQL("INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, syncedHeight, lastSynced, isImported, createdAt, lastUpdated) VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)") + execSQL("INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, walletId, createdAt) VALUES (x'0301', 0, x'02', x'01', 0)") + if (fromVersion == 11) { + execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321") + execSQL("UPDATE pending_inputs SET isSweptTombstone = 1, winnerMinedHeight = 1234") + } + close() + } + val db = helper.runMigrationsAndValidate( + dbName, 12, true, DashDatabase.MIGRATION_10_11, DashDatabase.MIGRATION_11_12, + ) + for ((table, name) in listOf("dashpay_profiles" to "Alice", "dashpay_contact_profiles" to "Bob")) { + db.query("SELECT displayName, corePaymentAddress, platformPaymentAddress, shieldedAddress FROM $table").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(name, cursor.getString(0)) + assertTrue(cursor.isNull(1) && cursor.isNull(2) && cursor.isNull(3)) + } + db.execSQL("UPDATE $table SET shieldedAddress = ?", arrayOf(ByteArray(43) { 0x45 })) + db.query("SELECT shieldedAddress FROM $table").use { cursor -> + assertTrue(cursor.moveToFirst()) + org.junit.Assert.assertArrayEquals(ByteArray(43) { 0x45 }, cursor.getBlob(0)) + } + } + db.query("SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(if (fromVersion == 11) 1 else 0, cursor.getInt(0)) + if (fromVersion == 11) assertEquals(1234, cursor.getInt(1)) else assertTrue(cursor.isNull(1)) + } + db.query("SELECT lastAppliedChainLockHeight FROM wallets").use { cursor -> + assertTrue(cursor.moveToFirst()) + if (fromVersion == 11) assertEquals(4321, cursor.getInt(0)) else assertTrue(cursor.isNull(0)) + } + db.close() + } + /** * v2 → v3 adds the `dashpay_contact_profiles` and `dashpay_payments` * tables (additive — no reshapes). Pre-existing v2 data must survive @@ -476,7 +526,7 @@ class DashDatabaseMigrationTest { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 11, + 12, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -485,16 +535,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).close() } - /** The full chain from v1 must also land on a valid v11 schema. */ + /** The full chain from v1 must also land on a valid v12 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 11, + 12, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -506,6 +557,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 1144dad7add..612ebd5e612 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -139,6 +139,12 @@ internal object DashpayNative { avatarBytes: ByteArray?, doCreate: Boolean, signerHandle: Long, + coreAddressAction: Int, + coreAddress: ByteArray?, + platformAddressAction: Int, + platformAddress: ByteArray?, + shieldedAddressAction: Int, + shieldedAddress: ByteArray?, ): String? /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index c1f9a6803d5..5e177af5082 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -16,6 +16,17 @@ package org.dashfoundation.dashsdk.ffi * non-shielded build throws [UnsatisfiedLinkError]. */ internal object FundingNative { + external fun tipAccountIndex(identityIndex: Int): Int + external fun prepareShieldedTipAddress( + manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray, + ): ByteArray + external fun resolveShieldedTip(wallet: Long, username: String): ByteArray + external fun sendShieldedTip( + manager: Long, walletId: ByteArray, resolver: Long, account: Int, + username: String, expectedId: ByteArray, expectedAddress: ByteArray, + amount: Long, memo: String?, + ) + /** Kick the ~30s Halo 2 proving-key build onto a background thread. Idempotent. */ external fun warmUpProver() 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 d199a07ee36..c404d00ef65 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 @@ -402,7 +402,7 @@ abstract class NativePersistenceBridge { /** * One `IdentityEntryFFI` upsert. DPNS labels + acquired-at timestamps * ride as parallel arrays. Descriptor - * `([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;)I`. + * `([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I`. */ @Suppress("LongParameterList") open fun onPersistIdentityUpsert( @@ -426,6 +426,9 @@ abstract class NativePersistenceBridge { dashpayAvatarFingerprint: ByteArray, dashpayAvatarFingerprintPresent: Boolean, dashpayPublicMessage: String?, + dashpayCorePaymentAddress: ByteArray? = null, + dashpayPlatformPaymentAddress: ByteArray? = null, + dashpayShieldedAddress: ByteArray? = null, ): Int = 0 /** One identity-id removal. Descriptor `([B[B)I`. */ @@ -573,7 +576,7 @@ abstract class NativePersistenceBridge { /** * One `ContactProfileRowFFI` delta riding an identity upsert * (`IdentityEntryFFI.contact_profiles`). Descriptor - * `([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;J)I`. + * `([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;J[B[B[B)I`. * * [isPresent] `true` ⇒ upsert the cached contact-profile row for * `(ownerId, contactId)`; `false` ⇒ tombstone — the contact removed @@ -597,6 +600,9 @@ abstract class NativePersistenceBridge { avatarFingerprintPresent: Boolean, publicMessage: String?, checkedAtMs: Long, + corePaymentAddress: ByteArray? = null, + platformPaymentAddress: ByteArray? = null, + shieldedAddress: ByteArray? = null, ): Int = 0 // ── Asset locks ─────────────────────────────────────────────────── @@ -1139,6 +1145,7 @@ class IdentityRestoreData( * re-fetches every contact. */ @JvmField val contactProfiles: Array, + @JvmField val dashpayProfile: ContactProfileRestoreData? = null, ) /** @@ -1171,6 +1178,9 @@ class ContactProfileRestoreData( @JvmField val avatarFingerprint: ByteArray?, @JvmField val publicMessage: String?, @JvmField val checkedAtMs: Long, + @JvmField val corePaymentAddress: ByteArray? = null, + @JvmField val platformPaymentAddress: ByteArray? = null, + @JvmField val shieldedAddress: ByteArray? = null, ) /** 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 822c08a242a..fd6032f9c56 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 @@ -142,9 +142,12 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * pre-migration row reads back as an ordinary, unstamped, non-tombstone * entry, and a wallet with no recorded chainlock height has no boundary * at all (nothing collects). + * + * Version 12 adds nullable Core, Platform, and shielded payment addresses + * to cached owner and contact profiles, preserving the version-11 sweep state. */ @Database( - version = 11, + version = 12, exportSchema = true, entities = [ WalletEntity::class, @@ -579,6 +582,17 @@ abstract class DashDatabase : RoomDatabase() { } } + /** v11 → v12: optional public payment addresses on cached profiles. */ + val MIGRATION_11_12: Migration = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + for (table in listOf("dashpay_profiles", "dashpay_contact_profiles")) { + for (column in listOf("corePaymentAddress", "platformPaymentAddress", "shieldedAddress")) { + db.execSQL("ALTER TABLE `$table` ADD COLUMN `$column` BLOB") + } + } + } + } + /** * v10 → v11: the four additive sweep-hold columns and the two * `pending_inputs` indexes — see the version-11 class doc above. @@ -635,6 +649,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, + MIGRATION_11_12, ) .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 d68d1758f93..58719c447b9 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 @@ -1626,6 +1626,9 @@ class PlatformWalletPersistenceHandler( dashpayAvatarFingerprint: ByteArray, dashpayAvatarFingerprintPresent: Boolean, dashpayPublicMessage: String?, + dashpayCorePaymentAddress: ByteArray?, + dashpayPlatformPaymentAddress: ByteArray?, + dashpayShieldedAddress: ByteArray?, ): Int = guarded { stage(walletId) { db -> val ownerWallet = if (walletIdIsSome) identityWalletId else walletId @@ -1740,6 +1743,9 @@ class PlatformWalletPersistenceHandler( avatarHash = if (dashpayAvatarHashPresent) dashpayAvatarHash else null, avatarFingerprint = if (dashpayAvatarFingerprintPresent) dashpayAvatarFingerprint else null, + corePaymentAddress = dashpayCorePaymentAddress, + platformPaymentAddress = dashpayPlatformPaymentAddress, + shieldedAddress = dashpayShieldedAddress, lastUpdated = now(), ), ) @@ -2185,6 +2191,9 @@ class PlatformWalletPersistenceHandler( avatarFingerprintPresent: Boolean, publicMessage: String?, checkedAtMs: Long, + corePaymentAddress: ByteArray?, + platformPaymentAddress: ByteArray?, + shieldedAddress: ByteArray?, ): Int = guarded { stage(walletId) { db -> // Owner identity must exist (networkRaw is read off it). In the @@ -2207,6 +2216,9 @@ class PlatformWalletPersistenceHandler( avatarUrl = avatarUrl, avatarHash = avatarHash.takeIf { avatarHashPresent }, avatarFingerprint = avatarFingerprint.takeIf { avatarFingerprintPresent }, + corePaymentAddress = corePaymentAddress, + platformPaymentAddress = platformPaymentAddress, + shieldedAddress = shieldedAddress, checkedAtMs = checkedAtMs, lastUpdated = now(), ), @@ -2940,6 +2952,9 @@ class PlatformWalletPersistenceHandler( avatarFingerprint = cp.avatarFingerprint, publicMessage = cp.publicMessage, checkedAtMs = cp.checkedAtMs, + corePaymentAddress = cp.corePaymentAddress, + platformPaymentAddress = cp.platformPaymentAddress, + shieldedAddress = cp.shieldedAddress, ) }.toTypedArray() IdentityRestoreData( @@ -2956,6 +2971,17 @@ class PlatformWalletPersistenceHandler( ignoredSenders = ignoredRows, payments = paymentRows, contactProfiles = contactProfileRows, + dashpayProfile = database.dashpayDao().getProfile(idRow.networkRaw, idRow.identityId)?.let { p -> + ContactProfileRestoreData( + contactId = idRow.identityId, + displayName = p.displayName, bio = p.bio, avatarUrl = p.avatarUrl, + avatarHash = p.avatarHash, avatarFingerprint = p.avatarFingerprint, + publicMessage = p.publicMessage, checkedAtMs = 0, + corePaymentAddress = p.corePaymentAddress, + platformPaymentAddress = p.platformPaymentAddress, + shieldedAddress = p.shieldedAddress, + ) + }, ) }.toTypedArray() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt index bb72b816457..669f5274936 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt @@ -67,6 +67,9 @@ data class DashpayContactProfileEntity( val avatarHash: ByteArray? = null, /** 8-byte perceptual hash. */ val avatarFingerprint: ByteArray? = null, + val corePaymentAddress: ByteArray? = null, + val platformPaymentAddress: ByteArray? = null, + val shieldedAddress: ByteArray? = null, /** * Wall-clock ms of the last fetch attempt on the Rust side * (`ContactProfileEntry.checked_at_ms`) — drives the self-heal diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt index 6d8a8806839..a956ef22cc8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt @@ -44,6 +44,9 @@ data class DashpayProfileEntity( val avatarHash: ByteArray? = null, /** 8-byte perceptual hash. */ val avatarFingerprint: ByteArray? = null, + val corePaymentAddress: ByteArray? = null, + val platformPaymentAddress: ByteArray? = null, + val shieldedAddress: ByteArray? = null, val createdAt: Date = Date(), val lastUpdated: Date = Date(), ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt index 1dfb441d1b0..0733d30a557 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt @@ -208,7 +208,9 @@ class ShieldedService(private val database: DashDatabase) { _state.value = ShieldedSyncState() _shieldedBalance.value = database.shieldedDao() .observeUnspentNotesByWallet(walletId) - .map { notes -> notes.sumOf { it.value } } + // Rust also scans dedicated tip accounts; ordinary wallet balances only + // include the accounts selected by this service's caller. + .map { notes -> notes.filter { it.accountIndex in sortedAccounts }.sumOf { it.value } } try { manager.configureShielded(dbPath) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 62eb7d33457..504481bd847 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -37,6 +37,13 @@ class Dashpay internal constructor(private val walletHandle: Long, private val gate: org.dashfoundation.dashsdk.wallet.TeardownGate? = null, ) { + /** Fetch and validate the current recipient through proof-verified DPNS and profile reads. */ + suspend fun resolveShieldedTip(username: String): ShieldedTipRecipient = gate.op { + val bytes = mapNativeErrors { org.dashfoundation.dashsdk.ffi.FundingNative.resolveShieldedTip(walletHandle, username) } + check(bytes.size == 75) { "Invalid recipient response" } + ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75)) + } + /** * Send a contact request to [recipientIdentityId], signing the document * state-transition with [signerHandle] and keying the contact crypto @@ -476,11 +483,17 @@ class Dashpay internal constructor(private val walletHandle: Long, avatarBytes: ByteArray? = null, doCreate: Boolean, signerHandle: Long, + corePaymentAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, + platformPaymentAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, + shieldedAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, ): String? = gate.op { mapNativeErrors { DashpayNative.createOrUpdateProfile( walletHandle, identityId, displayName, publicMessage, avatarUrl, avatarBytes, doCreate, signerHandle, + corePaymentAddress.action, corePaymentAddress.bytes, + platformPaymentAddress.action, platformPaymentAddress.bytes, + shieldedAddress.action, shieldedAddress.bytes, ) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt new file mode 100644 index 00000000000..2284dfa2b0c --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt @@ -0,0 +1,8 @@ +package org.dashfoundation.dashsdk.tokens + +/** Explicit partial-update semantics for a public profile payment address. */ +sealed class PaymentAddressUpdate(internal val action: Int, internal val bytes: ByteArray?) { + data object Keep : PaymentAddressUpdate(0, null) + class Set(bytes: ByteArray) : PaymentAddressUpdate(1, bytes.copyOf()) + data object Remove : PaymentAddressUpdate(2, null) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt new file mode 100644 index 00000000000..5ba1d253d31 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt @@ -0,0 +1,16 @@ +package org.dashfoundation.dashsdk.tokens + +/** Verified recipient snapshot to bind payment confirmation to an identity and address. */ +class ShieldedTipRecipient(identityId: ByteArray, address: ByteArray) { + private val identityBytes = identityId.copyOf() + private val addressBytes = address.copyOf() + val identityId: ByteArray get() = identityBytes.copyOf() + val address: ByteArray get() = addressBytes.copyOf() + init { + require(identityBytes.size == 32) + require(addressBytes.size == 43) + } + override fun equals(other: Any?): Boolean = other is ShieldedTipRecipient && + identityBytes.contentEquals(other.identityBytes) && addressBytes.contentEquals(other.addressBytes) + override fun hashCode(): Int = 31 * identityBytes.contentHashCode() + addressBytes.contentHashCode() +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt new file mode 100644 index 00000000000..ad29af2cf00 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt @@ -0,0 +1,40 @@ +package org.dashfoundation.dashsdk.tokens + +import android.content.SharedPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.persistence.normalizeDpnsLabel + +/** + * Local record of recipients explicitly confirmed by this wallet's user. + * Mirrors the Swift tip sheet's persistent recipient-change warning. This + * record only informs confirmation; Rust still verifies the recipient before sending. + */ +class ShieldedTipRecipientHistory(private val preferences: SharedPreferences) { + fun hasChanged( + network: Network, walletId: ByteArray, username: String, recipient: ShieldedTipRecipient, + ): Boolean { + val previous = preferences.getString(key(network, walletId, username), null) ?: return false + return previous != snapshot(recipient) + } + + /** Call only after explicit confirmation, before submitting the payment. */ + suspend fun confirm( + network: Network, walletId: ByteArray, username: String, recipient: ShieldedTipRecipient, + ) = withContext(Dispatchers.IO) { + check(preferences.edit().putString(key(network, walletId, username), snapshot(recipient)).commit()) { + "Could not save recipient confirmation" + } + } + + private fun key(network: Network, walletId: ByteArray, username: String): String { + val label = username.trim().lowercase().removeSuffix(".dash") + return "${network.ffiValue}:${walletId.hex()}:${normalizeDpnsLabel(label)}.dash" + } + + private fun snapshot(recipient: ShieldedTipRecipient): String = + "${recipient.identityId.hex()}:${recipient.address.hex()}" + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } +} 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 aba0c4ceb2d..75f0dc41e17 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 @@ -1768,6 +1768,26 @@ class PlatformWalletManager( // — the caller must NOT retry (the spent notes stay reserved Rust-side; // the next shielded sync reconciles the outcome). + /** Dedicated account index derived by Rust's wallet convention. */ + fun shieldedTipAccountIndex(identityIndex: Int): Int = mapNativeErrors { FundingNative.tipAccountIndex(identityIndex) } + + suspend fun prepareShieldedTipAddress(walletId: ByteArray, identityId: ByteArray): ByteArray = teardownGate.op { + mapNativeErrors { FundingNative.prepareShieldedTipAddress(managerHandle, walletId, mnemonicResolver.nativeHandle, identityId) } + } + + /** Send only if fresh resolution still matches the recipient shown in confirmation. */ + suspend fun sendShieldedTip( + walletId: ByteArray, username: String, + recipient: org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient, + amount: Long, account: Int = 0, memo: String? = null, + ): Unit = teardownGate.op { + require(amount > 0 && account >= 0) + mapNativeErrors { + FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, + account, username, recipient.identityId, recipient.address, amount, memo) + } + } + /** * Shielded → shielded transfer (Type 16) — port of Swift's * `PlatformWalletManager.shieldedTransfer(walletId:account:recipientRaw43:amount:memo:)` diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 524f6d057e8..3018c163edb 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,12 +233,12 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion11WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion12WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. - assertEquals(11, db.openHelper.readableDatabase.version) + assertEquals(12, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") 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 e9f941bcfd1..7ae21c3c336 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 @@ -905,6 +905,9 @@ class PlatformWalletPersistenceHandlerTest { dashpayAvatarFingerprint = ByteArray(8), dashpayAvatarFingerprintPresent = false, dashpayPublicMessage = "hi", + dashpayCorePaymentAddress = ByteArray(21) { 1 }, + dashpayPlatformPaymentAddress = ByteArray(21) { 2 }, + dashpayShieldedAddress = ByteArray(43) { 3 }, ) handler.onChangesetEnd(walletId, success = true) @@ -923,6 +926,9 @@ class PlatformWalletPersistenceHandlerTest { assertNotNull(profile) assertEquals("Alice", profile!!.displayName) assertEquals("hi", profile.publicMessage) + assertTrue(ByteArray(21) { 1 }.contentEquals(profile.corePaymentAddress)) + assertTrue(ByteArray(21) { 2 }.contentEquals(profile.platformPaymentAddress)) + assertTrue(ByteArray(43) { 3 }.contentEquals(profile.shieldedAddress)) assertNotNull(profile.avatarHash) assertNull(profile.avatarFingerprint) } @@ -4700,6 +4706,9 @@ class PlatformWalletPersistenceHandlerTest { avatarFingerprintPresent = false, publicMessage = "yo", checkedAtMs = 1_700_000_111_000, + corePaymentAddress = ByteArray(21) { 1 }, + platformPaymentAddress = ByteArray(21) { 2 }, + shieldedAddress = ByteArray(43) { 3 }, ) handler.onChangesetEnd(walletId, success = true) } @@ -4828,9 +4837,18 @@ class PlatformWalletPersistenceHandlerTest { ), ) + db.dashpayDao().upsertProfile( + org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity( + networkRaw = testnet, identityId = ownerId, + displayName = "Alice", shieldedAddress = ByteArray(43) { 7 }, + ), + ) val list = handler.onLoadWalletList() assertEquals(1, list.size) val identity = list[0].identities.single() + assertEquals("Alice", identity.dashpayProfile?.displayName) + assertTrue(ByteArray(43) { 7 }.contentEquals(identity.dashpayProfile?.shieldedAddress)) + assertEquals(1, identity.payments.size) val payment = identity.payments[0] @@ -4843,6 +4861,9 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(1, identity.contactProfiles.size) val profile = identity.contactProfiles[0] + assertTrue(ByteArray(21) { 1 }.contentEquals(profile.corePaymentAddress)) + assertTrue(ByteArray(21) { 2 }.contentEquals(profile.platformPaymentAddress)) + assertTrue(ByteArray(43) { 3 }.contentEquals(profile.shieldedAddress)) assertTrue(contactId.contentEquals(profile.contactId)) assertEquals("Bob", profile.displayName) assertNull(profile.bio) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt new file mode 100644 index 00000000000..a5ebd51c380 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.dashsdk.tokens + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.Network +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ShieldedTipRecipientHistoryTest { + private val preferences = ApplicationProvider.getApplicationContext() + .getSharedPreferences("tip-recipient-history-test", Context.MODE_PRIVATE) + private val walletId = ByteArray(32) { 1 } + private val recipient = ShieldedTipRecipient(ByteArray(32) { 2 }, ByteArray(43) { 3 }) + private val history get() = ShieldedTipRecipientHistory(preferences) + + @Before + fun reset() { + preferences.edit().clear().commit() + } + + @Test + fun confirmationSurvivesReopeningAndCanonicalSpellings() = runTest { + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Alice", recipient)) + history.confirm(Network.TESTNET, walletId, " Alice.DASH ", recipient) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "a11ce", recipient)) + val changed = ShieldedTipRecipient(recipient.identityId, ByteArray(43) { 4 }) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "a11ce.dash", changed)) + } + + @Test + fun detectsBothIdentityAndAddressReplacementWithoutChangingThePin() = runTest { + history.confirm(Network.TESTNET, walletId, "Alice", recipient) + val newIdentity = ShieldedTipRecipient(ByteArray(32) { 4 }, recipient.address) + val newAddress = ShieldedTipRecipient(recipient.identityId, ByteArray(43) { 5 }) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", newIdentity)) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", newAddress)) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Alice", recipient)) + history.confirm(Network.TESTNET, walletId, "Alice", newAddress) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Alice", newAddress)) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", recipient)) + } + + @Test + fun isolatesNetworksWalletsAndUsernames() = runTest { + history.confirm(Network.TESTNET, walletId, "Alice", recipient) + val changed = ShieldedTipRecipient(ByteArray(32) { 4 }, recipient.address) + assertFalse(history.hasChanged(Network.MAINNET, walletId, "Alice", changed)) + assertFalse(history.hasChanged(Network.TESTNET, ByteArray(32) { 6 }, "Alice", changed)) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Bob", changed)) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", changed)) + } +} diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 682291a6ce0..f41aa690884 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -645,7 +645,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002816630 + 24002927540 ); let check_result = platform @@ -1160,7 +1160,7 @@ mod tests { // Plus we have 24_000_000_000 in base costs assert_eq!( processing_result.aggregated_fees().processing_fee, - 24005633260 + 24005855080 ); let check_result = platform @@ -1635,7 +1635,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002816630 + 24002927540 ); platform @@ -1722,7 +1722,7 @@ mod tests { assert_eq!( update_processing_result.aggregated_fees().processing_fee, - 27002879350 + 27003006640 ); let check_result = platform @@ -2094,7 +2094,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002816630 + 24002927540 ); platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 4f66d59dce8..b1849e305c7 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -1117,6 +1117,8 @@ mod tests { "profile must not carry platformPaymentAddress before transition_to_version_14" ); + assert!(!pre_profile.iter().any(|p| p == "shieldedAddress")); + let result = platform.transition_to_version_14(&block_info, &transaction, platform_version); assert!(result.is_ok(), "transition failed: {:?}", result.err()); @@ -1153,6 +1155,7 @@ mod tests { profile.iter().any(|p| p == "platformPaymentAddress"), "profile must carry platformPaymentAddress after transition_to_version_14" ); + assert!(profile.iter().any(|p| p == "shieldedAddress")); } /// The v13→v14 boundary through the production dispatcher @@ -1286,7 +1289,11 @@ mod tests { .keys() .cloned() .collect::>(); - for field in ["corePaymentAddress", "platformPaymentAddress"] { + for field in [ + "corePaymentAddress", + "platformPaymentAddress", + "shieldedAddress", + ] { assert!( !pre_profile_properties.iter().any(|p| p == field), "profile must not carry {field} before the upgrade" @@ -1338,7 +1345,11 @@ mod tests { .keys() .cloned() .collect::>(); - for field in ["corePaymentAddress", "platformPaymentAddress"] { + for field in [ + "corePaymentAddress", + "platformPaymentAddress", + "shieldedAddress", + ] { assert!( post_profile_properties.iter().any(|p| p == field), "profile must carry {field} after the upgrade" @@ -2643,3 +2654,47 @@ mod tests { ); } } + +#[cfg(test)] +mod shielded_profile_schema_tests { + use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; + use dpp::platform_value::{platform_value, Value}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + #[test] + fn should_validate_shielded_profile_address_boundaries() { + for version in [13, 14] { + let pv = PlatformVersion::get(version).unwrap(); + let contract = load_system_data_contract(SystemDataContract::Dashpay, pv).unwrap(); + for length in [0, 42, 43, 44] { + let properties = + platform_value!({ "shieldedAddress": Value::Bytes(vec![0; length]) }); + let result = contract + .validate_document_properties("profile", properties, pv) + .unwrap(); + assert_eq!( + result.is_valid(), + version == 14 && length == 43, + "protocol {version}, address length {length}: {result:?}" + ); + } + let result = contract + .validate_document_properties( + "profile", + platform_value!({"shieldedAddress": "not bytes"}), + pv, + ) + .unwrap(); + assert!(!result.is_valid()); + let legacy = contract + .validate_document_properties( + "profile", + platform_value!({"displayName": "Alice"}), + pv, + ) + .unwrap(); + assert!(legacy.is_valid()); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index 2a14056d93e..f587d106691 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -11,8 +11,9 @@ mod deletion_tests { run_document_delete_on_document_type_that_is_mutable_and_can_be_deleted_at_protocol_version( PlatformVersion::latest().protocol_version, // v14: the deleted document carries the contract-version stamp - // (one stored byte, five estimated), shifting processing costs - 1699620, + // (one stored byte, five estimated); the larger DashPay v2 schema + // also increases byte-billed contract-tree reads. + 1720780, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 5b9c07a62c0..cf58d46aaca 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -498,8 +498,8 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_mutable() { run_document_replace_on_document_type_that_is_mutable_at_protocol_version( PlatformVersion::latest().protocol_version, - // v14: replaced documents carry the contract-version stamp - 1433220, + // v14: contract-version stamp plus the larger DashPay v2 schema + 1454380, ) .await; } @@ -1986,7 +1986,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let documents_batch_update_transition_1 = BatchTransition::new_document_replacement_transition_from_document( @@ -2067,7 +2067,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive @@ -2210,7 +2210,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2292,7 +2292,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_600_000_000, 902, 44, 1, false); //next epoch @@ -2336,7 +2336,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive @@ -2475,7 +2475,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2557,7 +2557,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_600_000_000, 902, 44, 1, false); //next epoch @@ -2601,7 +2601,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive @@ -2744,7 +2744,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2826,7 +2826,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_600_000_000, 905, 44, 2, true); //next epoch @@ -2870,7 +2870,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive diff --git a/packages/rs-drive/tests/deterministic_root_hash.rs b/packages/rs-drive/tests/deterministic_root_hash.rs index 63b362aecf1..38cb27f5b95 100644 --- a/packages/rs-drive/tests/deterministic_root_hash.rs +++ b/packages/rs-drive/tests/deterministic_root_hash.rs @@ -306,7 +306,7 @@ mod tests { // payment address fields, changing the contract's serialized bytes // and therefore the root hash after insertion. 9..=13 => "14d9e2cdc3f25d1dfd079c1f9dd0d44db5bf73d397b04258449231a2d5bafda7", - _ => "02a78b45ecac17a6c08ce352c22b888fcd044de9f4ff82983c5d8fd97e6e8793", + _ => "1c97b429d1be04f623b6fdd33561393e463922c17d38355549280aed513d9313", }; assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs index 6c83f4d459e..5ac265a66c7 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs @@ -4,7 +4,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use std::ptr; -use platform_wallet::{DashPayProfile, ProfileUpdate}; +use platform_wallet::{DashPayProfile, PaymentAddressUpdate, ProfileUpdate}; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -14,6 +14,35 @@ use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// Payment address update: action 0 keeps, 1 sets, and 2 removes the property. +/// For action 1, `bytes` must point to `len` readable bytes for the call duration. +#[repr(C)] +pub struct PaymentAddressUpdateFFI { + pub action: u32, + pub bytes: *const u8, + pub len: usize, +} + +unsafe fn decode_address_update( + input: *const PaymentAddressUpdateFFI, +) -> Result { + if input.is_null() { + return Ok(PaymentAddressUpdate::Keep); + } + let input = &*input; + match input.action { + 0 => Ok(PaymentAddressUpdate::Keep), + 2 => Ok(PaymentAddressUpdate::Remove), + 1 if !input.bytes.is_null() && matches!(input.len, 21 | 43) => Ok( + PaymentAddressUpdate::Set(std::slice::from_raw_parts(input.bytes, input.len).to_vec()), + ), + _ => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "Invalid payment address update".to_string(), + )), + } +} + /// Flat FFI view of a [`DashPayProfile`]. #[repr(C)] pub struct DashPayProfileFFI { @@ -24,6 +53,12 @@ pub struct DashPayProfileFFI { pub avatar_hash: [u8; 32], pub avatar_fingerprint_is_some: bool, pub avatar_fingerprint: [u8; 8], + pub core_payment_address_is_some: bool, + pub core_payment_address: [u8; 21], + pub platform_payment_address_is_some: bool, + pub platform_payment_address: [u8; 21], + pub shielded_address_is_some: bool, + pub shielded_address: [u8; 43], } impl DashPayProfileFFI { @@ -36,6 +71,12 @@ impl DashPayProfileFFI { avatar_hash: [0u8; 32], avatar_fingerprint_is_some: false, avatar_fingerprint: [0u8; 8], + core_payment_address_is_some: false, + core_payment_address: [0; 21], + platform_payment_address_is_some: false, + platform_payment_address: [0; 21], + shielded_address_is_some: false, + shielded_address: [0; 43], } } @@ -61,6 +102,33 @@ impl DashPayProfileFFI { avatar_hash, avatar_fingerprint_is_some, avatar_fingerprint, + core_payment_address_is_some: profile + .core_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + core_payment_address: profile + .core_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + platform_payment_address_is_some: profile + .platform_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + platform_payment_address: profile + .platform_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + shielded_address_is_some: profile + .shielded_address + .as_ref() + .is_some_and(|a| a.len() == 43), + shielded_address: profile + .shielded_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 43]), } } } @@ -331,14 +399,49 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s do_create: bool, signer_handle: *mut SignerHandle, out_profile: *mut DashPayProfileFFI, +) -> PlatformWalletFFIResult { + platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( + wallet_handle, + identity_id, + display_name, + public_message, + avatar_url, + avatar_bytes, + avatar_bytes_len, + ptr::null(), + ptr::null(), + ptr::null(), + do_create, + signer_handle, + out_profile, + ) +} + +/// Create or update a profile with explicit keep/set/remove payment address operations. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + display_name: *const c_char, + public_message: *const c_char, + avatar_url: *const c_char, + avatar_bytes: *const u8, + avatar_bytes_len: usize, + core_payment_address: *const PaymentAddressUpdateFFI, + platform_payment_address: *const PaymentAddressUpdateFFI, + shielded_address: *const PaymentAddressUpdateFFI, + do_create: bool, + signer_handle: *mut SignerHandle, + out_profile: *mut DashPayProfileFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_profile); - check_ptr!(signer_handle); // `DashPayProfileFFI` owns heap C-string pointer fields freed by // `dashpay_profile_ffi_free`; publish the empty sentinel before any // fallible work so an error path never leaves uninitialized stack bytes // in those pointer fields. Matches the read-side helpers in this file. *out_profile = DashPayProfileFFI::empty(); + check_ptr!(signer_handle); let id = unwrap_result_or_return!(read_identifier(identity_id)); @@ -352,6 +455,12 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s Some(std::slice::from_raw_parts(avatar_bytes, avatar_bytes_len).to_vec()) }; + let core_payment_address = + unwrap_result_or_return!(decode_address_update(core_payment_address)); + let platform_payment_address = + unwrap_result_or_return!(decode_address_update(platform_payment_address)); + let shielded_address = unwrap_result_or_return!(decode_address_update(shielded_address)); + let signer_addr = signer_handle as usize; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { @@ -361,6 +470,9 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s public_message, avatar_url, avatar_bytes: avatar_bytes_vec, + core_payment_address, + platform_payment_address, + shielded_address, }; block_on_worker(async move { @@ -401,6 +513,79 @@ mod tests { }) } + #[test] + fn null_signer_leaves_profile_safe_to_free() { + unsafe { + let mut out = std::mem::MaybeUninit::::uninit(); + let result = platform_wallet_create_or_update_dashpay_profile_with_signer( + 0, + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + 0, + false, + ptr::null_mut(), + out.as_mut_ptr(), + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + let mut out = out.assume_init(); + assert!(out.display_name.is_null()); + assert!(!out.shielded_address_is_some); + dashpay_profile_ffi_free(&mut out); + } + } + + #[test] + fn payment_address_updates_preserve_distinct_operations() { + unsafe { + assert!(matches!( + decode_address_update(ptr::null()).unwrap(), + PaymentAddressUpdate::Keep + )); + let mut update = PaymentAddressUpdateFFI { + action: 2, + bytes: ptr::null(), + len: 0, + }; + assert!(matches!( + decode_address_update(&update).unwrap(), + PaymentAddressUpdate::Remove + )); + update.action = 1; + assert!(decode_address_update(&update).is_err()); + let address = [7u8; 43]; + update.bytes = address.as_ptr(); + update.len = address.len(); + match decode_address_update(&update).unwrap() { + PaymentAddressUpdate::Set(bytes) => assert_eq!(bytes, address), + _ => panic!("expected set operation"), + } + update.action = 3; + assert!(decode_address_update(&update).is_err()); + } + } + + #[test] + fn profile_addresses_copy_to_owned_fixed_buffers() { + let profile = DashPayProfile { + core_payment_address: Some(vec![1; 21]), + platform_payment_address: Some(vec![2; 21]), + shielded_address: Some(vec![3; 43]), + ..Default::default() + }; + let mut ffi = DashPayProfileFFI::from_profile(&profile); + assert!(ffi.core_payment_address_is_some); + assert!(ffi.platform_payment_address_is_some); + assert!(ffi.shielded_address_is_some); + drop(profile); + assert_eq!(ffi.core_payment_address, [1; 21]); + assert_eq!(ffi.platform_payment_address, [2; 21]); + assert_eq!(ffi.shielded_address, [3; 43]); + unsafe { dashpay_profile_ffi_free(&mut ffi) }; + } + #[test] fn test_get_profile_absent_returns_false_flag() { unsafe { @@ -437,6 +622,7 @@ mod tests { avatar_hash: Some(hash), avatar_fingerprint: Some([1, 2, 3, 4, 5, 6, 7, 8]), public_message: Some("Hello world".to_string()), + ..Default::default() }); let handle = MANAGED_IDENTITY_STORAGE.insert(managed); diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 2d0c526e034..793c9cb625f 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -137,6 +137,13 @@ pub struct IdentityEntryFFI { pub dashpay_profile_avatar_fingerprint: [u8; 8], /// `true` iff the source `avatar_fingerprint` was `Some(_)`. pub dashpay_profile_avatar_fingerprint_present: bool, + pub dashpay_profile_core_payment_address: [u8; 21], + pub dashpay_profile_core_payment_address_present: bool, + pub dashpay_profile_platform_payment_address: [u8; 21], + pub dashpay_profile_platform_payment_address_present: bool, + pub dashpay_profile_shielded_address: [u8; 43], + pub dashpay_profile_shielded_address_present: bool, + /// Heap-allocated NUL-terminated UTF-8 C string for the DashPay /// profile's public message. `null` when the source field was /// `None`. Owned by this FFI struct; freed in @@ -213,6 +220,13 @@ pub struct ContactProfileRowFFI { pub avatar_fingerprint: [u8; 8], /// `true` iff the source `avatar_fingerprint` was `Some(_)`. pub avatar_fingerprint_present: bool, + pub core_payment_address: [u8; 21], + pub core_payment_address_present: bool, + pub platform_payment_address: [u8; 21], + pub platform_payment_address_present: bool, + pub shielded_address: [u8; 43], + pub shielded_address_present: bool, + /// Heap-allocated `publicMessage`; `null` when `None`. Freed in /// [`free_identity_entry_ffi`]. pub public_message: *const c_char, @@ -356,38 +370,8 @@ const _: [u8; 8] = [0u8; std::mem::align_of::()]; // than a build error. Pin the expected size here so any reshape // fails the cargo build first. // -// Expected layout on 64-bit targets (all fields in declaration -// order under `#[repr(C)]`): -// -// 0..=31 identity_id [u8; 32] -// 32..=39 balance u64 -// 40..=47 revision u64 -// 48 identity_index_is_some bool -// 49..=51 (padding to 4) -// 52..=55 identity_index u32 -// 56 status u8 -// 57 wallet_id_is_some bool -// 58..=89 wallet_id [u8; 32] -// 90..=95 (padding to 8 for pointer alignment) -// 96..=103 dpns_names *const *const c_char -// 104..=111 dpns_names_count usize -// 112..=119 dpns_names_acquired_at *const u64 -// 120 dashpay_profile_present bool -// 121..=127 (padding to 8 for pointer alignment) -// 128..=135 dashpay_profile_display_name *const c_char -// 136..=143 dashpay_profile_bio *const c_char -// 144..=151 dashpay_profile_avatar_url *const c_char -// 152..=183 dashpay_profile_avatar_hash [u8; 32] -// 184 dashpay_profile_avatar_hash_present bool -// 185..=192 dashpay_profile_avatar_fingerprint [u8; 8] -// 193 dashpay_profile_avatar_fingerprint_present bool -// 194..=199 (padding to 8 for pointer alignment) -// 200..=207 dashpay_profile_public_message *const c_char -// 208..=215 contact_profiles *const ContactProfileRowFFI -// 216..=223 contact_profiles_count usize -// -// Total size = 224, alignment = 8 (from u64 / pointer). -const _: [u8; 224] = [0u8; std::mem::size_of::()]; +// Includes three fixed-size payment addresses and their presence flags. +const _: [u8; 312] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // --------------------------------------------------------------------------- @@ -450,6 +434,15 @@ impl IdentityEntryFFI { dashpay_profile_avatar_hash_present: profile_fields.avatar_hash_present, dashpay_profile_avatar_fingerprint: profile_fields.avatar_fingerprint, dashpay_profile_avatar_fingerprint_present: profile_fields.avatar_fingerprint_present, + dashpay_profile_core_payment_address: profile_fields.core_payment_address, + dashpay_profile_core_payment_address_present: profile_fields + .core_payment_address_present, + dashpay_profile_platform_payment_address: profile_fields.platform_payment_address, + dashpay_profile_platform_payment_address_present: profile_fields + .platform_payment_address_present, + dashpay_profile_shielded_address: profile_fields.shielded_address, + dashpay_profile_shielded_address_present: profile_fields.shielded_address_present, + dashpay_profile_public_message: profile_fields.public_message, contact_profiles, contact_profiles_count, @@ -472,6 +465,13 @@ struct DashPayProfileFields { avatar_hash_present: bool, avatar_fingerprint: [u8; 8], avatar_fingerprint_present: bool, + core_payment_address: [u8; 21], + core_payment_address_present: bool, + platform_payment_address: [u8; 21], + platform_payment_address_present: bool, + shielded_address: [u8; 43], + shielded_address_present: bool, + public_message: *const c_char, } @@ -487,6 +487,13 @@ impl DashPayProfileFields { avatar_hash_present: false, avatar_fingerprint: [0u8; 8], avatar_fingerprint_present: false, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [0; 43], + shielded_address_present: false, + public_message: ptr::null(), } } @@ -514,6 +521,34 @@ impl DashPayProfileFields { avatar_hash_present, avatar_fingerprint, avatar_fingerprint_present, + core_payment_address: profile + .core_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + core_payment_address_present: profile + .core_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + platform_payment_address: profile + .platform_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + platform_payment_address_present: profile + .platform_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + shielded_address: profile + .shielded_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 43]), + shielded_address_present: profile + .shielded_address + .as_ref() + .is_some_and(|a| a.len() == 43), + public_message: optional_c_string(profile.public_message.as_deref()), } } @@ -613,6 +648,13 @@ fn allocate_contact_profile_rows( avatar_hash_present: false, avatar_fingerprint: [0u8; 8], avatar_fingerprint_present: false, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [0; 43], + shielded_address_present: false, + public_message: ptr::null(), checked_at_ms: entry.checked_at_ms, }); @@ -636,6 +678,34 @@ fn allocate_contact_profile_rows( avatar_hash_present, avatar_fingerprint, avatar_fingerprint_present, + core_payment_address: profile + .core_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + core_payment_address_present: profile + .core_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + platform_payment_address: profile + .platform_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + platform_payment_address_present: profile + .platform_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + shielded_address: profile + .shielded_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 43]), + shielded_address_present: profile + .shielded_address + .as_ref() + .is_some_and(|a| a.len() == 43), + public_message: optional_c_string(profile.public_message.as_deref()), checked_at_ms: entry.checked_at_ms, }); @@ -1015,6 +1085,7 @@ mod tests { avatar_hash: Some([0xAB; 32]), avatar_fingerprint: Some([0xCD; 8]), public_message: None, + ..Default::default() }), dashpay_payments: Default::default(), contact_profiles: Default::default(), @@ -1067,6 +1138,7 @@ mod tests { avatar_hash: Some([0x11; 32]), avatar_fingerprint: None, public_message: None, + ..Default::default() }), checked_at_ms: 111, }, diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2b0b23dbd49..a4992a5e37d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5523,6 +5523,9 @@ fn build_wallet_identity_bucket( unsafe { restore_dashpay_payments(spec, &mut managed) }; unsafe { restore_dashpay_ignored(spec, &mut managed) }; unsafe { restore_contact_profiles(spec, &mut managed) }; + if let Some(profile) = unsafe { spec.dashpay_profile.as_ref() } { + *managed.dashpay_profile_mut() = Some(unsafe { profile_from_restore_row(profile) }); + } bucket.insert(spec.identity_index, managed); } @@ -5683,6 +5686,42 @@ fn is_valid_avatar_url(url: &str) -> bool { !url.is_empty() && url.len() <= MAX_AVATAR_URL_LEN && url.starts_with("https://") } +/// Copy a host-owned profile row, retaining the avatar URL validation used for +/// cached contact profiles. The host frees the buffers after restoration returns. +/// +/// # Safety +/// The row's string pointers must be null or readable NUL-terminated strings. +unsafe fn profile_from_restore_row( + row: &ContactProfileRestoreEntryFFI, +) -> platform_wallet::DashPayProfile { + let opt_string = |ptr: *const std::os::raw::c_char| -> Option { + if ptr.is_null() { + None + } else { + CStr::from_ptr(ptr).to_str().ok().map(str::to_string) + } + }; + platform_wallet::DashPayProfile { + display_name: opt_string(row.display_name), + bio: opt_string(row.bio), + avatar_url: opt_string(row.avatar_url).filter(|u| is_valid_avatar_url(u)), + avatar_hash: row.avatar_hash_present.then_some(row.avatar_hash), + avatar_fingerprint: row + .avatar_fingerprint_present + .then_some(row.avatar_fingerprint), + public_message: opt_string(row.public_message), + core_payment_address: row + .core_payment_address_present + .then(|| row.core_payment_address.to_vec()), + platform_payment_address: row + .platform_payment_address_present + .then(|| row.platform_payment_address.to_vec()), + shielded_address: row + .shielded_address_present + .then(|| row.shielded_address.to_vec()), + } +} + /// Fold a slice of [`ContactProfileRestoreEntryFFI`] rows into /// the managed identity's contact-profile cache. Split out from /// [`restore_contact_profiles`] so the c-string decode + avatar-url @@ -5696,43 +5735,11 @@ unsafe fn apply_contact_profile_rows( rows: &[ContactProfileRestoreEntryFFI], managed: &mut ManagedIdentity, ) { - use platform_wallet::{ContactProfileEntry, DashPayProfile}; - - let opt_string = |ptr: *const std::os::raw::c_char| -> Option { - if ptr.is_null() { - None - } else { - CStr::from_ptr(ptr).to_str().ok().map(str::to_string) - } - }; - for row in rows { - let avatar_hash = if row.avatar_hash_present { - Some(row.avatar_hash) - } else { - None - }; - let avatar_fingerprint = if row.avatar_fingerprint_present { - Some(row.avatar_fingerprint) - } else { - None - }; - // Re-validate the public, attacker-controlled avatar URL; drop - // just the URL field (keep the rest of the profile) if it no - // longer passes the `https://` / length rule. - let avatar_url = opt_string(row.avatar_url).filter(|u| is_valid_avatar_url(u)); - managed.dashpay_contact_profiles_mut().insert( Identifier::from(row.contact_id), - ContactProfileEntry { - profile: Some(DashPayProfile { - display_name: opt_string(row.display_name), - bio: opt_string(row.bio), - avatar_url, - avatar_hash, - avatar_fingerprint, - public_message: opt_string(row.public_message), - }), + platform_wallet::ContactProfileEntry { + profile: Some(profile_from_restore_row(row)), checked_at_ms: row.checked_at_ms, }, ); @@ -8704,6 +8711,13 @@ mod tests { avatar_hash_present: true, avatar_fingerprint: [0x22; 8], avatar_fingerprint_present: true, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [3; 43], + shielded_address_present: true, + public_message: public_message.as_ptr(), checked_at_ms: 1_700_000_000_000, }, @@ -8716,6 +8730,13 @@ mod tests { avatar_hash_present: false, avatar_fingerprint: [0u8; 8], avatar_fingerprint_present: false, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [0; 43], + shielded_address_present: false, + public_message: std::ptr::null(), checked_at_ms: 1_700_000_000_001, }, @@ -8740,6 +8761,7 @@ mod tests { ); assert_eq!(alice_profile.avatar_hash, Some([0x11; 32])); assert_eq!(alice_profile.avatar_fingerprint, Some([0x22; 8])); + assert_eq!(alice_profile.shielded_address, Some(vec![3; 43])); assert!(alice_profile.bio.is_none()); let bob = managed diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c1cec289755..1c6b86156b9 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2606,3 +2606,187 @@ mod tests { ); } } + +/// Derive and register the identity's dedicated tip account, without publishing it. +/// All ID pointers must reference 32 bytes; `out_address` must reference 43 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_prepare_shielded_tip_address( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + identity_id_bytes: *const u8, + out_address: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(identity_id_bytes); + check_ptr!(out_address); + std::ptr::write_bytes(out_address, 0, 43); + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let identity_id = match crate::types::read_identifier(identity_id_bytes) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(value) => value, + Err(e) => return e, + }; + match block_on_worker(async move { + wallet + .prepare_shielded_tip_address(seed.as_ref(), &identity_id, &coordinator) + .await + }) { + Ok(address) => { + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } +} + +/// Resolve a username using verified current DPNS and profile documents. +/// Outputs are 32-byte identity ID and 43-byte Orchard address buffers. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_resolve_shielded_tip( + wallet_handle: Handle, + username: *const c_char, + out_identity_id: *mut u8, + out_address: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(username); + check_ptr!(out_identity_id); + check_ptr!(out_address); + std::ptr::write_bytes(out_identity_id, 0, 32); + std::ptr::write_bytes(out_address, 0, 43); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let result = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dashpay().resolve_shielded_tip(&username).await }) + }); + match result { + Some(Ok(recipient)) => { + std::ptr::copy_nonoverlapping( + recipient.identity_id.to_buffer().as_ptr(), + out_identity_id, + 32, + ); + std::ptr::copy_nonoverlapping(recipient.address.as_ptr(), out_address, 43); + PlatformWalletFFIResult::ok() + } + Some(Err(e)) => e.into(), + None => { + PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::NotFound, "Wallet not found") + } + } +} + +/// Send a tip only if fresh resolution matches the recipient the user confirmed. +/// ID/address pointers must reference 32/43 readable bytes respectively. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + username: *const c_char, + expected_identity_id: *const u8, + expected_address: *const u8, + amount: u64, + memo_text: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(username); + check_ptr!(expected_identity_id); + check_ptr!(expected_address); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let identity_id = match crate::types::read_identifier(expected_identity_id) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let address: [u8; 43] = std::slice::from_raw_parts(expected_address, 43) + .try_into() + .unwrap(); + let memo_str = match crate::dashpay_profile::decode_opt_c_str(memo_text) { + Ok(value) => value, + Err(e) => return e, + }; + let memo = match encode_memo_text(memo_str.as_deref()) { + Ok(value) => value, + Err(e) => return e, + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(value) => value, + Err(e) => return e, + }; + let result = block_on_worker(async move { + let recipient = platform_wallet::ShieldedTipRecipient { + identity_id, + address, + }; + let prover = CachedOrchardProver::new(); + let result = wallet + .send_shielded_tip( + &coordinator, + seed.as_ref(), + account, + &username, + &recipient, + amount, + memo, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + map_spend_result(result, "shielded tip") +} + +/// Return the dedicated ZIP-32 tip account for a wallet identity derivation index. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_shielded_tip_account_index( + identity_index: u32, + out_account: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_account); + *out_account = 0; + match platform_wallet::wallet::shielded::tips::shielded_tip_account_index(identity_index) { + Ok(account) => { + *out_account = account; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } +} + +/// Whether an account is reserved for explicitly selected DashPay tip activity. +#[no_mangle] +pub extern "C" fn platform_wallet_is_shielded_tip_account(account: u32) -> bool { + platform_wallet::wallet::shielded::is_shielded_tip_account(account) +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c49be7de1b7..edeaabbaf48 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -348,6 +348,8 @@ pub struct IdentityRestoreEntryFFI { /// identity has no cached contact profiles. pub contact_profiles: *const ContactProfileRestoreEntryFFI, pub contact_profiles_count: usize, + /// Optional owned DashPay profile; contact_id is ignored. + pub dashpay_profile: *const ContactProfileRestoreEntryFFI, } /// One DashPay payment-history row to rehydrate into @@ -378,7 +380,7 @@ pub struct PaymentRestoreEntryFFI { /// the managed identity's contact-profile cache (keyed by the contact's identity /// id) at load. Mirrors the persist-side /// [`crate::identity_persistence::ContactProfileRowFFI`] field-for-field -/// (the leading `contact_id` key, the five public profile fields with +/// (the leading `contact_id` key, the public profile fields with /// their `_present` byte-array flags, and the trailing `checked_at_ms` /// self-heal timestamp). /// @@ -410,6 +412,13 @@ pub struct ContactProfileRestoreEntryFFI { pub avatar_fingerprint: [u8; 8], /// `true` iff the source `avatar_fingerprint` was `Some(_)`. pub avatar_fingerprint_present: bool, + pub core_payment_address: [u8; 21], + pub core_payment_address_present: bool, + pub platform_payment_address: [u8; 21], + pub platform_payment_address_present: bool, + pub shielded_address: [u8; 43], + pub shielded_address_present: bool, + /// NUL-terminated `publicMessage`, or null when `None`. pub public_message: *const std::os::raw::c_char, /// Wall-clock ms of the last fetch attempt — the diff --git a/packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs b/packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs new file mode 100644 index 00000000000..2d679b5348c --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs @@ -0,0 +1,7 @@ +//! Profile addresses extend a positional bincode record embedded in identities. +//! Tag existing rows as format 0 and new writes as format 1; legacy decoders keep +//! old databases readable without treating a corrupt new record as an old one. +pub fn migration() -> String { + "ALTER TABLE identities ADD COLUMN entry_format INTEGER NOT NULL DEFAULT 0 CHECK(entry_format IN (0, 1)); + ALTER TABLE dashpay_profiles ADD COLUMN profile_format INTEGER NOT NULL DEFAULT 0 CHECK(profile_format IN (0, 1));".to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index 502d984116d..ed01b742239 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -3,7 +3,8 @@ //! Thin error-mapping wrappers around `bincode::serde` so every //! `_blob` column in the SQLite schema uses one encoding path. Schema //! evolution is gated by the refinery migration version on the -//! database as a whole — there is no per-blob revision tag. +//! database; identity/profile rows also carry an encoding column so older +//! positional records remain readable until rewritten. //! //! [`encode_outpoint`] / [`decode_outpoint`] encode a `dashcore::OutPoint` //! the same way — via bincode-serde — for the `outpoint` PRIMARY KEY diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index b6caa52fb11..4d91f194803 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -50,9 +50,9 @@ pub fn apply( let mut delete_stmt = tx.prepare_cached("DELETE FROM dashpay_profiles WHERE identity_id = ?1")?; let mut insert_stmt = tx.prepare_cached( - "INSERT INTO dashpay_profiles (identity_id, profile_blob) \ - VALUES (?1, ?2) \ - ON CONFLICT(identity_id) DO UPDATE SET profile_blob = excluded.profile_blob", + "INSERT INTO dashpay_profiles (identity_id, profile_blob, profile_format) \ + VALUES (?1, ?2, 1) \ + ON CONFLICT(identity_id) DO UPDATE SET profile_blob = excluded.profile_blob, profile_format = 1", )?; for (identity_id, profile) in profiles { match profile { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 72b22435f27..82cd6a19287 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -31,13 +31,13 @@ pub fn apply( // by the per-entry cross-check below. let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); let mut stmt = tx.prepare_cached( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ - VALUES (?1, ?2, ?3, ?4, 0) \ + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned, entry_format) \ + VALUES (?1, ?2, ?3, ?4, 0, 1) \ ON CONFLICT(identity_id) DO UPDATE SET \ wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ wallet_index = excluded.wallet_index, \ entry_blob = excluded.entry_blob, \ - tombstoned = 0", + tombstoned = 0, entry_format = 1", )?; let wallet_id_param = wallet_id_to_param(wallet_id); for (id, entry) in &cs.identities { @@ -118,17 +118,20 @@ pub fn fetch( // wallet_id); a real WalletId matches only that wallet's rows. // `IS` is NULL-safe equality so the NULL branch works uniformly. let wallet_id_param = wallet_id_to_param(wallet_id); - let row: Option<(Vec, i64)> = conn + let row: Option<(Vec, i64, i64)> = conn .query_row( - "SELECT entry_blob, tombstoned FROM identities \ + "SELECT entry_blob, tombstoned, entry_format FROM identities \ WHERE identity_id = ?1 AND wallet_id IS ?2", params![&identity_id[..], wallet_id_param], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; match row { None => Ok(None), - Some((payload, tombstoned)) => Ok(Some((blob::decode(&payload)?, tombstoned != 0))), + Some((payload, tombstoned, format)) => Ok(Some(( + super::identity_profile_encoding::decode_identity(&payload, format)?, + tombstoned != 0, + ))), } } @@ -156,7 +159,7 @@ pub fn load_state( // clause matches by wallet_id (orphan identities — wallet_id NULL — // are out of scope for this per-wallet loader). let mut stmt = conn.prepare( - "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", + "SELECT identity_id, entry_blob, tombstoned, entry_format FROM identities WHERE wallet_id = ?1", )?; // The ignored-senders TABLE is the authoritative ignore record (every // ignore/un-ignore maintains it transactionally); the `entry_blob`'s @@ -172,7 +175,7 @@ pub fn load_state( if tombstoned != 0 { continue; } - let entry: IdentityEntry = blob::decode(&payload)?; + let entry = super::identity_profile_encoding::decode_identity(&payload, row.get(3)?)?; let ignored = ignored_by_owner.remove(&entry.id).unwrap_or_default(); let managed = managed_identity_from_entry(&entry, wallet_id, ignored); match entry.identity_index { @@ -283,8 +286,8 @@ pub fn ensure_exists( let wallet_id_param = wallet_id_to_param(wallet_id); conn.execute( "INSERT OR IGNORE INTO identities \ - (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ - VALUES (?1, ?2, NULL, ?3, 0)", + (identity_id, wallet_id, wallet_index, entry_blob, tombstoned, entry_format) \ + VALUES (?1, ?2, NULL, ?3, 0, 1)", params![&identity_id[..], wallet_id_param, payload], )?; Ok(()) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs new file mode 100644 index 00000000000..21111ecf89c --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs @@ -0,0 +1,207 @@ +//! Frozen pre-address profile records. Do not edit their field order. +use super::blob; +use crate::sqlite::error::WalletStorageError; +use dpp::fee::Credits; +use dpp::prelude::{Identifier, Revision}; +use platform_wallet::changeset::IdentityEntry; +use platform_wallet::wallet::identity::types::block_time::BlockTime; +use platform_wallet::wallet::identity::PaymentEntry; +use platform_wallet::{ContactProfileEntry, DashPayProfile, DpnsNameInfo, IdentityStatus}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyProfile { + display_name: Option, + bio: Option, + avatar_url: Option, + avatar_hash: Option<[u8; 32]>, + avatar_fingerprint: Option<[u8; 8]>, + public_message: Option, +} +impl From for DashPayProfile { + fn from(p: LegacyProfile) -> Self { + Self { + display_name: p.display_name, + bio: p.bio, + avatar_url: p.avatar_url, + avatar_hash: p.avatar_hash, + avatar_fingerprint: p.avatar_fingerprint, + public_message: p.public_message, + ..Default::default() + } + } +} +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyContactProfile { + profile: Option, + checked_at_ms: u64, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyIdentityEntry { + pub id: Identifier, + pub balance: Credits, + pub revision: Revision, + pub identity_index: Option, + pub last_updated_balance_block_time: Option, + pub last_synced_keys_block_time: Option, + pub dpns_names: Vec, + pub contested_dpns_names: Vec, + pub status: IdentityStatus, + pub wallet_id: Option<[u8; 32]>, + pub dashpay_profile: Option, + pub dashpay_payments: BTreeMap, + pub contact_profiles: BTreeMap, + pub ignored_senders: BTreeSet, +} + +pub(super) fn decode_identity( + payload: &[u8], + format: i64, +) -> Result { + match format { + 1 => blob::decode(payload), + 0 => { + let old: LegacyIdentityEntry = blob::decode(payload)?; + Ok(IdentityEntry { + id: old.id, + balance: old.balance, + revision: old.revision, + identity_index: old.identity_index, + last_updated_balance_block_time: old.last_updated_balance_block_time, + last_synced_keys_block_time: old.last_synced_keys_block_time, + dpns_names: old.dpns_names, + contested_dpns_names: old.contested_dpns_names, + status: old.status, + wallet_id: old.wallet_id, + dashpay_profile: old.dashpay_profile.map(Into::into), + dashpay_payments: old.dashpay_payments, + contact_profiles: old + .contact_profiles + .into_iter() + .map(|(id, entry)| { + ( + id, + ContactProfileEntry { + profile: entry.profile.map(Into::into), + checked_at_ms: entry.checked_at_ms, + }, + ) + }) + .collect(), + ignored_senders: old.ignored_senders, + }) + } + _ => Err(WalletStorageError::blob_decode( + "unsupported identity profile encoding", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn old_profile() -> LegacyProfile { + LegacyProfile { + display_name: Some("Alice".into()), + bio: Some("hello".into()), + avatar_url: None, + avatar_hash: None, + avatar_fingerprint: None, + public_message: Some("hello".into()), + } + } + #[test] + fn should_read_old_owned_and_contact_profiles_without_losing_following_fields() { + let id = Identifier::from([1; 32]); + let old = LegacyIdentityEntry { + id, + balance: 123, + revision: 2, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: vec![], + contested_dpns_names: vec![], + status: IdentityStatus::Unknown, + wallet_id: Some([2; 32]), + dashpay_profile: Some(old_profile()), + dashpay_payments: BTreeMap::new(), + contact_profiles: [( + id, + LegacyContactProfile { + profile: Some(old_profile()), + checked_at_ms: 321, + }, + )] + .into(), + ignored_senders: [Identifier::from([3; 32])].into(), + }; + let encoded = blob::encode(&old).unwrap(); + // Apply the real V7 -> V8 migration around an existing binary record. + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::runner() + .set_target(refinery::Target::Version(7)) + .run(&mut conn) + .unwrap(); + conn.execute("INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", rusqlite::params![[2u8; 32].as_slice()]).unwrap(); + conn.execute("INSERT INTO identities (identity_id, wallet_id, entry_blob, tombstoned) VALUES (?1, ?2, ?3, 0)", rusqlite::params![id.as_slice(), [2u8; 32].as_slice(), &encoded]).unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let (restored, tombstoned) = + super::super::identities::fetch(&conn, &[2; 32], &id.to_buffer()) + .unwrap() + .unwrap(); + assert!(!tombstoned); + assert_eq!( + conn.query_row("SELECT entry_format FROM identities", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + assert_eq!(restored.balance, 123); + assert_eq!( + restored + .dashpay_profile + .as_ref() + .unwrap() + .display_name + .as_deref(), + Some("Alice") + ); + assert!(restored + .dashpay_profile + .as_ref() + .unwrap() + .shielded_address + .is_none()); + assert_eq!(restored.contact_profiles[&id].checked_at_ms, 321); + assert!(restored + .ignored_senders + .contains(&Identifier::from([3; 32]))); + let mut current = restored; + current.dashpay_profile.as_mut().unwrap().shielded_address = Some(vec![9; 43]); + let tx = conn.transaction().unwrap(); + let changes = platform_wallet::changeset::IdentityChangeSet { + identities: [(id, current.clone())].into(), + ..Default::default() + }; + super::super::identities::apply(&tx, &[2; 32], &changes).unwrap(); + tx.commit().unwrap(); + assert_eq!( + conn.query_row("SELECT entry_format FROM identities", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + super::super::identities::fetch(&conn, &[2; 32], &id.to_buffer()) + .unwrap() + .unwrap() + .0, + current + ); + let encoded = blob::encode(¤t).unwrap(); + assert_eq!(decode_identity(&encoded, 1).unwrap(), current); + assert!(decode_identity(&encoded[..encoded.len() - 1], 1).is_err()); + assert!(decode_identity(&encoded, 2).is_err()); + } +} 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 b545be7a9af..e664586a2b3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -10,8 +10,9 @@ //! `_blob` columns carry the full sub-changeset entry encoded with //! `bincode::serde::encode_to_vec` against the serde-derived types in //! `platform-wallet` — see [`blob::encode`] / [`blob::decode`]. -//! Schema evolution is gated by the refinery migration version on -//! the database; individual blobs have no inline revision tag. +//! Schema evolution is gated by refinery migrations. Identity and profile rows +//! additionally carry an encoding column because legacy positional bincode +//! records remain readable until the row is rewritten. pub mod accounts; pub mod asset_locks; @@ -95,3 +96,6 @@ pub(crate) fn assert_identities_belong_to_wallet( } Ok(()) } + +#[cfg(any(test, feature = "__test-helpers"))] +mod identity_profile_encoding; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 3f05d82c766..b798a0578ac 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -681,6 +681,7 @@ fn tc012_dashpay_overlay_roundtrip() { avatar_hash: None, avatar_fingerprint: None, public_message: Some("public".into()), + ..Default::default() }; let payment = PaymentEntry::new_sent(Identifier::from([0x66; 32]), 7_500, Some("lunch".into())); diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md new file mode 100644 index 00000000000..db3b415232d --- /dev/null +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -0,0 +1,79 @@ +# DashPay shielded tips + +DashPay profiles can publish a reusable Orchard receiving address. A payer resolves +DPNS to an identity, fetches its profile with proof verification, confirms the +recipient, and sends an ordinary shielded transfer. No contact request or payment +notification document is necessary. + +## Address format and updates + +`profile.shieldedAddress` contains 43 raw bytes: the 11-byte diversifier followed +by the 32-byte diversified transmission key. Text encoding, network prefix, and +checksum belong to the user interface. External addresses with any valid +diversifier are supported. The profile does not prove ownership of the address. + +`DashPayProfile` exposes `core_payment_address`, `platform_payment_address`, and +`shielded_address`. The transparent fields use their existing 21-byte storage +form. `ProfileUpdate` uses `PaymentAddressUpdate::Keep`, `Set(bytes)`, or `Remove` +for each address. An unrelated edit preserves all payment addresses. Clients +validate Orchard decoding before publication and payment; an invalid shielded +address does not prevent displaying the rest of the profile. + +Publication checks the connected chain's DashPay contract before submitting an +address field. A bundled schema alone does not demonstrate network activation. + +## Dedicated local accounts + +The wallet reserves ZIP-32 account indices `0x40000000..0x80000000` for DashPay +tips. A wallet-owned identity at derivation index `i` uses account +`0x40000000 + i`; identity indices outside the lower half are rejected by the tip +helper. Ordinary account allocation must stay below `0x40000000`. Use +`shielded_tip_account_index` and `is_shielded_tip_account` instead of duplicating +these constants in applications. + +Call `PlatformWallet::prepare_shielded_tip_address(seed, identity_id, coordinator)` +to obtain the default address of this account. The helper binds its viewing keys +to the synchronization coordinator and flushes persistence before returning. It +does not publish the address; publication remains an explicit profile operation. +Repeated preparation derives the same account and address. An identity without a +wallet derivation index can publish an external address instead. + +Tip accounts have distinct viewing keys and are excluded from ordinary receive +and automatic spending choices. Spending a tip balance is an explicit action. +The generic shielded transfer API still accepts an explicitly selected account. +A published address is publicly associated with the profile; account separation +does not eliminate correlations introduced by later transfers or provide a +blanket guarantee against future cryptographic attacks. + +## Restoration and address changes + +On seed restoration, discover the wallet's identities before binding and scanning +shielded accounts. Seed-backed `bind_shielded` adds each discovered identity's tip +account even if the caller supplies only account zero. This does not depend on the +current profile: removal or replacement of a published address does not remove +received funds from recovery. After discovering more identities in an existing +session, bind again. Newly bound accounts start with their own scan watermark at +zero, so the next synchronization scans their history while retaining the shared +commitment tree and previously synchronized accounts. + +Seedless binding includes persisted tip viewing keys, including retired accounts. +If a newly discovered identity's key is missing, it returns `false` so the host can +perform seed-backed binding. Address rotation within a tip account uses a new +diversifier; the account viewing key detects both old and new addresses. Removing +publication neither revokes old address copies nor stops their monitoring. + +An externally supplied address is not automatically owned by this wallet. Its +funds, viewing keys, and recovery belong to the external wallet. + +## Resolving and paying + +Use `wallet.identity().dashpay().resolve_shielded_tip(username)` to obtain a fresh +`ShieldedTipRecipient`. Display the resolved identity and address for confirmation. +`wallet.send_shielded_tip(...)` takes that confirmed recipient, re-resolves the +name and profile, and refuses payment if either changed. It never switches to a +transparent destination. This is a snapshot check: the submitted payment always +uses the address that was confirmed, even if the profile changes afterward. + +Tips to a public profile address are anonymous from the receiver's perspective +unless the payer provides additional context. This feature does not implement +per-contact addresses or authenticated sender attribution. diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 2fedc74a1a8..3e65bcd7b69 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -83,8 +83,9 @@ pub use wallet::identity::{ derive_contact_payment_addresses, derive_contact_xpub, pubkey_binds_expected_key_data, unmask_account_reference, BlockTime, ContactProfileEntry, ContactRequest, ContactXpubData, DashPayProfile, DashPayState, DpnsNameInfo, EstablishedContact, IdentityLocation, - IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, - RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, + IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PaymentAddressUpdate, + PrivateKeyData, ProfileUpdate, RegistrationIndex, ShieldedTipRecipient, + DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::masternode_withdrawal::{ MasternodeWithdrawalKey, MasternodeWithdrawalKeys, MasternodeWithdrawalRequest, @@ -114,3 +115,12 @@ pub use key_wallet_manager; // the crate can pass it to `ManagedIdentity` mutation methods // (`set_dashpay_profile`, `record_dashpay_payment`, `add_identity`, …). pub use wallet::persister::WalletPersister; + +pub use wallet::identity::types::dashpay::profile::{ + valid_transparent_payment_address, validated_shielded_address, +}; + +#[cfg(feature = "shielded")] +pub use wallet::shielded::tips::{ + is_shielded_tip_account, shielded_tip_account_index, SHIELDED_TIP_ACCOUNT_BASE, +}; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index abd89c534ec..51a5a2a8cd6 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -1458,6 +1458,7 @@ mod tests { avatar_hash: Some([0xaa; 32]), avatar_fingerprint: Some([0xbb; 8]), public_message: Some("hello world".into()), + ..Default::default() }; // Mutate A (persists internally via noop persister). diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 54b7b8bb300..df2d3b07ec2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -36,6 +36,6 @@ pub use state::{ pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ ContactProfileEntry, ContactRequest, DashPayProfile, DashpayAddressMatch, DpnsNameInfo, - EstablishedContact, IdentityStatus, KeyStorage, PaymentDirection, PaymentEntry, PaymentStatus, - PrivateKeyData, ProfileUpdate, + EstablishedContact, IdentityStatus, KeyStorage, PaymentAddressUpdate, PaymentDirection, + PaymentEntry, PaymentStatus, PrivateKeyData, ProfileUpdate, ShieldedTipRecipient, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 623a67cd2c8..c7dae321604 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -132,6 +132,7 @@ impl DashPayView<'_, B> { // 1. The DashPay data contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; + self.validate_payment_address_update(&input).await?; // 2. Compute avatar hashes when raw bytes are provided. let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { @@ -144,22 +145,9 @@ impl DashPayView<'_, B> { }; // 3. Build the document property map. - let mut properties = std::collections::BTreeMap::new(); - if let Some(ref name) = input.display_name { - properties.insert("displayName".to_string(), Value::Text(name.clone())); - } - if let Some(ref msg) = input.public_message { - properties.insert("publicMessage".to_string(), Value::Text(msg.clone())); - } - if let Some(ref url) = input.avatar_url { - properties.insert("avatarUrl".to_string(), Value::Text(url.clone())); - } - if let Some(hash) = avatar_hash { - properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); - } - if let Some(fp) = avatar_fingerprint { - properties.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); - } + let properties = + merge_profile_properties(Default::default(), &input, avatar_hash, avatar_fingerprint); + let profile = profile_from_properties(&properties); // 4. Look up identity + signing key. The identity_index is not // needed here — the signer is supplied externally. @@ -233,15 +221,6 @@ impl DashPayView<'_, B> { }) .await?; - let profile = crate::wallet::identity::DashPayProfile { - display_name: input.display_name, - bio: input.public_message.clone(), - avatar_url: input.avatar_url, - avatar_hash, - avatar_fingerprint, - public_message: input.public_message, - }; - { let mut wm = self.wallet_manager.write().await; if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { @@ -275,6 +254,7 @@ impl DashPayView<'_, B> { // 1. The DashPay contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; + self.validate_payment_address_update(&input).await?; // 2. Fetch existing profile document for ID + revision + its // current property map (seed for the read-modify-write merge). @@ -445,9 +425,30 @@ fn merge_profile_properties( if let Some(fp) = avatar_fingerprint { existing.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); } + for (field, patch) in payment_address_updates(input) { + match patch { + crate::PaymentAddressUpdate::Keep => {} + crate::PaymentAddressUpdate::Set(bytes) => { + existing.insert(field.to_string(), Value::Bytes(bytes.clone())); + } + crate::PaymentAddressUpdate::Remove => { + existing.remove(field); + } + } + } existing } +fn payment_address_updates( + input: &crate::ProfileUpdate, +) -> [(&'static str, &crate::PaymentAddressUpdate); 3] { + [ + ("corePaymentAddress", &input.core_payment_address), + ("platformPaymentAddress", &input.platform_payment_address), + ("shieldedAddress", &input.shielded_address), + ] +} + /// Parse a profile document's property map into a [`DashPayProfile`]. /// Empty strings are normalized to `None`. `avatarHash`/`avatarFingerprint` /// are read via `as_bytes_slice` so both `Bytes` and the sized `Bytes32` @@ -479,6 +480,21 @@ fn profile_from_properties( avatar_hash, avatar_fingerprint, public_message, + core_payment_address: props + .get("corePaymentAddress") + .and_then(|v| v.as_bytes_slice().ok()) + .filter(|bytes| crate::valid_transparent_payment_address(bytes)) + .map(|bytes| bytes.to_vec()), + platform_payment_address: props + .get("platformPaymentAddress") + .and_then(|v| v.as_bytes_slice().ok()) + .filter(|bytes| crate::valid_transparent_payment_address(bytes)) + .map(|bytes| bytes.to_vec()), + shielded_address: props + .get("shieldedAddress") + .and_then(|v| v.as_bytes_slice().ok()) + .and_then(crate::validated_shielded_address) + .map(|bytes| bytes.to_vec()), } } @@ -1020,3 +1036,374 @@ mod tests { } } } + +impl DashPayView<'_, B> { + /// Fetch a fresh, proof-verified profile, bypassing the contact cache. + pub async fn fetch_profile( + &self, + identity_id: &Identifier, + ) -> Result, PlatformWalletError> { + use dash_sdk::platform::FetchMany; + use dpp::document::Document; + let contract = super::dashpay_contract()?; + let documents = + Document::fetch_many(&self.sdk, single_profile_query(&contract, identity_id)).await?; + Ok(documents + .into_values() + .flatten() + .next() + .map(|doc| profile_from_properties(doc.properties()))) + } + + /// Resolve the current DPNS owner and its current shielded tip address. + pub async fn resolve_shielded_tip( + &self, + username: &str, + ) -> Result { + let identity_id = self.sdk.resolve_dpns_name(username).await?.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData("Username was not found".to_string()) + })?; + let address = self + .fetch_profile(&identity_id) + .await? + .and_then(|profile| profile.shielded_address) + .and_then(|bytes| crate::validated_shielded_address(&bytes)) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "This profile has no valid shielded tip address".to_string(), + ) + })?; + Ok(crate::ShieldedTipRecipient { + identity_id, + address, + }) + } + + async fn validate_payment_address_update( + &self, + input: &crate::ProfileUpdate, + ) -> Result<(), PlatformWalletError> { + use crate::PaymentAddressUpdate; + use dash_sdk::platform::Fetch; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::prelude::DataContract; + let updates = payment_address_updates(input); + if updates + .iter() + .all(|(_, patch)| matches!(patch, PaymentAddressUpdate::Keep)) + { + return Ok(()); + } + for (field, patch) in updates { + if let PaymentAddressUpdate::Set(bytes) = patch { + let valid = if field == "shieldedAddress" { + crate::validated_shielded_address(bytes).is_some() + } else { + crate::valid_transparent_payment_address(bytes) + }; + if !valid { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Invalid or unsupported {field}" + ))); + } + } + } + // The bundled latest schema does not prove the connected chain has + // activated it. Fetch the actual contract before submitting new fields. + let contract = DataContract::fetch( + &self.sdk, + dpp::data_contracts::SystemDataContract::Dashpay.id(), + ) + .await? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "DashPay contract is not available".to_string(), + ) + })?; + let profile = contract + .document_type_for_name("profile") + .map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?; + for (field, patch) in updates { + if !matches!(patch, PaymentAddressUpdate::Keep) + && !profile.properties().contains_key(field) + { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "The connected network does not support {field} yet" + ))); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod address_patch_tests { + use super::*; + use crate::{PaymentAddressUpdate, ProfileUpdate}; + + #[test] + fn should_preserve_replace_and_remove_payment_addresses_independently() { + let original = std::collections::BTreeMap::from([ + ("corePaymentAddress".into(), Value::Bytes(vec![0; 21])), + ("platformPaymentAddress".into(), Value::Bytes(vec![1; 21])), + ("shieldedAddress".into(), Value::Bytes(vec![2; 43])), + ("displayName".into(), Value::Text("Alice".into())), + ]); + let renamed = merge_profile_properties( + original.clone(), + &ProfileUpdate { + display_name: Some("Alice II".into()), + ..Default::default() + }, + None, + None, + ); + assert_eq!( + renamed.get("shieldedAddress"), + original.get("shieldedAddress") + ); + let changed = merge_profile_properties( + renamed, + &ProfileUpdate { + shielded_address: PaymentAddressUpdate::Set(vec![3; 43]), + core_payment_address: PaymentAddressUpdate::Remove, + ..Default::default() + }, + None, + None, + ); + assert!(!changed.contains_key("corePaymentAddress")); + assert_eq!( + changed.get("platformPaymentAddress"), + original.get("platformPaymentAddress") + ); + assert_eq!( + changed.get("shieldedAddress"), + Some(&Value::Bytes(vec![3; 43])) + ); + let removed = merge_profile_properties( + changed, + &ProfileUpdate { + shielded_address: PaymentAddressUpdate::Remove, + ..Default::default() + }, + None, + None, + ); + assert!(!removed.contains_key("shieldedAddress")); + assert_eq!( + removed.get("displayName"), + Some(&Value::Text("Alice II".into())) + ); + } + + #[test] + fn should_ignore_invalid_addresses_without_losing_profile() { + let profile = profile_from_properties(&std::collections::BTreeMap::from([ + ("displayName".into(), Value::Text("Alice".into())), + ("shieldedAddress".into(), Value::Bytes(vec![255; 43])), + ("corePaymentAddress".into(), Value::Bytes(vec![255; 21])), + ])); + assert_eq!(profile.display_name.as_deref(), Some("Alice")); + assert!(profile.shielded_address.is_none()); + assert!(profile.core_payment_address.is_none()); + } + + #[cfg(feature = "shielded")] + #[test] + fn should_accept_external_addresses_with_nondefault_diversifiers() { + let keys = crate::wallet::shielded::OrchardKeySet::from_seed( + &[9; 64], + key_wallet::Network::Testnet, + 7, + ) + .unwrap(); + let raw = keys.address_at(19).to_raw_address_bytes(); + assert_ne!(raw, keys.default_address.to_raw_address_bytes()); + let profile = profile_from_properties(&std::collections::BTreeMap::from([( + "shieldedAddress".into(), + Value::Bytes(raw.to_vec()), + )])); + assert_eq!(profile.shielded_address, Some(raw.to_vec())); + assert!(crate::validated_shielded_address(&raw[..42]).is_none()); + } +} + +#[cfg(all(test, feature = "shielded"))] +mod tip_resolution_tests { + use super::*; + use crate::wallet::shielded::{ + CachedOrchardProver, FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, + }; + use dash_sdk::drive::query::{WhereClause, WhereOperator}; + use dash_sdk::{platform::DocumentQuery, query_types::Documents, SdkBuilder}; + use dpp::{ + document::{Document, DocumentV0}, + system_data_contracts::{load_system_data_contract, SystemDataContract}, + version::PlatformVersion, + }; + + // Exercise both SDK queries through the real wallet API. No expectations + // permit a send/broadcast: a changed or invalid destination must stop first. + #[tokio::test] + async fn should_resolve_fresh_profiles_and_refuse_changed_tip_destinations() { + let original_owner = Identifier::from([1; 32]); + let new_owner = Identifier::from([2; 32]); + let keys = OrchardKeySet::from_seed(&[42; 64], key_wallet::Network::Testnet, 9).unwrap(); + let original_address = keys.default_address.to_raw_address_bytes(); + let changed_address = keys.address_at(7).to_raw_address_bytes(); + for (owner, address, expected_error) in [ + (original_owner, Some(original_address.to_vec()), "bound"), + ( + new_owner, + Some(original_address.to_vec()), + "recipient changed", + ), + ( + original_owner, + Some(changed_address.to_vec()), + "recipient changed", + ), + (original_owner, None, "no valid shielded tip address"), + ( + original_owner, + Some(vec![255; 43]), + "no valid shielded tip address", + ), + ] { + let dir = std::env::temp_dir().join(format!( + "tip-resolution-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut sdk = SdkBuilder::new_mock() + .with_network(key_wallet::Network::Testnet) + .with_version(PlatformVersion::latest()) + .with_dump_dir(&dir) + .build() + .unwrap(); + let dpns = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .unwrap(); + sdk.mock() + .expect_fetch(SystemDataContract::DPNS.id(), Some(dpns.clone())) + .await + .unwrap(); + let domain = Document::V0(DocumentV0 { + id: Identifier::from([3; 32]), + owner_id: owner, + revision: Some(1), + properties: [( + "records".into(), + Value::Map(vec![( + Value::Text("identity".into()), + Value::Identifier(owner.to_buffer()), + )]), + )] + .into(), + ..Default::default() + }); + let query = DocumentQuery { + data_contract: Arc::new(dpns), + document_type_name: "domain".into(), + where_clauses: vec![ + WhereClause { + field: "normalizedParentDomainName".into(), + operator: WhereOperator::Equal, + value: Value::Text("dash".into()), + }, + WhereClause { + field: "normalizedLabel".into(), + operator: WhereOperator::Equal, + value: Value::Text("a11ce".into()), + }, + ], + select: dash_sdk::drive::query::SelectProjection::documents(), + time_range_clauses: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + sub_queries: vec![], + }; + sdk.mock() + .expect_fetch_many::( + query, + Some([(domain.id(), Some(domain))].into()), + ) + .await + .unwrap(); + let profile = Document::V0(DocumentV0 { + id: Identifier::from([4; 32]), + owner_id: owner, + revision: Some(1), + properties: address + .map(|bytes| ("shieldedAddress".into(), Value::Bytes(bytes))) + .into_iter() + .collect(), + ..Default::default() + }); + let contract = super::super::dashpay_contract().unwrap(); + sdk.mock() + .expect_fetch_many::( + single_profile_query(&contract, &owner), + Some([(profile.id(), Some(profile))].into()), + ) + .await + .unwrap(); + let sdk = Arc::new(sdk); + let (wm, wallet_id, generation, _) = crate::test_support::funded_wallet_manager( + key_wallet::account::StandardAccountType::BIP44Account, + ) + .await; + let spv = Arc::new(crate::spv::SpvRuntime::new( + wm.clone(), + Arc::new(crate::events::PlatformEventManager::new(vec![])), + )); + let wallet = crate::PlatformWallet::new( + sdk.clone(), + wallet_id, + wm, + generation, + Arc::new(tokio::sync::Notify::new()), + Arc::new(crate::wallet::persister::NoPlatformPersistence), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + ); + let path = dir.join("tree.sqlite"); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + sdk, + key_wallet::Network::Testnet, + path, + store, + )); + let confirmed = crate::ShieldedTipRecipient { + identity_id: original_owner, + address: original_address, + }; + let error = wallet + .send_shielded_tip( + &coordinator, + &[42; 64], + 0, + "Alice.dash", + &confirmed, + 1000, + [0; 36], + &CachedOrchardProver::new(), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains(expected_error), + "expected {expected_error}, got {error}" + ); + drop(coordinator); + std::fs::remove_dir_all(dir).unwrap(); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs index 339520651d7..a0bd1440c38 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs @@ -10,5 +10,5 @@ pub use established_contact::EstablishedContact; pub use payment::{DashpayAddressMatch, PaymentDirection, PaymentEntry, PaymentStatus}; pub use profile::{ calculate_avatar_hash, calculate_dhash_fingerprint, ContactProfileEntry, DashPayProfile, - ProfileUpdate, + PaymentAddressUpdate, ProfileUpdate, ShieldedTipRecipient, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs index 83fc5f9a5dc..877e9512151 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs @@ -37,6 +37,15 @@ pub struct DashPayProfile { pub avatar_fingerprint: Option<[u8; 8]>, /// Public message broadcast to contacts. pub public_message: Option, + /// Core P2PKH/P2SH storage address (type byte plus HASH160, 21 bytes). + #[cfg_attr(feature = "serde", serde(default))] + pub core_payment_address: Option>, + /// Platform P2PKH/P2SH storage address (21 bytes). + #[cfg_attr(feature = "serde", serde(default))] + pub platform_payment_address: Option>, + /// Complete raw Orchard address (11-byte diversifier + 32-byte pk_d). + #[cfg_attr(feature = "serde", serde(default))] + pub shielded_address: Option>, } /// A cached **contact** profile, keyed by the contact's identity id on the @@ -79,6 +88,49 @@ pub struct ProfileUpdate { /// includes them in the document, then drops the bytes. /// `None` = no avatar / remove avatar. pub avatar_bytes: Option>, + pub core_payment_address: PaymentAddressUpdate, + pub platform_payment_address: PaymentAddressUpdate, + pub shielded_address: PaymentAddressUpdate, +} + +/// Explicit patch semantics: omission must never remove a published address. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum PaymentAddressUpdate { + #[default] + Keep, + Set(Vec), + Remove, +} + +/// Recipient shown at confirmation. Re-resolve before sending and compare both +/// fields so a changed DPNS owner or profile cannot silently redirect a tip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShieldedTipRecipient { + pub identity_id: dpp::prelude::Identifier, + pub address: [u8; 43], +} + +/// Decode a raw Orchard recipient using the same decoder as shielded transfers. +/// Without shielded support an address must never be advertised as payable. +pub fn validated_shielded_address(bytes: &[u8]) -> Option<[u8; 43]> { + #[cfg(feature = "shielded")] + { + let raw: [u8; 43] = bytes.try_into().ok()?; + Option::::from( + grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(&raw), + ) + .map(|_| raw) + } + #[cfg(not(feature = "shielded"))] + { + let _ = bytes; + None + } +} + +/// Storage-form transparent address validation, matching the DashPay trigger. +pub fn valid_transparent_payment_address(bytes: &[u8]) -> bool { + bytes.len() == 21 && matches!(bytes.first(), Some(0x00 | 0x01)) } /// Compute SHA-256 hash of image bytes (DIP-15 `avatarHash` field). diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs index 288f88a021c..80ac9a8fd04 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/mod.rs @@ -12,6 +12,7 @@ pub mod key_storage; pub use block_time::BlockTime; pub use dashpay::{ ContactProfileEntry, ContactRequest, DashPayProfile, DashpayAddressMatch, EstablishedContact, - PaymentDirection, PaymentEntry, PaymentStatus, ProfileUpdate, + PaymentAddressUpdate, PaymentDirection, PaymentEntry, PaymentStatus, ProfileUpdate, + ShieldedTipRecipient, }; pub use key_storage::{DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData}; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index dad43b97310..c33b8a06c3d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -794,12 +794,29 @@ impl PlatformWallet { // snapshot predates a Clear. let snapshot_generation = coordinator.clear_generation(); let network = self.sdk.network; + let mut accounts: std::collections::BTreeSet = accounts.iter().copied().collect(); + accounts.extend(self.discovered_tip_accounts().await?); + // Keep retired tip accounts scanning, including identities removed from + // the local manager after their address was published. + let start = self + .persister + .load() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + accounts.extend( + start + .shielded + .viewing_keys + .keys() + .filter(|id| { + id.wallet_id == self.wallet_id + && super::shielded::is_shielded_tip_account(id.account_index) + }) + .map(|id| id.account_index), + ); let mut account_views: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for &account in accounts { - // `accounts` may contain duplicates; the BTreeMap - // dedups by definition. The full keyset (with its - // `SpendAuthorizingKey`) is dropped at the end of + for &account in &accounts { + // The full keyset (with its `SpendAuthorizingKey`) is dropped at the end of // this iteration — only the viewing half survives. let ks = OrchardKeySet::from_seed(seed, network, account)?; account_views.insert(account, ks.viewing_keys()); @@ -817,11 +834,6 @@ impl PlatformWallet { // treat it like the malformed-row case below: surface it rather // than silently mixing two keys' state. The recovery is a // shielded Clear, which drops both sides at once. - let start = self.persister.load().map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!( - "persister load failed while binding shielded viewing keys: {e}" - )) - })?; for (account, views) in &account_views { let id = SubwalletId::new(self.wallet_id, *account); if let Some(persisted) = start.shielded.viewing_keys.get(&id) { @@ -911,9 +923,22 @@ impl PlatformWallet { "persister load failed while rebinding shielded viewing keys: {e}" )) })?; + let mut accounts: std::collections::BTreeSet = accounts.iter().copied().collect(); + accounts.extend(self.discovered_tip_accounts().await?); + accounts.extend( + start + .shielded + .viewing_keys + .keys() + .filter(|id| { + id.wallet_id == self.wallet_id + && super::shielded::is_shielded_tip_account(id.account_index) + }) + .map(|id| id.account_index), + ); let mut account_views: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for &account in accounts { + for &account in &accounts { let id = SubwalletId::new(self.wallet_id, account); let Some(fvk_bytes) = start.shielded.viewing_keys.get(&id) else { return Ok(false); @@ -2302,22 +2327,22 @@ mod shield_input_selection_tests { #[test] fn regression_reports_max_from_usable_suffix_not_total_account_balance() { - // Real account snapshot: the leading address is below the reserve, so - // capacity must come from the usable suffix, not the account total. - assert!( - 297_264_780 <= reserve(), - "regression shape requires the leading address to stay below the reserve; \ - re-seed the balances if the versioned reserve drops under 297_264_780" - ); + // Keep the leading address below the versioned reserve: PV14's lower + // fees made the historical 297_264_780-credit fixture spendable. + // Capacity must still come from the usable suffix, not the total. + let leading_balance = reserve() / 2; let candidates = vec![ - (addr(1), 297_264_780), + (addr(1), leading_balance), (addr(2), 2_000_000_000), (addr(3), 1_623_849_220), ]; let plan = plan(candidates).unwrap(); let expected_max = 3_623_849_220 - reserve(); - assert_eq!(plan.preflight.account_balance_credits, 3_921_114_000); + assert_eq!( + plan.preflight.account_balance_credits, + 3_623_849_220 + leading_balance + ); assert_eq!(plan.preflight.usable_balance_credits, 3_623_849_220); assert_eq!(plan.preflight.fee_reserve_credits, reserve()); assert_eq!(plan.preflight.max_shieldable_credits, expected_max); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 7685f44b883..d1f1dc2095e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -43,6 +43,8 @@ pub mod prover; pub mod seed_pool; pub mod store; pub mod sync; +pub mod tips; +pub use tips::{is_shielded_tip_account, shielded_tip_account_index, SHIELDED_TIP_ACCOUNT_BASE}; #[cfg(test)] mod viewing_key_bind_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs index 6cdb39536f8..010015e32e9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -281,3 +281,33 @@ fn shield_memo_round_trips_through_ovk_recovery() { "the OVK-recovered memo must decode back to the original text" ); } + +#[test] +fn should_restore_tip_notes_without_exposing_personal_notes_to_tip_viewing_key() { + use crate::wallet::shielded::shielded_tip_account_index; + let seed = [0x67; 64]; + let tip_index = shielded_tip_account_index(0).unwrap(); + let tips = OrchardKeySet::from_seed(&seed, Network::Testnet, tip_index).unwrap(); + let restored = OrchardKeySet::from_seed(&seed, Network::Testnet, tip_index).unwrap(); + let personal = OrchardKeySet::from_seed(&seed, Network::Testnet, 0).unwrap(); + // Rotation is just another address of the dedicated account. Both remain + // discoverable without a saved diversifier index or a current profile. + for recipient in [tips.default_address, tips.address_at(17)] { + let wire = make_own_ovk_wire_note( + recipient, + tips.outgoing_viewing_key.clone(), + 123_456, + [0; 36], + ); + assert!(try_decrypt_note_with_memo(&restored.prepared_ivk(), &wire).is_some()); + assert!(try_decrypt_note_with_memo(&personal.prepared_ivk(), &wire).is_none()); + } + let personal_wire = make_own_ovk_wire_note( + personal.default_address, + personal.outgoing_viewing_key.clone(), + 456_789, + [0; 36], + ); + assert!(try_decrypt_note_with_memo(&tips.prepared_ivk(), &personal_wire).is_none()); + assert!(try_recover_outgoing_note(&tips.outgoing_viewing_key, &personal_wire).is_none()); +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs new file mode 100644 index 00000000000..1ff0226fa03 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -0,0 +1,185 @@ +//! Dedicated DashPay tip accounts. Account selection is wallet policy, not +//! consensus: external Orchard addresses remain valid profile values. +//! +//! Reserve the upper half of the ZIP-32 account space for tips. A wallet-owned +//! identity at index i uses account 0x40000000 + i. Ordinary accounts use the +//! lower half. This mapping never depends on a username, the current profile, +//! or local allocation history: identity discovery followed by shielded bind +//! reconstructs it even after publication was removed. Address rotation uses +//! another diversifier in the same account, so all old addresses remain covered. + +use super::NetworkShieldedCoordinator; +use crate::wallet::platform_wallet::PlatformWallet; +use crate::{PlatformWalletError, ShieldedTipRecipient}; +use dpp::prelude::Identifier; +use std::sync::Arc; + +pub const SHIELDED_TIP_ACCOUNT_BASE: u32 = 0x4000_0000; + +pub fn shielded_tip_account_index(identity_index: u32) -> Result { + if identity_index >= SHIELDED_TIP_ACCOUNT_BASE { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "Identity index exceeds the dedicated tip account range".to_string(), + )); + } + Ok(SHIELDED_TIP_ACCOUNT_BASE + identity_index) +} + +pub fn is_shielded_tip_account(account: u32) -> bool { + (SHIELDED_TIP_ACCOUNT_BASE..0x8000_0000).contains(&account) +} + +impl PlatformWallet { + pub(crate) async fn discovered_tip_accounts(&self) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; + // An identity outside this convention can still use ordinary accounts + // or publish an external address; it must not block wallet-wide sync. + Ok(info + .identity_manager + .managed_identities() + .filter(|identity| identity.wallet_id == Some(self.wallet_id())) + .filter_map(|identity| identity.identity_index) + .filter_map(|index| shielded_tip_account_index(index).ok()) + .collect()) + } + + /// Prepare a dedicated receiving account without publishing anything. Full + /// bind persists its viewing key and registers it with the coordinator before + /// callers can publish the returned address. Repeated calls are idempotent. + pub async fn prepare_shielded_tip_address( + &self, + seed: &[u8], + identity_id: &Identifier, + coordinator: &Arc, + ) -> Result<[u8; 43], PlatformWalletError> { + // Do not publish a key from a mis-associated mnemonic. A first bind + // has no persisted FVK against which to detect a wrong seed. + let root = key_wallet::wallet::root_extended_keys::RootExtendedPrivKey::new_master(seed) + .map_err(|e| PlatformWalletError::ShieldedKeyDerivation(e.to_string()))?; + let public = root.to_root_extended_pub_key(); + drop(root); + let scoped_id = key_wallet::Wallet::compute_wallet_id_from_root_extended_pub_key( + &public, + Some(self.sdk.network), + ); + let legacy_id = + key_wallet::Wallet::compute_wallet_id_from_root_extended_pub_key(&public, None); + if self.wallet_id() != scoped_id && self.wallet_id() != legacy_id { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "The supplied seed does not belong to this wallet".to_string(), + )); + } + let account = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id()).ok_or_else(|| { + PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())) + })?; + let identity = info + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + if identity.wallet_id != Some(self.wallet_id()) { + return Err(PlatformWalletError::InvalidIdentityData( + "Tip account requires a wallet-owned identity".to_string(), + )); + } + shielded_tip_account_index(identity.identity_index.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Tip account requires a recoverable identity index".to_string(), + ) + })?)? + }; + let mut accounts = self.shielded_account_indices().await; + accounts.push(account); + self.bind_shielded(seed, &accounts, coordinator).await?; + self.persister() + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + self.shielded_default_address(account) + .await + .ok_or(PlatformWalletError::ShieldedNotBound) + } + + /// Send only to the identity/address pair that the user confirmed. A changed + /// name owner or profile requires a new confirmation; there is no fallback to + /// a transparent rail. Network changes after this check cannot change the + /// destination, which remains the explicitly confirmed raw address. + #[allow(clippy::too_many_arguments)] + pub async fn send_shielded_tip( + &self, + coordinator: &Arc, + seed: &[u8], + account: u32, + username: &str, + expected_recipient: &ShieldedTipRecipient, + amount: u64, + memo: [u8; 36], + prover: P, + ) -> Result<(), PlatformWalletError> { + let current = self + .identity() + .dashpay() + .resolve_shielded_tip(username) + .await?; + if current != *expected_recipient { + return Err(PlatformWalletError::InvalidIdentityData( + "The tip recipient changed; review and confirm the payment again".to_string(), + )); + } + self.shielded_transfer_to( + coordinator, + seed, + account, + ¤t.address, + amount, + memo, + prover, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::super::OrchardKeySet; + use super::*; + use key_wallet::Network; + + #[test] + fn should_derive_distinct_recoverable_tip_accounts() { + let seed = [42u8; 64]; + let ordinary = OrchardKeySet::from_seed(&seed, Network::Testnet, 0).unwrap(); + let index = shielded_tip_account_index(0).unwrap(); + let tip = OrchardKeySet::from_seed(&seed, Network::Testnet, index).unwrap(); + let restored = OrchardKeySet::from_seed( + &seed, + Network::Testnet, + shielded_tip_account_index(0).unwrap(), + ) + .unwrap(); + let other = OrchardKeySet::from_seed( + &seed, + Network::Testnet, + shielded_tip_account_index(1).unwrap(), + ) + .unwrap(); + assert_ne!( + ordinary.full_viewing_key.to_bytes(), + tip.full_viewing_key.to_bytes() + ); + assert_ne!( + tip.default_address.to_raw_address_bytes(), + other.default_address.to_raw_address_bytes() + ); + assert_eq!( + tip.full_viewing_key.to_bytes(), + restored.full_viewing_key.to_bytes() + ); + assert!(!is_shielded_tip_account(0)); + assert!(is_shielded_tip_account(index)); + assert!(shielded_tip_account_index(SHIELDED_TIP_ACCOUNT_BASE).is_err()); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 600c5fbbbde..a064a9d479f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -750,3 +750,166 @@ async fn rebind_without_persisted_rows_reports_false_and_binds_nothing() { "malformed persisted viewing key must surface as an error" ); } + +/// A profile is not a recovery record. Identity discovery alone must restore +/// the reserved account, even when the owner removed the tip address entirely. +#[tokio::test] +async fn should_restore_tip_account_from_identity_discovery_and_register_for_sync() { + use crate::shielded_tip_account_index; + use dpp::identity::{Identity, IdentityV0}; + use dpp::prelude::Identifier; + let phrase = crate::test_support::MESSAGE_SIGNING_TEST_MNEMONIC; + let seed = key_wallet::Mnemonic::from_phrase(phrase, key_wallet::Language::English) + .unwrap() + .to_seed(""); + let id = Identifier::from([0x33; 32]); + let account = shielded_tip_account_index(3).unwrap(); + let mut original = None; + for session in 0..2 { + let persister = Arc::new(CapturingPersistence::default()); + let (wallet_manager, wallet_id, _, _) = + crate::test_support::mnemonic_wallet_manager(phrase).await; + let generation = wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .unwrap() + .generation + .clone(); + let spv = Arc::new(crate::spv::SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(crate::events::PlatformEventManager::new(Vec::new())), + )); + let sdk = dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .unwrap(); + let wallet = PlatformWallet::new( + Arc::new(sdk), + wallet_id, + wallet_manager, + generation, + Arc::new(tokio::sync::Notify::new()), + persister.clone(), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + ); + { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 3, + wallet.wallet_id(), + wallet.persister(), + ) + .unwrap(); + } + { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: Identifier::from([0x44; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + crate::SHIELDED_TIP_ACCOUNT_BASE, + wallet.wallet_id(), + wallet.persister(), + ) + .unwrap(); + } + let coordinator = coordinator_at(&temp_dir(&format!("tips_restore_{session}"))); + // Fresh database, no viewing keys, no published profile, and caller + // only knows about ordinary account zero. + assert!(!wallet + .bind_shielded_from_persisted(&[0], &coordinator) + .await + .unwrap()); + assert!(wallet + .prepare_shielded_tip_address(&[0x11; 64], &id, &coordinator) + .await + .is_err()); + assert!(persister.captured_viewing_keys().is_empty()); + assert!(coordinator.registered_subwallets().await.is_empty()); + wallet + .bind_shielded(&seed, &[0], &coordinator) + .await + .unwrap(); + assert!(coordinator + .registered_subwallets() + .await + .contains(&SubwalletId::new(wallet.wallet_id(), account))); + assert!(wallet + .prepare_shielded_tip_address(&[0x11; 64], &id, &coordinator) + .await + .is_err()); + let address = wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(); + assert!(wallet + .prepare_shielded_tip_address(&seed, &Identifier::from([0x44; 32]), &coordinator) + .await + .is_err()); + assert_ne!(wallet.shielded_default_address(0).await.unwrap(), address); + assert_eq!( + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(), + address + ); + assert!(persister + .captured_viewing_keys() + .contains_key(&SubwalletId::new(wallet.wallet_id(), account))); + if let Some(first) = original { + assert_eq!(first, address); + } else { + original = Some(address); + } + } +} + +#[tokio::test] +async fn should_rebind_retired_tip_accounts_without_profile_or_identity() { + let seed = [0x42; 64]; + let account = crate::shielded_tip_account_index(7).unwrap(); + let persister = Arc::new(CapturingPersistence::default()); + let wallet = platform_wallet_with(Arc::clone(&persister)).await; + let keys = super::OrchardKeySet::from_seed(&seed, Network::Testnet, account).unwrap(); + let ordinary = super::OrchardKeySet::from_seed(&seed, Network::Testnet, 0).unwrap(); + persister.serve_viewing_keys(BTreeMap::from([ + ( + SubwalletId::new(wallet.wallet_id(), 0), + ordinary.full_viewing_key.to_bytes().to_vec(), + ), + ( + SubwalletId::new(wallet.wallet_id(), account), + keys.full_viewing_key.to_bytes().to_vec(), + ), + ])); + let coordinator = coordinator_at(&temp_dir("retired_tips")); + assert!(wallet + .bind_shielded_from_persisted(&[0], &coordinator) + .await + .unwrap()); + assert_eq!( + wallet.shielded_default_address(account).await.unwrap(), + keys.default_address.to_raw_address_bytes() + ); + assert!(coordinator + .registered_subwallets() + .await + .contains(&SubwalletId::new(wallet.wallet_id(), account))); +} diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index f7831f29c4b..93be353459c 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -418,12 +418,13 @@ impl Sdk { // Extract the identity from records.identity if let Some(Value::Map(records)) = doc.properties().get("records") { for (key, value) in records { - if let (Value::Text(k), Value::Identifier(id_bytes)) = (key, value) { - if k == "identity" { - return Ok(Some(Identifier::from_bytes(id_bytes).map_err(|e| { - Error::Generic(format!("Invalid identifier: {}", e)) - })?)); - } + if key.as_text() == Some("identity") { + // CBOR and document decoding can represent the same + // identifier as Bytes/Bytes32 instead of Identifier. + return value + .to_identifier() + .map(Some) + .map_err(|e| Error::Generic(format!("Invalid identifier: {e}"))); } } } diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 57557093aea..edb64744665 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -653,6 +653,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createO avatar_bytes: JByteArray, do_create: jboolean, signer_handle: jlong, + core_action: jint, + core_address: JByteArray, + platform_action: jint, + platform_address: JByteArray, + shielded_action: jint, + shielded_address: JByteArray, ) -> jstring { guard(&mut env, ptr::null_mut(), |env| { let Some(id) = read_id32(env, &identity_id, "identityId") else { @@ -683,9 +689,34 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createO } }; + let address_bytes = [&core_address, &platform_address, &shielded_address] + .into_iter() + .map(|address| { + if address.is_null() { + Ok(Vec::new()) + } else { + env.convert_byte_array(address) + } + }) + .collect::, _>>(); + let address_bytes = match address_bytes { + Ok(bytes) => bytes, + Err(_) => { + crate::support::throw_sdk_exception(env, 1, "Unreadable payment address"); + return ptr::null_mut(); + } + }; + let actions = [core_action, platform_action, shielded_action]; + let updates: [_; 3] = std::array::from_fn(|index| { + platform_wallet_ffi::dashpay_profile::PaymentAddressUpdateFFI { + action: actions[index] as u32, + bytes: address_bytes[index].as_ptr(), + len: address_bytes[index].len(), + } + }); let mut profile = DashPayProfileFFI::empty(); let result = unsafe { - platform_wallet_ffi::platform_wallet_create_or_update_dashpay_profile_with_signer( + platform_wallet_ffi::platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( wallet_handle as Handle, id.as_ptr(), display.as_ref().map_or(ptr::null(), |c| c.as_ptr()), @@ -693,6 +724,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createO url.as_ref().map_or(ptr::null(), |c| c.as_ptr()), avatar.as_ref().map_or(ptr::null(), |v| v.as_ptr()), avatar.as_ref().map_or(0, |v| v.len()), + &updates[0], + &updates[1], + &updates[2], do_create != 0, signer_handle as *mut SignerHandle, &mut profile as *mut DashPayProfileFFI, diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 1a53abf9652..1b518fb8453 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1019,3 +1019,155 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let _ = take_pwffi_error(env, result); }) } + +/// Dedicated tip account preparation; returns the complete raw Orchard address. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_prepareShieldedTipAddress( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, + identity_id: JByteArray, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let mut address = [0; 43]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_prepare_shielded_tip_address( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + id.as_ptr(), + address.as_mut_ptr(), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&address) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Current verified recipient: 32-byte identity ID followed by 43-byte address. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_resolveShieldedTip( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + username: JString, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return ptr::null_mut(); + } + }; + let mut recipient = [0; 75]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_resolve_shielded_tip( + wallet as Handle, + username.as_ptr(), + recipient.as_mut_ptr(), + recipient.as_mut_ptr().add(32), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&recipient) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Revalidates the confirmed recipient before spending. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShieldedTip( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, + account: jint, + username: JString, + expected_id: JByteArray, + expected_address: JByteArray, + amount: jlong, + memo: JString, +) { + guard(&mut env, (), |env| { + if account < 0 || amount <= 0 { + throw_sdk_exception(env, 1, "Invalid account or amount"); + return; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return; + }; + let Some(id) = read_id32(env, &expected_id, "expectedIdentityId") else { + return; + }; + let Some(address) = read_recipient43(env, &expected_address) else { + return; + }; + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return; + } + }; + let memo = match read_cstring_opt(env, &memo, "memo") { + Ok(value) => value, + Err(()) => return, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_send_shielded_tip( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + account as u32, + username.as_ptr(), + id.as_ptr(), + address.as_ptr(), + amount as u64, + memo.as_ref().map_or(ptr::null(), |m| m.as_ptr()), + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_tipAccountIndex( + mut env: JNIEnv, + _class: JClass, + identity_index: jint, +) -> jint { + guard(&mut env, -1, |env| { + if identity_index < 0 { + throw_sdk_exception(env, 1, "identity index must be non-negative"); + return -1; + } + let mut account = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_shielded_tip_account_index( + identity_index as u32, + &mut account, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + account as jint + }) +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 732534d25f0..e60d5343908 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1183,13 +1183,28 @@ unsafe fn persist_identity_upsert( let public_message = cstr_opt(env, e.dashpay_profile_public_message)?; let avatar_hash = env.byte_array_from_slice(&e.dashpay_profile_avatar_hash)?; let avatar_fp = env.byte_array_from_slice(&e.dashpay_profile_avatar_fingerprint)?; + let core_payment_address = if e.dashpay_profile_core_payment_address_present { + JObject::from(env.byte_array_from_slice(&e.dashpay_profile_core_payment_address)?) + } else { + JObject::null() + }; + let platform_payment_address = if e.dashpay_profile_platform_payment_address_present { + JObject::from(env.byte_array_from_slice(&e.dashpay_profile_platform_payment_address)?) + } else { + JObject::null() + }; + let shielded_address = if e.dashpay_profile_shielded_address_present { + JObject::from(env.byte_array_from_slice(&e.dashpay_profile_shielded_address)?) + } else { + JObject::null() + }; let code = env .call_method( bridge, "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ - Ljava/lang/String;[BZ[BZLjava/lang/String;)I", + Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I", &[ wid.into(), (&identity_id).into(), @@ -1211,6 +1226,9 @@ unsafe fn persist_identity_upsert( (&avatar_fp).into(), JValue::Bool(e.dashpay_profile_avatar_fingerprint_present as u8), (&public_message).into(), + (&core_payment_address).into(), + (&platform_payment_address).into(), + (&shielded_address).into(), ], )? .i()?; @@ -1234,11 +1252,26 @@ unsafe fn persist_identity_upsert( let public_message = cstr_opt(env, row.public_message)?; let avatar_hash = env.byte_array_from_slice(&row.avatar_hash)?; let avatar_fp = env.byte_array_from_slice(&row.avatar_fingerprint)?; + let core_payment_address = if row.core_payment_address_present { + JObject::from(env.byte_array_from_slice(&row.core_payment_address)?) + } else { + JObject::null() + }; + let platform_payment_address = if row.platform_payment_address_present { + JObject::from(env.byte_array_from_slice(&row.platform_payment_address)?) + } else { + JObject::null() + }; + let shielded_address = if row.shielded_address_present { + JObject::from(env.byte_array_from_slice(&row.shielded_address)?) + } else { + JObject::null() + }; env.call_method( bridge, "onPersistContactProfileDelta", "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ - [BZ[BZLjava/lang/String;J)I", + [BZ[BZLjava/lang/String;J[B[B[B)I", &[ wid.into(), (&identity_id).into(), @@ -1253,6 +1286,9 @@ unsafe fn persist_identity_upsert( JValue::Bool(row.avatar_fingerprint_present as u8), (&public_message).into(), JValue::Long(row.checked_at_ms as i64), + (&core_payment_address).into(), + (&platform_payment_address).into(), + (&shielded_address).into(), ], )? .i() @@ -2080,6 +2116,7 @@ struct IdentityRestoreStaged { ignored_senders: Vec<[u8; 32]>, payments: Vec, contact_profiles: Vec, + dashpay_profile: Option, } /// Staged DashPay payment-history row: FFI struct with `txid` / `memo` @@ -2108,6 +2145,21 @@ struct ContactProfileRestoreStaged { public_message: Option, } +fn seal_profile(profile: ContactProfileRestoreStaged) -> ContactProfileRestoreEntryFFI { + let ContactProfileRestoreStaged { + mut row, + display_name, + bio, + avatar_url, + public_message, + } = profile; + row.display_name = opt_cstring_into_raw(display_name); + row.bio = opt_cstring_into_raw(bio); + row.avatar_url = opt_cstring_into_raw(avatar_url); + row.public_message = opt_cstring_into_raw(public_message); + row +} + /// Staged DashPay contact-restore row: FFI struct with every pointer /// field still null / 0 until sealed, plus the owned buffers that back /// them. Mirrors the Swift `buildIdentityRestoreBuffer` contact block: @@ -2293,6 +2345,7 @@ fn seal_wallet_entries(staged: Vec) -> Vec = keys .into_iter() @@ -2365,26 +2418,12 @@ fn seal_wallet_entries(staged: Vec) -> Vec = - contact_profiles - .into_iter() - .map( - |ContactProfileRestoreStaged { - mut row, - display_name, - bio, - avatar_url, - public_message, - }| { - row.display_name = opt_cstring_into_raw(display_name); - row.bio = opt_cstring_into_raw(bio); - row.avatar_url = opt_cstring_into_raw(avatar_url); - row.public_message = - opt_cstring_into_raw(public_message); - row - }, - ) - .collect(); + contact_profiles.into_iter().map(seal_profile).collect(); (entry.contact_profiles, entry.contact_profiles_count) = vec_into_raw(contact_profiles); entry @@ -3164,6 +3203,18 @@ fn build_identity_restore( contact_profiles.push(cp); } + let owned_profile = env + .get_field( + holder, + "dashpayProfile", + "Lorg/dashfoundation/dashsdk/ffi/ContactProfileRestoreData;", + )? + .l()?; + let dashpay_profile = if owned_profile.is_null() { + None + } else { + Some(build_contact_profile_restore(env, &owned_profile)?) + }; let entry = IdentityRestoreEntryFFI { identity_id, balance, @@ -3184,6 +3235,7 @@ fn build_identity_restore( ignored_senders_count: 0, contact_profiles: ptr::null(), contact_profiles_count: 0, + dashpay_profile: ptr::null(), }; Ok(IdentityRestoreStaged { entry, @@ -3192,6 +3244,7 @@ fn build_identity_restore( ignored_senders, payments, contact_profiles, + dashpay_profile, }) } @@ -3260,6 +3313,24 @@ fn build_contact_profile_restore( Err(_) => ([0u8; 8], false), }; + let bytes = read_bytes_field_vec(env, holder, "corePaymentAddress")?; + let (core_payment_address, core_payment_address_present) = + match <[u8; 21]>::try_from(bytes.as_slice()) { + Ok(value) => (value, true), + Err(_) => ([0; 21], false), + }; + let bytes = read_bytes_field_vec(env, holder, "platformPaymentAddress")?; + let (platform_payment_address, platform_payment_address_present) = + match <[u8; 21]>::try_from(bytes.as_slice()) { + Ok(value) => (value, true), + Err(_) => ([0; 21], false), + }; + let bytes = read_bytes_field_vec(env, holder, "shieldedAddress")?; + let (shielded_address, shielded_address_present) = match <[u8; 43]>::try_from(bytes.as_slice()) + { + Ok(value) => (value, true), + Err(_) => ([0; 43], false), + }; Ok(ContactProfileRestoreStaged { row: ContactProfileRestoreEntryFFI { contact_id, @@ -3270,6 +3341,12 @@ fn build_contact_profile_restore( avatar_hash_present, avatar_fingerprint, avatar_fingerprint_present, + core_payment_address, + core_payment_address_present, + platform_payment_address, + platform_payment_address_present, + shielded_address, + shielded_address_present, public_message: ptr::null(), checked_at_ms, }, @@ -3603,6 +3680,16 @@ unsafe extern "C" fn tramp_load_wallet_list_free( e.identities_count, )); for ident in idents.iter() { + if !ident.dashpay_profile.is_null() { + let profile = Box::from_raw( + ident.dashpay_profile as *mut ContactProfileRestoreEntryFFI, + ); + free_raw_cstring(profile.display_name); + free_raw_cstring(profile.bio); + free_raw_cstring(profile.avatar_url); + free_raw_cstring(profile.public_message); + } + if !ident.keys.is_null() && ident.keys_count > 0 { let keys: Box<[IdentityKeyRestoreFFI]> = Box::from_raw(std::ptr::slice_from_raw_parts_mut( @@ -4525,13 +4612,13 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ - Ljava/lang/String;[BZ[BZLjava/lang/String;)I", + Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I", ), ("onPersistIdentityRemoval", "([B[B)I"), ( "onPersistContactProfileDelta", "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ - [BZ[BZLjava/lang/String;J)I", + [BZ[BZLjava/lang/String;J[B[B[B)I", ), ( "onPersistIdentityKeyUpsert", diff --git a/packages/rs-unified-sdk-jni/src/tokens.rs b/packages/rs-unified-sdk-jni/src/tokens.rs index d16b4cc9081..b2488d52ba5 100644 --- a/packages/rs-unified-sdk-jni/src/tokens.rs +++ b/packages/rs-unified-sdk-jni/src/tokens.rs @@ -1583,6 +1583,42 @@ pub(crate) fn profile_to_json(profile: &DashPayProfileFFI) -> String { .collect(); fields.push(format!("\"avatarFingerprint\":{}", json_string(&hex))); } + if profile.core_payment_address_is_some { + fields.push(format!( + "\"corePaymentAddress\":{}", + json_string( + &profile + .core_payment_address + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + )); + } + if profile.platform_payment_address_is_some { + fields.push(format!( + "\"platformPaymentAddress\":{}", + json_string( + &profile + .platform_payment_address + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + )); + } + if profile.shielded_address_is_some { + fields.push(format!( + "\"shieldedAddress\":{}", + json_string( + &profile + .shielded_address + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + )); + } format!("{{{}}}", fields.join(",")) } @@ -2006,7 +2042,19 @@ mod tests { profile.avatar_hash[31] = 0x01; profile.avatar_fingerprint_is_some = true; profile.avatar_fingerprint = [0x10, 0x20, 0, 0, 0, 0, 0, 0xFF]; + profile.core_payment_address_is_some = true; + profile.core_payment_address = [1; 21]; + profile.platform_payment_address_is_some = true; + profile.platform_payment_address = [2; 21]; + profile.shielded_address_is_some = true; + profile.shielded_address = [3; 43]; let json = profile_to_json(&profile); + assert!(json.contains(&format!("\"corePaymentAddress\":\"{}\"", "01".repeat(21)))); + assert!(json.contains(&format!( + "\"platformPaymentAddress\":\"{}\"", + "02".repeat(21) + ))); + assert!(json.contains(&format!("\"shieldedAddress\":\"{}\"", "03".repeat(43)))); // 32-byte hash: 0xAB … 0x01 → "ab" prefix, "01" suffix (64 hex chars). assert!(json.contains("\"avatarHash\":\"ab"), "got {json}"); assert!(json.contains("01\""), "hash suffix; got {json}"); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index eaa0ff44317..03fadfc95cf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -123,17 +123,22 @@ public enum DashModelContainer { + [PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V4). + /// The V4 model set includes the sweep columns, before payment metadata. + fileprivate static var v4ModelTypes: [any PersistentModel.Type] { + allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] + } + + /// All persistent model types in the current Dash SDK schema (V5). /// Unlike the lists above this one tracks the LIVE models, so it moves /// whenever a model gains a property — which is exactly why the /// released versions must not. public static var modelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] + v4ModelTypes + [PersistentDashpayPaymentAddresses.self] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV5.self) } /// Create a persistent model container for storing data @@ -181,14 +186,15 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -391,6 +397,12 @@ public enum DashSchemaV4: VersionedSchema { } public static var models: [any PersistentModel.Type] { - DashModelContainer.modelTypes + DashModelContainer.v4ModelTypes } } + +/// Version 5 adds a separate payment-address metadata table for DashPay profiles. +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift index 3106c69cc04..5bbbf4173b3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift @@ -11,7 +11,7 @@ import SwiftData /// without re-fetching on every launch. The cache is /// relationship-independent — it serves established contacts, pending /// incoming-request senders, and (later) ignored senders from one table, -/// matching the Rust map. It holds **only the five public profile +/// matching the Rust map. It holds **only the public profile /// fields** parsed from the on-chain `profile` document; it must never /// receive anything derived from the encrypted `contactInfo` path. /// @@ -176,3 +176,16 @@ extension PersistentDashpayContactProfile { } } } + +// Address metadata is stored separately so released profile/identity relationships +// retain their SwiftData schema identity across upgrades. +extension PersistentDashpayContactProfile { + private var paymentAddresses: PersistentDashpayPaymentAddresses? { + guard let modelContext else { return nil } + return try? PersistentDashpayPaymentAddresses.fetch(in: modelContext, networkRaw: networkRaw, + ownerIdentityId: ownerIdentityId, profileIdentityId: contactIdentityId) + } + public var corePaymentAddress: Data? { paymentAddresses?.corePaymentAddress } + public var platformPaymentAddress: Data? { paymentAddresses?.platformPaymentAddress } + public var shieldedAddress: Data? { paymentAddresses?.shieldedAddress } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift new file mode 100644 index 00000000000..8d484ecbb36 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftData + +/// Raw payment addresses for an owned or cached contact profile. Keeping this +/// metadata independent preserves the released profile/identity relationship schema. +@Model +public final class PersistentDashpayPaymentAddresses { + #Unique([\.networkRaw, \.ownerIdentityId, \.profileIdentityId]) + + public var networkRaw: UInt32 + public var ownerIdentityId: Data + public var profileIdentityId: Data + public var corePaymentAddress: Data? + public var platformPaymentAddress: Data? + public var shieldedAddress: Data? + + public init(networkRaw: UInt32, ownerIdentityId: Data, profileIdentityId: Data, + corePaymentAddress: Data? = nil, platformPaymentAddress: Data? = nil, + shieldedAddress: Data? = nil) { + self.networkRaw = networkRaw + self.ownerIdentityId = ownerIdentityId + self.profileIdentityId = profileIdentityId + self.corePaymentAddress = corePaymentAddress + self.platformPaymentAddress = platformPaymentAddress + self.shieldedAddress = shieldedAddress + } + + static func fetch(in context: ModelContext, networkRaw: UInt32, + ownerIdentityId: Data, profileIdentityId: Data, fetcher: any ModelFetching = LiveModelFetcher()) throws -> PersistentDashpayPaymentAddresses? { + let query = FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerIdentityId && $0.profileIdentityId == profileIdentityId + }) + return try fetcher.fetch(query, in: context).first + } + + /// Called within the persister's transaction; empty metadata removes the row. + static func replace(in context: ModelContext, networkRaw: UInt32, + ownerIdentityId: Data, profileIdentityId: Data, + core: Data?, platform: Data?, shielded: Data?, + fetcher: any ModelFetching = LiveModelFetcher()) throws { + let existing = try fetch(in: context, networkRaw: networkRaw, + ownerIdentityId: ownerIdentityId, profileIdentityId: profileIdentityId, fetcher: fetcher) + if core == nil && platform == nil && shielded == nil { + if let existing { context.delete(existing) } + return + } + let row = existing ?? PersistentDashpayPaymentAddresses(networkRaw: networkRaw, + ownerIdentityId: ownerIdentityId, profileIdentityId: profileIdentityId) + row.corePaymentAddress = core + row.platformPaymentAddress = platform + row.shieldedAddress = shielded + if existing == nil { context.insert(row) } + } + + static func removeOwned(in context: ModelContext, networkRaw: UInt32, ownerIdentityId: Data) throws { + try context.delete(model: PersistentDashpayPaymentAddresses.self, where: #Predicate { + $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerIdentityId + }) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift index 3e6eb2dd126..f0cdf470a70 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift @@ -128,3 +128,16 @@ extension PersistentDashpayProfile { } } } + +// Address metadata is stored separately so released profile/identity relationships +// retain their SwiftData schema identity across upgrades. +extension PersistentDashpayProfile { + private var paymentAddresses: PersistentDashpayPaymentAddresses? { + guard let modelContext else { return nil } + return try? PersistentDashpayPaymentAddresses.fetch(in: modelContext, networkRaw: networkRaw, + ownerIdentityId: identity.identityId, profileIdentityId: identity.identityId) + } + public var corePaymentAddress: Data? { paymentAddresses?.corePaymentAddress } + public var platformPaymentAddress: Data? { paymentAddresses?.platformPaymentAddress } + public var shieldedAddress: Data? { paymentAddresses?.shieldedAddress } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift index e7208cbe53b..5b9f17eb610 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift @@ -26,19 +26,33 @@ public struct DashPayProfile: Sendable, Equatable { /// Perceptual dHash (8 bytes / 64 bits) of the avatar. Present /// whenever the on-chain document carried an `avatarFingerprint`. public let avatarFingerprint: Data? + /// Core address storage encoding: type byte followed by HASH160 (21 bytes). + public let corePaymentAddress: Data? + /// Platform address storage encoding: type byte followed by HASH160 (21 bytes). + public let platformPaymentAddress: Data? + /// Complete raw Orchard address: diversifier (11 bytes) and public key (32 bytes). + public let shieldedAddress: Data? + public init( displayName: String? = nil, publicMessage: String? = nil, avatarUrl: String? = nil, avatarHash: Data? = nil, - avatarFingerprint: Data? = nil + avatarFingerprint: Data? = nil, + corePaymentAddress: Data? = nil, + platformPaymentAddress: Data? = nil, + shieldedAddress: Data? = nil ) { self.displayName = displayName self.publicMessage = publicMessage self.avatarUrl = avatarUrl self.avatarHash = avatarHash self.avatarFingerprint = avatarFingerprint + self.corePaymentAddress = corePaymentAddress + self.platformPaymentAddress = platformPaymentAddress + self.shieldedAddress = shieldedAddress + } /// Copy a `DashPayProfileFFI` into a Swift-owned value. The @@ -55,12 +69,16 @@ public struct DashPayProfile: Sendable, Equatable { self.avatarFingerprint = ffi.avatar_fingerprint_is_some ? Data(fromTuple8: ffi.avatar_fingerprint) : nil + self.corePaymentAddress = ffi.core_payment_address_is_some ? Swift.withUnsafeBytes(of: ffi.core_payment_address) { Data($0) } : nil + self.platformPaymentAddress = ffi.platform_payment_address_is_some ? Swift.withUnsafeBytes(of: ffi.platform_payment_address) { Data($0) } : nil + self.shieldedAddress = ffi.shielded_address_is_some ? Swift.withUnsafeBytes(of: ffi.shielded_address) { Data($0) } : nil + } } /// Input for `ManagedPlatformWallet.createDashPayProfile` / -/// `updateDashPayProfile`. Every field is optional; fields left as -/// `nil` are simply omitted from the outgoing document. +/// `updateDashPayProfile`. Optional text/avatar fields left as `nil` +/// are omitted. Payment addresses explicitly distinguish keep, set, and remove. /// /// `avatarBytes` is the raw image payload pre-downloaded by the app /// layer. When provided, platform-wallet computes the SHA-256 hash @@ -71,17 +89,28 @@ public struct DashPayProfileUpdate: Sendable { public var publicMessage: String? public var avatarUrl: String? public var avatarBytes: Data? + public var corePaymentAddress: DashPayPaymentAddressUpdate + public var platformPaymentAddress: DashPayPaymentAddressUpdate + public var shieldedAddress: DashPayPaymentAddressUpdate + public init( displayName: String? = nil, publicMessage: String? = nil, avatarUrl: String? = nil, - avatarBytes: Data? = nil + avatarBytes: Data? = nil, + corePaymentAddress: DashPayPaymentAddressUpdate = .keep, + platformPaymentAddress: DashPayPaymentAddressUpdate = .keep, + shieldedAddress: DashPayPaymentAddressUpdate = .keep ) { self.displayName = displayName self.publicMessage = publicMessage self.avatarUrl = avatarUrl self.avatarBytes = avatarBytes + self.corePaymentAddress = corePaymentAddress + self.platformPaymentAddress = platformPaymentAddress + self.shieldedAddress = shieldedAddress + } } @@ -122,3 +151,24 @@ private extension Data { self = Swift.withUnsafeBytes(of: &value) { Data($0) } } } + +/// Explicit operation on a payment address property. +public enum DashPayPaymentAddressUpdate: Sendable, Equatable { + case keep + case set(Data) + case remove + + func withFFI(_ body: (UnsafePointer) throws -> T) rethrows -> T { + let action: UInt32 + let data: Data + switch self { + case .keep: action = 0; data = Data() + case .set(let bytes): action = 1; data = bytes + case .remove: action = 2; data = Data() + } + return try data.withUnsafeBytes { bytes in + var value = PaymentAddressUpdateFFI(action: action, bytes: bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), len: UInt(data.count)) + return try withUnsafePointer(to: &value, body) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 8664e1dfb90..19a76588d1d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2763,37 +2763,19 @@ extension ManagedPlatformWallet { publicMessage, avatarUrl ) { namePtr, msgPtr, urlPtr -> PlatformWalletFFIResult in - let bytes = avatarBytes ?? Data() - if let avatarBytes, !avatarBytes.isEmpty { - return avatarBytes.withUnsafeBytes { rawBuf -> PlatformWalletFFIResult in - let bytesPtr = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) - return platform_wallet_create_or_update_dashpay_profile_with_signer( - handle, - idPtr, - namePtr, - msgPtr, - urlPtr, - bytesPtr, - UInt(avatarBytes.count), - doCreate, - signerHandle, - &outProfile - ) + return update.corePaymentAddress.withFFI { core in + update.platformPaymentAddress.withFFI { platform in + update.shieldedAddress.withFFI { shielded in + (avatarBytes ?? Data()).withUnsafeBytes { bytes in + platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( + handle, idPtr, namePtr, msgPtr, urlPtr, + bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(bytes.count), core, platform, shielded, + doCreate, signerHandle, &outProfile + ) + } + } } - } else { - _ = bytes - return platform_wallet_create_or_update_dashpay_profile_with_signer( - handle, - idPtr, - namePtr, - msgPtr, - urlPtr, - nil, - 0, - doCreate, - signerHandle, - &outProfile - ) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index ed60ab38dfd..4bb693102c3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -1236,3 +1236,88 @@ func shieldedTreeProgressCallback( ) } } + +public struct ShieldedTipRecipient: Sendable, Equatable { + public let identityId: Data + public let address: Data +} + +extension ManagedPlatformWallet { + /// Fetch and verify the current DPNS identity and shielded tip address. + public func resolveShieldedTip(username: String) async throws -> ShieldedTipRecipient { + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + var identityId = [UInt8](repeating: 0, count: 32) + var address = [UInt8](repeating: 0, count: 43) + try username.withCString { + try platform_wallet_resolve_shielded_tip(handle, $0, &identityId, &address).check() + } + return ShieldedTipRecipient(identityId: Data(identityId), address: Data(address)) + }.value + } +} + +extension PlatformWalletManager { + /// Prepare the dedicated tip account. Publication is a separate profile update. + public func prepareShieldedTipAddress(walletId: Data, identityId: Data, resolver: MnemonicResolver) async throws -> Data { + guard walletId.count == 32, identityId.count == 32, let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter("Expected wallet/identity IDs and mnemonic resolver") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + try withExtendedLifetime(resolver) { + var address = [UInt8](repeating: 0, count: 43) + try walletId.withUnsafeBytes { wallet in + try identityId.withUnsafeBytes { identity in + try platform_wallet_manager_prepare_shielded_tip_address( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle, + identity.baseAddress!.assumingMemoryBound(to: UInt8.self), &address + ).check() + } + } + return Data(address) + } + }.value + } + + /// Recheck the confirmed recipient before building and broadcasting the payment. + public func sendShieldedTip(walletId: Data, resolver: MnemonicResolver, account: UInt32 = 0, + username: String, recipient: ShieldedTipRecipient, amount: UInt64) async throws { + guard walletId.count == 32, recipient.identityId.count == 32, recipient.address.count == 43, + let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter("Invalid shielded tip recipient or wallet") + } + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try withExtendedLifetime(resolver) { + try walletId.withUnsafeBytes { wallet in + try recipient.identityId.withUnsafeBytes { identity in + try recipient.address.withUnsafeBytes { address in + try username.withCString { name in + try platform_wallet_manager_send_shielded_tip( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle, + account, name, identity.baseAddress!.assumingMemoryBound(to: UInt8.self), + address.baseAddress!.assumingMemoryBound(to: UInt8.self), amount, nil + ).check() + } + } + } + } + } + }.value + } +} + +extension PlatformWalletManager { + public static func shieldedTipAccountIndex(identityIndex: UInt32) throws -> UInt32 { + var account: UInt32 = 0 + try platform_wallet_shielded_tip_account_index(identityIndex, &account).check() + return account + } +} + +extension PlatformWalletManager { + public static func isShieldedTipAccount(_ account: UInt32) -> Bool { + platform_wallet_is_shielded_tip_account(account) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 49e97964602..eebbf7bfecd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3326,12 +3326,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// were dropped from the Rust side — the former moved to the UI /// layer, the latter is now derived from /// `IdentityManager.highestRegistrationIndex(...)` at read time. + @discardableResult func persistIdentities( walletId: Data, upserts: [IdentityEntrySnapshot], removed: [Data] - ) { + ) -> Bool { onQueue { + do { for entry in upserts { let identityId = entry.identityId let descriptor = FetchDescriptor( @@ -3425,7 +3427,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // shape: a missing snapshot leaves any existing row // intact. if let profile = entry.dashpayProfile { - upsertDashpayProfile(identityRow: row, profile: profile) + try upsertDashpayProfile(identityRow: row, profile: profile) } // Upsert the cached contact-profile rows for this identity. @@ -3437,7 +3439,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // contact simply MISSING from this flush is "no update" (not a // delete). An empty array leaves any existing rows intact. if !entry.contactProfiles.isEmpty { - upsertDashpayContactProfiles( + try upsertDashpayContactProfiles( identityRow: row, profiles: entry.contactProfiles ) @@ -3494,11 +3496,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.identityId == identityId } ) if let existing = try? backgroundContext.fetch(descriptor).first { + try PersistentDashpayPaymentAddresses.removeOwned(in: backgroundContext, + networkRaw: existing.networkRaw, ownerIdentityId: identityId) backgroundContext.delete(existing) } } // No save() — bracketed by changesetBegin/End. + return true + } catch { + SDKLogger.event("persistence_profile_addresses_failed", category: .persistence, + severity: .error, error: error) + return false + } } // onQueue } @@ -3632,7 +3642,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func upsertDashpayProfile( identityRow: PersistentIdentity, profile: DashpayProfileSnapshot - ) { + ) throws { if let existing = identityRow.dashpayProfile { // Field-level refresh. Every column is overwritten on // every flush — the FFI snapshot is authoritative for @@ -3647,6 +3657,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.avatarUrl = profile.avatarUrl existing.avatarHash = profile.avatarHash existing.avatarFingerprint = profile.avatarFingerprint + existing.lastUpdated = Date() } else { let row = PersistentDashpayProfile( @@ -3664,6 +3675,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `PersistentIdentity.dashpayProfile`, so we don't need // to assign `identityRow.dashpayProfile = row` here. } + try PersistentDashpayPaymentAddresses.replace(in: backgroundContext, + networkRaw: identityRow.networkRaw, ownerIdentityId: identityRow.identityId, + profileIdentityId: identityRow.identityId, core: profile.corePaymentAddress, + platform: profile.platformPaymentAddress, shielded: profile.shieldedAddress, fetcher: modelFetcher) } /// Upsert one `PersistentDashpayContactProfile` row per cached @@ -3693,7 +3708,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func upsertDashpayContactProfiles( identityRow: PersistentIdentity, profiles: [ContactProfileSnapshot] - ) { + ) throws { let ownerIdentityId = identityRow.identityId for profile in profiles { let contactIdentityId = profile.contactIdentityId @@ -3704,6 +3719,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) ) guard profile.isPresent else { + try PersistentDashpayPaymentAddresses.replace(in: backgroundContext, + networkRaw: identityRow.networkRaw, ownerIdentityId: ownerIdentityId, + profileIdentityId: contactIdentityId, core: nil, platform: nil, shielded: nil, fetcher: modelFetcher) // Confirmed-absent: delete the stale row if one exists; a // never-persisted contact is a no-op. if let existing = try? backgroundContext.fetch(descriptor).first { @@ -3718,6 +3736,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.avatarUrl = profile.avatarUrl existing.avatarHash = profile.avatarHash existing.avatarFingerprint = profile.avatarFingerprint + existing.checkedAtMs = profile.checkedAtMs existing.lastUpdated = Date() } else { @@ -3737,6 +3756,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // collection from the `inverse:` declaration on // `PersistentIdentity.contactProfiles`. } + try PersistentDashpayPaymentAddresses.replace(in: backgroundContext, + networkRaw: identityRow.networkRaw, ownerIdentityId: ownerIdentityId, + profileIdentityId: contactIdentityId, core: profile.corePaymentAddress, + platform: profile.platformPaymentAddress, shielded: profile.shieldedAddress, fetcher: modelFetcher) } } @@ -4796,6 +4819,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// 8-byte DHash perceptual fingerprint. `nil` when the source /// `avatar_fingerprint_present == false`. let avatarFingerprint: Data? + var corePaymentAddress: Data? = nil + var platformPaymentAddress: Data? = nil + var shieldedAddress: Data? = nil /// Wall-clock ms of the last fetch attempt on the Rust side /// (`ContactProfileEntry.checked_at_ms`). let checkedAtMs: UInt64 @@ -4819,6 +4845,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `avatarFingerprint`). `nil` when the source /// `avatar_fingerprint_present == false`. let avatarFingerprint: Data? + var corePaymentAddress: Data? = nil + var platformPaymentAddress: Data? = nil + var shieldedAddress: Data? = nil } /// Swift-side snapshot of `IdentityKeyEntryFFI` — public-key @@ -6154,6 +6183,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // that their problematic cascade children are // gone from the store. for identity in identitiesToDelete { + try PersistentDashpayPaymentAddresses.removeOwned(in: backgroundContext, + networkRaw: identity.networkRaw, ownerIdentityId: identity.identityId) backgroundContext.delete(identity) } try backgroundContext.save() @@ -6696,6 +6727,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + let paymentAddressRows: [PersistentDashpayPaymentAddresses] + do { + paymentAddressRows = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + } catch { + SDKLogger.event("persistence_profile_addresses_load_failed", category: .persistence, + severity: .error, error: error) + return (nil, 0, true) + } + let addressesByOwner = Dictionary(grouping: paymentAddressRows, by: \.ownerIdentityId) + // Allocate `entriesPtr` and the `LoadAllocation` here — past // the fallible SwiftData fetch above — so an early-error path // doesn't leak the entries buffer (LoadAllocation only gets @@ -6850,6 +6891,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } let identitiesBuffer = buildIdentityRestoreBuffer( identities: sortedIdentities, + addressesByOwner: addressesByOwner, allocation: allocation ) @@ -7712,6 +7754,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func buildIdentityRestoreBuffer( identities: [PersistentIdentity], + addressesByOwner: [Data: [PersistentDashpayPaymentAddresses]], allocation: LoadAllocation ) -> UnsafeMutablePointer? { if identities.isEmpty { @@ -7987,6 +8030,62 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // under a wrong key (matching the abort-on-corrupt convention the // UTXO restore uses). Filtering up front also keeps the fixed- // capacity buffer fully initialized so the count stays exact. + let addressRows = (addressesByOwner[identity.identityId] ?? []).filter { $0.networkRaw == identity.networkRaw } + if let profile = identity.dashpayProfile { + let addresses = addressRows.first { $0.profileIdentityId == identity.identityId } + var row = ContactProfileRestoreEntryFFI() + copyBytes(identity.identityId, into: &row.contact_id) + if let displayName = profile.displayName, !displayName.isEmpty { + row.display_name = UnsafePointer( + duplicateCString(displayName, allocation: allocation)) + } + if let bio = profile.bio, !bio.isEmpty { + row.bio = UnsafePointer( + duplicateCString(bio, allocation: allocation)) + } + if let avatarUrl = profile.avatarUrl, !avatarUrl.isEmpty { + row.avatar_url = UnsafePointer( + duplicateCString(avatarUrl, allocation: allocation)) + } + if let publicMessage = profile.publicMessage, !publicMessage.isEmpty { + row.public_message = UnsafePointer( + duplicateCString(publicMessage, allocation: allocation)) + } + // Gate the byte arrays on presence — an absent hash / + // fingerprint must round-trip as `_present == false`, + // not as an all-zero value (which Rust would otherwise + // restore as a real `Some([0u8; N])`). + if let avatarHash = profile.avatarHash, avatarHash.count == 32 { + copyBytes(avatarHash, into: &row.avatar_hash) + row.avatar_hash_present = true + } else { + row.avatar_hash_present = false + } + if let address = addresses?.corePaymentAddress, address.count == 21 { + copyBytes(address, into: &row.core_payment_address) + row.core_payment_address_present = true + } + if let address = addresses?.platformPaymentAddress, address.count == 21 { + copyBytes(address, into: &row.platform_payment_address) + row.platform_payment_address_present = true + } + if let address = addresses?.shieldedAddress, address.count == 43 { + copyBytes(address, into: &row.shielded_address) + row.shielded_address_present = true + } + if let avatarFingerprint = profile.avatarFingerprint, + avatarFingerprint.count == 8 { + copyBytes(avatarFingerprint, into: &row.avatar_fingerprint) + row.avatar_fingerprint_present = true + } else { + row.avatar_fingerprint_present = false + } + let own = UnsafeMutablePointer.allocate(capacity: 1) + own.initialize(to: row) + entry.dashpay_profile = UnsafePointer(own) + allocation.contactProfileArrays.append((own, 1)) + } + let contactProfileRows = identity.contactProfiles.filter { $0.contactIdentityId.count == 32 } @@ -7998,6 +8097,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { capacity: contactProfileRows.count ) for (c, profile) in contactProfileRows.enumerated() { + let addresses = addressRows.first { $0.profileIdentityId == profile.contactIdentityId } var row = ContactProfileRestoreEntryFFI() copyBytes(profile.contactIdentityId, into: &row.contact_id) if let displayName = profile.displayName, !displayName.isEmpty { @@ -8026,6 +8126,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } else { row.avatar_hash_present = false } + if let address = addresses?.corePaymentAddress, address.count == 21 { + copyBytes(address, into: &row.core_payment_address) + row.core_payment_address_present = true + } + if let address = addresses?.platformPaymentAddress, address.count == 21 { + copyBytes(address, into: &row.platform_payment_address) + row.platform_payment_address_present = true + } + if let address = addresses?.shieldedAddress, address.count == 43 { + copyBytes(address, into: &row.shielded_address) + row.shielded_address_present = true + } if let avatarFingerprint = profile.avatarFingerprint, avatarFingerprint.count == 8 { copyBytes(avatarFingerprint, into: &row.avatar_fingerprint) @@ -9343,7 +9455,10 @@ private func persistIdentitiesCallback( publicMessage: e.dashpay_profile_public_message.map { String(cString: $0) }, avatarUrl: e.dashpay_profile_avatar_url.map { String(cString: $0) }, avatarHash: avatarHash, - avatarFingerprint: avatarFingerprint + avatarFingerprint: avatarFingerprint, + corePaymentAddress: e.dashpay_profile_core_payment_address_present ? Swift.withUnsafeBytes(of: e.dashpay_profile_core_payment_address) { Data($0) } : nil, + platformPaymentAddress: e.dashpay_profile_platform_payment_address_present ? Swift.withUnsafeBytes(of: e.dashpay_profile_platform_payment_address) { Data($0) } : nil, + shieldedAddress: e.dashpay_profile_shielded_address_present ? Swift.withUnsafeBytes(of: e.dashpay_profile_shielded_address) { Data($0) } : nil ) } else { dashpayProfile = nil @@ -9380,6 +9495,10 @@ private func persistIdentitiesCallback( avatarUrl: row.avatar_url.map { String(cString: $0) }, avatarHash: avatarHash, avatarFingerprint: avatarFingerprint, + corePaymentAddress: row.core_payment_address_present ? Swift.withUnsafeBytes(of: row.core_payment_address) { Data($0) } : nil, + platformPaymentAddress: row.platform_payment_address_present ? Swift.withUnsafeBytes(of: row.platform_payment_address) { Data($0) } : nil, + shieldedAddress: row.shielded_address_present ? Swift.withUnsafeBytes(of: row.shielded_address) { Data($0) } : nil, + checkedAtMs: row.checked_at_ms ) ) @@ -9411,12 +9530,12 @@ private func persistIdentitiesCallback( } } - handler.persistIdentities( + let success = handler.persistIdentities( walletId: walletId, upserts: upserts, removed: removed ) - return 0 + return success ? 0 : 1 } /// C shim for `on_persist_identity_keys_fn`. Same snapshot + cast diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index a08b9d2bc7d..b10ac59322e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -598,3 +598,30 @@ do { - [SwiftExampleApp Integration](../../../SwiftExampleApp/SwiftExampleApp/Services/DashPayService.swift) - Real-world usage example - [Unit Tests](../../../SwiftTests/SwiftDashSDKTests/PlatformWalletTests.swift) - Comprehensive test examples - [Integration Tests](../../../SwiftTests/SwiftDashSDKTests/PlatformWalletIntegrationTests.swift) - Full workflow examples + +### Shielded DashPay tips + +DashPay profiles can publish `shieldedAddress`, a complete 43-byte raw Orchard +address. `DashPayProfileUpdate` uses `.keep`, `.set(Data)`, and `.remove` for +payment address changes; unrelated profile edits preserve the published address. +Wallet-generated tip addresses use a dedicated shielded account for each local +identity. Call `prepareShieldedTipAddress` and then explicitly publish the returned +address through a signed profile update. External receiving addresses may also be +published; their funds are managed and recovered by the external wallet. + +For seed restoration, discover the wallet's identities and call `bindShielded` +again. The next shielded sync automatically scans historical notes for newly +bound tip accounts; no reset is needed. Rust derives the reserved tip accounts +from the recovered identity indices, including accounts whose profile addresses +were subsequently removed. Removing a published address does not revoke copies +already shared or stop monitoring previously received tips. + +To pay a username, call `resolveShieldedTip`, show the returned identity and +address for confirmation, then call `sendShieldedTip` with that recipient. The +send operation verifies fresh resolution still matches the confirmed recipient. +A `shieldedSpendUnconfirmed` error must not be retried automatically: the payment +may already have been accepted. + +The published address is publicly associated with the username. Dedicated +accounts isolate viewing keys and ordinary receiving activity; transfers between +accounts can still introduce correlations. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipRecipientHistory.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipRecipientHistory.swift new file mode 100644 index 00000000000..29bec556b03 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipRecipientHistory.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Local confirmation history. This is a change warning, never an authorization +/// to pay: `sendShieldedTip` still verifies the confirmed destination on Platform. +public final class ShieldedTipRecipientHistory { + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func hasChanged(network: Network, walletId: Data, username: String, + recipient: ShieldedTipRecipient) -> Bool { + guard let previous = defaults.data(forKey: key(network: network, walletId: walletId, username: username)) else { + return false + } + return previous != recipient.identityId + recipient.address + } + + /// Call only after the user explicitly confirms, including any change warning. + public func confirm(network: Network, walletId: Data, username: String, + recipient: ShieldedTipRecipient) { + defaults.set(recipient.identityId + recipient.address, + forKey: key(network: network, walletId: walletId, username: username)) + } + + private func key(network: Network, walletId: Data, username: String) -> String { + let name = PersistentDPNSName.normalize(username.trimmingCharacters(in: .whitespacesAndNewlines)) + let canonical = name.hasSuffix(".dash") ? name : name + ".dash" + return "dashpay.tipRecipient.\(network.rawValue).\(walletId.toBase58String()).\(canonical)" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index c4376359aaf..1303f54d3a0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -1146,7 +1146,7 @@ struct WalletRowView: View { /// as `platformBalance` (1e11 credits/DASH), so it folds into /// the same divisor in [`combinedDashAmount(coreTotal:)`]. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(UInt64(0)) { $0 + $1.value } + shieldedNotes.filter { !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) }.reduce(UInt64(0)) { $0 + $1.value } } /// Combined wallet balance expressed in DASH for a precomputed diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index de48fdf33bb..c543bc43064 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -493,7 +493,7 @@ struct SendTransactionView: View { /// send source is correct for a non-`firstWallet` wallet whose /// engine binding is live but whose UI mirror is pointed elsewhere. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(0) { $0 + $1.value } + shieldedNotes.filter { !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) }.reduce(0) { $0 + $1.value } } /// Mirrors `WalletDetailView.platformBalance`: BLAST-synced diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 04b69f05cd3..bc7289dacec 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -1047,7 +1047,7 @@ struct BalanceCardView: View { /// non-`firstWallet` wallet whose engine binding is live but whose UI /// mirror is pointed elsewhere. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(0) { $0 + $1.value } + shieldedNotes.filter { !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) }.reduce(0) { $0 + $1.value } } /// Core-chain balance summed from one Rust in-memory account snapshot. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index d47274cd161..9502c749810 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -1834,7 +1834,7 @@ struct CreateIdentityView: View { /// FetchDescriptor round-trip. private func shieldedPoolBalance(for walletId: Data) -> UInt64 { unspentShieldedNotes - .filter { $0.walletId == walletId } + .filter { $0.walletId == walletId && !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) } .reduce(0) { $0 + $1.value } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index b3b9aad1a98..a4061757af6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -1,6 +1,7 @@ import CoreImage.CIFilterBuiltins import SwiftDashSDK import SwiftUI +import SwiftData /// Read-only DashPay profile sheet, promoted out of /// `IdentityDetailView`'s inline card: large avatar, display name, @@ -19,6 +20,15 @@ struct DashPayProfileView: View { @State private var qrImage: UIImage? @State private var qrURI: String? @State private var qrError: String? + @State private var showSpendTips = false + @Query private var shieldedNotes: [PersistentShieldedNote] + + private var tipBalance: UInt64 { + guard let walletId = identity.wallet?.walletId, + let account = try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: identity.identityIndex) else { return 0 } + return shieldedNotes.filter { $0.walletId == walletId && $0.accountIndex == account && !$0.isSpent } + .reduce(UInt64(0)) { $0 &+ $1.value } + } private var displayName: String { if let name = profile?.displayName? @@ -64,6 +74,20 @@ struct DashPayProfileView: View { .listRowBackground(Color.clear) } + Section("Shielded tips") { + if let address = profile?.shieldedAddress, + let display = DashAddress.encodeOrchard(rawBytes: address, network: identity.network) { + Text(display).font(.caption).textSelection(.enabled) + } else { + Text("No tip address published") + } + Text("Dedicated account balance: \(NSDecimalNumber(decimal: Decimal(tipBalance) / 100_000_000_000).stringValue) DASH") + .font(.caption) + Button("Send from dedicated tip account") { showSpendTips = true } + .disabled(tipBalance == 0) + Text("An external receiving address is managed by its own wallet.").font(.caption).foregroundStyle(.secondary) + } + Section("Identity") { Text(identity.identityIdBase58) .font(.caption) @@ -124,6 +148,12 @@ struct DashPayProfileView: View { } } } + .sheet(isPresented: $showSpendTips) { + if let walletId = identity.wallet?.walletId, + let account = try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: identity.identityIndex) { + SendShieldedTipSheet(walletId: walletId, account: account, sourceLabel: "dedicated tip account") + } + } .navigationTitle("Your Profile") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 1eb043a4c22..236b39f95cb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -33,6 +33,7 @@ struct DashPayTabView: View { @State private var segment: DashPaySegment = .contacts @State private var showAddContact = false + @State private var showShieldedTip = false @State private var showAddViaQR = false /// Drives the claim sheet via `.sheet(item:)`. A fresh value (new `id`) @@ -162,6 +163,11 @@ struct DashPayTabView: View { content .navigationTitle("DashPay") .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShieldedTip = true } label: { Image(systemName: "gift") } + .accessibilityLabel("Send shielded tip") + .disabled(walletManager.firstWallet == nil) + } ToolbarItem(placement: .navigationBarTrailing) { Button { refresh() @@ -242,6 +248,11 @@ struct DashPayTabView: View { } } } + .sheet(isPresented: $showShieldedTip) { + if let walletId = activeIdentity?.wallet?.walletId ?? walletManager.firstWallet?.walletId { + SendShieldedTipSheet(walletId: walletId) + } + } .sheet(isPresented: $showAddViaQR) { if let identity = activeIdentity { AddViaQRSheet(identity: identity) @@ -818,7 +829,10 @@ struct DashPayTabView: View { publicMessage: persisted.publicMessage, avatarUrl: persisted.avatarUrl, avatarHash: persisted.avatarHash, - avatarFingerprint: persisted.avatarFingerprint + avatarFingerprint: persisted.avatarFingerprint, + corePaymentAddress: persisted.corePaymentAddress, + platformPaymentAddress: persisted.platformPaymentAddress, + shieldedAddress: persisted.shieldedAddress ) } } @@ -1019,3 +1033,98 @@ private struct AddViaQRSheet: View { } } } + +/// Resolve first, then explicitly confirm the identity, address, and amount. +struct SendShieldedTipSheet: View { + let walletId: Data + var account: UInt32 = 0 + var sourceLabel: String = "ordinary shielded account" + @EnvironmentObject private var walletManager: PlatformWalletManager + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var username = "" + @State private var amount = "" + @State private var recipient: ShieldedTipRecipient? + @State private var busy = false + @State private var error: String? + @State private var submitted = false + @State private var showRecipientChanged = false + + + private var credits: UInt64? { + guard let value = Decimal(string: amount, locale: Locale(identifier: "en_US_POSIX")), value > 0 else { return nil } + let scaled = value * 100_000_000_000 + let number = NSDecimalNumber(decimal: scaled) + guard scaled <= Decimal(UInt64.max), Decimal(number.uint64Value) == scaled else { return nil } + return number.uint64Value + } + + var body: some View { + NavigationStack { + Form { + TextField("Username", text: $username) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .disabled(busy || submitted) + .onChange(of: username) { _, _ in recipient = nil } + TextField("Amount in DASH", text: $amount).keyboardType(.decimalPad) + .disabled(busy || submitted) + if let recipient { + Section("Confirm recipient") { + Text(username) + Text(recipient.identityId.toBase58String()).font(.caption).textSelection(.enabled) + Text(DashAddress.encodeOrchard(rawBytes: recipient.address, network: appState.currentNetwork) ?? "") + .font(.caption2).textSelection(.enabled) + Text("Send \(amount) DASH from your \(sourceLabel).") + } + } + if let error { Text(error).foregroundStyle(.red) } + if submitted { + Text("Tip submitted. Check shielded activity for confirmation.") + } else { + Button(recipient == nil ? "Review recipient" : "Confirm and send tip") { submit() } + .disabled(busy || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || credits == nil) + } + if busy { ProgressView() } + } + .navigationTitle("Shielded tip") + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() }.disabled(busy) } } + .interactiveDismissDisabled(busy) + .alert("Tip recipient changed", isPresented: $showRecipientChanged) { + Button("Review new recipient") {} + Button("Cancel", role: .cancel) { recipient = nil } + } message: { + Text("This username now resolves to a different identity or shielded address than your last confirmation. Verify the change with the recipient before sending.") + } + } + } + + private func submit() { + guard let wallet = walletManager.wallet(for: walletId), let credits else { return } + busy = true + error = nil + Task { @MainActor in + defer { busy = false } + do { + if let recipient { + ShieldedTipRecipientHistory().confirm(network: appState.currentNetwork, walletId: walletId, + username: username, recipient: recipient) + try await walletManager.sendShieldedTip(walletId: walletId, resolver: MnemonicResolver(), + account: account, username: username, recipient: recipient, amount: credits) + submitted = true + } else { + let resolved = try await wallet.resolveShieldedTip(username: username) + if ShieldedTipRecipientHistory().hasChanged(network: appState.currentNetwork, walletId: walletId, + username: username, recipient: resolved) { + showRecipientChanged = true + } + recipient = resolved + } + } catch { + self.error = error.localizedDescription + // A relay-accepted payment may already exist; prevent a second send. + if case PlatformWalletError.shieldedSpendUnconfirmed = error { submitted = true } + recipient = nil + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index edc3ed10dbf..b3cf96dff6c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -1146,6 +1146,8 @@ struct DashPayProfileEditorView: View { @State private var displayName: String = "" @State private var publicMessage: String = "" @State private var avatarUrl: String = "" + @State private var shieldedTipAddress: String = "" + @EnvironmentObject private var appState: AppState @State private var isSaving = false @State private var errorMessage: String? @@ -1203,6 +1205,22 @@ struct DashPayProfileEditorView: View { .foregroundColor(.secondary) } + Section { + TextField("Shielded receiving address", text: $shieldedTipAddress, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.profile.shieldedAddress") + Button("Use a dedicated tip account") { prepareTipAddress() } + .disabled(isSaving) + if !shieldedTipAddress.isEmpty { + Button("Remove tip address", role: .destructive) { shieldedTipAddress = "" } + } + } header: { + Text("Shielded tips") + } footer: { + Text("This address is public. Use a separate account for tips, or paste an address from another wallet. Save to publish. Removing it does not revoke copies already shared.") + } + if let err = errorMessage { Section { Text(err) @@ -1238,11 +1256,27 @@ struct DashPayProfileEditorView: View { displayName = existing.displayName ?? "" publicMessage = existing.publicMessage ?? "" avatarUrl = existing.avatarUrl ?? "" + shieldedTipAddress = existing.shieldedAddress.flatMap { DashAddress.encodeOrchard(rawBytes: $0, network: appState.currentNetwork) } ?? "" } } } } + private func prepareTipAddress() { + guard let walletId else { errorMessage = "This identity has no local wallet."; return } + isSaving = true + errorMessage = nil + Task { @MainActor in + defer { isSaving = false } + do { + let address = try await walletManager.prepareShieldedTipAddress( + walletId: walletId, identityId: identityId, resolver: MnemonicResolver() + ) + shieldedTipAddress = DashAddress.encodeOrchard(rawBytes: address, network: appState.currentNetwork) ?? "" + } catch { errorMessage = error.localizedDescription } + } + } + /// Submit the create / update transition. /// /// When the user enters an avatar URL, we fetch the image bytes @@ -1294,11 +1328,22 @@ struct DashPayProfileEditorView: View { avatarBytes = nil } + let tipUpdate: DashPayPaymentAddressUpdate + let tipText = shieldedTipAddress.trimmingCharacters(in: .whitespacesAndNewlines) + if tipText.isEmpty { + tipUpdate = existing?.shieldedAddress == nil ? .keep : .remove + } else if case .orchard(let address) = DashAddress.parse(tipText, network: appState.currentNetwork).type { + tipUpdate = address == existing?.shieldedAddress ? .keep : .set(address) + } else { + errorMessage = "Enter a valid shielded address for this network." + return + } let update = DashPayProfileUpdate( displayName: cleanedDisplay.isEmpty ? nil : cleanedDisplay, publicMessage: cleanedMsg.isEmpty ? nil : cleanedMsg, avatarUrl: cleanedUrl.isEmpty ? nil : cleanedUrl, - avatarBytes: avatarBytes + avatarBytes: avatarBytes, + shieldedAddress: tipUpdate ) // Resolve the wallet via the identity's `walletId`; diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index b30bdecf72c..29e2634a33c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -361,6 +361,9 @@ struct SearchWalletsForIdentitiesView: View { startIndex: nil, // resume from cache gapLimit: nil // Rust default (IDENTITY_GAP_LIMIT) ) + // Newly discovered identities introduce deterministic tip accounts. + // Rebind before scanning so historical tips are included. + try walletManager.bindShielded(walletId: walletId, resolver: MnemonicResolver()) result = WalletFinding( walletId: walletId, label: label, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 08d1f84c383..b53690b5425 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -281,7 +281,8 @@ final class DashModelMigrationTests: XCTestCase { Schema(versionedSchema: DashSchemaV1.self), Schema(versionedSchema: DashSchemaV2.self), Schema(versionedSchema: DashSchemaV3.self), - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV4.self), + Schema(versionedSchema: DashSchemaV5.self) ] { let names = schema.entities.map(\.name) XCTAssertTrue( @@ -381,3 +382,106 @@ final class DashModelMigrationTests: XCTestCase { true) } } + +extension DashModelMigrationTests { + @MainActor + func testV4StoreMigratesToV5PreservingSweepStateAndProfiles() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("sweep-profile.store") + let oldSchema = Schema(versionedSchema: DashSchemaV4.self) + let oldConfig = ModelConfiguration(schema: oldSchema, url: storeURL, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer(for: oldSchema, configurations: [oldConfig]) + let identityId = Data(repeating: 0x21, count: 32) + let walletId = Data(repeating: 0x31, count: 32) + let winnerTxid = Data(repeating: 0x41, count: 32) + do { + let context = oldContainer!.mainContext + let identity = PersistentIdentity(identityId: identityId, isLocal: true, network: .testnet) + context.insert(identity) + context.insert(PersistentDashpayProfile(identity: identity, displayName: "Preserved profile")) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + wallet.lastAppliedChainLockHeight = 4321 + context.insert(wallet) + let pending = PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, + spendingTxid: winnerTxid, spendingTransaction: nil, walletId: walletId) + pending.isSweptTombstone = true + pending.winnerMinedHeight = 1234 + context.insert(pending) + let funding = PersistentTransaction(txid: Data(repeating: 0x51, count: 32), + transactionData: Data([0x03, 0x00]), context: 2, blockHeight: 100) + context.insert(funding) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 1000, address: "yV4Coin", height: 100) + coin.walletId = walletId + coin.isSpent = true + coin.supersededByTxid = winnerTxid + context.insert(coin) + try context.save() + } + oldContainer = nil + + let schema = Schema(versionedSchema: DashSchemaV5.self) + let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none) + var container: ModelContainer? = try ModelContainer( + for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + do { + let context = container!.mainContext + let profiles = try context.fetch(FetchDescriptor()) + XCTAssertEqual(profiles.map(\.displayName), ["Preserved profile"]) + XCTAssertEqual(profiles.first?.identity.identityId, identityId) + XCTAssertNil(profiles.first?.shieldedAddress) + let wallets = try context.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.map(\.lastAppliedChainLockHeight), [4321]) + let pending = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertTrue(pending.isSweptTombstone) + XCTAssertEqual(pending.winnerMinedHeight, 1234) + XCTAssertEqual(pending.spendingTxid, winnerTxid) + let coin = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertEqual(coin.transaction?.blockHeight, 100) + try PersistentDashpayPaymentAddresses.replace(in: context, + networkRaw: Network.testnet.rawValue, ownerIdentityId: identityId, profileIdentityId: identityId, + core: nil, platform: nil, shielded: Data(repeating: 0x45, count: 43)) + try context.save() + } + container = nil + let reopened = try ModelContainer(for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + let profile = try XCTUnwrap(reopened.mainContext.fetch(FetchDescriptor()).first) + XCTAssertEqual(profile.shieldedAddress, Data(repeating: 0x45, count: 43)) + } + + @MainActor + func testV3ProfileStoreMigratesToPaymentAddressesWithoutLosingProfile() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("profile.store") + let oldSchema = Schema(versionedSchema: DashSchemaV3.self) + let oldConfig = ModelConfiguration(schema: oldSchema, url: storeURL, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer(for: oldSchema, configurations: [oldConfig]) + let identityId = Data(repeating: 0x21, count: 32) + do { + let identity = DashSchemaV1.PersistentIdentity(identityId: identityId, isLocal: true, network: .testnet) + oldContainer!.mainContext.insert(identity) + oldContainer!.mainContext.insert(DashSchemaV1.PersistentDashpayProfile( + identity: identity, displayName: "Preserved profile")) + try oldContainer!.mainContext.save() + } + oldContainer = nil + let schema = Schema(versionedSchema: DashSchemaV5.self) + let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + let profiles = try container.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(profiles.count, 1) + XCTAssertEqual(profiles[0].displayName, "Preserved profile") + XCTAssertNil(profiles[0].shieldedAddress) + try PersistentDashpayPaymentAddresses.replace(in: container.mainContext, + networkRaw: Network.testnet.rawValue, ownerIdentityId: identityId, profileIdentityId: identityId, + core: nil, platform: nil, shielded: Data(repeating: 0x45, count: 43)) + try container.mainContext.save() + XCTAssertEqual(profiles[0].identity.identityId, identityId) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift index 5b509c904e1..80ac52b26e5 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift @@ -120,14 +120,15 @@ final class DashPayContactPersistenceTests: XCTestCase { /// Apply one identity persister round carrying only the given contact /// profiles for the fixture owner — the seam `upsertDashpayContactProfiles` /// runs under. + @discardableResult private func applyContactProfiles( _ profiles: [PlatformWalletPersistenceHandler.ContactProfileSnapshot] - ) { + ) -> Bool { // Bracket the round like the FFI does: `endChangeset` is the only // atomic `save()`, so a bare `persistIdentities` would stage the writes // without committing them. handler.beginChangeset(walletId: walletId) - handler.persistIdentities( + let success = handler.persistIdentities( walletId: walletId, upserts: [ PlatformWalletPersistenceHandler.IdentityEntrySnapshot( @@ -145,7 +146,102 @@ final class DashPayContactPersistenceTests: XCTestCase { ], removed: [] ) - handler.endChangeset(walletId: walletId, success: true) + return handler.endChangeset(walletId: walletId, success: success) + } + + func testContactPaymentAddressesAreReplacedAndRemovedTogetherWithProfile() throws { + let shielded = Data(repeating: 0x33, count: 43) + applyContactProfiles([.init( + contactIdentityId: contactId, isPresent: true, displayName: "Tips", bio: nil, + publicMessage: nil, avatarUrl: nil, avatarHash: nil, avatarFingerprint: nil, + corePaymentAddress: Data(repeating: 1, count: 21), + platformPaymentAddress: Data(repeating: 2, count: 21), + shieldedAddress: shielded, checkedAtMs: 1)]) + let first = try XCTUnwrap(fetchContactProfileRows().first) + XCTAssertEqual(first.shieldedAddress, shielded) + XCTAssertEqual(first.corePaymentAddress, Data(repeating: 1, count: 21)) + XCTAssertEqual(first.platformPaymentAddress, Data(repeating: 2, count: 21)) + applyContactProfiles([.init( + contactIdentityId: contactId, isPresent: true, displayName: "Tips", bio: nil, + publicMessage: nil, avatarUrl: nil, avatarHash: nil, avatarFingerprint: nil, + checkedAtMs: 2)]) + let replaced = try XCTUnwrap(fetchContactProfileRows().first) + XCTAssertNil(replaced.shieldedAddress) + XCTAssertNil(replaced.corePaymentAddress) + XCTAssertNil(replaced.platformPaymentAddress) + } + + func testPaymentAddressReadFailureRejectsProfilePersistenceRound() throws { + handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet, + modelFetcher: PaymentAddressReadFailure()) + let saved = applyContactProfiles([.init(contactIdentityId: contactId, isPresent: true, + displayName: "Must roll back", bio: nil, publicMessage: nil, avatarUrl: nil, + avatarHash: nil, avatarFingerprint: nil, shieldedAddress: Data(repeating: 3, count: 43), + checkedAtMs: 1)]) + XCTAssertFalse(saved) + XCTAssertTrue(try fetchContactProfileRows().isEmpty) + } + + func testPaymentAddressReadFailureRejectsWalletRestore() throws { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount(wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") + account.accountExtendedPubKeyBytes = Data(repeating: 0xEE, count: 78) + context.insert(account) + try context.save() + handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet, + modelFetcher: PaymentAddressReadFailure()) + let (entries, count, errored) = handler.loadWalletList() + XCTAssertTrue(errored) + XCTAssertNil(entries) + XCTAssertEqual(count, 0) + } + + func testOwnedProfilePaymentAddressesSurviveRestoreBuffer() throws { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount(wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") + account.accountExtendedPubKeyBytes = Data(repeating: 0xEE, count: 78) + context.insert(account) + let owner = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + owner.wallet = wallet + context.insert(PersistentDashpayProfile(identity: owner, displayName: "Tip recipient")) + try PersistentDashpayPaymentAddresses.replace(in: context, networkRaw: Network.testnet.rawValue, + ownerIdentityId: ownerId, profileIdentityId: ownerId, core: Data(repeating: 1, count: 21), + platform: Data(repeating: 2, count: 21), shielded: Data(repeating: 3, count: 43)) + try context.save() + + let (entries, count, errored) = handler.loadWalletList() + XCTAssertFalse(errored) + XCTAssertEqual(count, 1) + let buffer = try XCTUnwrap(entries) + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(buffer)) } + let restored = try XCTUnwrap(buffer[0].identities?[0].dashpay_profile).pointee + XCTAssertEqual(restored.display_name.map { String(cString: $0) }, "Tip recipient") + XCTAssertTrue(restored.core_payment_address_present) + XCTAssertTrue(restored.platform_payment_address_present) + XCTAssertTrue(restored.shielded_address_present) + XCTAssertEqual(Swift.withUnsafeBytes(of: restored.shielded_address) { Data($0) }, Data(repeating: 3, count: 43)) + } + + func testTipConfirmationHistoryPersistsAndDoesNotOverwriteOnChangeCheck() throws { + let suite = "tip-history-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let original = ShieldedTipRecipient(identityId: ownerId, address: Data(repeating: 3, count: 43)) + let changed = ShieldedTipRecipient(identityId: contactId, address: original.address) + let rotated = ShieldedTipRecipient(identityId: ownerId, address: Data(repeating: 4, count: 43)) + ShieldedTipRecipientHistory(defaults: defaults).confirm(network: .testnet, walletId: walletId, + username: "Alice", recipient: original) + let restored = ShieldedTipRecipientHistory(defaults: defaults) + XCTAssertFalse(restored.hasChanged(network: .testnet, walletId: walletId, username: "a11ce.dash", recipient: original)) + XCTAssertTrue(restored.hasChanged(network: .testnet, walletId: walletId, username: "Alice", recipient: changed)) + XCTAssertTrue(restored.hasChanged(network: .testnet, walletId: walletId, username: "Alice", recipient: rotated)) + XCTAssertFalse(restored.hasChanged(network: .testnet, walletId: walletId, username: "Alice", recipient: original)) + XCTAssertFalse(restored.hasChanged(network: .mainnet, walletId: walletId, username: "Alice", recipient: changed)) + XCTAssertFalse(restored.hasChanged(network: .testnet, walletId: contactId, username: "Alice", recipient: changed)) } // MARK: Contact-profile tombstone delete @@ -1154,3 +1250,11 @@ final class DashPayPaymentFFIMarshallingTests: XCTestCase { XCTAssertEqual(payment.txid, "") } } + +private struct PaymentAddressReadFailure: ModelFetching { + struct ReadError: Error {} + func fetch(_ descriptor: FetchDescriptor, in context: ModelContext) throws -> [T] { + if T.self == PersistentDashpayPaymentAddresses.self { throw ReadError() } + return try context.fetch(descriptor) + } +} From 4956dfacd4a7aa291a5f0b99bb7b1cefb4a4e519 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 18:21:19 -0500 Subject: [PATCH 02/15] fix(swift-example-app): expose shielded tip address storage --- .../Views/StorageExplorerView.swift | 8 +++++ .../Views/StorageModelListViews.swift | 34 +++++++++++++++++++ .../Views/StorageRecordDetailViews.swift | 23 +++++++++++++ scripts/check-storage-explorer.sh | 11 +++--- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 64bd42c8867..10277115e06 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -55,6 +55,13 @@ struct StorageExplorerView: View { ) { DashpayContactProfileStorageListView(network: network) } + modelRow( + "DashPay Payment Addresses", + icon: "qrcode", + type: PersistentDashpayPaymentAddresses.self + ) { + DashpayPaymentAddressesStorageListView(network: network) + } modelRow( "DashPay Payments", icon: "arrow.left.arrow.right.circle", @@ -281,6 +288,7 @@ struct StorageExplorerView: View { directCount(PersistentDashpayProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactRequest.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactProfile.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentDashpayPaymentAddresses.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayPayment.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayIgnoredSender.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDocument.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 6a735390481..dd822002e59 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -474,6 +474,40 @@ struct DashpayContactProfileStorageListView: View { } } +// MARK: - PersistentDashpayPaymentAddresses + +struct DashpayPaymentAddressesStorageListView: View { + let network: Network + @Query private var records: [PersistentDashpayPaymentAddresses] + + private var filtered: [PersistentDashpayPaymentAddresses] { + records.filter { $0.networkRaw == network.rawValue } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: DashpayPaymentAddressesStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.profileIdentityId.toHexString()) + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Owner: \(record.ownerIdentityId.toHexString())") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .navigationTitle("Payment Addresses (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Records", systemImage: "qrcode") + } + } + } +} + // MARK: - PersistentDashpayContactRequest /// Storage-explorer list of every DashPay contact-request row. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index d5394d9436a..d6b4ced129e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -337,6 +337,29 @@ struct DashpayContactProfileStorageDetailView: View { } } +// MARK: - PersistentDashpayPaymentAddresses + +struct DashpayPaymentAddressesStorageDetailView: View { + let record: PersistentDashpayPaymentAddresses + + var body: some View { + Form { + Section("Profile") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? String(record.networkRaw)) + FieldRow(label: "Owner ID (Hex)", value: hexString(record.ownerIdentityId)) + FieldRow(label: "Profile ID (Hex)", value: hexString(record.profileIdentityId)) + } + Section("Payment Addresses (Hex)") { + FieldRow(label: "Core (21 B)", value: record.corePaymentAddress.map(hexString) ?? "—") + FieldRow(label: "Platform (21 B)", value: record.platformPaymentAddress.map(hexString) ?? "—") + FieldRow(label: "Shielded (43 B)", value: record.shieldedAddress.map(hexString) ?? "—") + } + } + .navigationTitle("Payment Addresses") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentDashpayPayment /// Detail view for one DashPay payment-history row. Read-only dump diff --git a/scripts/check-storage-explorer.sh b/scripts/check-storage-explorer.sh index 55e150dc9d3..3689566710f 100755 --- a/scripts/check-storage-explorer.sh +++ b/scripts/check-storage-explorer.sh @@ -16,13 +16,12 @@ DETAIL_VIEWS="$REPO_ROOT/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/View errors=0 -# Extract model type names from DashModelContainer.modelTypes array. +# Extract model type names from the current schema's model list and its +# shared declarations: modelTypes -> v3ModelTypes -> allModelTypes. # Matches lines like "PersistentFoo.self," and extracts "PersistentFoo". -# Scoped to the body of the `modelTypes` computed property so other -# `.self` references in the file (e.g. `migrationPlan: -# DashMigrationPlan.self` passed to ModelContainer) aren't mistaken -# for SwiftData models. -model_types=$(awk '/var modelTypes/{flag=1} flag{print} flag && /^ \}/{flag=0}' "$CONTAINER" \ +# Scope the scan to these declarations so frozen schema substitutions and +# unrelated references (e.g. DashMigrationPlan.self) aren't treated as models. +model_types=$(awk '/static func allModelTypes\(|static var (v3ModelTypes|modelTypes):/{flag=1} flag{print} flag && /^ \}/{flag=0}' "$CONTAINER" \ | grep -oE '[A-Z][A-Za-z0-9]+\.self' \ | sed 's/\.self//' \ | sort -u) From 75e15926caf9f477f807a63e872da5aa452c329c Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 19:10:01 -0500 Subject: [PATCH 03/15] fix(platform-wallet): harden shielded tip recovery and mobile error handling --- .../example/ui/dashpay/DashPayTabScreen.kt | 15 +++++-- .../example/ui/dashpay/ShieldedTipFailure.kt | 24 +++++++++++ .../example/ui/dashpay/ShieldedTipSheet.kt | 3 +- .../ui/dashpay/ShieldedTipFailureTest.kt | 41 +++++++++++++++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 3 +- .../rs-platform-wallet/src/wallet/apply.rs | 1 + .../src/wallet/platform_wallet.rs | 3 ++ .../wallet/shielded/viewing_key_bind_tests.rs | 16 +++++++- .../Views/DashPay/DashPayProfileView.swift | 11 +++++ .../SearchWalletsForIdentitiesView.swift | 16 +++++--- .../DashModelMigrationTests.swift | 1 + 11 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailureTest.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index fdc1d6e6724..9b72ead9947 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -323,9 +323,13 @@ fun DashPayTabScreen(navController: NavHostController) { val tipManager = manager val tipWalletId = identity.walletId - if (showTipSheet && managed != null && tipManager != null && tipWalletId != null) { + val tipAccountResult = remember(tipManager, identity.identityIndex) { + runCatching { requireNotNull(tipManager).shieldedTipAccountIndex(identity.identityIndex) } + } + val tipAccount = tipAccountResult.getOrNull() + if (showTipSheet && managed != null && tipManager != null && tipWalletId != null && tipAccount != null) { ModalBottomSheet(onDismissRequest = { showTipSheet = false }) { - ShieldedTipSheet(tipManager, managed, tipWalletId, tipManager.shieldedTipAccountIndex(identity.identityIndex)) + ShieldedTipSheet(tipManager, managed, tipWalletId, tipAccount) } } FormSection(title = "DashPay") { @@ -333,7 +337,12 @@ fun DashPayTabScreen(navController: NavHostController) { EntityRow( icon = Icons.AutoMirrored.Filled.Send, title = "Send shielded tip", - onClick = { showTipSheet = true }, + onClick = { + tipAccountResult.fold( + onSuccess = { showTipSheet = true }, + onFailure = { unlockError = it.message ?: "Could not open shielded tips" }, + ) + }, modifier = Modifier.testTag("dashpay.sendShieldedTip"), ) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt new file mode 100644 index 00000000000..e21e1ca8522 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt @@ -0,0 +1,24 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError + +/** + * Permit a fresh user review only for failures known not to have executed a tip. + * On the tip path, Rust maps selection/build/recipient-check failures to + * WalletOperation. Broadcast ambiguity maps to ShieldedSpendUnconfirmed, and + * successful post-broadcast bookkeeping is best-effort (never WalletOperation). + * Unknown exceptions, including JNI failures and cancellation, remain locked. + */ +internal fun canReviewShieldedTipAfterFailure(error: Exception): Boolean = when (error) { + is IllegalArgumentException, + is DashSdkError.InvalidParameter, + is DashSdkError.PlatformWallet.InvalidHandle, + is DashSdkError.PlatformWallet.NotFound, + is DashSdkError.PlatformWallet.SigningKeyUnavailable, + is DashSdkError.PlatformWallet.WalletOperation, + is DashSdkError.PlatformWallet.ShieldedNoRecordedAnchor, + is DashSdkError.PlatformWallet.ShieldedBroadcastFailed -> true + // ErrorInvalidParameter is a preflight-only FFI failure on this call path. + is DashSdkError.PlatformWallet.Generic -> error.nativeCode == 2 + else -> false +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt index f693aac66c2..92b45cf1cca 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt @@ -100,7 +100,7 @@ fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWall recipient = resolved } } else { - // No retry button after submission: ambiguous broadcast outcomes require sync. + // Ambiguous or unclassified outcomes remain locked; definitive failures permit a fresh review. history.confirm(manager.network, walletId, username, selected) submitted = true manager.sendShieldedTip(walletId, username.trim(), selected, requireNotNull(confirmedAmount), account = if (spendTips) tipAccount else 0) @@ -108,6 +108,7 @@ fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWall } } catch (e: Exception) { message = e.message ?: "Unable to send tip" + if (canReviewShieldedTipAfterFailure(e)) submitted = false recipient = null } finally { busy = false } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailureTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailureTest.kt new file mode 100644 index 00000000000..a75fc08352e --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailureTest.kt @@ -0,0 +1,41 @@ +package org.dashfoundation.example.ui.dashpay + +import java.util.concurrent.CancellationException +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShieldedTipFailureTest { + @Test + fun permitsFreshReviewAfterDefinitiveFailures() { + val errors = listOf( + IllegalArgumentException("amount must be positive"), + DashSdkError.InvalidParameter("invalid account"), + DashSdkError.PlatformWallet.Generic(2, "invalid memo"), + DashSdkError.PlatformWallet.InvalidHandle("closed"), + DashSdkError.PlatformWallet.NotFound("wallet removed"), + DashSdkError.PlatformWallet.SigningKeyUnavailable("unlock wallet"), + DashSdkError.PlatformWallet.WalletOperation("The tip recipient changed; review and confirm again"), + DashSdkError.PlatformWallet.WalletOperation("insufficient notes"), + DashSdkError.PlatformWallet.ShieldedNoRecordedAnchor("sync first"), + DashSdkError.PlatformWallet.ShieldedBroadcastFailed("consensus rejected"), + ) + errors.forEach { assertTrue(it.toString(), canReviewShieldedTipAfterFailure(it)) } + } + + @Test + fun retainsLockForAmbiguousAndUnclassifiedOutcomes() { + val errors = listOf( + DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed("no result proof"), + DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed("no relay verdict"), + DashSdkError.Timeout("timed out"), + DashSdkError.NetworkError("connection lost"), + DashSdkError.PlatformWallet.Generic(999, "unknown native error"), + RuntimeException("JNI failure"), + CancellationException("cancelled"), + IllegalStateException("unexpected state"), + ) + errors.forEach { assertFalse(it.toString(), canReviewShieldedTipAfterFailure(it)) } + } +} 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 75f0dc41e17..6094224b0eb 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 @@ -1781,7 +1781,8 @@ class PlatformWalletManager( recipient: org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient, amount: Long, account: Int = 0, memo: String? = null, ): Unit = teardownGate.op { - require(amount > 0 && account >= 0) + require(amount > 0) { "amount must be positive, got $amount" } + require(account >= 0) { "account must be non-negative, got $account" } mapNativeErrors { FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle, account, username, recipient.identityId, recipient.address, amount, memo) diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 51a5a2a8cd6..3a0f7b946ad 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -1458,6 +1458,7 @@ mod tests { avatar_hash: Some([0xaa; 32]), avatar_fingerprint: Some([0xbb; 8]), public_message: Some("hello world".into()), + shielded_address: Some(vec![0xcc; 43]), ..Default::default() }; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index c33b8a06c3d..b924320540c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -924,6 +924,9 @@ impl PlatformWallet { )) })?; let mut accounts: std::collections::BTreeSet = accounts.iter().copied().collect(); + // Newly discovered identities require their tip accounts too. Missing + // FVKs must trigger seed-backed binding: skipping them would report a + // successful restart while silently omitting recoverable tip history. accounts.extend(self.discovered_tip_accounts().await?); accounts.extend( start diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index a064a9d479f..678dcb4f29e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -759,7 +759,7 @@ async fn should_restore_tip_account_from_identity_discovery_and_register_for_syn use dpp::identity::{Identity, IdentityV0}; use dpp::prelude::Identifier; let phrase = crate::test_support::MESSAGE_SIGNING_TEST_MNEMONIC; - let seed = key_wallet::Mnemonic::from_phrase(phrase, key_wallet::Language::English) + let seed = key_wallet::Mnemonic::from_phrase(phrase) .unwrap() .to_seed(""); let id = Identifier::from([0x33; 32]); @@ -836,6 +836,20 @@ async fn should_restore_tip_account_from_identity_discovery_and_register_for_syn .bind_shielded_from_persisted(&[0], &coordinator) .await .unwrap()); + // Upgrading an existing wallet can leave account zero persisted while + // identity discovery introduces a tip account without an FVK. Seedless + // success here would suppress the host's seed fallback and lose scans + // for that identity's tip history. + let ordinary = super::OrchardKeySet::from_seed(&seed, Network::Testnet, 0).unwrap(); + persister.serve_viewing_keys(BTreeMap::from([( + SubwalletId::new(wallet.wallet_id(), 0), + ordinary.full_viewing_key.to_bytes().to_vec(), + )])); + assert!(!wallet + .bind_shielded_from_persisted(&[0], &coordinator) + .await + .unwrap()); + assert!(!wallet.is_shielded_bound().await); assert!(wallet .prepare_shielded_tip_address(&[0x11; 64], &id, &coordinator) .await diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index a4061757af6..7eb06710363 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -23,6 +23,17 @@ struct DashPayProfileView: View { @State private var showSpendTips = false @Query private var shieldedNotes: [PersistentShieldedNote] + init(identity: PersistentIdentity, profile: DashPayProfile?, onEdit: @escaping () -> Void) { + self.identity = identity + self.profile = profile + self.onEdit = onEdit + if let walletId = identity.wallet?.walletId { + _shieldedNotes = Query(filter: PersistentShieldedNote.unspentPredicate(walletId: walletId)) + } else { + _shieldedNotes = Query(filter: #Predicate { _ in false }) + } + } + private var tipBalance: UInt64 { guard let walletId = identity.wallet?.walletId, let account = try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: identity.identityIndex) else { return 0 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index 29e2634a33c..f733c0ffb88 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -363,17 +363,23 @@ struct SearchWalletsForIdentitiesView: View { ) // Newly discovered identities introduce deterministic tip accounts. // Rebind before scanning so historical tips are included. - try walletManager.bindShielded(walletId: walletId, resolver: MnemonicResolver()) + let bindError: String? + do { + try walletManager.bindShielded(walletId: walletId, resolver: MnemonicResolver()) + bindError = nil + } catch { + bindError = "Identity discovery completed, but shielded wallet binding failed: " + + error.localizedDescription + } result = WalletFinding( walletId: walletId, label: label, foundCount: found.count, - error: nil + error: bindError ) - // Zero hits → ask Rust for the preview keypairs the - // scan walked so the user can eyeball / copy them. - if found.isEmpty { + // Preserve preview access for empty results or binding failures. + if found.isEmpty || bindError != nil { await loadPreviewKeys(on: managed) } } catch { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index b53690b5425..898d517cba9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -483,5 +483,6 @@ extension DashModelMigrationTests { core: nil, platform: nil, shielded: Data(repeating: 0x45, count: 43)) try container.mainContext.save() XCTAssertEqual(profiles[0].identity.identityId, identityId) + XCTAssertEqual(profiles[0].shieldedAddress, Data(repeating: 0x45, count: 43)) } } From b02eb22bcd1857454a535f24b3879331a08141ff Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 8 Sep 2026 15:44:17 -0500 Subject: [PATCH 04/15] fix(platform-wallet): address shielded tip review findings --- .../dashfoundation/example/di/AppContainer.kt | 3 + .../example/ui/dashpay/DashPayTabScreen.kt | 19 +- .../example/ui/dashpay/ShieldedTipSheet.kt | 50 +- .../ui/dashpay/ShieldedTipSubmission.kt | 57 + .../SearchWalletsForIdentitiesScreen.kt | 8 +- .../ui/dashpay/ShieldedTipSubmissionTest.kt | 85 + .../src/shielded_send.rs | 63 +- .../src/shielded_sync.rs | 83 + packages/rs-platform-wallet-storage/SCHEMA.md | 2 + .../src/sqlite/mod.rs | 3 + .../src/sqlite/schema/dashpay.rs | 2 + .../src/sqlite/schema/identities.rs | 9 +- .../schema/identity_profile_encoding.rs | 77 +- .../src/sqlite/schema/mod.rs | 1 - .../rs-platform-wallet/docs/SHIELDED_TIPS.md | 10 +- .../src/wallet/identity/network/profile.rs | 71 +- .../wallet/identity/types/dashpay/profile.rs | 2 + .../src/wallet/shielded/tips.rs | 5 + .../wallet/shielded/viewing_key_bind_tests.rs | 41 + .../Persistence/DashModelContainer.swift | 43 +- .../Persistence/DashSchemaFrozenModels.swift | 7 +- .../DashSchemaV4FrozenModels.swift | 1857 +++++++++++++++++ .../PlatformWalletManagerShieldedSync.swift | 17 + .../PlatformWallet/ShieldedTipAmount.swift | 37 + .../Core/Services/ShieldedService.swift | 32 +- .../Core/Views/CoreContentView.swift | 3 +- .../Views/DashPay/DashPayProfileView.swift | 14 +- .../Views/DashPay/DashPayTabView.swift | 11 +- .../SearchWalletsForIdentitiesView.swift | 22 +- .../DashModelMigrationTests.swift | 60 +- .../ShieldedAccountSnapshotTests.swift | 24 + .../ShieldedTipAmountTests.swift | 24 + scripts/check-storage-explorer.sh | 6 +- 33 files changed, 2638 insertions(+), 110 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmissionTest.kt create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipAmount.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift 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 fe919a0d872..df91d5cb210 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 @@ -33,6 +33,9 @@ class AppContainer(private val context: Context) { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val shieldedTipSubmissions = + org.dashfoundation.example.ui.dashpay.ShieldedTipSubmissions(applicationScope) + val database: DashDatabase = DashDatabase.create(context) val dataStore = context.preferencesStore diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 9b72ead9947..21f91263e3c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -38,6 +40,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -327,9 +330,19 @@ fun DashPayTabScreen(navController: NavHostController) { runCatching { requireNotNull(tipManager).shieldedTipAccountIndex(identity.identityIndex) } } val tipAccount = tipAccountResult.getOrNull() - if (showTipSheet && managed != null && tipManager != null && tipWalletId != null && tipAccount != null) { - ModalBottomSheet(onDismissRequest = { showTipSheet = false }) { - ShieldedTipSheet(tipManager, managed, tipWalletId, tipAccount) + val tipSubmission = tipWalletId?.let { + container.shieldedTipSubmissions.forWallet(network.ffiValue, it.toHex()) + } + val tipSending by rememberUpdatedState(tipSubmission?.busy == true) + val tipSheetState = rememberModalBottomSheetState( + confirmValueChange = { value -> value != SheetValue.Hidden || !tipSending }, + ) + if (showTipSheet && managed != null && tipManager != null && tipWalletId != null && tipAccount != null && tipSubmission != null) { + ModalBottomSheet( + sheetState = tipSheetState, + onDismissRequest = { if (!tipSubmission.busy) showTipSheet = false }, + ) { + ShieldedTipSheet(tipManager, managed, tipWalletId, tipAccount, tipSubmission) } } FormSection(title = "DashPay") { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt index 92b45cf1cca..c5418cdde79 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt @@ -33,7 +33,7 @@ import org.dashfoundation.example.util.DashAddress /** Username tipping uses the same verified recipient and confirmation boundary as Swift. */ @Composable -fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWallet, walletId: ByteArray, tipAccount: Int) { +fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWallet, walletId: ByteArray, tipAccount: Int, submission: ShieldedTipSubmission) { val scope = rememberCoroutineScope() val context = LocalContext.current val history = remember(context) { @@ -44,8 +44,9 @@ fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWall var amount by remember { mutableStateOf("") } var recipient by remember { mutableStateOf(null) } var confirmedAmount by remember { mutableStateOf(null) } - var busy by remember { mutableStateOf(false) } - var submitted by remember { mutableStateOf(false) } + var resolving by remember { mutableStateOf(false) } + val busy = resolving || submission.busy + val submitted = submission.submitted var spendTips by remember { mutableStateOf(false) } var message by remember { mutableStateOf(null) } @@ -79,17 +80,29 @@ fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWall } Text("Send $amount DASH from your ${if (spendTips) "tip" else "main shielded"} account to $username?") } - message?.let { Text(it) } + (message ?: submission.message)?.let { Text(it) } + if (submission.status == ShieldedTipSubmission.Status.Sent) { + TextButton(onClick = { submission.startNewTip() }) { Text("Start a new tip") } + } SubmitButton( text = if (recipient == null) "Review tip" else "Confirm and send", isLoading = busy, enabled = !busy && !submitted && changedRecipient == null, modifier = Modifier.fillMaxWidth(), ) { - busy = true - message = null - scope.launch { - try { - val selected = recipient - if (selected == null) { + val selected = recipient + if (selected != null) { + val sendUsername = username.trim() + val sendAmount = requireNotNull(confirmedAmount) + val sendAccount = if (spendTips) tipAccount else 0 + recipient = null + submission.submit { + history.confirm(manager.network, walletId, sendUsername, selected) + manager.sendShieldedTip(walletId, sendUsername, selected, sendAmount, account = sendAccount) + } + } else { + resolving = true + message = null + scope.launch { + try { val credits = BigDecimal(amount.trim()).movePointRight(11).longValueExact() require(credits > 0) { "Enter a positive amount" } confirmedAmount = credits @@ -99,18 +112,11 @@ fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWall } else { recipient = resolved } - } else { - // Ambiguous or unclassified outcomes remain locked; definitive failures permit a fresh review. - history.confirm(manager.network, walletId, username, selected) - submitted = true - manager.sendShieldedTip(walletId, username.trim(), selected, requireNotNull(confirmedAmount), account = if (spendTips) tipAccount else 0) - message = "Shielded tip sent." - } - } catch (e: Exception) { - message = e.message ?: "Unable to send tip" - if (canReviewShieldedTipAfterFailure(e)) submitted = false - recipient = null - } finally { busy = false } + } catch (e: Exception) { + message = e.message ?: "Unable to review tip" + recipient = null + } finally { resolving = false } + } } } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt new file mode 100644 index 00000000000..af37ff54ca6 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** Application-owned submissions survive sheet dismissal, navigation and activity recreation. */ +class ShieldedTipSubmissions(private val scope: CoroutineScope) { + private val wallets = mutableMapOf() + + /** Called on the main thread; one guard per network/wallet, across all of its identities. */ + fun forWallet(network: Int, walletId: String): ShieldedTipSubmission = + wallets.getOrPut("$network:$walletId") { ShieldedTipSubmission(scope) } +} + +/** Main-thread state. A cancelled UI must never imply that blocking JNI stopped broadcasting. */ +class ShieldedTipSubmission(private val scope: CoroutineScope) { + enum class Status { Ready, Sending, Sent, Uncertain } + + var status by mutableStateOf(Status.Ready) + private set + var message by mutableStateOf(null) + private set + val busy: Boolean get() = status == Status.Sending + val submitted: Boolean get() = status != Status.Ready + + fun submit(send: suspend () -> Unit) { + if (submitted) return + // Lock synchronously, before launching, so two UI events cannot submit twice. + status = Status.Sending + message = null + scope.launch { + try { + send() + status = Status.Sent + message = "Shielded tip sent." + } catch (error: Exception) { + status = if (canReviewShieldedTipAfterFailure(error)) Status.Ready else Status.Uncertain + message = if (status == Status.Uncertain) { + "The tip may have been sent. Check shielded activity before sending again. ${error.message.orEmpty()}" + } else { + error.message ?: "Unable to send tip" + } + if (error is kotlinx.coroutines.CancellationException) throw error + } + } + } + + /** Starting another payment requires an explicit action after a confirmed successful return. */ + fun startNewTip() { + if (status != Status.Sent) return + status = Status.Ready + message = null + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt index f0bd2c54cd5..2386e995efc 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt @@ -141,7 +141,13 @@ fun SearchWalletsForIdentitiesScreen(navController: NavHostController) { mnemonicResolverHandle = mgr.mnemonicResolverHandle, ) if (container.shieldedService.isAvailable && found.isNotEmpty()) { - mgr.bindShielded(wallet.walletId) + try { + mgr.bindShielded(wallet.walletId) + } catch (error: kotlinx.coroutines.CancellationException) { + throw error + } catch (error: Exception) { + android.util.Log.w("IdentityDiscovery", "Identities found; shielded bind failed", error) + } } summary = "Found ${found.size} identity(ies)." if (found.isEmpty()) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmissionTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmissionTest.kt new file mode 100644 index 00000000000..e3d0d102a84 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmissionTest.kt @@ -0,0 +1,85 @@ +package org.dashfoundation.example.ui.dashpay + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ShieldedTipSubmissionTest { + @Test + fun dismissAndReopenCannotSubmitAgainWhileNativeSendRuns() = runTest { + val submissions = ShieldedTipSubmissions(backgroundScope) + val payment = submissions.forWallet(1, "wallet") + val nativeFinished = CompletableDeferred() + var sends = 0 + payment.submit { sends++; nativeFinished.await() } + assertTrue(payment.busy) + // Even a second click before the first coroutine starts is ignored. + payment.submit { sends++ } + runCurrent() + val sheetScope = CoroutineScope(coroutineContext + SupervisorJob()) + sheetScope.cancel() + val reopened = submissions.forWallet(1, "wallet") + assertSame(payment, reopened) + reopened.submit { sends++ } + assertEquals(1, sends) + nativeFinished.complete(Unit) + runCurrent() + assertEquals(ShieldedTipSubmission.Status.Sent, reopened.status) + assertFalse(reopened.busy) + reopened.submit { sends++ } + runCurrent() + assertEquals(1, sends) + reopened.startNewTip() + reopened.submit { sends++ } + runCurrent() + assertEquals(2, sends) + } + + @Test + fun uncertainOutcomeStaysLockedAcrossReopeningAndNewTipAction() = runTest { + val submissions = ShieldedTipSubmissions(backgroundScope) + val payment = submissions.forWallet(1, "wallet") + payment.submit { throw IllegalStateException("JNI outcome lost") } + runCurrent() + val reopened = submissions.forWallet(1, "wallet") + assertEquals(ShieldedTipSubmission.Status.Uncertain, reopened.status) + reopened.startNewTip() + var retried = false + reopened.submit { retried = true } + runCurrent() + assertFalse(retried) + assertTrue(reopened.submitted) + assertFalse(reopened.busy) + } + + @Test + fun definitivePreflightFailureAllowsFreshReview() = runTest { + val payment = ShieldedTipSubmission(backgroundScope) + payment.submit { throw IllegalArgumentException("invalid amount") } + runCurrent() + assertEquals(ShieldedTipSubmission.Status.Ready, payment.status) + assertEquals("invalid amount", payment.message) + var retried = false + payment.submit { retried = true } + runCurrent() + assertTrue(retried) + } + + @Test + fun walletAndNetworkGuardsAreIndependent() = runTest { + val submissions = ShieldedTipSubmissions(backgroundScope) + submissions.forWallet(1, "a").submit { } + assertFalse(submissions.forWallet(2, "a").submitted) + assertFalse(submissions.forWallet(1, "b").submitted) + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 1c6b86156b9..978e01ddb8c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2256,6 +2256,23 @@ mod tests { ); } + #[test] + fn should_contain_tip_worker_panic_as_uncertain_spend() { + let result = catch_spend_panic("shielded tip", || { + let result = block_on_worker(async { + panic!("tip worker failed after possible broadcast"); + #[allow(unreachable_code)] + Ok(()) + }); + map_spend_result(result, "shielded tip") + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed + ); + assert!(message_of(&result).contains("do NOT retry")); + } + /// A panic inside the CoinJoin-drain funding export must NOT unwind into the `extern "C"` /// frame (that aborts the Android process before the JNI layer's own guard can translate it /// into a Java exception). It becomes `ErrorTransactionBroadcastUnconfirmed` — the @@ -2744,28 +2761,30 @@ pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( Ok(value) => value, Err(e) => return e, }; - let result = block_on_worker(async move { - let recipient = platform_wallet::ShieldedTipRecipient { - identity_id, - address, - }; - let prover = CachedOrchardProver::new(); - let result = wallet - .send_shielded_tip( - &coordinator, - seed.as_ref(), - account, - &username, - &recipient, - amount, - memo, - &prover, - ) - .await; - poke_sync_on_unconfirmed(&result, handle); - result - }); - map_spend_result(result, "shielded tip") + catch_spend_panic("shielded tip", || { + let result = block_on_worker(async move { + let recipient = platform_wallet::ShieldedTipRecipient { + identity_id, + address, + }; + let prover = CachedOrchardProver::new(); + let result = wallet + .send_shielded_tip( + &coordinator, + seed.as_ref(), + account, + &username, + &recipient, + amount, + memo, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + map_spend_result(result, "shielded tip") + }) } /// Return the dedicated ZIP-32 tip account for a wallet identity derivation index. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 8b41f702b8e..e0d3074935d 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -568,3 +568,86 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( ), } } + +/// Snapshot the effective bound accounts, including discovered and restored tip +/// accounts. An unbound wallet returns an empty array. The caller must release +/// a nonempty result with `platform_wallet_manager_free_shielded_account_indices`. +/// +/// # Safety +/// `wallet_id_bytes` must point to 32 readable bytes; both output pointers must +/// be writable. Returned indices are valid until freed by the caller. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_account_indices( + handle: Handle, + wallet_id_bytes: *const u8, + out_indices: *mut *mut u32, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_indices); + check_ptr!(out_count); + *out_indices = std::ptr::null_mut(); + *out_count = 0; + check_ptr!(wallet_id_bytes); + let mut wallet_id = [0; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + match manager.get_wallet(&wallet_id).await { + Some(wallet) => Some(wallet.shielded_account_indices().await), + None => None, + } + }) + }); + let Some(indices) = unwrap_option_or_return!(option) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "wallet not found", + ); + }; + if !indices.is_empty() { + *out_count = indices.len(); + *out_indices = Box::into_raw(indices.into_boxed_slice()) as *mut u32; + } + PlatformWalletFFIResult::ok() +} + +/// Release an account snapshot returned by the matching getter. Null is a no-op. +/// +/// # Safety +/// A nonnull pointer and count must be an unfreed pair from +/// `platform_wallet_manager_shielded_account_indices`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_shielded_account_indices( + indices: *mut u32, + count: usize, +) { + if !indices.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + indices, count, + ))); + } +} + +#[cfg(test)] +mod account_indices_tests { + use super::*; + + #[test] + fn invalid_handle_clears_account_snapshot_outputs() { + let mut indices = std::ptr::NonNull::::dangling().as_ptr(); + let mut count = 99; + let wallet_id = [0; 32]; + unsafe { + let result = platform_wallet_manager_shielded_account_indices( + 0, + wallet_id.as_ptr(), + &mut indices, + &mut count, + ); + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert!(indices.is_null()); + assert_eq!(count, 0); + platform_wallet_manager_free_shielded_account_indices(indices, count); + } + } +} diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 830b7a366ef..a9dd1cee79d 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -148,6 +148,7 @@ erDiagram BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities" BLOB entry_blob "bincode-encoded IdentityEntry" + INTEGER entry_format "0 = legacy profile shape; 1 = payment addresses" INTEGER tombstoned "0 | 1 (logical delete)" } @@ -168,6 +169,7 @@ erDiagram DASHPAY_PROFILES { BLOB identity_id PK "one row per identity" BLOB profile_blob "bincode-encoded DashPayProfile" + INTEGER profile_format "0 = legacy profile shape; 1 = payment addresses" } DASHPAY_PAYMENTS_OVERLAY { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs index c669e885dd6..18d5be669d8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -36,3 +36,6 @@ pub use config::{ pub use error::{AutoBackupOperation, WalletStorageError}; pub use persister::{PruneReport, RetentionPolicy, SqlitePersister}; pub use reports::{CommitReport, DeleteWalletReport}; + +// Versioned blob readers are useful to hosts inspecting pre-migration backups. +pub use schema::{dashpay::decode_profile, identities::decode_identity}; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index 4d91f194803..e85890ad9c1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -23,6 +23,8 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +pub use super::identity_profile_encoding::decode_profile; + /// Both dashpay tables are keyed by identity only; their FK targets /// `identities(identity_id)` so cascade flows through the /// `wallet_metadata → identities` chain. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 82cd6a19287..ea4ccdc09e3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -12,6 +12,8 @@ use {platform_wallet::changeset::IdentityEntry, rusqlite::Connection}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +pub use super::identity_profile_encoding::decode_identity; + pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -128,10 +130,9 @@ pub fn fetch( .optional()?; match row { None => Ok(None), - Some((payload, tombstoned, format)) => Ok(Some(( - super::identity_profile_encoding::decode_identity(&payload, format)?, - tombstoned != 0, - ))), + Some((payload, tombstoned, format)) => { + Ok(Some((decode_identity(&payload, format)?, tombstoned != 0))) + } } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs index 21111ecf89c..d801f4f1d1b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs @@ -54,10 +54,22 @@ struct LegacyIdentityEntry { pub ignored_senders: BTreeSet, } -pub(super) fn decode_identity( - payload: &[u8], - format: i64, -) -> Result { +/// Decode a `dashpay_profiles.profile_blob` using its V008 `profile_format` +/// stamp: zero is the pre-address shape, one includes payment addresses. Never +/// fall back to the legacy shape after a current-format decoding error. +pub fn decode_profile(payload: &[u8], format: i64) -> Result { + match format { + 0 => blob::decode::(payload).map(Into::into), + 1 => blob::decode(payload), + _ => Err(WalletStorageError::blob_decode( + "unsupported profile encoding", + )), + } +} + +/// Decode an `identities.entry_blob` using its `entry_format` stamp, preserving +/// the pre-address owned and contact profile shapes for format zero. +pub fn decode_identity(payload: &[u8], format: i64) -> Result { match format { 1 => blob::decode(payload), 0 => { @@ -110,6 +122,27 @@ mod tests { public_message: Some("hello".into()), } } + #[test] + fn should_decode_standalone_profiles_by_format_without_fallback() { + let legacy = blob::encode(&old_profile()).unwrap(); + let mut profile = super::super::dashpay::decode_profile(&legacy, 0).unwrap(); + assert_eq!(profile.display_name.as_deref(), Some("Alice")); + assert_eq!(profile.public_message.as_deref(), Some("hello")); + assert!(profile.core_payment_address.is_none()); + assert!(profile.platform_payment_address.is_none()); + assert!(profile.shielded_address.is_none()); + assert!(super::super::dashpay::decode_profile(&legacy, 1).is_err()); + profile.shielded_address = Some(vec![9; 43]); + let current = blob::encode(&profile).unwrap(); + assert_eq!( + super::super::dashpay::decode_profile(¤t, 1).unwrap(), + profile + ); + assert!(super::super::dashpay::decode_profile(¤t, 0).is_err()); + assert!(super::super::dashpay::decode_profile(¤t, 2).is_err()); + assert!(super::super::dashpay::decode_profile(¤t[..current.len() - 1], 1).is_err()); + } + #[test] fn should_read_old_owned_and_contact_profiles_without_losing_following_fields() { let id = Identifier::from([1; 32]); @@ -145,7 +178,24 @@ mod tests { .unwrap(); conn.execute("INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", rusqlite::params![[2u8; 32].as_slice()]).unwrap(); conn.execute("INSERT INTO identities (identity_id, wallet_id, entry_blob, tombstoned) VALUES (?1, ?2, ?3, 0)", rusqlite::params![id.as_slice(), [2u8; 32].as_slice(), &encoded]).unwrap(); + conn.execute( + "INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, ?2)", + rusqlite::params![id.as_slice(), blob::encode(&old_profile()).unwrap()], + ) + .unwrap(); crate::sqlite::migrations::run(&mut conn).unwrap(); + let (profile_blob, profile_format): (Vec, i64) = conn + .query_row( + "SELECT profile_blob, profile_format FROM dashpay_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(profile_format, 0); + assert_eq!( + super::super::dashpay::decode_profile(&profile_blob, profile_format).unwrap(), + DashPayProfile::from(old_profile()), + ); let (restored, tombstoned) = super::super::identities::fetch(&conn, &[2; 32], &id.to_buffer()) .unwrap() @@ -185,7 +235,26 @@ mod tests { ..Default::default() }; super::super::identities::apply(&tx, &[2; 32], &changes).unwrap(); + super::super::dashpay::apply( + &tx, + &[2; 32], + Some(&BTreeMap::from([(id, current.dashpay_profile.clone())])), + None, + ) + .unwrap(); tx.commit().unwrap(); + let (profile_blob, profile_format): (Vec, i64) = conn + .query_row( + "SELECT profile_blob, profile_format FROM dashpay_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(profile_format, 1); + assert_eq!( + super::super::dashpay::decode_profile(&profile_blob, profile_format).unwrap(), + current.dashpay_profile.clone().unwrap(), + ); assert_eq!( conn.query_row("SELECT entry_format FROM identities", [], |row| row .get::<_, i64>(0)) 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 e664586a2b3..aade8ce2901 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -97,5 +97,4 @@ pub(crate) fn assert_identities_belong_to_wallet( Ok(()) } -#[cfg(any(test, feature = "__test-helpers"))] mod identity_profile_encoding; diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md index db3b415232d..c184b7b4ecb 100644 --- a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -38,13 +38,19 @@ does not publish the address; publication remains an explicit profile operation. Repeated preparation derives the same account and address. An identity without a wallet derivation index can publish an external address instead. -Tip accounts have distinct viewing keys and are excluded from ordinary receive -and automatic spending choices. Spending a tip balance is an explicit action. +Tip accounts have distinct viewing keys. Hosts must exclude them from ordinary +receive, balance, and automatic spending choices, as the example apps do. +`shielded_balances()` returns all bound accounts separately, including tip +accounts; it does not apply this policy. Spending a tip balance is an explicit +host action. The generic shielded transfer API still accepts an explicitly selected account. A published address is publicly associated with the profile; account separation does not eliminate correlations introduced by later transfers or provide a blanket guarantee against future cryptographic attacks. +Address preparation requires an existing shielded bind. Bind the host's ordinary +accounts first; preparation preserves them while adding the dedicated account. + ## Restoration and address changes On seed restoration, discover the wallet's identities before binding and scanning diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index c7dae321604..ec9810e9eb0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -1091,7 +1091,7 @@ impl DashPayView<'_, B> { let updates = payment_address_updates(input); if updates .iter() - .all(|(_, patch)| matches!(patch, PaymentAddressUpdate::Keep)) + .all(|(_, patch)| !matches!(patch, PaymentAddressUpdate::Set(_))) { return Ok(()); } @@ -1125,7 +1125,7 @@ impl DashPayView<'_, B> { .document_type_for_name("profile") .map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?; for (field, patch) in updates { - if !matches!(patch, PaymentAddressUpdate::Keep) + if matches!(patch, PaymentAddressUpdate::Set(_)) && !profile.properties().contains_key(field) { return Err(PlatformWalletError::InvalidIdentityData(format!( @@ -1142,6 +1142,73 @@ mod address_patch_tests { use super::*; use crate::{PaymentAddressUpdate, ProfileUpdate}; + #[tokio::test] + async fn should_allow_removal_without_contract_activation_but_reject_unsupported_set() { + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + let contract = load_system_data_contract( + SystemDataContract::Dashpay, + PlatformVersion::get(13).unwrap(), + ) + .unwrap(); + sdk.mock() + .expect_fetch(SystemDataContract::Dashpay.id(), Some(contract)) + .await + .unwrap(); + let (wm, wallet_id, generation, _) = crate::test_support::funded_wallet_manager( + key_wallet::account::StandardAccountType::BIP44Account, + ) + .await; + let spv = Arc::new(crate::spv::SpvRuntime::new( + wm.clone(), + Arc::new(crate::events::PlatformEventManager::new(vec![])), + )); + let wallet = crate::PlatformWallet::new( + Arc::new(sdk), + wallet_id, + wm, + generation, + Arc::new(tokio::sync::Notify::new()), + Arc::new(crate::wallet::persister::NoPlatformPersistence), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + ); + let remove = ProfileUpdate { + display_name: Some("Alice II".into()), + core_payment_address: PaymentAddressUpdate::Remove, + platform_payment_address: PaymentAddressUpdate::Remove, + shielded_address: PaymentAddressUpdate::Remove, + ..Default::default() + }; + wallet + .identity() + .dashpay() + .validate_payment_address_update(&remove) + .await + .unwrap(); + let updated = merge_profile_properties(Default::default(), &remove, None, None); + assert_eq!(updated.len(), 1); + assert_eq!( + updated.get("displayName"), + Some(&Value::Text("Alice II".into())) + ); + // Removal must not bypass activation checks on a simultaneously set field. + let mixed = ProfileUpdate { + core_payment_address: PaymentAddressUpdate::Set(vec![0; 21]), + shielded_address: PaymentAddressUpdate::Remove, + ..Default::default() + }; + let error = wallet + .identity() + .dashpay() + .validate_payment_address_update(&mixed) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("does not support corePaymentAddress")); + } + #[test] fn should_preserve_replace_and_remove_payment_addresses_independently() { let original = std::collections::BTreeMap::from([ diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs index 877e9512151..529d2dced12 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs @@ -38,6 +38,8 @@ pub struct DashPayProfile { /// Public message broadcast to contacts. pub public_message: Option, /// Core P2PKH/P2SH storage address (type byte plus HASH160, 21 bytes). + /// The address defaults support map formats such as JSON. Positional + /// bincode records require the storage layer's versioned legacy decoder. #[cfg_attr(feature = "serde", serde(default))] pub core_payment_address: Option>, /// Platform P2PKH/P2SH storage address (21 bytes). diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index 1ff0226fa03..e6098dc6eb1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -49,6 +49,8 @@ impl PlatformWallet { /// Prepare a dedicated receiving account without publishing anything. Full /// bind persists its viewing key and registers it with the coordinator before /// callers can publish the returned address. Repeated calls are idempotent. + /// Requires an existing shielded bind so preparing a tip account cannot + /// silently replace the host's ordinary account configuration. pub async fn prepare_shielded_tip_address( &self, seed: &[u8], @@ -93,6 +95,9 @@ impl PlatformWallet { })?)? }; let mut accounts = self.shielded_account_indices().await; + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedNotBound); + } accounts.push(account); self.bind_shielded(seed, &accounts, coordinator).await?; self.persister() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 678dcb4f29e..8882d294088 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -33,6 +33,8 @@ use crate::wallet::shielded::{FileBackedShieldedStore, NetworkShieldedCoordinato #[derive(Default)] struct CapturingPersistence { stored: Mutex>, + durability_events: Mutex>, + fail_flush: std::sync::atomic::AtomicBool, serve: Mutex>>, serve_subwallets: Mutex>, load_calls: Mutex, @@ -106,11 +108,18 @@ impl PlatformWalletPersistence for CapturingPersistence { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + self.durability_events.lock().unwrap().push("store"); self.stored.lock().expect("stored lock").push(changeset); Ok(()) } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.durability_events.lock().unwrap().push("flush"); + if self.fail_flush.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend(std::io::Error::other( + "injected flush failure", + ))); + } Ok(()) } @@ -854,6 +863,13 @@ async fn should_restore_tip_account_from_identity_discovery_and_register_for_syn .prepare_shielded_tip_address(&[0x11; 64], &id, &coordinator) .await .is_err()); + assert!(matches!( + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await, + Err(crate::PlatformWalletError::ShieldedNotBound) + )); + assert!(!wallet.is_shielded_bound().await); assert!(persister.captured_viewing_keys().is_empty()); assert!(coordinator.registered_subwallets().await.is_empty()); wallet @@ -868,6 +884,27 @@ async fn should_restore_tip_account_from_identity_discovery_and_register_for_syn .prepare_shielded_tip_address(&[0x11; 64], &id, &coordinator) .await .is_err()); + // A queued FVK is insufficient: no publishable address may escape a + // failed durability barrier, even though bind installed keys in memory. + persister.durability_events.lock().unwrap().clear(); + persister + .fail_flush + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(matches!( + wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await, + Err(crate::PlatformWalletError::Persistence(_)) + )); + assert_eq!( + *persister.durability_events.lock().unwrap(), + ["store", "flush"] + ); + assert!(wallet.shielded_default_address(account).await.is_some()); + persister + .fail_flush + .store(false, std::sync::atomic::Ordering::SeqCst); + persister.durability_events.lock().unwrap().clear(); let address = wallet .prepare_shielded_tip_address(&seed, &id, &coordinator) .await @@ -876,6 +913,10 @@ async fn should_restore_tip_account_from_identity_discovery_and_register_for_syn .prepare_shielded_tip_address(&seed, &Identifier::from([0x44; 32]), &coordinator) .await .is_err()); + assert_eq!( + *persister.durability_events.lock().unwrap(), + ["store", "flush"] + ); assert_ne!(wallet.shielded_default_address(0).await.unwrap(), address); assert_eq!( wallet diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 03fadfc95cf..54b62862057 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -123,9 +123,45 @@ public enum DashModelContainer { + [PersistentTrackedMasternode.self] } - /// The V4 model set includes the sweep columns, before payment metadata. + /// Historical V4 shape, independent of every live model definition. fileprivate static var v4ModelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] + [ + DashSchemaV4.PersistentIdentity.self, + DashSchemaV4.PersistentDPNSName.self, + DashSchemaV4.PersistentDashpayProfile.self, + DashSchemaV4.PersistentDashpayContactProfile.self, + DashSchemaV4.PersistentDashpayContactRequest.self, + DashSchemaV4.PersistentDashpayPayment.self, + DashSchemaV4.PersistentDashpayIgnoredSender.self, + DashSchemaV4.PersistentDocument.self, + DashSchemaV4.PersistentDataContract.self, + DashSchemaV4.PersistentPublicKey.self, + DashSchemaV4.PersistentTokenBalance.self, + DashSchemaV4.PersistentKeyword.self, + DashSchemaV4.PersistentToken.self, + DashSchemaV4.PersistentDocumentType.self, + DashSchemaV4.PersistentIndex.self, + DashSchemaV4.PersistentProperty.self, + DashSchemaV4.PersistentTokenHistoryEvent.self, + DashSchemaV4.PersistentPlatformAddress.self, + DashSchemaV4.PersistentPlatformAddressesSyncState.self, + DashSchemaV4.PersistentWallet.self, + DashSchemaV4.PersistentAccount.self, + DashSchemaV4.PersistentCoreAddress.self, + DashSchemaV4.PersistentTransaction.self, + DashSchemaV4.PersistentTxo.self, + DashSchemaV4.PersistentPendingInput.self, + DashSchemaV4.PersistentWalletManagerMetadata.self, + DashSchemaV4.PersistentShieldedNote.self, + DashSchemaV4.PersistentShieldedOutgoingNote.self, + DashSchemaV4.PersistentShieldedSyncState.self, + DashSchemaV4.PersistentShieldedActivity.self, + DashSchemaV4.PersistentShieldedViewingKey.self, + DashSchemaV4.PersistentAssetLock.self, + DashSchemaV4.PersistentInvitation.self, + DashSchemaV4.PersistentMasternode.self, + DashSchemaV4.PersistentTrackedMasternode.self + ] } /// All persistent model types in the current Dash SDK schema (V5). @@ -133,7 +169,8 @@ public enum DashModelContainer { /// whenever a model gains a property — which is exactly why the /// released versions must not. public static var modelTypes: [any PersistentModel.Type] { - v4ModelTypes + [PersistentDashpayPaymentAddresses.self] + allModelTypes(assetLock: PersistentAssetLock.self) + + [PersistentTrackedMasternode.self, PersistentDashpayPaymentAddresses.self] } /// Create the schema for all Dash Platform models diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index 1b535ec001d..bc1601a879f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -29,13 +29,12 @@ import SwiftData // // - `PersistentAssetLock`, frozen at its V2 shape (everything the live // model has EXCEPT `recipientIsExternal`, which V3 added). Referenced by -// `DashSchemaV1.models` and `DashSchemaV2.models`; V3 and V4 reference -// the live type. +// `DashSchemaV1.models` and `DashSchemaV2.models`; V3 references the live type. V4 uses its own frozen copy. // - The 24 models of the relationship component that contains // `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput` and // `PersistentWallet`, frozen at their V3 shape (everything the live // models had before V4's sweep columns). Referenced by V1, V2 and V3; -// V4 references the live types. The component travels as a whole +// V4 has its own copies in `DashSchemaV4FrozenModels.swift`. The component travels as a whole // because a frozen model must declare its relationships against frozen // counterparts (an `inverse:` key path is typed on the destination // model), and following those relationships in both directions closes @@ -45,7 +44,7 @@ import SwiftData // The ten models outside the component (shielded storage, invitations, // masternodes, the tracked-masternode registry, wallet-manager metadata, // the platform-addresses sync state) are still referenced live by every -// version and still carry the latent defect described above. When the +// V1–V3 version and still carry the latent defect described above. When the // next change touches one of them, freeze it here too — and if it sits in // a relationship component, freeze that component with it — then add a // version and a stage. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift new file mode 100644 index 00000000000..f059fe757f7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift @@ -0,0 +1,1857 @@ +import Foundation +import SwiftData + +// Historical V4 definitions. Preserve stored properties, defaults, indexes and +// relationship topology exactly. Constructors only support migration fixtures; +// application behavior belongs to the live models. All relationship targets are +// nested here so SwiftData cannot pull a live entity into the historical schema. +extension DashSchemaV4 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + @Attribute(.unique) var identityId: Data + + var balance: Int64 + var revision: Int64 + + var isLocal: Bool + var alias: String? + + var dpnsName: String? + var mainDpnsName: String? + + var identityType: String + var votingPrivateKeyIdentifier: String? + + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + var createdAt: Date + + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + var wallet: PersistentWallet? + + var identityIndex: UInt32 = 0 + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + var ownedDataContracts: [PersistentDataContract] + + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + } + + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + var networkRaw: UInt32 + + var label: String + var normalizedLabel: String + + var parentDomainName: String + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + var isOwned: Bool = true + + var documentIdBase58: String? + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = label.lowercased().replacingOccurrences(of: "o", with: "0").replacingOccurrences(of: "i", with: "1").replacingOccurrences(of: "l", with: "1") + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = parentDomainName.lowercased().replacingOccurrences(of: "o", with: "0").replacingOccurrences(of: "i", with: "1").replacingOccurrences(of: "l", with: "1") + self.acquiredAt = acquiredAt + self.isOwned = isOwned + 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() + } + } + + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + var networkRaw: UInt32 + + var displayName: String? + var publicMessage: String? + + var bio: String? + var avatarUrl: String? + + var avatarHash: Data? + var avatarFingerprint: Data? + + var identity: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var contactIdentityId: Data + var displayName: String? + + var publicMessage: String? + var bio: String? + + var avatarUrl: String? + var avatarHash: Data? + + var avatarFingerprint: Data? + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var contactIdentityId: Data + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + var contactAlias: String? + + var contactNote: String? + var contactHidden: Bool = false + + var contactAccountLabel: String? + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + var amountDuffs: UInt64 + + var directionRaw: UInt8 + var statusRaw: UInt8 + + var txid: String + var memo: String? + + var owner: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var ignoredSenderId: Data + var owner: PersistentIdentity + + var ignoredAt: Date + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } + + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + + var data: Data + var contractId: String + + var ownerId: String + var contractIdData: Data + + var ownerIdData: Data + var createdAt: Date + + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + + var transferredAtBlockHeight: Int64? + var createdAtCoreBlockHeight: Int64? + + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + } + + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + @Attribute(.unique) var id: Data + + var name: String + var serializedContract: Data + + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + var version: Int? + + var ownerId: Data? + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + var networkRaw: UInt32 + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + + var documentsCanBeDeletedContractDefault: Bool + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + var hasTokens: Bool + + var tokensData: Data? + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + } + + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + + var securityLevel: String + var keyType: String + + var readOnly: Bool + var disabledAt: Int64? + + var publicKeyData: Data + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + + var lastAccessed: Date? + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + } + + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + var tokenId: String + + var identityId: Data + var balance: Int64 + + var frozen: Bool + var createdAt: Date + + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + + var tokenDecimals: Int32? + var networkRaw: UInt32 + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + } + + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + + var contractId: String + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } + + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + + var decimals: Int + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + + var distributionChangeRules: TokenDistributionChangeRules? + var tradeMode: TokenTradeMode + + var tradeModeChangeRules: ChangeControlRules? + var mainControlGroupPosition: Int? + + var mainControlGroupCanBeModified: String? + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + var dataContract: PersistentDataContract? + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } + + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + + var name: String + var schemaJSON: Data + + var propertiesJSON: Data + var documentsKeepHistory: Bool + + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + + var documentsTransferable: Bool + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } + + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + + var contested: Bool + var countable: String? + + var rangeCountable: Bool = false + var summable: String? + + var rangeSummable: Bool = false + var averageable: String? + + var rangeAverageable: Bool = false + var rankedCountable: Bool = false + + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + var preallocated: Bool = false + + var timeRangeJSON: Data? + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + var createdAt: Date + + var documentType: PersistentDocumentType? + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } + + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + + var documentTypeName: String + var name: String + + var type: String + var format: String? + + var contentMediaType: String? + var byteArray: Bool + + var minItems: Int? + var maxItems: Int? + + var pattern: String? + var minLength: Int? + + var maxLength: Int? + var minValue: Int? + + var maxValue: Int? + var fieldDescription: String? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } + + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + var eventType: String + + var transactionId: Data? + var blockHeight: Int64? + + var coreBlockHeight: Int64? + var fromIdentity: Data? + + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + + var balanceAfter: String? + var additionalDataJSON: Data? + + var eventDescription: String? + var createdAt: Date + + var eventTimestamp: Date + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + } + + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + @Attribute(.unique) var address: String + + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + + var publicKey: Data + var accountIndex: UInt32 + + var addressIndex: UInt32 + var derivationPath: String + + var isUsed: Bool + var balance: UInt64 + + var nonce: UInt32 + var firstSeenHeight: UInt32 + + var lastSeenHeight: UInt64 + var walletId: Data + + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var syncHeight: UInt64 + var syncTimestamp: UInt64 + + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } + + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + + var networkRaw: UInt32? + var name: String? + + var walletDescription: String? + var birthHeight: UInt32 + + var syncedHeight: UInt32 + var lastSynced: UInt64 + + var lastAppliedChainLockBytes: Data? + var lastAppliedChainLockHeight: UInt32? + + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } + + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + + var accountTypeName: String + var balanceConfirmed: UInt64 + + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + + var internalHighestUsed: Int32 + var standardTag: UInt8 + + var registrationIndex: UInt32 + var keyClass: UInt32 + + var userIdentityId: Data + var friendIdentityId: Data + + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + + var lastUpdated: Date + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } + + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + + var addressIndex: UInt32 + var derivationPath: String + + var isUsed: Bool + var firstSeenHeight: UInt32 + + var lastSeenHeight: UInt32 + var balance: UInt64 + + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + @Attribute(.unique) var txid: Data + + var transactionData: Data + var context: UInt32 + + var blockHeight: UInt32 + var blockHash: Data? + + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + + var hasBlockPosition: Bool = false + var direction: UInt32 + + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + + var netAmount: Int64 + var fee: UInt64? + + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentTxo { + #Index([\.walletId]) + @Attribute(.unique) var outpoint: Data + + var vout: UInt32 + var amount: UInt64 + + var address: String + var scriptPubKey: Data + + var height: UInt32 + var isCoinbase: Bool + + var isConfirmed: Bool + var isInstantLocked: Bool + + var isLocked: Bool + var isSpent: Bool + + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + var supersededByTxid: Data? + + var spendingInputIndex: UInt32? = nil + var account: PersistentAccount? + + var coreAddress: PersistentCoreAddress? + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + var outpoint = transaction.txid + var outputIndex = vout.littleEndian + withUnsafeBytes(of: &outputIndex) { outpoint.append(contentsOf: $0) } + self.outpoint = outpoint + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + } + + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) + var outpoint: Data + + var inputIndex: UInt32 + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + var walletId: Data + + var createdAt: Date + var isSweptTombstone: Bool = false + + var winnerMinedHeight: UInt32? + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } + + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + + var combinedSyncBlockHash: Data? + var walletCount: Int + + var createdAt: Date + var lastUpdated: Date + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + var walletId: Data + + var accountIndex: UInt32 + var position: UInt64 + + var cmx: Data + @Attribute(.unique) var nullifier: Data + + var blockHeight: UInt64 + var isSpent: Bool + + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } + + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + + var cmx: Data + var recipient: Data + + var value: UInt64 + var memo: Data + + var blockHeight: UInt64 + var createdAt: Date + + var lastUpdated: Date + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } + + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + + var lastSyncedIndex: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } + + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + + var entryId: Data + var kindTag: Int + + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + + var hasFee: Bool + var blockHeight: UInt64 + + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + + var memo: Data + var noteCmxs: Data + + var spentNullifiers: Data + var createdAt: Date + + var lastUpdated: Date + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } + + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + + var fvkBytes: Data + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } + + @Model + final class PersistentInvitation { + #Index([\.walletId]) + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + var walletId: Data + + var fundingIndexRaw: Int + var amountDuffs: Int64 + + var expiryUnix: Int + var createdAtSecs: Int + + var hasInviter: Bool + var statusRaw: Int + + var reclaimInFlight: Bool = false + var createdAt: Date + + var updatedAt: Date + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } + + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + var walletId: Data + + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + + var ownerAddress: String? + var votingAddress: String? + + var operatorPublicKey: Data? + var platformNodeId: Data? + + var payoutAddress: String? + var operatorPseudoAddress: String? + + var platformNodeAddress: String? + var ownerInWallet: Bool = false + + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + + var platformKeyIndex: UInt32 = 0 + var collateralTxid: Data? + + var collateralVout: UInt32 + var revoked: Bool + + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + + var txCount: UInt32 + var orderIndex: UInt32 + + var typeIndex: UInt32 = 0 + var createdAt: Date + + var lastUpdated: Date + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + @Attribute(.unique) var outPointHex: String + + var walletId: Data + var transactionBytes: Data + + var fundingTypeRaw: Int + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + var amountDuffs: Int64 + + var statusRaw: Int + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + var createdAt: Date + + var updatedAt: Date + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } + + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + + var label: String? + var addedAt: UInt64 + + var snapshotJSON: String + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 4bb693102c3..2b690250416 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -154,6 +154,23 @@ extension PlatformWalletManager { currentShieldedTreeTotal = total } + /// Effective engine account set, including automatically discovered tip accounts. + public func shieldedAccountIndices(walletId: Data) throws -> [UInt32] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("Configured manager and 32-byte walletId required") + } + var indices: UnsafeMutablePointer? + var count: UInt = 0 + try walletId.withUnsafeBytes { bytes in + try platform_wallet_manager_shielded_account_indices( + handle, bytes.bindMemory(to: UInt8.self).baseAddress!, &indices, &count + ).check() + } + defer { platform_wallet_manager_free_shielded_account_indices(indices, count) } + guard let indices else { return [] } + return Array(UnsafeBufferPointer(start: indices, count: Int(count))) + } + /// Bind `walletId`'s multi-account shielded sub-wallet to the /// `PlatformWallet` — from viewing keys the persister already /// holds when possible, deriving from the mnemonic only when it diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipAmount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipAmount.swift new file mode 100644 index 00000000000..8485728a627 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ShieldedTipAmount.swift @@ -0,0 +1,37 @@ +import Foundation + +/// An exact positive DASH amount. Input uses ASCII digits and a period decimal +/// separator, without grouping, signs, or exponent notation. Unsupported locale +/// separators are rejected rather than partially parsed by Foundation. +public struct ShieldedTipAmount: Equatable, Sendable { + public let credits: UInt64 + + public init?(_ input: String) { + let parts = input.split(separator: ".", omittingEmptySubsequences: false) + guard (1...2).contains(parts.count), + !parts[0].isEmpty, + parts.allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } }), + let whole = UInt64(parts[0]) else { return nil } + let fraction = parts.count == 2 ? String(parts[1]) : "" + // Additional trailing zeros are exact and safe; extra nonzero digits + // would represent a fraction of a credit. + guard fraction.dropFirst(11).allSatisfy({ $0 == "0" }) else { return nil } + let digits = String(fraction.prefix(11)) + let fractionalCredits = UInt64(digits + String(repeating: "0", count: 11 - digits.count))! + let (integralCredits, overflow) = whole.multipliedReportingOverflow(by: 100_000_000_000) + let (credits, additionOverflow) = integralCredits.addingReportingOverflow(fractionalCredits) + guard !overflow, !additionOverflow, credits > 0 else { return nil } + self.credits = credits + } + + /// Canonical confirmation text derived from the exact amount sent. + public var dashString: String { + let whole = credits / 100_000_000_000 + let fraction = credits % 100_000_000_000 + guard fraction != 0 else { return String(whole) } + var digits = String(fraction) + digits = String(repeating: "0", count: 11 - digits.count) + digits + while digits.last == "0" { digits.removeLast() } + return "\(whole).\(digits)" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 982d064f4df..1af66b383b3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -186,6 +186,32 @@ class ShieldedService: ObservableObject { // MARK: - Lifecycle + /// Re-register newly discovered identity accounts while retaining the engine's + /// existing ordinary accounts. Binding remains independent of discovery success. + @discardableResult + func rebindAfterIdentityDiscovery( + walletManager: PlatformWalletManager, + walletId: Data, + network: Network, + resolver: MnemonicResolver + ) -> Bool { + let existing: [UInt32] + do { + existing = try walletManager.shieldedAccountIndices(walletId: walletId) + } catch { + lastError = error.localizedDescription + return false + } + let accounts = existing.isEmpty ? [0] : existing + if boundWalletId == walletId || boundWalletId == nil { + bind(walletManager: walletManager, walletId: walletId, network: network, + resolver: resolver, accounts: accounts) + return isBound + } + return bindEngine(walletManager: walletManager, walletId: walletId, network: network, + resolver: resolver, accounts: accounts) + } + /// Bind the service to a wallet. Drives `bindShielded` on the /// Rust side first (resolver-driven mnemonic lookup, ZIP-32 /// derivation per `accounts`, per-network commitment tree @@ -275,15 +301,15 @@ class ShieldedService: ObservableObject { resolver: resolver, accounts: sortedAccounts ) + boundAccounts = try walletManager.shieldedAccountIndices(walletId: walletId) isBound = true lastError = nil - boundAccounts = sortedAccounts // Populate per-account default addresses. Best-effort — // a failure on any one account leaves that entry // missing from `addressesByAccount` (the row in the UI // shows blank) but doesn't unbind the wallet. - for account in sortedAccounts { + for account in boundAccounts { if let raw = try? walletManager.shieldedDefaultAddress( walletId: walletId, account: account @@ -298,7 +324,7 @@ class ShieldedService: ObservableObject { // the existing Receive sheet which only renders one // address. Use account 0 if bound, else the lowest // bound account. - let primary = sortedAccounts.contains(0) ? 0 : (sortedAccounts.first ?? 0) + let primary = boundAccounts.contains(0) ? 0 : (boundAccounts.first ?? 0) orchardDisplayAddress = addressesByAccount[primary] SDKLogger.event( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 1303f54d3a0..368f77cb40c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -1528,7 +1528,8 @@ private struct ShieldedNetworkSummaryRows: View { /// Sum of `value` over this network's unspent notes, in credits. private var totalUnspentCredits: UInt64 { allNotes.lazy - .filter { !$0.isSpent && walletIds.contains($0.walletId) } + .filter { !$0.isSpent && walletIds.contains($0.walletId) + && !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) } .reduce(UInt64(0)) { $0 &+ $1.value } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index 7eb06710363..5d0c30110d2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -34,9 +34,19 @@ struct DashPayProfileView: View { } } + /// The persisted row uses zero as a historical placeholder. Only Rust's + /// optional derivation metadata can distinguish that from a real index zero. + private var tipAccount: UInt32? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId), + let managed = try? wallet.managedIdentity(identityId: identity.identityId), + let index = try? managed.getIdentityIndex() else { return nil } + return try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: index) + } + private var tipBalance: UInt64 { guard let walletId = identity.wallet?.walletId, - let account = try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: identity.identityIndex) else { return 0 } + let account = tipAccount else { return 0 } return shieldedNotes.filter { $0.walletId == walletId && $0.accountIndex == account && !$0.isSpent } .reduce(UInt64(0)) { $0 &+ $1.value } } @@ -161,7 +171,7 @@ struct DashPayProfileView: View { } .sheet(isPresented: $showSpendTips) { if let walletId = identity.wallet?.walletId, - let account = try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: identity.identityIndex) { + let account = tipAccount { SendShieldedTipSheet(walletId: walletId, account: account, sourceLabel: "dedicated tip account") } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 236b39f95cb..276e0f966bc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -1051,13 +1051,8 @@ struct SendShieldedTipSheet: View { @State private var showRecipientChanged = false - private var credits: UInt64? { - guard let value = Decimal(string: amount, locale: Locale(identifier: "en_US_POSIX")), value > 0 else { return nil } - let scaled = value * 100_000_000_000 - let number = NSDecimalNumber(decimal: scaled) - guard scaled <= Decimal(UInt64.max), Decimal(number.uint64Value) == scaled else { return nil } - return number.uint64Value - } + private var tipAmount: ShieldedTipAmount? { ShieldedTipAmount(amount) } + private var credits: UInt64? { tipAmount?.credits } var body: some View { NavigationStack { @@ -1074,7 +1069,7 @@ struct SendShieldedTipSheet: View { Text(recipient.identityId.toBase58String()).font(.caption).textSelection(.enabled) Text(DashAddress.encodeOrchard(rawBytes: recipient.address, network: appState.currentNetwork) ?? "") .font(.caption2).textSelection(.enabled) - Text("Send \(amount) DASH from your \(sourceLabel).") + Text("Send \(tipAmount?.dashString ?? "—") DASH from your \(sourceLabel).") } } if let error { Text(error).foregroundStyle(.red) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index f733c0ffb88..ead3d72f285 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift @@ -21,6 +21,7 @@ import SwiftData struct SearchWalletsForIdentitiesView: View { @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState + @EnvironmentObject private var shieldedService: ShieldedService @Environment(\.dismiss) private var dismiss /// Every persisted wallet, across all networks. Sorted by @@ -60,6 +61,7 @@ struct SearchWalletsForIdentitiesView: View { /// Rendered in full (no truncation) so path-derivation /// failures and similar long messages aren't cut off. let error: String? + var bindingWarning: String? = nil } /// Resolved runtime wallet for the current selection, or `nil` @@ -181,6 +183,9 @@ struct SearchWalletsForIdentitiesView: View { .fontWeight(.semibold) .foregroundColor(finding.foundCount > 0 ? .green : .secondary) } + if let warning = finding.bindingWarning { + Text(warning).font(.caption).foregroundColor(.orange) + } if let err = finding.error { // No `.lineLimit` — identity-derivation errors can // be long (full path + SECP error chain). Let @@ -363,23 +368,18 @@ struct SearchWalletsForIdentitiesView: View { ) // Newly discovered identities introduce deterministic tip accounts. // Rebind before scanning so historical tips are included. - let bindError: String? - do { - try walletManager.bindShielded(walletId: walletId, resolver: MnemonicResolver()) - bindError = nil - } catch { - bindError = "Identity discovery completed, but shielded wallet binding failed: " - + error.localizedDescription - } + let bound = shieldedService.rebindAfterIdentityDiscovery( + walletManager: walletManager, walletId: walletId, + network: platformState.currentNetwork, resolver: MnemonicResolver()) result = WalletFinding( walletId: walletId, label: label, foundCount: found.count, - error: bindError + error: nil, + bindingWarning: bound ? nil : "Identities were discovered successfully. Shielded wallet binding could not complete; retry from the Sync tab." ) - // Preserve preview access for empty results or binding failures. - if found.isEmpty || bindError != nil { + if found.isEmpty { await loadPreviewKeys(on: managed) } } catch { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 898d517cba9..aeb0b891ad9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -5,6 +5,38 @@ import XCTest @testable import SwiftDashSDK final class DashModelMigrationTests: XCTestCase { + /// The PR only adds a new entity to the live models. Removing that entity + /// reconstructs the exact pre-freeze V4 definition, independently of the + /// new frozen classes. A store written with it must match frozen V4. + @MainActor + func testPreFreezeV4StoreMigratesUsingFrozenV4() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("historical-v4.store") + let historicalModels = DashModelContainer.modelTypes.filter { + $0 != PersistentDashpayPaymentAddresses.self + } + let historical = Schema(historicalModels, version: .init(4, 0, 0)) + let oldConfiguration = ModelConfiguration(schema: historical, url: url, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer(for: historical, configurations: [oldConfiguration]) + let identityId = Data(repeating: 0x62, count: 32) + do { + let identity = PersistentIdentity(identityId: identityId, network: .testnet) + oldContainer!.mainContext.insert(identity) + oldContainer!.mainContext.insert(PersistentDashpayProfile(identity: identity, displayName: "Historical V4")) + try oldContainer!.mainContext.save() + } + oldContainer = nil + let current = DashModelContainer.schema + let configuration = ModelConfiguration(schema: current, url: url, cloudKitDatabase: .none) + let migrated = try ModelContainer(for: current, migrationPlan: DashMigrationPlan.self, + configurations: [configuration]) + let profile = try XCTUnwrap(migrated.mainContext.fetch(FetchDescriptor()).first) + XCTAssertEqual(profile.identity.identityId, identityId) + XCTAssertEqual(profile.displayName, "Historical V4") + } + @MainActor func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { let directory = FileManager.default.temporaryDirectory @@ -134,18 +166,18 @@ final class DashModelMigrationTests: XCTestCase { configurations: [v4Configuration]) let wallets = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") XCTAssertNil( wallets.first?.lastAppliedChainLockHeight, "a wallet migrated from V3 has no chainlock boundary yet, so no " + "tombstone it later takes can be collected on a fabricated one") let pending = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") - let coins = try migrated.mainContext.fetch(FetchDescriptor()) + let coins = try migrated.mainContext.fetch(FetchDescriptor()) XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") XCTAssertNil( @@ -153,7 +185,7 @@ final class DashModelMigrationTests: XCTestCase { "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " + "so the release and re-delivery rules see an ordinary spent coin") let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") } @@ -218,15 +250,15 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v4Configuration]) - let wallets = try migrated.mainContext.fetch(FetchDescriptor()) + let wallets = try migrated.mainContext.fetch(FetchDescriptor()) XCTAssertEqual(wallets.map(\.walletId), [walletId]) XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(transactions.map(\.txid), [txid]) XCTAssertEqual(transactions.first?.context, 3) XCTAssertEqual(transactions.first?.netAmount, 2_000) - let coins = try migrated.mainContext.fetch(FetchDescriptor()) + let coins = try migrated.mainContext.fetch(FetchDescriptor()) XCTAssertEqual(coins.count, 1) XCTAssertEqual(coins.first?.vout, 1) XCTAssertEqual(coins.first?.amount, 2_000) @@ -240,7 +272,7 @@ final class DashModelMigrationTests: XCTestCase { /// What makes the V3 -> V4 stage lightweight: the two versions name the /// same entity set, and V4 only widens three of them. Also pins that - /// `PersistentTransaction` is NOT one of the three — a swept row is + /// `DashSchemaV4.PersistentTransaction` is NOT one of the three — a swept row is /// deleted outright, so the transaction entity carries no sweep marker, /// and one that came back would silently change V4's checksum. func testV3AndV4NameTheSameEntitySet() throws { @@ -398,22 +430,22 @@ extension DashModelMigrationTests { let winnerTxid = Data(repeating: 0x41, count: 32) do { let context = oldContainer!.mainContext - let identity = PersistentIdentity(identityId: identityId, isLocal: true, network: .testnet) + let identity = DashSchemaV4.PersistentIdentity(identityId: identityId, isLocal: true, network: .testnet) context.insert(identity) - context.insert(PersistentDashpayProfile(identity: identity, displayName: "Preserved profile")) - let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(DashSchemaV4.PersistentDashpayProfile(identity: identity, displayName: "Preserved profile")) + let wallet = DashSchemaV4.PersistentWallet(walletId: walletId, network: .testnet) wallet.lastAppliedChainLockHeight = 4321 context.insert(wallet) - let pending = PersistentPendingInput( + let pending = DashSchemaV4.PersistentPendingInput( outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, spendingTxid: winnerTxid, spendingTransaction: nil, walletId: walletId) pending.isSweptTombstone = true pending.winnerMinedHeight = 1234 context.insert(pending) - let funding = PersistentTransaction(txid: Data(repeating: 0x51, count: 32), + let funding = DashSchemaV4.PersistentTransaction(txid: Data(repeating: 0x51, count: 32), transactionData: Data([0x03, 0x00]), context: 2, blockHeight: 100) context.insert(funding) - let coin = PersistentTxo(transaction: funding, vout: 0, amount: 1000, address: "yV4Coin", height: 100) + let coin = DashSchemaV4.PersistentTxo(transaction: funding, vout: 0, amount: 1000, address: "yV4Coin", height: 100) coin.walletId = walletId coin.isSpent = true coin.supersededByTxid = winnerTxid diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift new file mode 100644 index 00000000000..56598d8dabc --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import SwiftDashSDK + +@MainActor +final class ShieldedAccountSnapshotTests: XCTestCase { + /// Identity discovery can precede configureShielded. Reading the current + /// account set must still succeed so the service can perform the first bind. + func testUnconfiguredShieldedWalletReturnsEmptySnapshot() async throws { + let sdk = try SDK(network: .testnet) + let manager = try PlatformWalletManager(sdk: sdk) + do { + let wallet = try await manager.createWallet( + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + network: .testnet, + createDefaultAccounts: false) + XCTAssertEqual(try manager.shieldedAccountIndices(walletId: wallet.walletId), []) + XCTAssertThrowsError(try manager.shieldedAccountIndices(walletId: Data(repeating: 0x71, count: 32))) + await manager.shutdown() + } catch { + await manager.shutdown() + throw error + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift new file mode 100644 index 00000000000..cb9d1374cd4 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import SwiftDashSDK + +final class ShieldedTipAmountTests: XCTestCase { + func testRejectsPartialLocaleAndMalformedAmounts() { + for text in ["1,5", "1abc", "1.5abc", "1,000.5", "1e2", "+1", "-1", " 1", "1 ", "", ".", "1.", ".5", "12", "0", "0.000000000001"] { + XCTAssertNil(ShieldedTipAmount(text), text) + } + } + + func testExactCreditsAndCanonicalConfirmation() throws { + for (input, credits, display) in [("1.5", UInt64(150_000_000_000), "1.5"), + ("001.500000000000", 150_000_000_000, "1.5"), + ("0.00000000001", 1, "0.00000000001"), + ("184467440.73709551615", UInt64.max, "184467440.73709551615")] { + let amount = try XCTUnwrap(ShieldedTipAmount(input)) + XCTAssertEqual(amount.credits, credits) + XCTAssertEqual(amount.dashString, display) + } + XCTAssertNil(ShieldedTipAmount("184467440.73709551616")) + XCTAssertNil(ShieldedTipAmount("184467441")) + XCTAssertNil(ShieldedTipAmount("18446744073709551616")) + } +} diff --git a/scripts/check-storage-explorer.sh b/scripts/check-storage-explorer.sh index 3689566710f..3f21948187c 100755 --- a/scripts/check-storage-explorer.sh +++ b/scripts/check-storage-explorer.sh @@ -52,7 +52,7 @@ fi # Check each model type is referenced in the explorer top-level view. echo "=== Checking StorageExplorerView.swift ===" for model in $model_types; do - if ! grep -q "$model" "$EXPLORER"; then + if ! grep -qw "$model" "$EXPLORER"; then echo " MISSING: $model not referenced in StorageExplorerView.swift" errors=$((errors + 1)) else @@ -65,7 +65,7 @@ echo "" # containing @Query of the model type). echo "=== Checking StorageModelListViews.swift ===" for model in $model_types; do - if ! grep -q "$model" "$LIST_VIEWS"; then + if ! grep -qw "$model" "$LIST_VIEWS"; then echo " MISSING: $model has no list view in StorageModelListViews.swift" errors=$((errors + 1)) else @@ -77,7 +77,7 @@ echo "" # Check each model type has a detail view. echo "=== Checking StorageRecordDetailViews.swift ===" for model in $model_types; do - if ! grep -q "$model" "$DETAIL_VIEWS"; then + if ! grep -qw "$model" "$DETAIL_VIEWS"; then echo " MISSING: $model has no detail view in StorageRecordDetailViews.swift" errors=$((errors + 1)) else From a637a0e2bd2bfc7d616d4323a237b4fc45db7ff7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 04:31:56 +0700 Subject: [PATCH 05/15] fix(platform-wallet): contain tip export panics and share the mobile tip send guard - platform-wallet-ffi: `platform_wallet_manager_prepare_shielded_tip_address` and `platform_wallet_resolve_shielded_tip` run their worker call inside `catch_query_panic` (ErrorWalletOperation, retryable); the raw output writes happen only on success so the zeroed buffers survive a panic. A test-only fault injected inside the worker future drives the three real `extern "C"` tip entry points, so removing a guard at a call site fails `exported_tip_entry_points_contain_a_worker_panic`. - SwiftExampleApp: `ShieldedTipSubmissions`, an app-owned per network and wallet guard, keeps an in-flight or uncertain tip send locked across sheet dismissal and both entry points (parity with the Kotlin app); tests. - KotlinExampleApp: the tip account is derived from the live identity's optional derivation index (`ManagedPlatformWallet.identityIndex`), not Room's non-null `identityIndex` whose 0 is a placeholder that aliases identity 0's tip pool. Co-Authored-By: Claude Fable 5.1 --- .../ui/dashpay/DashPayProfileScreen.kt | 8 +- .../example/ui/dashpay/DashPayTabScreen.kt | 27 +- .../dashsdk/wallet/ManagedPlatformWallet.kt | 23 ++ .../src/shielded_send.rs | 303 ++++++++++++++++-- .../SwiftExampleApp/SwiftExampleAppApp.swift | 4 + .../Views/DashPay/DashPayTabView.swift | 91 ++++-- .../Views/DashPay/ShieldedTipSubmission.swift | 84 +++++ .../ShieldedTipSubmissionTests.swift | 98 ++++++ 8 files changed, 578 insertions(+), 60 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt index 6e85c35d62b..dd9bf8a0a12 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -79,8 +79,12 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val walletId = identity?.walletId val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } - val tipAccount = remember(manager, identity?.identityIndex) { - identity?.identityIndex?.let { index -> runCatching { manager?.shieldedTipAccountIndex(index) }.getOrNull() } + // Derived from the live identity's index, not Room's placeholder 0 (see DashPayTabScreen). + val tipAccount by produceState(initialValue = null, wallet, manager, identityIdHex) { + value = wallet?.let { w -> + runCatching { w.identityIndex(idBytes)?.let { index -> manager?.shieldedTipAccountIndex(index) } } + .getOrNull() + } } val tipBalance by remember(walletId, tipAccount) { if (walletId == null || tipAccount == null) flowOf(0L) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 21f91263e3c..9e79c4547f8 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -326,10 +326,20 @@ fun DashPayTabScreen(navController: NavHostController) { val tipManager = manager val tipWalletId = identity.walletId - val tipAccountResult = remember(tipManager, identity.identityIndex) { - runCatching { requireNotNull(tipManager).shieldedTipAccountIndex(identity.identityIndex) } + // The tip account follows the live identity's derivation index, not + // Room's non-null `identityIndex`: its 0 is a placeholder for an identity + // with no recoverable index and would alias identity 0's tip pool. + val tipAccountResult by produceState?>( + initialValue = null, managed, tipManager, identityHex, + ) { + value = runCatching { + val index = requireNotNull(managed) { "Wallet is not loaded" } + .identityIndex(identity.identityId) + ?: error("Tip account requires a recoverable identity index") + requireNotNull(tipManager).shieldedTipAccountIndex(index) + } } - val tipAccount = tipAccountResult.getOrNull() + val tipAccount = tipAccountResult?.getOrNull() val tipSubmission = tipWalletId?.let { container.shieldedTipSubmissions.forWallet(network.ffiValue, it.toHex()) } @@ -351,10 +361,13 @@ fun DashPayTabScreen(navController: NavHostController) { icon = Icons.AutoMirrored.Filled.Send, title = "Send shielded tip", onClick = { - tipAccountResult.fold( - onSuccess = { showTipSheet = true }, - onFailure = { unlockError = it.message ?: "Could not open shielded tips" }, - ) + when (val result = tipAccountResult) { + null -> unlockError = "The tip account is still loading" + else -> result.fold( + onSuccess = { showTipSheet = true }, + onFailure = { unlockError = it.message ?: "Could not open shielded tips" }, + ) + } }, modifier = Modifier.testTag("dashpay.sendShieldedTip"), ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 9cee58497f9..88b5c4f8567 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -1073,6 +1073,29 @@ class ManagedPlatformWallet internal constructor( } } + /** + * The HD derivation index of one managed identity, or null when the wallet + * holds no recoverable index for it (a watched or index-less identity). + * Per-identity state derived from the index — the dedicated shielded tip + * account — must treat null as "unavailable" rather than fall back to 0, + * which is identity 0's slot. Room's `IdentityEntity.identityIndex` is + * non-null and stores 0 as a placeholder, so it cannot answer this. + */ + suspend fun identityIndex(identityId: ByteArray): Int? = withContext(Dispatchers.IO) { + mapNativeErrors { + val identityHandle = translateManagedIdentityNotFoundToZero { + TokensNative.getManagedIdentity(handle, identityId) + } + if (identityHandle == 0L) return@mapNativeErrors null + try { + val index = WalletManagerNative.managedIdentityGetIdentityIndex(identityHandle) + if (index < 0) null else index.toInt() + } finally { + TokensNative.managedIdentityDestroy(identityHandle) + } + } + } + /** * The advisory "why not" reason a withdrawal preflight records when the * account can't fund one — port of the `success_with_message` reason the diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index f3bdac170d5..8250a63d09f 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -754,6 +754,48 @@ fn catch_spend_panic( ) } +/// Post-panic guidance for exports that move no funds. Paired with +/// `ErrorWalletOperation` in [`catch_query_panic`]. +const QUERY_PANIC_GUIDANCE: &str = "No funds were moved; the call may be retried."; + +/// [`catch_panic_to_code`] specialized for exports that spend nothing (address +/// preparation, recipient resolution). A panic there is a plain failure of this +/// call, so it maps to [`PlatformWalletFFIResultCode::ErrorWalletOperation`] and +/// the host may retry. The boundary matters as much as on the spend path: an +/// unwind that reaches the `extern "C"` frame aborts the process before the JNI +/// guard on the far side can translate it. +fn catch_query_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorWalletOperation, + QUERY_PANIC_GUIDANCE, + body, + ) +} + +/// Test-only fault injection for the tip exports: while set, the worker future +/// of each guarded tip call panics before it touches the wallet, so a test can +/// prove the real `extern "C"` entry point (not just the helper) contains the +/// unwind. Process-global because the future runs on a tokio worker thread; +/// consulted only by the three tip exports. +#[cfg(test)] +static INJECT_TIP_WORKER_PANIC: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(test)] +fn maybe_inject_tip_worker_panic() { + if INJECT_TIP_WORKER_PANIC.load(std::sync::atomic::Ordering::SeqCst) { + panic!("injected tip worker panic"); + } +} + +#[cfg(not(test))] +#[inline(always)] +fn maybe_inject_tip_worker_panic() {} + /// Post-panic guidance for the asset-lock funding exports. Paired with /// `ErrorTransactionBroadcastUnconfirmed` in [`catch_funding_panic`]. const FUNDING_PANIC_GUIDANCE: &str = "The asset lock may or may not have been broadcast — do \ @@ -2314,17 +2356,29 @@ pub unsafe extern "C" fn platform_wallet_manager_prepare_shielded_tip_address( Ok(value) => value, Err(e) => return e, }; - match block_on_worker(async move { - wallet - .prepare_shielded_tip_address(seed.as_ref(), &identity_id, &coordinator) - .await - }) { - Ok(address) => { - std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); - PlatformWalletFFIResult::ok() - } - Err(e) => e.into(), + // The worker call and its result mapping sit inside the panic boundary; + // the raw output write happens outside it, only on success, so a panic + // leaves the zeroed buffer untouched. + let mut address = [0u8; 43]; + let result = catch_query_panic( + "shielded tip address preparation", + || match block_on_worker(async move { + maybe_inject_tip_worker_panic(); + wallet + .prepare_shielded_tip_address(seed.as_ref(), &identity_id, &coordinator) + .await + }) { + Ok(bytes) => { + address = bytes; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + }, + ); + if result.code == PlatformWalletFFIResultCode::Success { + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); } + result } /// Resolve a username using verified current DPNS and profile documents. @@ -2345,25 +2399,34 @@ pub unsafe extern "C" fn platform_wallet_resolve_shielded_tip( Ok(s) => s.to_owned(), Err(e) => return e.into(), }; - let result = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { - let identity = wallet.identity().clone(); - block_on_worker(async move { identity.dashpay().resolve_shielded_tip(&username).await }) - }); - match result { - Some(Ok(recipient)) => { - std::ptr::copy_nonoverlapping( - recipient.identity_id.to_buffer().as_ptr(), - out_identity_id, - 32, - ); - std::ptr::copy_nonoverlapping(recipient.address.as_ptr(), out_address, 43); - PlatformWalletFFIResult::ok() - } - Some(Err(e)) => e.into(), - None => { - PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::NotFound, "Wallet not found") + let mut identity_id = [0u8; 32]; + let mut address = [0u8; 43]; + let result = catch_query_panic("shielded tip resolution", || { + let result = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + maybe_inject_tip_worker_panic(); + identity.dashpay().resolve_shielded_tip(&username).await + }) + }); + match result { + Some(Ok(recipient)) => { + identity_id = recipient.identity_id.to_buffer(); + address = recipient.address; + PlatformWalletFFIResult::ok() + } + Some(Err(e)) => e.into(), + None => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Wallet not found", + ), } + }); + if result.code == PlatformWalletFFIResultCode::Success { + std::ptr::copy_nonoverlapping(identity_id.as_ptr(), out_identity_id, 32); + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); } + result } /// Send a tip only if fresh resolution matches the recipient the user confirmed. @@ -2420,6 +2483,7 @@ pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( }; catch_spend_panic("shielded tip", || { let result = block_on_worker(async move { + maybe_inject_tip_worker_panic(); let recipient = platform_wallet::ShieldedTipRecipient { identity_id, address, @@ -3017,6 +3081,191 @@ mod tests { ); } + /// A fixed BIP-39 phrase the test resolver answers for every wallet id; + /// the wallet under test is created from the same phrase so the resolved + /// seed matches it. + const TIP_TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about"; + + unsafe extern "C" fn resolve_tip_test_mnemonic( + _ctx: *const std::ffi::c_void, + _wallet_id: *const u8, + out: *mut c_char, + cap: usize, + out_len: *mut usize, + ) -> i32 { + let phrase = TIP_TEST_MNEMONIC.as_bytes(); + assert!(cap >= phrase.len()); + std::ptr::copy_nonoverlapping(phrase.as_ptr(), out as *mut u8, phrase.len()); + *out_len = phrase.len(); + rs_sdk_ffi::mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn destroy_tip_test_resolver(_ctx: *mut std::ffi::c_void) {} + + /// A manager with a configured shielded store and one wallet created from + /// `TIP_TEST_MNEMONIC`: the state a tip export needs to get past every + /// argument check and into its guarded worker call. + fn tip_test_manager() -> (Handle, [u8; 32], std::path::PathBuf) { + let handle = recovery_test_manager(); + let path = + std::env::temp_dir().join(format!("shielded-ffi-tip-{}-{handle}", std::process::id())); + std::fs::create_dir_all(&path).expect("temporary store directory"); + let wallet_id = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| { + runtime().block_on(async { + manager + .configure_shielded(path.join("tree.sqlite")) + .await + .expect("configure shielded store"); + manager + .create_wallet_from_mnemonic( + TIP_TEST_MNEMONIC, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("register wallet") + .wallet_id() + }) + }) + .expect("live manager"); + (handle, wallet_id, path) + } + + /// The real exports contain a worker panic instead of letting it unwind into + /// the `extern "C"` frame: this drives the exported entry points themselves, + /// so deleting a guard at a call site fails here even while the helper-level + /// tests below stay green. One test covers all three exports because the + /// injection flag is process-global. + #[test] + fn exported_tip_entry_points_contain_a_worker_panic() { + use std::sync::atomic::Ordering::SeqCst; + + let (handle, wallet_id, path) = tip_test_manager(); + let mut vtable = rs_sdk_ffi::MnemonicResolverVTable { + resolve: resolve_tip_test_mnemonic, + destroy: destroy_tip_test_resolver, + }; + let mut resolver = MnemonicResolverHandle { + ctx: std::ptr::null_mut(), + vtable: &mut vtable, + }; + let username = std::ffi::CString::new("alice").expect("username"); + let identity_id = [0x21u8; 32]; + let address = [0x43u8; 43]; + + INJECT_TIP_WORKER_PANIC.store(true, SeqCst); + + // Spending export: the ambiguous, do-not-retry contract. + let mut send = unsafe { + platform_wallet_manager_send_shielded_tip( + handle, + wallet_id.as_ptr(), + &mut resolver, + 0, + username.as_ptr(), + identity_id.as_ptr(), + address.as_ptr(), + 1_000, + std::ptr::null(), + ) + }; + assert_eq!( + send.code, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed + ); + let message = message_of(&send); + assert!( + message.contains("injected tip worker panic") && message.contains("do NOT retry"), + "{message}" + ); + + // Non-spending exports: a retryable failure with the outputs left zeroed. + let mut out_address = [0xAAu8; 43]; + let mut prepare = unsafe { + platform_wallet_manager_prepare_shielded_tip_address( + handle, + wallet_id.as_ptr(), + &mut resolver, + identity_id.as_ptr(), + out_address.as_mut_ptr(), + ) + }; + assert_eq!( + prepare.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = message_of(&prepare); + assert!( + message.contains("injected tip worker panic") && message.contains("may be retried"), + "{message}" + ); + assert_eq!( + out_address, [0u8; 43], + "no address may escape a failed call" + ); + + let mut wallet_handle = NULL_HANDLE; + let mut got = unsafe { + crate::manager::platform_wallet_manager_get_wallet( + handle, + &wallet_id, + &mut wallet_handle, + ) + }; + assert_eq!(got.code, PlatformWalletFFIResultCode::Success); + let mut out_identity = [0xAAu8; 32]; + let mut out_recipient = [0xAAu8; 43]; + let mut resolve = unsafe { + platform_wallet_resolve_shielded_tip( + wallet_handle, + username.as_ptr(), + out_identity.as_mut_ptr(), + out_recipient.as_mut_ptr(), + ) + }; + assert_eq!( + resolve.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + assert!(message_of(&resolve).contains("injected tip worker panic")); + assert_eq!(out_identity, [0u8; 32]); + assert_eq!(out_recipient, [0u8; 43]); + + INJECT_TIP_WORKER_PANIC.store(false, SeqCst); + unsafe { + crate::platform_wallet_ffi_result_free(&mut send); + crate::platform_wallet_ffi_result_free(&mut prepare); + crate::platform_wallet_ffi_result_free(&mut resolve); + crate::platform_wallet_ffi_result_free(&mut got); + let mut destroyed = crate::wallet::platform_wallet_destroy(wallet_handle); + crate::platform_wallet_ffi_result_free(&mut destroyed); + let mut destroyed = platform_wallet_manager_destroy(handle); + crate::platform_wallet_ffi_result_free(&mut destroyed); + } + let _ = std::fs::remove_dir_all(path); + } + + #[test] + fn catch_query_panic_maps_a_panic_to_a_retryable_wallet_operation() { + let result = catch_query_panic("shielded tip resolution", || { + panic!("tokio worker panicked"); + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = message_of(&result); + assert!( + message.contains("shielded tip resolution panicked") + && message.contains("tokio worker panicked") + && message.contains("may be retried"), + "{message}" + ); + } + #[test] fn should_contain_tip_worker_panic_as_uncertain_spend() { let result = catch_spend_panic("shielded tip", || { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 9fbfa802dcc..3278c6072cd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -60,6 +60,9 @@ struct SwiftExampleAppApp: App { @StateObject private var platformBalanceSyncService = PlatformBalanceSyncService() @StateObject private var transitionState = TransitionState() @StateObject private var appUIState = AppUIState() + // Shielded tip sends outlive the sheet that starts them (see + // `ShieldedTipSubmissions`), so their state is owned here. + @StateObject private var shieldedTipSubmissions = ShieldedTipSubmissions() /// Current manager exposed to views via the env object pipeline. /// Reads from the published `activeManager` on every body @@ -144,6 +147,7 @@ struct SwiftExampleAppApp: App { .environmentObject(platformBalanceSyncService) .environmentObject(transitionState) .environmentObject(appUIState) + .environmentObject(shieldedTipSubmissions) .environment(\.modelContext, modelContainer.mainContext) .onOpenURL { url in // DashPay invitation deep link: route to the DashPay tab and diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 276e0f966bc..60459935b40 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -1035,24 +1035,43 @@ private struct AddViaQRSheet: View { } /// Resolve first, then explicitly confirm the identity, address, and amount. +/// The send itself is owned by the app-wide `ShieldedTipSubmissions`, so a +/// dismissed or reopened sheet finds the same in-flight or uncertain state +/// instead of a fresh, unlocked one. struct SendShieldedTipSheet: View { let walletId: Data var account: UInt32 = 0 var sourceLabel: String = "ordinary shielded account" + @EnvironmentObject private var submissions: ShieldedTipSubmissions + @EnvironmentObject private var appState: AppState + + var body: some View { + SendShieldedTipSheetContent( + walletId: walletId, account: account, sourceLabel: sourceLabel, + submission: submissions.forWallet(network: appState.currentNetwork, walletId: walletId)) + } +} + +private struct SendShieldedTipSheetContent: View { + let walletId: Data + let account: UInt32 + let sourceLabel: String + @ObservedObject var submission: ShieldedTipSubmission @EnvironmentObject private var walletManager: PlatformWalletManager @EnvironmentObject private var appState: AppState @Environment(\.dismiss) private var dismiss @State private var username = "" @State private var amount = "" @State private var recipient: ShieldedTipRecipient? - @State private var busy = false + @State private var resolving = false @State private var error: String? - @State private var submitted = false @State private var showRecipientChanged = false - private var tipAmount: ShieldedTipAmount? { ShieldedTipAmount(amount) } private var credits: UInt64? { tipAmount?.credits } + /// Recipient resolution is sheet-local; the send lock is the shared submission. + private var busy: Bool { resolving || submission.busy } + private var submitted: Bool { submission.submitted } var body: some View { NavigationStack { @@ -1063,7 +1082,7 @@ struct SendShieldedTipSheet: View { .onChange(of: username) { _, _ in recipient = nil } TextField("Amount in DASH", text: $amount).keyboardType(.decimalPad) .disabled(busy || submitted) - if let recipient { + if let recipient, !submitted { Section("Confirm recipient") { Text(username) Text(recipient.identityId.toBase58String()).font(.caption).textSelection(.enabled) @@ -1073,9 +1092,22 @@ struct SendShieldedTipSheet: View { } } if let error { Text(error).foregroundStyle(.red) } - if submitted { - Text("Tip submitted. Check shielded activity for confirmation.") - } else { + switch submission.status { + case .sent: + Text(submission.message ?? "Tip submitted. Check shielded activity for confirmation.") + Button("Send another tip") { + submission.startNewTip() + recipient = nil + amount = "" + } + case .uncertain: + Text(submission.message + ?? "The tip may have been sent. Check shielded activity before sending again.") + .foregroundStyle(.orange) + case .ready, .sending: + if let message = submission.message, error == nil { + Text(message).foregroundStyle(.red) + } Button(recipient == nil ? "Review recipient" : "Confirm and send tip") { submit() } .disabled(busy || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || credits == nil) } @@ -1095,29 +1127,40 @@ struct SendShieldedTipSheet: View { private func submit() { guard let wallet = walletManager.wallet(for: walletId), let credits else { return } - busy = true error = nil + let network = appState.currentNetwork + let name = username + if let recipient { + let confirmed = recipient + let manager = walletManager + let account = account + let walletId = walletId + submission.submit { + ShieldedTipRecipientHistory().confirm(network: network, walletId: walletId, + username: name, recipient: confirmed) + do { + try await manager.sendShieldedTip(walletId: walletId, resolver: MnemonicResolver(), + account: account, username: name, recipient: confirmed, amount: credits) + } catch { + // Whatever the outcome, the next attempt re-reviews the recipient. + self.recipient = nil + throw error + } + } + return + } + resolving = true Task { @MainActor in - defer { busy = false } + defer { resolving = false } do { - if let recipient { - ShieldedTipRecipientHistory().confirm(network: appState.currentNetwork, walletId: walletId, - username: username, recipient: recipient) - try await walletManager.sendShieldedTip(walletId: walletId, resolver: MnemonicResolver(), - account: account, username: username, recipient: recipient, amount: credits) - submitted = true - } else { - let resolved = try await wallet.resolveShieldedTip(username: username) - if ShieldedTipRecipientHistory().hasChanged(network: appState.currentNetwork, walletId: walletId, - username: username, recipient: resolved) { - showRecipientChanged = true - } - recipient = resolved + let resolved = try await wallet.resolveShieldedTip(username: name) + if ShieldedTipRecipientHistory().hasChanged(network: network, walletId: walletId, + username: name, recipient: resolved) { + showRecipientChanged = true } + recipient = resolved } catch { self.error = error.localizedDescription - // A relay-accepted payment may already exist; prevent a second send. - if case PlatformWalletError.shieldedSpendUnconfirmed = error { submitted = true } recipient = nil } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift new file mode 100644 index 00000000000..b4be45f6a91 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftDashSDK + +/// Application-owned tip submissions. They outlive the sheet that started +/// them: a dismissed sheet must never imply the native send stopped +/// broadcasting, and reopening from either entry point (the DashPay tab or +/// the profile) has to find the same guard. Mirrors the Kotlin example app's +/// `ShieldedTipSubmissions`. +@MainActor +final class ShieldedTipSubmissions: ObservableObject { + private var wallets: [String: ShieldedTipSubmission] = [:] + + /// One guard per network and wallet, across all of its identities. + func forWallet(network: Network, walletId: Data) -> ShieldedTipSubmission { + let key = "\(network.rawValue):\(walletId.hexString)" + if let existing = wallets[key] { return existing } + let created = ShieldedTipSubmission() + wallets[key] = created + return created + } +} + +/// Main-actor state of one wallet's shielded tip submission. +@MainActor +final class ShieldedTipSubmission: ObservableObject { + enum Status: Equatable { case ready, sending, sent, uncertain } + + @Published private(set) var status: Status = .ready + @Published private(set) var message: String? + + var busy: Bool { status == .sending } + var submitted: Bool { status != .ready } + + /// Locks synchronously, before the task starts, so two UI events cannot + /// submit twice. Returns nil when a submission already holds the lock; the + /// task is returned so tests can await the outcome. + @discardableResult + func submit(_ send: @escaping @MainActor () async throws -> Void) -> Task? { + guard !submitted else { return nil } + status = .sending + message = nil + return Task { @MainActor in + do { + try await send() + status = .sent + message = "Shielded tip sent." + } catch { + if Self.canReviewAfterFailure(error) { + status = .ready + message = error.localizedDescription + } else { + status = .uncertain + message = "The tip may have been sent. Check shielded activity before sending " + + "again. \(error.localizedDescription)" + } + } + } + } + + /// Starting another payment is an explicit action after a confirmed success. + func startNewTip() { + guard status == .sent else { return } + status = .ready + message = nil + } + + /// Only failures known not to have executed a tip permit a fresh review. + /// On the tip path Rust maps selection, build and recipient-check failures + /// to `walletOperation`, and broadcast ambiguity to + /// `shieldedSpendUnconfirmed`; anything unknown, including cancellation, + /// stays locked until shielded activity shows the outcome. + static func canReviewAfterFailure(_ error: Error) -> Bool { + guard let error = error as? PlatformWalletError else { return false } + switch error { + case .nullPointer, .invalidHandle, .invalidParameter, .invalidIdentifier, .invalidNetwork, + .walletOperation, .identityNotFound, .contactNotFound, .utf8Conversion, + .noSelectableInputs, .shieldedBroadcastFailed, .shieldedNoRecordedAnchor, + .shieldedInsufficientBalance: + return true + default: + return false + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift new file mode 100644 index 00000000000..bf466fa55b1 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// The app-owned shielded tip guard: one submission per network and wallet, +/// locked from the first tap, and unlocked only by a confirmed result. A +/// dismissed sheet reopening on the same wallet must find the same guard. +@MainActor +final class ShieldedTipSubmissionTests: XCTestCase { + /// Parks a submission until the test releases it, like a native send in flight. + @MainActor + private final class Gate { + private var continuation: CheckedContinuation? + private var opened = false + func wait() async { + if opened { return } + await withCheckedContinuation { continuation = $0 } + } + func open() { + opened = true + continuation?.resume() + continuation = nil + } + } + + @MainActor + private final class Counter { + var sends = 0 + } + + func testInFlightSendRefusesASecondSubmissionAndReopeningFindsTheSameGuard() async { + let submissions = ShieldedTipSubmissions() + let walletId = Data(repeating: 0x31, count: 32) + let gate = Gate() + let counter = Counter() + + let submission = submissions.forWallet(network: .testnet, walletId: walletId) + let task = submission.submit { + counter.sends += 1 + await gate.wait() + } + XCTAssertNotNil(task) + XCTAssertTrue(submission.busy) + + // The sheet was dismissed and reopened: the owner hands back the same + // guard, which is still locked, so the second tap never sends. + let reopened = submissions.forWallet(network: .testnet, walletId: walletId) + XCTAssertTrue(reopened === submission) + XCTAssertNil(reopened.submit { counter.sends += 1 }) + + gate.open() + await task?.value + XCTAssertEqual(counter.sends, 1) + XCTAssertEqual(submission.status, .sent) + XCTAssertTrue(submission.submitted) + + // Another payment needs an explicit action after the confirmed result. + submission.startNewTip() + XCTAssertEqual(submission.status, .ready) + XCTAssertFalse(submission.submitted) + + // Another wallet on the same network has its own guard. + let other = submissions.forWallet(network: .testnet, walletId: Data(repeating: 0x32, count: 32)) + XCTAssertFalse(other === submission) + } + + func testUnconfirmedSpendStaysLockedAcrossReopenAndCannotStartANewTip() async { + let submission = ShieldedTipSubmission() + let task = submission.submit { + throw PlatformWalletError.shieldedSpendUnconfirmed("relay accepted the transition") + } + await task?.value + XCTAssertEqual(submission.status, .uncertain) + XCTAssertTrue(submission.submitted) + XCTAssertTrue(submission.message?.contains("may have been sent") == true) + + // Neither a fresh tap nor "send another" unlocks an uncertain outcome. + XCTAssertNil(submission.submit {}) + submission.startNewTip() + XCTAssertEqual(submission.status, .uncertain) + } + + func testPreflightFailureReturnsToReadyWhileUnknownFailuresLock() async { + let submission = ShieldedTipSubmission() + await submission.submit { + throw PlatformWalletError.walletOperation("insufficient shielded notes") + }?.value + XCTAssertEqual(submission.status, .ready) + XCTAssertEqual( + submission.message, + PlatformWalletError.walletOperation("insufficient shielded notes").localizedDescription) + + struct Unknown: Error {} + await submission.submit { throw Unknown() }?.value + XCTAssertEqual(submission.status, .uncertain) + XCTAssertNil(submission.submit {}) + } +} From 4e070e9b1298a96ecc3e7e48306058979702cb11 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 01:55:01 -0500 Subject: [PATCH 06/15] fix(platform-wallet): address remaining shielded tip review threads - FFI: platform_wallet_resolve_shielded_tip clones the identity wallet under the registry guard and releases it before the DPNS/profile network lookups, so a slow resolution no longer blocks handle creation and teardown for unrelated wallets. - Kotlin: tag the produceState tip-account result with the (wallet, manager, identity) it was resolved for; produceState keeps its previous value when its keys change, so identity A's account could otherwise be offered to the tip sheet while B's lookup runs. - Swift: payment-address rows are keyed by owner id with no relationship cascade, so PersistentIdentity.remove, the identity list's local removal and the orphan-identity deletion now remove them too (removeOwned is public for host deletion paths). - Storage: pin the pre-V019 profile record with hand-written bincode bytes, independent of the frozen legacy declarations. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../example/ui/dashpay/DashPayTabScreen.kt | 11 ++++-- .../src/shielded_send.rs | 9 +++-- .../tests/sqlite_profile_address_encoding.rs | 35 +++++++++++++++++++ .../PersistentDashpayPaymentAddresses.swift | 5 ++- .../Models/PersistentIdentity.swift | 4 +++ .../Core/Views/IdentitiesContentView.swift | 3 ++ .../Core/Views/WalletKeyHealthSheet.swift | 3 ++ 7 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 9e79c4547f8..50fce16af12 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -329,16 +329,23 @@ fun DashPayTabScreen(navController: NavHostController) { // The tip account follows the live identity's derivation index, not // Room's non-null `identityIndex`: its 0 is a placeholder for an identity // with no recoverable index and would alias identity 0's tip pool. - val tipAccountResult by produceState?>( + // produceState keeps its last value when its keys change, so each result + // is tagged with the inputs it was resolved for and a result for another + // identity or wallet is never offered while the new lookup runs. + val tipAccountKey = listOf(managed, tipManager, identityHex) + val taggedTipAccount by produceState, Result>?>( initialValue = null, managed, tipManager, identityHex, ) { - value = runCatching { + value = tipAccountKey to runCatching { val index = requireNotNull(managed) { "Wallet is not loaded" } .identityIndex(identity.identityId) ?: error("Tip account requires a recoverable identity index") requireNotNull(tipManager).shieldedTipAccountIndex(index) } } + val tipAccountResult = taggedTipAccount + ?.takeIf { (key, _) -> key == tipAccountKey } + ?.second val tipAccount = tipAccountResult?.getOrNull() val tipSubmission = tipWalletId?.let { container.shieldedTipSubmissions.forWallet(network.ffiValue, it.toHex()) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 34ae9db02e2..3eb45c13712 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2402,8 +2402,13 @@ pub unsafe extern "C" fn platform_wallet_resolve_shielded_tip( let mut identity_id = [0u8; 32]; let mut address = [0u8; 43]; let result = catch_query_panic("shielded tip resolution", || { - let result = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { - let identity = wallet.identity().clone(); + // Clone under the registry guard, then release it: the resolution makes + // DPNS and profile network requests, and holding the global wallet + // registry lock across them would block handle creation and teardown + // for unrelated wallets. + let identity = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.identity().clone()); + let result = identity.map(|identity| { block_on_worker(async move { maybe_inject_tip_worker_panic(); identity.dashpay().resolve_shielded_tip(&username).await diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs index 60432de4fc7..7165c936550 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs @@ -231,3 +231,38 @@ fn unknown_stamp_is_rejected_by_the_check_constraint() { .unwrap_err(); assert!(err.to_string().contains("CHECK"), "{err}"); } + +/// Literal bytes of a pre-V019 `profile_blob`, written out by hand from the +/// `bincode::serde` standard encoding of the six-field record (no struct +/// declaration involved), so a reorder of the frozen legacy record cannot +/// regenerate the fixture and its decoder in step. Every field holds a +/// distinct value, so a positional shift misreads at least one of them. +#[test] +fn literal_pre_v019_profile_bytes_decode_positionally() { + let mut bytes = vec![0x01, 0x02, b'A', b'l']; // display_name: Some("Al") + bytes.extend([0x01, 0x01, b'b']); // bio: Some("b") + bytes.push(0x00); // avatar_url: None + bytes.push(0x01); // avatar_hash: Some([0x11; 32]) + bytes.extend([0x11; 32]); + bytes.push(0x01); // avatar_fingerprint: Some([0x22; 8]) + bytes.extend([0x22; 8]); + bytes.extend([0x01, 0x01, b'm']); // public_message: Some("m") + + let decoded = decode_profile(&bytes, 0).unwrap(); + assert_eq!( + decoded, + DashPayProfile { + display_name: Some("Al".into()), + bio: Some("b".into()), + avatar_url: None, + avatar_hash: Some([0x11; 32]), + avatar_fingerprint: Some([0x22; 8]), + public_message: Some("m".into()), + ..Default::default() + } + ); + // The legacy writer the migration fixture uses produces the same bytes. + assert_eq!(encode_legacy_profile(&decoded).unwrap(), bytes); + // Read as the current shape, the same row is not a valid record. + assert_ne!(decode_profile(&bytes, 1).ok(), Some(decoded)); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift index 8d484ecbb36..3bf115ecba2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift @@ -52,7 +52,10 @@ public final class PersistentDashpayPaymentAddresses { if existing == nil { context.insert(row) } } - static func removeOwned(in context: ModelContext, networkRaw: UInt32, ownerIdentityId: Data) throws { + /// Delete every address row an identity owns (its own profile's and its + /// cached contacts'). Rows are keyed by id, not by relationship, so any + /// path that deletes an identity outside the persistence handler must call this. + public static func removeOwned(in context: ModelContext, networkRaw: UInt32, ownerIdentityId: Data) throws { try context.delete(model: PersistentDashpayPaymentAddresses.self, where: #Predicate { $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerIdentityId }) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift index 72e637a815c..b56aea95eb4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift @@ -425,6 +425,10 @@ extension PersistentIdentity { identityId: Data ) -> Bool { guard let row = fetch(in: context, identityId: identityId) else { return false } + // Payment-address rows reference their owner by id only, so no + // relationship cascade reaches them. + try? PersistentDashpayPaymentAddresses.removeOwned( + in: context, networkRaw: row.networkRaw, ownerIdentityId: identityId) context.delete(row) return true } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift index a5bca7088c5..b58b6b7a912 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/IdentitiesContentView.swift @@ -530,6 +530,9 @@ struct IdentitiesContentView: View { _ = keychain.deleteKeyData(identifier: payoutIdentifier) } + // Payment-address rows are keyed by owner id; no cascade reaches them. + try? PersistentDashpayPaymentAddresses.removeOwned( + in: modelContext, networkRaw: identity.networkRaw, ownerIdentityId: identity.identityId) modelContext.delete(identity) do { try modelContext.save() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift index 15004a1158b..344df016871 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift @@ -418,6 +418,9 @@ enum WalletKeyHealthChecker { for cr in Array(identity.contactRequests) { modelContext.delete(cr) } + // Payment-address rows are keyed by owner id; no cascade reaches them. + try PersistentDashpayPaymentAddresses.removeOwned( + in: modelContext, networkRaw: identity.networkRaw, ownerIdentityId: identity.identityId) try modelContext.save() // PHASE 2: delete the identity itself. Its problematic From 0c61806ee91a1b42b5930738597ef3873086c272 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 02:03:22 -0500 Subject: [PATCH 07/15] test(platform-wallet): set integer_range_clauses in the tip resolver mock query v5.1-dev added DocumentQuery::integer_range_clauses; the branch's DPNS mock query in the profile tests predates it and no longer compiled. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../rs-platform-wallet/src/wallet/identity/network/profile.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index f82ce7ed7cd..d9e4fbfcd39 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -1501,6 +1501,7 @@ mod tip_resolution_tests { ], select: dash_sdk::drive::query::SelectProjection::documents(), time_range_clauses: vec![], + integer_range_clauses: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], From 4e803f264787b4831c5edd767df013fff8570ff5 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 03:18:51 -0500 Subject: [PATCH 08/15] fix(wallet-storage): decode payment-only identity rewrites by their format stamp patch_payments_into_entry_blobs read entry_blob without entry_format, decoded it as the current IdentityEntry and wrote current-format bytes under the old stamp. A pre-V019 identity whose first post-migration write was a payment overlay round (changeset::core_bridge) would fail to decode, or with empty profiles silently end up as current bytes stamped 0. Decode through decode_identity and restamp the row 1 in the same UPDATE; the regression test walks a V007 legacy identity through a payment-only round and reads it back through both identity readers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sqlite/schema/dashpay.rs | 22 ++++-- .../tests/sqlite_profile_address_encoding.rs | 67 +++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index fad8e200eee..c39ad9b7cbe 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -45,6 +45,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; +use crate::sqlite::schema::identity_profile_encoding::decode_identity; // PUBLIC material only: DashPay overlay types reaching `_blob` columns. impl_persistable_blob!(DashPayProfile, PaymentEntry); @@ -129,6 +130,11 @@ pub fn apply( /// order the two mean: the snapshot is the round's view of the identity, the /// overlay is the round's view of the payments that moved. /// +/// The row is decoded through its `entry_format` stamp (a pre-V019 row is +/// the legacy shape) and written back in the current shape stamped 1 in the +/// same statement, so a payment-only rewrite never leaves current bytes +/// under a legacy stamp or reads legacy bytes as the current record. +/// /// A missing `identities` row is not an error here. The FK would have /// rejected the overlay insert above, so by this point the row exists for /// every identity in `payments` unless it was deleted inside this same @@ -138,28 +144,30 @@ fn patch_payments_into_entry_blobs( payments: &BTreeMap>, ) -> Result<(), WalletStorageError> { let mut read = tx.prepare_cached( - "SELECT length(entry_blob), entry_blob FROM identities WHERE identity_id = ?1", + "SELECT length(entry_blob), entry_blob, entry_format FROM identities \ + WHERE identity_id = ?1", + )?; + let mut write = tx.prepare_cached( + "UPDATE identities SET entry_blob = ?2, entry_format = 1 WHERE identity_id = ?1", )?; - let mut write = - tx.prepare_cached("UPDATE identities SET entry_blob = ?2 WHERE identity_id = ?1")?; for (identity_id, by_tx) in payments { if by_tx.is_empty() { continue; } - let stored: Option<(i64, Vec)> = read + let stored: Option<(i64, Vec, i64)> = read .query_row(params![identity_id.as_slice()], |row| { - Ok((row.get(0)?, row.get(1)?)) + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) }) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), })?; - let Some((len, payload)) = stored else { + let Some((len, payload, format)) = stored else { continue; }; blob::check_size(len)?; - let mut entry: IdentityEntry = blob::decode(&payload)?; + let mut entry: IdentityEntry = decode_identity(&payload, format)?; for (tx_id, row) in by_tx { entry.dashpay_payments.insert(tx_id.clone(), row.clone()); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs index 7165c936550..ed50763c10c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; +use platform_wallet::wallet::identity::PaymentEntry; use platform_wallet::{ContactProfileEntry, DashPayProfile, IdentityStatus}; use platform_wallet_storage::sqlite::schema::identity_profile_encoding::{ encode_legacy_identity, encode_legacy_profile, @@ -266,3 +267,69 @@ fn literal_pre_v019_profile_bytes_decode_positionally() { // Read as the current shape, the same row is not a valid record. assert_ne!(decode_profile(&bytes, 1).ok(), Some(decoded)); } + +/// A payment-only round (the `dashpay_payments_overlay` path) rewrites the +/// identity blob too. On a row the migration stamped 0 it must decode the +/// legacy record and write the current one stamped 1, so both identity +/// readers still see every profile field plus the new payment. +#[test] +fn payment_only_rewrite_of_a_legacy_identity_restamps_it() { + let id = Identifier::from([5; 32]); + let old = entry(id); + + let mut conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(7)) + .run(&mut conn) + .unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![WALLET_ID.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + id.as_slice(), + WALLET_ID.as_slice(), + encode_legacy_identity(&old).unwrap() + ], + ) + .unwrap(); + migrations::run(&mut conn).unwrap(); + let format = |conn: &Connection| -> i64 { + conn.query_row("SELECT entry_format FROM identities", [], |row| row.get(0)) + .unwrap() + }; + assert_eq!(format(&conn), 0); + + let payment = PaymentEntry::new_sent(Identifier::from([6; 32]), 42_000, Some("tip".into())); + let tx = conn.transaction().unwrap(); + dashpay::apply( + &tx, + &WALLET_ID, + None, + Some(&BTreeMap::from([( + id, + BTreeMap::from([("ab".repeat(32), payment.clone())]), + )])), + ) + .unwrap(); + tx.commit().unwrap(); + + assert_eq!(format(&conn), 1, "the rewritten blob is the current shape"); + let mut expected = old; + expected.dashpay_payments.insert("ab".repeat(32), payment); + assert_eq!( + identities::fetch(&conn, &WALLET_ID, &id.to_buffer()) + .unwrap() + .unwrap(), + expected + ); + let state = identities::load_state(&conn, &WALLET_ID).unwrap(); + let managed = &state.wallet_identities[&WALLET_ID][&0]; + assert_eq!(managed.identity.id(), id); + assert_eq!(managed.identity.balance(), 123); +} From 43b7eb7651feaa158f8f7e602c33ffc2d261b9a2 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 03:18:51 -0500 Subject: [PATCH 09/15] fix(kotlin-example-app): make the shielded tip screens compile DashPayTabScreen used produceState without importing it, and DashPayProfileScreen smart-cast the delegated tipAccount property, which Kotlin rejects. Both predate this round; CI skips Kotlin for fork PRs, so neither had been compiled. Verified with :app:compileDebugKotlin, :sdk/:app compileDebugUnitTestKotlin and :sdk:compileDebugAndroidTestKotlin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../example/ui/dashpay/DashPayProfileScreen.kt | 5 +++-- .../dashfoundation/example/ui/dashpay/DashPayTabScreen.kt | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt index dd9bf8a0a12..131a7fce9cf 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -87,8 +87,9 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController } } val tipBalance by remember(walletId, tipAccount) { - if (walletId == null || tipAccount == null) flowOf(0L) - else container.database.shieldedDao().observeNotesByWalletAccount(walletId, tipAccount) + val account = tipAccount + if (walletId == null || account == null) flowOf(0L) + else container.database.shieldedDao().observeNotesByWalletAccount(walletId, account) .map { notes -> notes.filter { !it.isSpent }.sumOf { it.value } } }.collectAsStateWithLifecycle(initialValue = 0L) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 50fce16af12..6002634a948 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope From a69b2d01fcc4f9559103d93669f806a1b9c49f73 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 04:27:40 -0500 Subject: [PATCH 10/15] fix(swift-example-app): rebind shielded with ordinary accounts only ShieldedService rebuilt its bind request from the native shieldedAccountIndices snapshot (after identity discovery and on the post-Clear resume), and that snapshot includes every discovered and retired tip account Rust binds on its own. With account 0 plus 64 tip accounts the request tripped the 64-entry cap in the Swift wrapper and the C export, so discovery could no longer register new accounts. Both sites now go through ordinaryBindRequest, which drops tip accounts and falls back to [0]; Rust still unions the tip accounts in. Kotlin is unaffected: it binds with the default [0] request. Tests: a Rust test binds 70 persisted tip accounts from a [0] request (seedless and seeded) and shows the resulting snapshot exceeds the cap; an app unit test covers ordinaryBindRequest with a 71-entry snapshot. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../wallet/shielded/viewing_key_bind_tests.rs | 59 +++++++++++++++++++ .../Core/Services/ShieldedService.swift | 14 ++++- .../ShieldedRebindRequestTests.swift | 30 ++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedRebindRequestTests.swift diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 2697172c74e..abe5fa49668 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -1134,3 +1134,62 @@ async fn should_rebind_retired_tip_accounts_without_profile_or_identity() { .await .contains(&SubwalletId::new(wallet.wallet_id(), account))); } + +/// A host rebind passes only ordinary accounts (FFI and host SDKs cap the +/// request at 64 entries); Rust adds every persisted tip account itself. With +/// more than 64 tip accounts on record, a `[0]` request must still bind them +/// all, both seedlessly and from the seed, and the resulting +/// `shielded_account_indices` snapshot exceeds the request cap, which is why +/// hosts must not echo it back as a bind request. +#[tokio::test] +async fn should_bind_more_than_64_persisted_tip_accounts_from_an_ordinary_request() { + let seed = [0x42; 64]; + let tips: Vec = (0..70) + .map(|index| crate::shielded_tip_account_index(index).unwrap()) + .collect(); + for (tag, seeded) in [("many_tips_persisted", false), ("many_tips_seeded", true)] { + let persister = Arc::new(CapturingPersistence::default()); + let wallet = platform_wallet_with(Arc::clone(&persister)).await; + let mut rows = BTreeMap::new(); + for &account in std::iter::once(&0).chain(tips.iter()) { + let keys = + super::OrchardKeySet::from_seed(&seed, wallet.sdk().network, account).unwrap(); + rows.insert( + SubwalletId::new(wallet.wallet_id(), account), + keys.full_viewing_key.to_bytes().to_vec(), + ); + } + persister.serve_viewing_keys(rows); + let coordinator = coordinator_at(&temp_dir(tag)); + if seeded { + wallet + .bind_shielded(&seed, &[0], &coordinator) + .await + .unwrap(); + } else { + assert!(wallet + .bind_shielded_from_persisted(&[0], &coordinator) + .await + .unwrap()); + } + let bound = wallet.shielded_account_indices().await; + assert_eq!(bound.len(), 71, "{tag}"); + assert!( + bound.len() > 64, + "{tag}: the snapshot exceeds the request cap" + ); + let ordinary: Vec = bound + .iter() + .copied() + .filter(|&account| !super::is_shielded_tip_account(account)) + .collect(); + assert_eq!(ordinary, vec![0], "{tag}"); + let registered = coordinator.registered_subwallets().await; + for &account in &tips { + assert!( + registered.contains(&SubwalletId::new(wallet.wallet_id(), account)), + "{tag}: tip account {account} registered for sync" + ); + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 3a4a6503715..dc2a0555cd4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -185,6 +185,16 @@ class ShieldedService: ObservableObject { // MARK: - Lifecycle + /// The accounts to request when rebinding from a native snapshot: only the + /// ordinary ones, defaulting to `[0]`. The snapshot also lists the dedicated + /// tip accounts Rust binds on its own (discovered and retired ones); echoing + /// them back could exceed the 64-entry bind request cap and stop later + /// discovery from registering new accounts. + static func ordinaryBindRequest(_ snapshot: [UInt32]) -> [UInt32] { + let ordinary = snapshot.filter { !PlatformWalletManager.isShieldedTipAccount($0) } + return ordinary.isEmpty ? [0] : ordinary + } + /// Re-register newly discovered identity accounts while retaining the engine's /// existing ordinary accounts. Binding remains independent of discovery success. @discardableResult @@ -201,7 +211,7 @@ class ShieldedService: ObservableObject { lastError = error.localizedDescription return false } - let accounts = existing.isEmpty ? [0] : existing + let accounts = Self.ordinaryBindRequest(existing) if boundWalletId == walletId || boundWalletId == nil { bind(walletManager: walletManager, walletId: walletId, network: network, resolver: resolver, accounts: accounts) @@ -575,7 +585,7 @@ class ShieldedService: ObservableObject { let resolver, let network else { return } - let accounts = boundAccounts.isEmpty ? [0] : boundAccounts + let accounts = Self.ordinaryBindRequest(boundAccounts) bind( walletManager: walletManager, walletId: walletId, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedRebindRequestTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedRebindRequestTests.swift new file mode 100644 index 00000000000..4aa64b88362 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedRebindRequestTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// A rebind request is built from the native account snapshot, which also +/// lists the tip accounts Rust binds itself. Only ordinary accounts may go +/// back into the request, or more than 64 tip accounts would exceed the bind +/// request cap. +@MainActor +final class ShieldedRebindRequestTests: XCTestCase { + private func tipAccounts(_ count: UInt32) throws -> [UInt32] { + try (0.. Date: Mon, 5 Oct 2026 05:37:07 -0500 Subject: [PATCH 11/15] test(platform-wallet-ffi): cover owned DashPay profile hydration through load Drive FFIPersister::load with a host wallet-restore callback whose identity carries a non-null dashpay_profile with distinct values for every string and all three payment addresses, plus an identity with a null profile pointer. The free callback scribbles over the host buffers before the restored ManagedIdentity is asserted, so a restored profile still borrowing host memory would fail. Removing the hydration branch fails the test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../rs-platform-wallet-ffi/src/persistence.rs | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5a1fed971f1..34ed1c2a0c4 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -7550,6 +7550,155 @@ mod tests { "both host allocations freed" ); } + /// The owned DashPay profile a host supplies on the wallet-restore load + /// callback reaches the restored `ManagedIdentity` with every field, + /// including the three payment addresses, and is an owned copy: the free + /// callback scribbles over the host buffers before anything is asserted. A + /// null profile pointer restores no profile. + #[test] + fn owned_dashpay_profile_hydrates_through_the_load_callback() { + use crate::wallet_restore_types::{ContactProfileRestoreEntryFFI, IdentityRestoreEntryFFI}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Host { + strings: Vec>, + profile: ContactProfileRestoreEntryFFI, + identities: Vec, + wallet: WalletRestoreEntryFFI, + frees: AtomicUsize, + } + fn c_buf(text: &str) -> Box<[u8]> { + let mut bytes = text.as_bytes().to_vec(); + bytes.push(0); + bytes.into_boxed_slice() + } + fn identity(id: u8, index: u32) -> IdentityRestoreEntryFFI { + IdentityRestoreEntryFFI { + identity_id: [id; 32], + balance: 7, + revision: 1, + identity_index: index, + status: 2, + dpns_names: std::ptr::null(), + dpns_names_count: 0, + contested_dpns_names: std::ptr::null(), + contested_dpns_names_count: 0, + keys: std::ptr::null(), + keys_count: 0, + contacts: std::ptr::null(), + contacts_count: 0, + payments: std::ptr::null(), + payments_count: 0, + ignored_senders: std::ptr::null(), + ignored_senders_count: 0, + contact_profiles: std::ptr::null(), + contact_profiles_count: 0, + dashpay_profile: std::ptr::null(), + } + } + unsafe extern "C" fn load_wallet( + context: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let host = &mut *(context as *mut Host); + host.strings = ["Alice", "bio text", "https://example.com/a.png", "hello"] + .into_iter() + .map(c_buf) + .collect(); + host.profile = ContactProfileRestoreEntryFFI { + contact_id: [0; 32], + display_name: host.strings[0].as_ptr().cast(), + bio: host.strings[1].as_ptr().cast(), + avatar_url: host.strings[2].as_ptr().cast(), + avatar_hash: [0xA1; 32], + avatar_hash_present: true, + avatar_fingerprint: [0xF1; 8], + avatar_fingerprint_present: true, + core_payment_address: [0xC1; 21], + core_payment_address_present: true, + platform_payment_address: [0xD1; 21], + platform_payment_address_present: true, + shielded_address: [0xE1; 43], + shielded_address_present: true, + public_message: host.strings[3].as_ptr().cast(), + checked_at_ms: 0, + }; + let mut with_profile = identity(0x11, 0); + with_profile.dashpay_profile = &host.profile; + host.identities = vec![with_profile, identity(0x22, 1)]; + host.wallet = WalletRestoreEntryFFI { + wallet_id: [42; 32], + identities: host.identities.as_ptr(), + identities_count: host.identities.len(), + ..Default::default() + }; + *entries = &host.wallet; + *count = 1; + 0 + } + unsafe extern "C" fn free_wallet( + context: *mut c_void, + _entries: *const WalletRestoreEntryFFI, + _count: usize, + ) { + let host = &mut *(context as *mut Host); + // Anything Rust still borrowed would now read as garbage. + for buf in &mut host.strings { + let len = buf.len(); + buf[..len - 1].fill(b'X'); + } + host.profile.avatar_hash = [0; 32]; + host.profile.core_payment_address = [0; 21]; + host.profile.platform_payment_address = [0; 21]; + host.profile.shielded_address = [0; 43]; + host.frees.fetch_add(1, Ordering::SeqCst); + } + + let host = Box::leak(Box::new(Host { + strings: Vec::new(), + profile: unsafe { std::mem::zeroed() }, + identities: Vec::new(), + wallet: WalletRestoreEntryFFI::default(), + frees: AtomicUsize::new(0), + })); + let persister = FFIPersister::new(PersistenceCallbacks { + context: (host as *mut Host).cast(), + on_load_wallet_list_fn: Some(load_wallet), + on_load_wallet_list_free_fn: Some(free_wallet), + ..Default::default() + }); + let snapshot = persister.load().expect("host snapshot"); + assert_eq!(host.frees.load(Ordering::SeqCst), 1, "host buffers freed"); + + let bucket = &snapshot.wallets[&[42; 32]] + .identity_manager + .wallet_identities[&[42; 32]]; + let profile = bucket[&0] + .dashpay() + .profile + .clone() + .expect("owned profile restored"); + assert_eq!( + profile, + platform_wallet::DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("bio text".into()), + avatar_url: Some("https://example.com/a.png".into()), + avatar_hash: Some([0xA1; 32]), + avatar_fingerprint: Some([0xF1; 8]), + public_message: Some("hello".into()), + core_payment_address: Some(vec![0xC1; 21]), + platform_payment_address: Some(vec![0xD1; 21]), + shielded_address: Some(vec![0xE1; 43]), + } + ); + assert!( + bucket[&1].dashpay().profile.is_none(), + "a null profile pointer restores no profile" + ); + } + unsafe extern "C" fn noop_free_wallets( _ctx: *mut c_void, _entries: *const WalletRestoreEntryFFI, From a1c33fa9e076cd9cd980d2c0edb5eedd1691edd1 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 05:37:07 -0500 Subject: [PATCH 12/15] fix(platform-wallet): verify the identity index before allocating a tip account Identity load and discovery prove an identity's index by deriving its MASTER key, but for an identity already known (observed out-of-wallet, or restored at a host placeholder slot) they only attached wallet_id and left identity_index unset. That snapshot persists without an index, and Kotlin/Swift, which cannot store "unknown", restore it as index 0, so the dedicated tip account mapping would hand that identity identity 0's tip pool. - IdentityManager::adopt_into_wallet moves such an identity into the verified wallet slot and sets its index (never evicting a different identity from the slot); load and discovery call it, so new snapshots carry the real index and placeholder rows self-heal on rediscovery. - prepare_shielded_tip_address now requires the seed to reproduce the identity's MASTER key at the claimed index before it allocates or returns a tip address, failing closed for unverified indices. Tests: adopt_into_wallet re-slotting (observed, placeholder, occupied slot); a cold-reload regression where a wallet-attached identity sits at placeholder slot 0 alongside bound identity-0 tip funds is refused and then accepted after rediscovery proves index 5. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/wallet/identity/network/discovery.rs | 9 ++ .../src/wallet/identity/network/loading.rs | 5 + .../identity/state/manager/lifecycle.rs | 73 +++++++++ .../src/wallet/identity/state/manager/mod.rs | 45 ++++++ .../src/wallet/shielded/tips.rs | 49 ++++++- .../wallet/shielded/viewing_key_bind_tests.rs | 138 +++++++++++++++++- 6 files changed, 316 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 6117a170f7b..1f988d00ef3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -457,6 +457,15 @@ impl IdentityWallet { wallet_id, &self.persister, )?; + } else { + // The key hash at `identity_index` proved the index; + // an identity known from elsewhere takes that slot + // (see `adopt_into_wallet`). + info_guard.identity_manager.adopt_into_wallet( + &identity_id, + wallet_id, + identity_index, + ); } if let Some(managed) = info_guard diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index ecc7897ae4b..12021cd4b33 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -250,6 +250,11 @@ impl IdentityWallet { wallet_id, &self.persister, )?; + } else { + // The probe at `identity_index` proved the index; an identity + // known from elsewhere takes that slot (see `adopt_into_wallet`). + info.identity_manager + .adopt_into_wallet(&identity_id, wallet_id, identity_index); } if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index f5af15abaf1..8cfaddf7baa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -92,6 +92,79 @@ impl IdentityManager { Ok(()) } + /// Place an already-known identity in `wallet_id`'s bucket at the + /// `identity_index` a key derivation just proved for it. + /// + /// Identity load and discovery find identities by deriving this wallet's + /// MASTER key at an index, so the index they report is verified. An + /// identity first seen another way (observed out-of-wallet, or restored + /// with a host placeholder index) must take that verified slot rather than + /// just gaining a `wallet_id`: an index-less wallet identity is persisted + /// without its index, and hosts that cannot store "unknown" restore it as + /// index 0, which per-identity derivations (the dedicated tip account) + /// would then attribute to identity 0. + /// + /// Does not persist; the caller's following snapshot write carries the + /// updated `wallet_id` / `identity_index`. Returns `false` and leaves the + /// identity where it is when the target slot already holds a different + /// identity, or the id is unknown. + pub fn adopt_into_wallet( + &mut self, + identity_id: &Identifier, + wallet_id: WalletId, + identity_index: u32, + ) -> bool { + let target = IdentityLocation::InWallet { + wallet_id, + registration_index: identity_index, + }; + let Some(current) = self.location_index().get(identity_id).copied() else { + return false; + }; + if current != target { + let occupied = self + .wallet_identities + .get(&wallet_id) + .and_then(|bucket| bucket.get(&identity_index)) + .is_some_and(|other| other.identity.id() != *identity_id); + if occupied { + tracing::warn!( + identity = %identity_id, + identity_index, + "identity index slot already holds another identity; not re-slotting" + ); + return false; + } + let managed = match current { + IdentityLocation::OutOfWallet => self.out_of_wallet_identities.remove(identity_id), + IdentityLocation::InWallet { + wallet_id, + registration_index, + } => self + .wallet_identities + .get_mut(&wallet_id) + .and_then(|bucket| bucket.remove(®istration_index)), + }; + let Some(managed) = managed else { + return false; + }; + self.wallet_identities + .entry(wallet_id) + .or_default() + .insert(identity_index, managed); + self.location_index_insert(*identity_id, target); + } + if let Some(managed) = self + .wallet_identities + .get_mut(&wallet_id) + .and_then(|bucket| bucket.get_mut(&identity_index)) + { + managed.wallet_id = Some(wallet_id); + managed.identity_index = Some(identity_index); + } + true + } + /// Add an identity to the out-of-wallet (observed read-only) bucket. /// /// There is no separate `WatchedIdentity` type; observed identities 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 32237f583e4..ab35255f4c7 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 @@ -269,6 +269,51 @@ mod tests { assert_eq!(manager.highest_registration_index(&wallet_id), Some(0)); } + /// Load and discovery prove an index by derivation. An identity already + /// known as observed, or restored at a placeholder slot, must move into the + /// verified slot with its index set, never keep a `wallet_id` without one. + #[test] + fn adopt_into_wallet_moves_identities_into_their_verified_slot() { + let mut manager = IdentityManager::new(); + let wallet_id: WalletId = [9u8; 32]; + let p = noop_persister(); + + let observed = Identifier::from([1u8; 32]); + manager + .add_out_of_wallet_identity(create_test_identity(observed), &p) + .unwrap(); + assert!(manager.adopt_into_wallet(&observed, wallet_id, 4)); + assert!(manager.out_of_wallet_identities.is_empty()); + let managed = manager.identity(&observed).expect("present"); + assert_eq!(managed.identity_index, Some(4)); + assert_eq!(managed.wallet_id, Some(wallet_id)); + assert_eq!(manager.identity_index(&observed), Some(4)); + + // A host-placeholder slot 0 is corrected to the verified index. + let placeholder = Identifier::from([2u8; 32]); + manager + .add_identity(create_test_identity(placeholder), 0, wallet_id, &p) + .unwrap(); + assert!(manager.adopt_into_wallet(&placeholder, wallet_id, 7)); + let bucket = &manager.wallet_identities[&wallet_id]; + assert!(!bucket.contains_key(&0)); + assert_eq!(bucket[&7].identity.id(), placeholder); + assert_eq!(manager.identity_index(&placeholder), Some(7)); + + // Already in place: a no-op success. + assert!(manager.adopt_into_wallet(&placeholder, wallet_id, 7)); + + // Never evict a different identity from the target slot. + let other = Identifier::from([3u8; 32]); + manager + .add_out_of_wallet_identity(create_test_identity(other), &p) + .unwrap(); + assert!(!manager.adopt_into_wallet(&other, wallet_id, 7)); + assert_eq!(manager.identity_index(&other), None); + assert_eq!(manager.identity_index(&placeholder), Some(7)); + assert!(!manager.adopt_into_wallet(&Identifier::from([4u8; 32]), wallet_id, 9)); + } + #[test] fn test_add_out_of_wallet_identity() { let mut manager = IdentityManager::new(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs index e6098dc6eb1..bccc4489d71 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -29,6 +29,44 @@ pub fn is_shielded_tip_account(account: u32) -> bool { (SHIELDED_TIP_ACCOUNT_BASE..0x8000_0000).contains(&account) } +/// Prove `identity_index` with the seed: the identity must carry the MASTER +/// authentication key this wallet derives at that index (the key discovery and +/// registration both use). Fails closed when no key matches, including when +/// no public keys were restored. +fn verify_identity_index( + seed: &[u8], + network: key_wallet::Network, + identity_index: u32, + identity: &dpp::identity::Identity, +) -> Result<(), PlatformWalletError> { + use crate::wallet::identity::network::{ + derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, + }; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + + let master = key_wallet::bip32::ExtendedPrivKey::new_master(network, seed) + .map_err(|e| PlatformWalletError::ShieldedKeyDerivation(e.to_string()))?; + let expected = derive_identity_auth_key_hash_from_master( + &master, + network, + identity_index, + MASTER_KEY_INDEX, + )?; + let verified = identity + .public_keys() + .values() + .any(|key| key.public_key_hash().ok() == Some(expected)); + if verified { + Ok(()) + } else { + Err(PlatformWalletError::InvalidIdentityData(format!( + "Identity index {identity_index} is not verified for this wallet; \ + rediscover the identity before using its tip account" + ))) + } +} + impl PlatformWallet { pub(crate) async fn discovered_tip_accounts(&self) -> Result, PlatformWalletError> { let wm = self.wallet_manager.read().await; @@ -88,11 +126,18 @@ impl PlatformWallet { "Tip account requires a wallet-owned identity".to_string(), )); } - shielded_tip_account_index(identity.identity_index.ok_or_else(|| { + let index = identity.identity_index.ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "Tip account requires a recoverable identity index".to_string(), ) - })?)? + })?; + // A restored index is metadata, and hosts that cannot represent + // "unknown" have restored a placeholder 0 for wallet-attached + // identities. Publishing then would hand out identity 0's tip + // address, so require the seed to reproduce this identity's + // MASTER key at the claimed index before allocating its account. + verify_identity_index(seed, self.sdk.network, index, &identity.identity)?; + shielded_tip_account_index(index)? }; let mut accounts = self.shielded_account_indices().await; if accounts.is_empty() { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index abe5fa49668..6560e9a0966 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -928,6 +928,142 @@ async fn should_keep_wallet_and_coordinator_keys_when_guarded_registration_is_re /// A profile is not a recovery record. Identity discovery alone must restore /// the reserved account, even when the owner removed the tip address entirely. +/// The MASTER authentication key this seed derives for `identity_index` on +/// testnet, as an identity public-key map: what discovery and registration +/// leave on a wallet-owned identity, and what tip preparation verifies. +fn seed_master_key( + seed: &[u8], + identity_index: u32, +) -> BTreeMap { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + let master = key_wallet::bip32::ExtendedPrivKey::new_master(Network::Testnet, seed).unwrap(); + let derived = crate::wallet::identity::network::derive_ecdsa_identity_auth_keypair_from_master( + &master, + Network::Testnet, + identity_index, + crate::wallet::identity::network::MASTER_KEY_INDEX, + ) + .unwrap(); + BTreeMap::from([( + 0, + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(derived.public_key.to_vec()), + disabled_at: None, + }), + )]) +} + +/// Cold reload of a host that cannot store "index unknown": a wallet-attached +/// identity whose index was lost comes back at placeholder slot 0 while +/// identity 0's tip account (and its funds) is retained and bound. Tip +/// preparation must refuse it instead of handing out identity 0's address, +/// and must accept it once rediscovery proves its real index. +#[tokio::test] +async fn should_refuse_tip_account_for_a_placeholder_index_until_it_is_verified() { + use crate::shielded_tip_account_index; + use dpp::identity::{Identity, IdentityV0}; + use dpp::prelude::Identifier; + let phrase = crate::test_support::MESSAGE_SIGNING_TEST_MNEMONIC; + let seed = key_wallet::Mnemonic::from_phrase(phrase) + .unwrap() + .to_seed(""); + let (wallet_manager, wallet_id, _, _) = + crate::test_support::mnemonic_wallet_manager(phrase).await; + let generation = wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .unwrap() + .generation + .clone(); + let spv = Arc::new(crate::spv::SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(crate::events::PlatformEventManager::new(Vec::new())), + )); + let sdk = dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .unwrap(); + let persister = Arc::new(CapturingPersistence::default()); + let wallet = PlatformWallet::new( + Arc::new(sdk), + wallet_id, + wallet_manager, + generation, + Arc::new(tokio::sync::Notify::new()), + persister.clone(), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + ); + // Restored at the host's placeholder 0, but its keys are identity 5's. + let id = Identifier::from([0x55; 32]); + { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: seed_master_key(&seed, 5), + balance: 0, + revision: 0, + }), + 0, + wallet.wallet_id(), + wallet.persister(), + ) + .unwrap(); + } + let coordinator = coordinator_at(&temp_dir("tips_placeholder_index")); + let identity_zero_tips = shielded_tip_account_index(0).unwrap(); + wallet + .bind_shielded(&seed, &[0, identity_zero_tips], &coordinator) + .await + .unwrap(); + let identity_zero_address = wallet + .shielded_default_address(identity_zero_tips) + .await + .unwrap(); + + let refused = wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await; + assert!( + matches!(refused, Err(crate::PlatformWalletError::InvalidIdentityData(ref msg)) if msg.contains("not verified")), + "{refused:?}" + ); + + // Rediscovery proves index 5 and re-slots the identity. + { + let mut wm = wallet.wallet_manager().write().await; + assert!(wm + .get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .adopt_into_wallet(&id, wallet.wallet_id(), 5)); + } + let address = wallet + .prepare_shielded_tip_address(&seed, &id, &coordinator) + .await + .unwrap(); + assert_ne!(address, identity_zero_address); + assert_eq!( + wallet + .shielded_default_address(shielded_tip_account_index(5).unwrap()) + .await + .unwrap(), + address + ); +} + #[tokio::test] async fn should_restore_tip_account_from_identity_discovery_and_register_for_sync() { use crate::shielded_tip_account_index; @@ -976,7 +1112,7 @@ async fn should_restore_tip_account_from_identity_discovery_and_register_for_syn .add_identity( Identity::V0(IdentityV0 { id, - public_keys: BTreeMap::new(), + public_keys: seed_master_key(&seed, 3), balance: 0, revision: 0, }), From 1e27b9f45c4e02dc055236e86b5f13b9b76b4eba Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 06:46:05 -0500 Subject: [PATCH 13/15] fix(platform-wallet): refuse verified identity placement into an occupied slot adopt_into_wallet returned false when the verified slot held a different identity, and both load and discovery ignored it, then marked the unmoved identity active, set wallet_id and persisted key breadcrumbs at the new index, leaving bucket, identity_index and persisted keys in disagreement. - adopt_into_wallet returns a typed IdentityIndexOccupied error and changes nothing; place_verified_identity (add when unknown, adopt otherwise) is the single step both callers run before touching status or keys, and reports Added vs Adopted. - Load is an explicit single-identity operation and propagates the error. - Discovery skips the conflicting index without touching either identity, counts the hit for the gap limit, and records the index as unanswered so the verdict is incomplete and the next launch rescans. Aborting would also stop the stale record from reaching its own verified slot later in the walk, which is what frees the contested one. Tests: placement adds, adopts (not reported as new) and refuses an occupied slot with the typed error and zero persisted stores; the scan tally keeps walking and leaves the verdict incomplete on a conflict. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-platform-wallet/src/error.rs | 13 +++ .../src/wallet/identity/network/discovery.rs | 87 +++++++++++++----- .../src/wallet/identity/network/loading.rs | 23 ++--- .../identity/state/manager/lifecycle.rs | 72 ++++++++++----- .../src/wallet/identity/state/manager/mod.rs | 92 ++++++++++++++++--- .../src/wallet/identity/state/mod.rs | 1 + .../wallet/shielded/viewing_key_bind_tests.rs | 6 +- 7 files changed, 221 insertions(+), 73 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index ee3307e4623..fe63e794209 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -53,6 +53,19 @@ pub enum PlatformWalletError { #[error("Identity already exists: {0}")] IdentityAlreadyExists(Identifier), + /// A key derivation proved `identity_id` at `identity_index`, but this + /// wallet already holds a different identity in that slot. One of the two + /// records is stale; nothing was moved or persisted. + #[error( + "Identity {identity_id} is verified at index {identity_index}, but that slot holds \ + identity {occupant}" + )] + IdentityIndexOccupied { + identity_id: Identifier, + identity_index: u32, + occupant: Identifier, + }, + #[error("Identity not found: {0}")] IdentityNotFound(Identifier), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 1f988d00ef3..5429fd15506 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -6,6 +6,7 @@ use key_wallet::bip32::ExtendedPrivKey; use crate::error::PlatformWalletError; use crate::wallet::identity::network::contact_requests::budget_spent; +use crate::wallet::identity::state::VerifiedPlacement; use super::*; @@ -449,24 +450,37 @@ impl IdentityWallet { "Wallet info not found in wallet manager".to_string(), ) })?; - let is_new = info_guard.identity_manager.identity(&identity_id).is_none(); - if is_new { - info_guard.identity_manager.add_identity( - identity.clone(), - identity_index, - wallet_id, - &self.persister, - )?; - } else { - // The key hash at `identity_index` proved the index; - // an identity known from elsewhere takes that slot - // (see `adopt_into_wallet`). - info_guard.identity_manager.adopt_into_wallet( - &identity_id, - wallet_id, - identity_index, - ); - } + // The key hash at `identity_index` proved the index: add + // the identity there, or move an identity known from + // elsewhere into that slot. A slot held by a different + // identity means one of the two local records is stale. + // Skip this index without touching either identity, mark + // it unanswered so the next launch rescans, and keep + // walking: aborting here would also stop the stale + // record from reaching its own verified slot higher up + // the walk, which is what frees this one. + let is_new = match info_guard.identity_manager.place_verified_identity( + identity.clone(), + identity_index, + wallet_id, + &self.persister, + ) { + Ok(placement) => placement == VerifiedPlacement::Added, + Err(e @ PlatformWalletError::IdentityIndexOccupied { .. }) => { + drop(wm_guard); + tracing::warn!( + index = identity_index, + error = %e, + "identity discovery: verified slot holds another identity; \ + leaving both untouched and rescanning later" + ); + tally.record_sighting(); + tally.record_conflict(identity_index); + identity_index += 1; + continue; + } + Err(e) => return Err(e), + }; if let Some(managed) = info_guard .identity_manager @@ -735,6 +749,9 @@ struct ScanTally { /// together, because to a later launch they are the same fact: an index /// nobody answered. aborted_at_index: Option, + /// Indices whose verified identity could not be placed because its slot + /// holds a different local identity. Unanswered for the verdict. + conflicted_indices: Vec, } impl ScanTally { @@ -775,16 +792,22 @@ impl ScanTally { self.aborted_at_index = Some(index); } + /// Platform answered at `index`, but the identity could not be placed + /// because its verified slot holds a different local identity. The index + /// counts as unanswered (so the verdict is incomplete and a later launch + /// rescans) without touching the probe counters: Platform did answer. + fn record_conflict(&mut self, index: u32) { + self.conflicted_indices.push(index); + } + /// Every index this scan did not answer, ascending — unanswered probes /// plus the index a local fault abandoned it at. fn unanswered_indices(&self) -> Vec { let mut indices = self.failed_indices.clone(); - if let Some(index) = self.aborted_at_index { - if !indices.contains(&index) { - indices.push(index); - indices.sort_unstable(); - } - } + indices.extend(self.aborted_at_index); + indices.extend(self.conflicted_indices.iter().copied()); + indices.sort_unstable(); + indices.dedup(); indices } @@ -1338,6 +1361,22 @@ mod tests { ); } + /// A verified identity whose slot holds another local identity is skipped, + /// not a fault: the walk goes on (the hit still resets the gap counter), + /// no probe counter moves, and the index is unanswered so the next launch + /// rescans instead of trusting a complete verdict. + #[test] + fn a_slot_conflict_keeps_walking_and_leaves_the_scan_incomplete() { + let mut tally = run_scan(5, [Ok(None), Ok(None)]); + tally.record_sighting(); + tally.record_conflict(2); + assert!(tally.should_continue(3), "the hit resets the gap counter"); + let verdict = tally.verdict(0, 3); + assert!(!verdict.complete); + assert_eq!(verdict.failed_indices, vec![2]); + assert_eq!(tally.failed_probes, 0, "Platform answered the probe"); + } + /// A local fault at an index that ALSO went unanswered is recorded once. #[test] fn an_abort_at_an_already_unanswered_index_is_not_duplicated() { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 12021cd4b33..81e38d240a9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -243,19 +243,16 @@ impl IdentityWallet { "Wallet info not found in wallet manager".to_string(), ) })?; - if info.identity_manager.identity(&identity_id).is_none() { - info.identity_manager.add_identity( - identity.clone(), - identity_index, - wallet_id, - &self.persister, - )?; - } else { - // The probe at `identity_index` proved the index; an identity - // known from elsewhere takes that slot (see `adopt_into_wallet`). - info.identity_manager - .adopt_into_wallet(&identity_id, wallet_id, identity_index); - } + // The probe at `identity_index` proved the index: add the identity + // there, or move an identity known from elsewhere into that slot. + // A slot held by another identity refuses here, before status or + // keys are touched, so nothing contradictory is persisted. + info.identity_manager.place_verified_identity( + identity.clone(), + identity_index, + wallet_id, + &self.persister, + )?; if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { // Fold the freshly fetched on-chain state into an diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index 8cfaddf7baa..c1404115d28 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -15,6 +15,15 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; +/// How [`IdentityManager::place_verified_identity`] placed an identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifiedPlacement { + /// Newly added to the wallet bucket. + Added, + /// Already known; moved into (or confirmed at) the verified slot. + Adopted, +} + impl IdentityManager { /// Create a new identity manager pub fn new() -> Self { @@ -92,7 +101,29 @@ impl IdentityManager { Ok(()) } - /// Place an already-known identity in `wallet_id`'s bucket at the + /// Place an identity a key derivation just proved at `identity_index` in + /// `wallet_id`'s bucket: add it when unknown, otherwise move it into that + /// slot (see [`Self::adopt_into_wallet`]). Identity load and discovery both + /// go through here before they touch status or keys, so a refused + /// placement leaves nothing persisted at the new coordinates. + pub fn place_verified_identity( + &mut self, + identity: Identity, + identity_index: u32, + wallet_id: WalletId, + persister: &WalletPersister, + ) -> Result { + let identity_id = identity.id(); + if self.identity(&identity_id).is_none() { + self.add_identity(identity, identity_index, wallet_id, persister)?; + Ok(VerifiedPlacement::Added) + } else { + self.adopt_into_wallet(&identity_id, wallet_id, identity_index)?; + Ok(VerifiedPlacement::Adopted) + } + } + + /// Move an already-known identity into `wallet_id`'s bucket at the /// `identity_index` a key derivation just proved for it. /// /// Identity load and discovery find identities by deriving this wallet's @@ -105,35 +136,36 @@ impl IdentityManager { /// would then attribute to identity 0. /// /// Does not persist; the caller's following snapshot write carries the - /// updated `wallet_id` / `identity_index`. Returns `false` and leaves the - /// identity where it is when the target slot already holds a different - /// identity, or the id is unknown. + /// updated `wallet_id` / `identity_index`. Fails with + /// [`PlatformWalletError::IdentityIndexOccupied`], changing nothing, when + /// the slot already holds a different identity. pub fn adopt_into_wallet( &mut self, identity_id: &Identifier, wallet_id: WalletId, identity_index: u32, - ) -> bool { + ) -> Result<(), PlatformWalletError> { let target = IdentityLocation::InWallet { wallet_id, registration_index: identity_index, }; - let Some(current) = self.location_index().get(identity_id).copied() else { - return false; - }; + let current = self + .location_index() + .get(identity_id) + .copied() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; if current != target { - let occupied = self + if let Some(other) = self .wallet_identities .get(&wallet_id) .and_then(|bucket| bucket.get(&identity_index)) - .is_some_and(|other| other.identity.id() != *identity_id); - if occupied { - tracing::warn!( - identity = %identity_id, + .filter(|other| other.identity.id() != *identity_id) + { + return Err(PlatformWalletError::IdentityIndexOccupied { + identity_id: *identity_id, identity_index, - "identity index slot already holds another identity; not re-slotting" - ); - return false; + occupant: other.identity.id(), + }); } let managed = match current { IdentityLocation::OutOfWallet => self.out_of_wallet_identities.remove(identity_id), @@ -144,10 +176,8 @@ impl IdentityManager { .wallet_identities .get_mut(&wallet_id) .and_then(|bucket| bucket.remove(®istration_index)), - }; - let Some(managed) = managed else { - return false; - }; + } + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; self.wallet_identities .entry(wallet_id) .or_default() @@ -162,7 +192,7 @@ impl IdentityManager { managed.wallet_id = Some(wallet_id); managed.identity_index = Some(identity_index); } - true + Ok(()) } /// Add an identity to the out-of-wallet (observed read-only) bucket. 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 ab35255f4c7..d882606519a 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 @@ -29,6 +29,7 @@ mod accessors; mod apply; mod lifecycle; +pub use lifecycle::VerifiedPlacement; use super::managed_identity::ManagedIdentity; use crate::changeset::{IdentityManagerStartState, IdentityScanStateEntry}; @@ -222,6 +223,7 @@ impl IdentityManager { #[cfg(test)] mod tests { use super::*; + use crate::error::PlatformWalletError; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; @@ -269,20 +271,64 @@ mod tests { assert_eq!(manager.highest_registration_index(&wallet_id), Some(0)); } - /// Load and discovery prove an index by derivation. An identity already - /// known as observed, or restored at a placeholder slot, must move into the - /// verified slot with its index set, never keep a `wallet_id` without one. + #[derive(Default)] + struct CountingPersistence { + stores: std::sync::atomic::AtomicUsize, + } + impl crate::changeset::PlatformWalletPersistence for CountingPersistence { + fn store( + &self, + _wallet_id: WalletId, + _changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + self.stores + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + fn load( + &self, + ) -> Result + { + Ok(Default::default()) + } + } + + /// Load and discovery prove an index by derivation and place the identity + /// through `place_verified_identity` before touching status or keys. An + /// identity already known (observed, or restored at a placeholder slot) + /// moves into the verified slot as `Adopted`, which discovery does not + /// report as newly discovered. A slot held by a different identity is a + /// typed refusal that changes and persists nothing. #[test] - fn adopt_into_wallet_moves_identities_into_their_verified_slot() { + fn verified_placement_adopts_known_identities_and_refuses_occupied_slots() { + use std::sync::atomic::Ordering; let mut manager = IdentityManager::new(); let wallet_id: WalletId = [9u8; 32]; - let p = noop_persister(); + let counter = Arc::new(CountingPersistence::default()); + let p = WalletPersister::new(wallet_id, Arc::clone(&counter) as _); + + let fresh = Identifier::from([5u8; 32]); + assert_eq!( + manager + .place_verified_identity(create_test_identity(fresh), 2, wallet_id, &p) + .unwrap(), + VerifiedPlacement::Added + ); + // Promotion of an observed identity: no new id is reported. let observed = Identifier::from([1u8; 32]); manager .add_out_of_wallet_identity(create_test_identity(observed), &p) .unwrap(); - assert!(manager.adopt_into_wallet(&observed, wallet_id, 4)); + assert_eq!( + manager + .place_verified_identity(create_test_identity(observed), 4, wallet_id, &p) + .unwrap(), + VerifiedPlacement::Adopted + ); assert!(manager.out_of_wallet_identities.is_empty()); let managed = manager.identity(&observed).expect("present"); assert_eq!(managed.identity_index, Some(4)); @@ -294,24 +340,46 @@ mod tests { manager .add_identity(create_test_identity(placeholder), 0, wallet_id, &p) .unwrap(); - assert!(manager.adopt_into_wallet(&placeholder, wallet_id, 7)); + manager + .adopt_into_wallet(&placeholder, wallet_id, 7) + .unwrap(); let bucket = &manager.wallet_identities[&wallet_id]; assert!(!bucket.contains_key(&0)); assert_eq!(bucket[&7].identity.id(), placeholder); assert_eq!(manager.identity_index(&placeholder), Some(7)); - // Already in place: a no-op success. - assert!(manager.adopt_into_wallet(&placeholder, wallet_id, 7)); + manager + .adopt_into_wallet(&placeholder, wallet_id, 7) + .unwrap(); - // Never evict a different identity from the target slot. + // Never evict a different identity from the target slot, and persist + // nothing on the refusal. let other = Identifier::from([3u8; 32]); manager .add_out_of_wallet_identity(create_test_identity(other), &p) .unwrap(); - assert!(!manager.adopt_into_wallet(&other, wallet_id, 7)); + let stores_before = counter.stores.load(Ordering::SeqCst); + let refused = + manager.place_verified_identity(create_test_identity(other), 7, wallet_id, &p); + assert!( + matches!( + refused, + Err(PlatformWalletError::IdentityIndexOccupied { + identity_id, + identity_index: 7, + occupant, + }) if identity_id == other && occupant == placeholder + ), + "{refused:?}" + ); + assert_eq!(counter.stores.load(Ordering::SeqCst), stores_before); assert_eq!(manager.identity_index(&other), None); + assert!(manager.identity(&other).unwrap().wallet_id.is_none()); assert_eq!(manager.identity_index(&placeholder), Some(7)); - assert!(!manager.adopt_into_wallet(&Identifier::from([4u8; 32]), wallet_id, 9)); + assert!(matches!( + manager.adopt_into_wallet(&Identifier::from([4u8; 32]), wallet_id, 9), + Err(PlatformWalletError::IdentityNotFound(_)) + )); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs index affa70d0156..9279f89b36a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs @@ -12,3 +12,4 @@ pub use managed_identity::{BlockTime, DashPayState, ManagedIdentity}; pub use manager::IdentityLocation; pub use manager::IdentityManager; pub use manager::RegistrationIndex; +pub use manager::VerifiedPlacement; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 6560e9a0966..602eb7a8b79 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -1044,11 +1044,11 @@ async fn should_refuse_tip_account_for_a_placeholder_index_until_it_is_verified( // Rediscovery proves index 5 and re-slots the identity. { let mut wm = wallet.wallet_manager().write().await; - assert!(wm - .get_wallet_info_mut(&wallet.wallet_id()) + wm.get_wallet_info_mut(&wallet.wallet_id()) .unwrap() .identity_manager - .adopt_into_wallet(&id, wallet.wallet_id(), 5)); + .adopt_into_wallet(&id, wallet.wallet_id(), 5) + .unwrap(); } let address = wallet .prepare_shielded_tip_address(&seed, &id, &coordinator) From 86af2c20b01b3f94472b5e827eabbae1e01c896e Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 06:46:05 -0500 Subject: [PATCH 14/15] fix(kotlin-example-app): rebind shielded after every successful discovery Discovery reports only newly added identities, but it can also promote an already-known identity into its verified slot, whose dedicated tip account then needs registering. Rebind whenever shielded support is available, keeping a bind failure non-fatal. The Swift screen already rebinds unconditionally. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui/identity/SearchWalletsForIdentitiesScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt index 2386e995efc..631cb2a6d24 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt @@ -140,7 +140,13 @@ fun SearchWalletsForIdentitiesScreen(navController: NavHostController) { walletHandle = wallet.handle, mnemonicResolverHandle = mgr.mnemonicResolverHandle, ) - if (container.shieldedService.isAvailable && found.isNotEmpty()) { + // Rebind after every successful scan, not only when new ids + // came back: discovery can also promote an already-known + // identity into its verified slot (not reported in `found`), + // and its dedicated tip account needs registering too. + // Rust adds tip accounts itself, so the default [0] request + // suffices; a bind failure stays non-fatal. + if (container.shieldedService.isAvailable) { try { mgr.bindShielded(wallet.walletId) } catch (error: kotlinx.coroutines.CancellationException) { From 5b6e8d65838badeab23f92d040233434a5d7ebac Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 07:57:26 -0500 Subject: [PATCH 15/15] fix(platform-wallet): refuse adding an identity into an occupied slot add_identity rejected duplicate ids but inserted into the target slot unconditionally, so an unknown identity verified at a slot held by another (e.g. a restored placeholder) overwrote the occupant while the occupant's reverse-index entry kept resolving to the newcomer; a later adoption of the occupant then moved the wrong identity. The occupied-slot check now lives in add_identity itself, before any change or persist, returning the same IdentityIndexOccupied error, so place_verified_identity refuses both the Added and Adopted paths and discovery skips the index and marks it unanswered either way. Every production caller (registration, invitation, register-from-addresses, shielded-pool create) already treats an add_identity error as non-fatal local bookkeeping that heals on the next sync; registration targets a free slot. Test: an unknown id targeting an occupied slot is refused through both add_identity and place_verified_identity with both lookups, the bucket and the persister write count unchanged; a free slot still works. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/wallet/identity/network/discovery.rs | 3 +- .../identity/state/manager/lifecycle.rs | 16 +++++- .../src/wallet/identity/state/manager/mod.rs | 56 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 5429fd15506..60ec34f78e7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -453,7 +453,8 @@ impl IdentityWallet { // The key hash at `identity_index` proved the index: add // the identity there, or move an identity known from // elsewhere into that slot. A slot held by a different - // identity means one of the two local records is stale. + // identity (whether the incoming one is new or known) + // means one of the two local records is stale. // Skip this index without touching either identity, mark // it unanswered so the next launch rescans, and keep // walking: aborting here would also stop the stale diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index c1404115d28..0f76a84a6be 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -39,7 +39,10 @@ impl IdentityManager { /// onto the value at insert time. /// /// Errors if an identity with the same id already exists in either - /// bucket. + /// bucket, or with [`PlatformWalletError::IdentityIndexOccupied`] if the + /// slot already holds a different identity (inserting would silently + /// discard the occupant while its reverse-index entry kept pointing at + /// the slot). Nothing is changed or persisted on either error. /// /// Persists the resulting changeset via `persister` and returns `()`. pub fn add_identity( @@ -54,6 +57,17 @@ impl IdentityManager { if self.identity(&identity_id).is_some() { return Err(PlatformWalletError::IdentityAlreadyExists(identity_id)); } + if let Some(occupant) = self + .wallet_identities + .get(&wallet_id) + .and_then(|bucket| bucket.get(&identity_index)) + { + return Err(PlatformWalletError::IdentityIndexOccupied { + identity_id, + identity_index, + occupant: occupant.identity.id(), + }); + } let mut managed_identity = ManagedIdentity::new(identity, identity_index); // Denormalize the bucket keys onto the value so callers that 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 d882606519a..b4dff042b2b 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 @@ -382,6 +382,62 @@ mod tests { )); } + /// An unknown identity verified at a slot that already holds a different + /// identity (a restored placeholder, say) must not overwrite it: the + /// occupant's state would be discarded while its reverse-index entry kept + /// resolving to the newcomer. Refused with the typed error, changing and + /// persisting nothing, through `add_identity` and the shared placement. + #[test] + fn an_unknown_identity_cannot_take_an_occupied_slot() { + use std::sync::atomic::Ordering; + let mut manager = IdentityManager::new(); + let wallet_id: WalletId = [9u8; 32]; + let counter = Arc::new(CountingPersistence::default()); + let p = WalletPersister::new(wallet_id, Arc::clone(&counter) as _); + + let occupant = Identifier::from([0xB0; 32]); + manager + .add_identity(create_test_identity(occupant), 0, wallet_id, &p) + .unwrap(); + let newcomer = Identifier::from([0xA0; 32]); + let stores_before = counter.stores.load(Ordering::SeqCst); + + for attempt in [ + manager.add_identity(create_test_identity(newcomer), 0, wallet_id, &p), + manager + .place_verified_identity(create_test_identity(newcomer), 0, wallet_id, &p) + .map(|_| ()), + ] { + assert!( + matches!( + attempt, + Err(PlatformWalletError::IdentityIndexOccupied { + identity_id, + identity_index: 0, + occupant: held, + }) if identity_id == newcomer && held == occupant + ), + "{attempt:?}" + ); + } + + assert_eq!(counter.stores.load(Ordering::SeqCst), stores_before); + assert_eq!( + manager.identity(&occupant).map(|m| m.identity.id()), + Some(occupant) + ); + assert!(manager.identity(&newcomer).is_none()); + assert_eq!(manager.identity_index(&occupant), Some(0)); + let bucket = &manager.wallet_identities[&wallet_id]; + assert_eq!(bucket.len(), 1); + assert_eq!(bucket[&0].identity.id(), occupant); + // A free slot is still fine. + manager + .add_identity(create_test_identity(newcomer), 1, wallet_id, &p) + .unwrap(); + assert_eq!(manager.identity_index(&newcomer), Some(1)); + } + #[test] fn test_add_out_of_wallet_identity() { let mut manager = IdentityManager::new();