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 b2d0b1fed42..c4a7e811361 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 @@ -35,6 +35,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/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..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 @@ -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,24 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val walletId = identity?.walletId val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + // 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) { + 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) + 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 +111,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 +157,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 +198,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 +245,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 +284,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..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 @@ -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 @@ -37,7 +39,9 @@ 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 import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -159,6 +163,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 +325,61 @@ fun DashPayTabScreen(navController: NavHostController) { onError = { unlockError = it }, ) + val tipManager = manager + val tipWalletId = identity.walletId + // 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. + // 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 = 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()) + } + 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") { + if (container.shieldedService.isAvailable) { + EntityRow( + icon = Icons.AutoMirrored.Filled.Send, + title = "Send shielded tip", + onClick = { + 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"), + ) + } EntityRow( icon = Icons.Default.Group, title = "Contacts", 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 new file mode 100644 index 00000000000..c5418cdde79 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt @@ -0,0 +1,123 @@ +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, submission: ShieldedTipSubmission) { + 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 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) } + + 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 ?: 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(), + ) { + 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 + val resolved = wallet.dashpay.resolveShieldedTip(username.trim()) + if (history.hasChanged(manager.network, walletId, username, resolved)) { + changedRecipient = resolved + } else { + recipient = resolved + } + } 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 a92013c0bd8..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,6 +140,21 @@ fun SearchWalletsForIdentitiesScreen(navController: NavHostController) { walletHandle = wallet.handle, mnemonicResolverHandle = mgr.mnemonicResolverHandle, ) + // 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) { + 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()) { 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/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/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/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/15.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/15.json new file mode 100644 index 00000000000..1e2523b3442 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/15.json @@ -0,0 +1,4212 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "163422834a84f1c7c89ba32f7fade7e5", + "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, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `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": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "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, `oncePerIdentityDistribution` 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": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "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, '163422834a84f1c7c89ba32f7fade7e5')" + ] + } +} \ 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 49398a2d13a..04eda676568 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,63 @@ class DashDatabaseMigrationTest { private val dbName = "migration-test.db" + @Test + fun migrate14To15PreservesProfilesAndSweepState() { + migrateProfilesAndSweepState(14) + } + + @Test + fun migrate10To15PreservesProfilesThroughEveryMigration() { + 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, + 15, + true, + DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, + DashDatabase.MIGRATION_12_13, + DashDatabase.MIGRATION_13_14, + DashDatabase.MIGRATION_14_15, + ) + 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 @@ -619,7 +676,7 @@ class DashDatabaseMigrationTest { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 14, + 15, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -631,16 +688,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_11_12, DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, + DashDatabase.MIGRATION_14_15, ).close() } - /** The full chain from v1 must also land on a valid v14 schema. */ + /** The full chain from v1 must also land on a valid v15 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 14, + 15, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -655,6 +713,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_11_12, DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, + DashDatabase.MIGRATION_14_15, ).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 fb82e03ccb3..070df65f3fe 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?, + ) + external fun shieldedIdentityDebitRecoveryRecords( managerHandle: Long, 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 76759d63ed3..bdf40f8ee1e 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 @@ -442,7 +442,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( @@ -466,6 +466,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`. */ @@ -626,7 +629,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 @@ -650,6 +653,9 @@ abstract class NativePersistenceBridge { avatarFingerprintPresent: Boolean, publicMessage: String?, checkedAtMs: Long, + corePaymentAddress: ByteArray? = null, + platformPaymentAddress: ByteArray? = null, + shieldedAddress: ByteArray? = null, ): Int = 0 // ── Asset locks ─────────────────────────────────────────────────── @@ -1196,6 +1202,7 @@ class IdentityRestoreData( * re-fetches every contact. */ @JvmField val contactProfiles: Array, + @JvmField val dashpayProfile: ContactProfileRestoreData? = null, ) /** @@ -1228,6 +1235,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 e74e3b00954..4ea096a1323 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 @@ -161,9 +161,12 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * contract's `oncePerIdentityDistribution` block as JSON, so the claim * screen can offer the third distribution kind. NULL for every pre-existing * row; the next contract materialization fills it in. + * + * Version 15 adds nullable Core, Platform, and shielded payment addresses + * to cached owner and contact profiles. */ @Database( - version = 14, + version = 15, exportSchema = true, entities = [ WalletEntity::class, @@ -679,6 +682,17 @@ abstract class DashDatabase : RoomDatabase() { } } + /** v14 → v15: optional public payment addresses on cached profiles. */ + val MIGRATION_14_15: Migration = object : Migration(14, 15) { + 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") + } + } + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -708,6 +722,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, + MIGRATION_14_15, ) .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 2f4ad4d12d5..877014dcf0b 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 @@ -1635,6 +1635,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 @@ -1749,6 +1752,9 @@ class PlatformWalletPersistenceHandler( avatarHash = if (dashpayAvatarHashPresent) dashpayAvatarHash else null, avatarFingerprint = if (dashpayAvatarFingerprintPresent) dashpayAvatarFingerprint else null, + corePaymentAddress = dashpayCorePaymentAddress, + platformPaymentAddress = dashpayPlatformPaymentAddress, + shieldedAddress = dashpayShieldedAddress, lastUpdated = now(), ), ) @@ -2206,6 +2212,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 @@ -2228,6 +2237,9 @@ class PlatformWalletPersistenceHandler( avatarUrl = avatarUrl, avatarHash = avatarHash.takeIf { avatarHashPresent }, avatarFingerprint = avatarFingerprint.takeIf { avatarFingerprintPresent }, + corePaymentAddress = corePaymentAddress, + platformPaymentAddress = platformPaymentAddress, + shieldedAddress = shieldedAddress, checkedAtMs = checkedAtMs, lastUpdated = now(), ), @@ -2987,6 +2999,9 @@ class PlatformWalletPersistenceHandler( avatarFingerprint = cp.avatarFingerprint, publicMessage = cp.publicMessage, checkedAtMs = cp.checkedAtMs, + corePaymentAddress = cp.corePaymentAddress, + platformPaymentAddress = cp.platformPaymentAddress, + shieldedAddress = cp.shieldedAddress, ) }.toTypedArray() IdentityRestoreData( @@ -3003,6 +3018,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/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index b0710595759..10e46fef555 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 @@ -1078,6 +1078,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/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 bce8324ef49..184a8c8e003 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 @@ -1871,6 +1871,27 @@ 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) { "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) + } + } + /** * 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 fb2ba7896b7..312e6b91848 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,15 +233,16 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion14WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion15WithTheSweepHoldIndexes() = 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. // 11 → 12 adds the identity key usage limits columns on top, // 12 → 13 the contract bounds kind, and 13 → 14 the token - // once-per-identity distribution block. - assertEquals(14, db.openHelper.readableDatabase.version) + // once-per-identity distribution block, and 14 → 15 the profile + // payment addresses. + assertEquals(15, 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 b7b1bfd63e9..d3695f01c3a 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 @@ -906,6 +906,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) @@ -924,6 +927,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) } @@ -4879,6 +4885,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) } @@ -5007,9 +5016,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] @@ -5022,6 +5040,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-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 e04c82a2ff0..5d988fb33cd 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, @@ -377,38 +391,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::()]; // --------------------------------------------------------------------------- @@ -471,6 +455,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, @@ -493,6 +486,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, } @@ -508,6 +508,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(), } } @@ -535,6 +542,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()), } } @@ -634,6 +669,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, }); @@ -657,6 +699,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, }); @@ -1050,6 +1120,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(), @@ -1102,6 +1173,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 d95a1f31db8..34ed1c2a0c4 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6098,6 +6098,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); } @@ -6258,6 +6261,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 @@ -6271,43 +6310,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, }, ); @@ -7543,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, @@ -10007,6 +10163,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, }, @@ -10019,6 +10182,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, }, @@ -10043,6 +10213,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 f0570b466d4..3eb45c13712 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 \ @@ -2281,6 +2323,219 @@ fn resolve_wallet_and_coordinator( Ok((wallet, coordinator)) } +/// 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, + }; + // 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. +/// 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 mut identity_id = [0u8; 32]; + let mut address = [0u8; 43]; + let result = catch_query_panic("shielded tip resolution", || { + // 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 + }) + }); + 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. +/// 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, + }; + 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, + }; + 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) +} + #[cfg(test)] mod tests { use super::*; @@ -2831,6 +3086,208 @@ 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", || { + 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 diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index dfda7eeb4c1..04943fae428 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -739,6 +739,89 @@ fn map_shielded_error(error: PlatformWalletError, operation: &str) -> PlatformWa } } +/// 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); + } + } +} + #[cfg(test)] mod recovery_error_tests { use super::*; 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 22a71ade25b..fa3523aea4f 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -358,6 +358,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 @@ -388,7 +390,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). /// @@ -420,6 +422,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/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 769378695c1..68ba5003f62 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -132,6 +132,7 @@ erDiagram BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" INTEGER identity_index "BIP-32 index; NULL for out-of-wallet identities" BLOB entry_blob "bincode-encoded IdentityEntry" + INTEGER entry_format "V019: 0 = pre-payment-address record, 1 = current" } IDENTITY_KEYS { @@ -153,6 +154,7 @@ erDiagram DASHPAY_PROFILES { BLOB identity_id PK "one row per identity" BLOB profile_blob "bincode-encoded DashPayProfile" + INTEGER profile_format "V019: 0 = pre-payment-address record, 1 = current" } DASHPAY_PAYMENTS_OVERLAY { @@ -486,6 +488,13 @@ every identity-scoped child writer, so a changeset carrying both an upsert and a removal for one identity commits instead of pulling the FK parent out from under its own child inserts. +`entry_format` (V019) stamps the bincode shape of `entry_blob`: 0 is the +record written before `DashPayProfile` gained its payment addresses, 1 the +current one. The writer always stamps 1; the reader dispatches on the stamp +(`schema::identity_profile_encoding::decode_identity`), so rows written +before V019 stay readable until they are rewritten. The DEFAULT is 1 and the +migration stamps the rows it finds 0, so only pre-V019 rows are ever legacy. + - PK: `identity_id`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE` (nullable). - Index: `idx_identities_wallet(wallet_id)`. @@ -618,6 +627,11 @@ DELETE rather than a NULL blob — the row is absent, not nulled. `apply` writes these rows, but `load()` does not return them today; `DashPaySyncManager` rebuilds the canonical profile from Platform. +`profile_format` (V019) stamps the bincode shape of `profile_blob` the same +way `identities.entry_format` does (0 = pre-payment-address record, 1 = +current); `schema::identity_profile_encoding::decode_profile` is the stamped +reader for the day `load()` grows one. + - PK: `identity_id` (single-row-per-identity). - FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. @@ -847,3 +861,4 @@ table-rebuild migration, as V004 does. | V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | | V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | | V018 | `V018__identity_hard_delete.rs` | Retires identity tombstoning. Adds `cascade_children_on_identity_delete` (brooms `identity_keys` / `contacts` / `ignored_senders` / `pending_contact_crypto` by the deleted identity id, covering the rows no live FK reaches) plus its access-path indexes `idx_contacts_owner`, `idx_ignored_senders_owner`, and `idx_pending_contact_crypto_owner`; purges every already-tombstoned identity and its dependents; drops `identities.tombstoned`. | +| V019 | `V019__profile_address_encoding.rs` | Adds the encoding stamps `identities.entry_format` and `dashpay_profiles.profile_format` (`INTEGER NOT NULL DEFAULT 1 CHECK IN (0, 1)`) and stamps every row present at migration time 0. `DashPayProfile` gained the core, platform and shielded payment addresses, which widens the positional bincode record embedded in both blobs; the stamp routes each row to the decoder for the shape it carries (`schema::identity_profile_encoding`). | diff --git a/packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs b/packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs new file mode 100644 index 00000000000..2b98b77cd6a --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs @@ -0,0 +1,19 @@ +//! Stamp the bincode encoding of `identities.entry_blob` and +//! `dashpay_profiles.profile_blob`. +//! +//! Payment addresses (core, platform, shielded) extend `DashPayProfile`, which +//! is embedded positionally in both blobs, so a record written before this +//! migration decodes only against the pre-address shape. The stamp says which +//! shape a row carries: 0 is the pre-address record, 1 the current one, and +//! `schema::identity_profile_encoding` holds the decoder for each. +//! +//! Every row present when the migration runs is stamped 0. The column DEFAULT +//! is 1, so a later insert that does not name the column is read as the shape +//! the current writer produces; the CHECK refuses a stamp no decoder knows. +pub fn migration() -> String { + "ALTER TABLE identities ADD COLUMN entry_format INTEGER NOT NULL DEFAULT 1 CHECK(entry_format IN (0, 1)); + UPDATE identities SET entry_format = 0; + ALTER TABLE dashpay_profiles ADD COLUMN profile_format INTEGER NOT NULL DEFAULT 1 CHECK(profile_format IN (0, 1)); + UPDATE dashpay_profiles SET profile_format = 0;" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs index 6ed1f6a26ee..a179d6336a7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -51,3 +51,6 @@ pub use persister::{prune_backups_in, PruneReport, RetentionPolicy, SqlitePersis pub use reports::{CommitReport, DeleteWalletReport}; #[doc(inline)] pub use schema::core_pool::OwningAccount; + +// 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/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index c53f2584be2..164d8236743 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -1,6 +1,9 @@ //! BLOB-column codec helpers: thin `bincode::serde` wrappers so every //! `_blob` column uses one encoding path. Schema evolution is gated by the -//! refinery migration version — no per-blob revision tag. +//! refinery migration version — no per-blob revision tag, except the +//! `entry_format` / `profile_format` row stamps V019 added for the two +//! blobs that embed the widened `DashPayProfile` record (see +//! `identity_profile_encoding`). //! //! [`encode_outpoint`] / [`decode_outpoint`] encode `dashcore::OutPoint` //! the same way for the `outpoint` PK columns. The key is variable-width, 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 0ec8c2bfe10..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,10 +45,18 @@ 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); +// `profile_blob` carries an encoding stamp (`profile_format`, V019): 0 is the +// pre-payment-address `DashPayProfile` shape, 1 the current one. Every write +// stamps 1; nothing in the crate reads the column back yet (see the module +// doc), so the stamped decoder is exported for hosts and for the day `load()` +// grows a reader. +pub use super::identity_profile_encoding::decode_profile; + /// Both tables are keyed by identity only; their FK to /// `identities(identity_id)` cascades via the `wallets → identities` chain. /// `wallet_id` feeds the precondition check only — no column. @@ -69,9 +77,10 @@ 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 { @@ -121,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 @@ -130,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/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index d29c622f5eb..e40e80b04b6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -22,6 +22,11 @@ use crate::sqlite::schema::blob::impl_persistable_blob; // PUBLIC material only: identity snapshot reaching the `entry_blob` column. impl_persistable_blob!(IdentityEntry); +// `entry_blob` carries an encoding stamp (`entry_format`, V019): 0 is the +// pre-payment-address `IdentityEntry` shape, 1 the current one. Every write +// stamps 1; the stamped reader keeps rows written before V019 decodable. +pub use super::identity_profile_encoding::decode_identity; + /// Write the changeset's inserted / updated identities. /// /// The insert half of a two-part apply: [`apply_removals`] must run for @@ -49,12 +54,13 @@ pub fn apply_upserts( // upsert without erroring), preserving the resident blob and index. // `IS` is the NULL-safe match for the nullable column. let mut stmt = tx.prepare_cached( - "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob) \ - VALUES (?1, ?2, ?3, ?4) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, entry_format) \ + VALUES (?1, ?2, ?3, ?4, 1) \ ON CONFLICT(identity_id) DO UPDATE SET \ wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ identity_index = excluded.identity_index, \ - entry_blob = excluded.entry_blob \ + entry_blob = excluded.entry_blob, \ + entry_format = 1 \ WHERE identities.wallet_id IS NULL OR identities.wallet_id IS excluded.wallet_id", )?; let wallet_id_param = wallet_id_to_param(wallet_id); @@ -280,7 +286,7 @@ pub fn fetch( // the identity-id row can't leak through; sentinel matches orphan rows. let wallet_id_param = wallet_id_to_param(wallet_id); let mut stmt = conn.prepare( - "SELECT length(entry_blob), entry_blob FROM identities \ + "SELECT length(entry_blob), entry_blob, entry_format FROM identities \ WHERE identity_id = ?1 AND wallet_id IS ?2", )?; let mut rows = stmt.query(params![&identity_id[..], wallet_id_param])?; @@ -289,7 +295,7 @@ pub fn fetch( Some(row) => { blob::check_size(row.get::<_, i64>(0)?)?; let payload: Vec = row.get(1)?; - Ok(Some(blob::decode(&payload)?)) + Ok(Some(decode_identity(&payload, row.get(2)?)?)) } } } @@ -351,7 +357,7 @@ pub fn load_state_with_ctx( // unowned bucket. A plain `=` could not express the second case at all. let wallet_id_param = wallet_id_to_param(wallet_id); let mut stmt = conn.prepare( - "SELECT identity_id, length(entry_blob), entry_blob, identity_index \ + "SELECT identity_id, length(entry_blob), entry_blob, identity_index, entry_format \ FROM identities WHERE wallet_id IS ?1 ORDER BY identity_id", )?; // The ignored-senders TABLE is the authoritative ignore record (every @@ -366,7 +372,7 @@ pub fn load_state_with_ctx( blob::check_size(row.get::<_, i64>(1)?)?; let payload: Vec = row.get(2)?; let typed_identity_index: Option = row.get(3)?; - let entry: IdentityEntry = blob::decode(&payload)?; + let entry: IdentityEntry = decode_identity(&payload, row.get(4)?)?; // Cross-check the decoded blob against the typed columns it was // selected by (mirrors the accounts / identity_keys readers): the // blob must name the same identity, and its own wallet_id (when set) @@ -572,8 +578,9 @@ pub fn ensure_exists( let payload = blob::encode(&stub)?; let wallet_id_param = wallet_id_to_param(wallet_id); conn.execute( - "INSERT OR IGNORE INTO identities (identity_id, wallet_id, identity_index, entry_blob) \ - VALUES (?1, ?2, NULL, ?3)", + "INSERT OR IGNORE INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, entry_format) \ + VALUES (?1, ?2, NULL, ?3, 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..8cc869fc11d --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs @@ -0,0 +1,259 @@ +//! Frozen pre-V019 profile records, and the stamped decoders that route each +//! `identities.entry_blob` / `dashpay_profiles.profile_blob` row to the shape +//! it carries. Do not edit the legacy field order. +//! +//! `DashPayProfile` gained its payment addresses as trailing `Option` fields. +//! Under `bincode::serde` a struct is a fixed-arity positional record, so a +//! record written before the widening is never "short": the trailing fields +//! are simply absent, and decoding it against the current shape reads the +//! next field's bytes as an address. The row stamp (V019) is what says which +//! shape to decode; `serde(default)` on the new fields serves map formats +//! such as JSON only. +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() + } + } +} +impl From for LegacyProfile { + fn from(p: DashPayProfile) -> 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, + } + } +} +#[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, +} +impl From for IdentityEntry { + fn from(old: LegacyIdentityEntry) -> Self { + 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, + } + } +} +impl From for LegacyIdentityEntry { + fn from(entry: IdentityEntry) -> Self { + Self { + id: entry.id, + balance: entry.balance, + revision: entry.revision, + identity_index: entry.identity_index, + last_updated_balance_block_time: entry.last_updated_balance_block_time, + last_synced_keys_block_time: entry.last_synced_keys_block_time, + dpns_names: entry.dpns_names, + contested_dpns_names: entry.contested_dpns_names, + status: entry.status, + wallet_id: entry.wallet_id, + dashpay_profile: entry.dashpay_profile.map(Into::into), + dashpay_payments: entry.dashpay_payments, + contact_profiles: entry + .contact_profiles + .into_iter() + .map(|(id, entry)| { + ( + id, + LegacyContactProfile { + profile: entry.profile.map(Into::into), + checked_at_ms: entry.checked_at_ms, + }, + ) + }) + .collect(), + ignored_senders: entry.ignored_senders, + } + } +} + +// Test-only: production never writes the legacy shapes, but migration +// fixtures have to produce the bytes a pre-V019 writer produced. PUBLIC +// material only, like the current shapes they mirror. +#[cfg(any(test, feature = "__test-helpers"))] +super::blob::impl_persistable_blob!(LegacyProfile, LegacyIdentityEntry); + +/// The bytes a pre-V019 writer stored for `profile`: the record without the +/// payment-address fields. For fixtures of databases written before V019. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn encode_legacy_profile(profile: &DashPayProfile) -> Result, WalletStorageError> { + blob::encode(&LegacyProfile::from(profile.clone())) +} + +/// The bytes a pre-V019 writer stored for `entry`: its own and its contacts' +/// profiles in the pre-payment-address shape. For fixtures of databases +/// written before V019. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn encode_legacy_identity(entry: &IdentityEntry) -> Result, WalletStorageError> { + blob::encode(&LegacyIdentityEntry::from(entry.clone())) +} + +/// Decode a `dashpay_profiles.profile_blob` using its V019 `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 => blob::decode::(payload).map(Into::into), + _ => Err(WalletStorageError::blob_decode( + "unsupported identity profile encoding", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn old_profile() -> DashPayProfile { + DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("hello".into()), + public_message: Some("hello".into()), + ..Default::default() + } + } + + #[test] + fn should_decode_standalone_profiles_by_format_without_fallback() { + let legacy = encode_legacy_profile(&old_profile()).unwrap(); + let mut profile = decode_profile(&legacy, 0).unwrap(); + assert_eq!(profile, old_profile()); + assert!(profile.core_payment_address.is_none()); + assert!(profile.platform_payment_address.is_none()); + assert!(profile.shielded_address.is_none()); + assert!(decode_profile(&legacy, 1).is_err()); + profile.shielded_address = Some(vec![9; 43]); + let current = blob::encode(&profile).unwrap(); + assert_eq!(decode_profile(¤t, 1).unwrap(), profile); + assert!(decode_profile(¤t, 0).is_err()); + assert!(decode_profile(¤t, 2).is_err()); + assert!(decode_profile(¤t[..current.len() - 1], 1).is_err()); + } + + /// The legacy identity record round-trips through the stamped decoder + /// with every field after the embedded profiles intact: the profile is + /// positional, so a shape mismatch would shift `dashpay_payments`, + /// `contact_profiles` and `ignored_senders`, not just drop an address. + #[test] + fn should_read_old_owned_and_contact_profiles_without_losing_following_fields() { + let id = Identifier::from([1; 32]); + let entry = IdentityEntry { + 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, + ContactProfileEntry { + profile: Some(old_profile()), + checked_at_ms: 321, + }, + )] + .into(), + ignored_senders: [Identifier::from([3; 32])].into(), + }; + let legacy = encode_legacy_identity(&entry).unwrap(); + assert_eq!(decode_identity(&legacy, 0).unwrap(), entry); + // Current bytes carry three more `None`s per embedded profile. + let current = blob::encode(&entry).unwrap(); + assert_eq!(current.len(), legacy.len() + 6); + assert_eq!(decode_identity(¤t, 1).unwrap(), entry); + assert!(decode_identity(¤t[..current.len() - 1], 1).is_err()); + assert!(decode_identity(¤t, 2).is_err()); + assert!(decode_identity(&legacy, 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 88bff87e377..daaefce1a9d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -4,7 +4,10 @@ //! columns (heights, hashes, outpoints, flags); `_blob` columns carry the //! full sub-changeset entry via [`blob::encode`] / [`blob::decode`]. Schema //! evolution is gated by the refinery migration version — blobs carry no -//! inline revision tag. +//! inline revision tag. The two exceptions are the identity and DashPay +//! profile blobs, whose rows carry an encoding stamp column (V019) because +//! the positional `DashPayProfile` record grew payment addresses; see +//! [`identity_profile_encoding`]. pub mod accounts; pub mod asset_locks; @@ -16,6 +19,7 @@ pub mod dashpay; pub mod dpns_name_states; pub mod identities; pub mod identity_keys; +pub mod identity_profile_encoding; pub mod identity_scan_states; pub mod invitations; pub mod pending_contact_crypto; 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 9757deee4de..910d8e5f1ff 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -674,6 +674,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-storage/tests/sqlite_profile_address_encoding.rs b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs new file mode 100644 index 00000000000..ed50763c10c --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs @@ -0,0 +1,335 @@ +#![allow(clippy::field_reassign_with_default)] + +//! A database written before V019 keeps its identity and DashPay profile rows +//! readable: the migration stamps the rows it finds 0, the stamped decoders +//! route them to the pre-payment-address record, and a rewrite stamps 1. +//! The fixture is written at V007 (the last published pre-rehydration +//! schema), so the whole V008 -> V019 chain runs over the legacy bytes. + +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, +}; +use platform_wallet_storage::sqlite::schema::{blob, dashpay, identities}; +use platform_wallet_storage::sqlite::{decode_identity, decode_profile, migrations}; +use rusqlite::{params, Connection}; + +const WALLET_ID: [u8; 32] = [0x62; 32]; + +fn old_profile() -> DashPayProfile { + DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("hello".into()), + public_message: Some("hello".into()), + ..Default::default() + } +} + +fn entry(id: Identifier) -> IdentityEntry { + IdentityEntry { + 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(WALLET_ID), + dashpay_profile: Some(old_profile()), + dashpay_payments: BTreeMap::new(), + contact_profiles: [( + id, + ContactProfileEntry { + profile: Some(old_profile()), + checked_at_ms: 321, + }, + )] + .into(), + ignored_senders: [Identifier::from([3; 32])].into(), + } +} + +fn stamps(conn: &Connection) -> (i64, i64) { + let entry_format = conn + .query_row("SELECT entry_format FROM identities", [], |row| row.get(0)) + .unwrap(); + let profile_format = conn + .query_row("SELECT profile_format FROM dashpay_profiles", [], |row| { + row.get(0) + }) + .unwrap(); + (entry_format, profile_format) +} + +fn stored_profile(conn: &Connection) -> DashPayProfile { + let (payload, format): (Vec, i64) = conn + .query_row( + "SELECT profile_blob, profile_format FROM dashpay_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + decode_profile(&payload, format).unwrap() +} + +/// Rows a pre-V019 writer stored are stamped 0 by the migration and decode +/// to the pre-address shape through both readers; a rewrite stamps them 1 +/// and the new fields round-trip; an insert that omits the stamp is read as +/// the current shape. +#[test] +fn pre_v019_rows_stay_readable_and_rewrites_restamp() { + let id = Identifier::from([1; 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(); + conn.execute( + "INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, ?2)", + params![ + id.as_slice(), + encode_legacy_profile(&old_profile()).unwrap() + ], + ) + .unwrap(); + + migrations::run(&mut conn).unwrap(); + + assert_eq!(stamps(&conn), (0, 0), "rows present at V019 are legacy"); + assert_eq!(stored_profile(&conn), old_profile()); + let restored = identities::fetch(&conn, &WALLET_ID, &id.to_buffer()) + .unwrap() + .unwrap(); + assert_eq!(restored, old); + assert!(restored + .dashpay_profile + .as_ref() + .unwrap() + .shielded_address + .is_none()); + // The reader the runtime restores from dispatches on the stamp too: a + // legacy row decoded against the current shape would fail this load. + 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); + + // Rewriting the rows through the live writers stamps them current. + let mut current = restored; + current.dashpay_profile.as_mut().unwrap().shielded_address = Some(vec![9; 43]); + let tx = conn.transaction().unwrap(); + identities::apply_upserts( + &tx, + &WALLET_ID, + &IdentityChangeSet { + identities: [(id, current.clone())].into(), + ..Default::default() + }, + ) + .unwrap(); + dashpay::apply( + &tx, + &WALLET_ID, + Some(&BTreeMap::from([(id, current.dashpay_profile.clone())])), + None, + ) + .unwrap(); + tx.commit().unwrap(); + assert_eq!(stamps(&conn), (1, 1)); + assert_eq!( + stored_profile(&conn), + current.dashpay_profile.clone().unwrap() + ); + assert_eq!( + identities::fetch(&conn, &WALLET_ID, &id.to_buffer()) + .unwrap() + .unwrap(), + current + ); + let encoded = blob::encode(¤t).unwrap(); + assert_eq!(decode_identity(&encoded, 1).unwrap(), current); + + // The stamp DEFAULT follows the writer: an insert that omits it reads as + // the current shape, so only rows the migration found are ever legacy. + let other = Identifier::from([7; 32]); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob) \ + VALUES (?1, ?2, 1, ?3)", + params![ + other.as_slice(), + WALLET_ID.as_slice(), + blob::encode(&IdentityEntry { + id: other, + identity_index: Some(1), + ..current.clone() + }) + .unwrap() + ], + ) + .unwrap(); + let format: i64 = conn + .query_row( + "SELECT entry_format FROM identities WHERE identity_id = ?1", + [other.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(format, 1); + assert_eq!( + identities::fetch(&conn, &WALLET_ID, &other.to_buffer()) + .unwrap() + .unwrap() + .dashpay_profile + .unwrap() + .shielded_address, + Some(vec![9; 43]) + ); +} + +/// A stamp no decoder knows is refused at the schema, not misread. +#[test] +fn unknown_stamp_is_rejected_by_the_check_constraint() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![WALLET_ID.as_slice()], + ) + .unwrap(); + let id = Identifier::from([4; 32]); + let err = conn + .execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, entry_format) \ + VALUES (?1, ?2, NULL, ?3, 2)", + params![id.as_slice(), WALLET_ID.as_slice(), blob::encode(&entry(id)).unwrap()], + ) + .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)); +} + +/// 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); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 282a2f5abf1..20593d4171c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "91f2fab573900a41066b8d237a29b83ca94b2fc730702389ab9c088271da522f"; + "e7451271a3b29686a7ca0d09bdc1e1bac5c02c70af98987448d6fd0ca7a7b047"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "0dbcfb2ab8d8362a206c0ab948051028c7eafc640861a823c4c50f13b0602be8"; + "0daf4e59295ca8f254fe5d402ab37290341bacccd9cb3727097ec9e4eb06171c"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's 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..c184b7b4ecb --- /dev/null +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -0,0 +1,85 @@ +# 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. 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 +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/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/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 0f4ec4a8c33..9ddc5ccb529 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -1459,6 +1459,8 @@ 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() }; // 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/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 6117a170f7b..60ec34f78e7 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,15 +450,38 @@ 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, - )?; - } + // 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 (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 + // 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 @@ -726,6 +750,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 { @@ -766,16 +793,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 } @@ -1329,6 +1362,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 ecc7897ae4b..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,14 +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, - )?; - } + // 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/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index c888194d9a6..d9e4fbfcd39 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -152,6 +152,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 { @@ -164,22 +165,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. @@ -244,15 +232,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) { @@ -286,6 +265,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). @@ -447,9 +427,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` @@ -481,6 +482,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()), } } @@ -1132,3 +1148,442 @@ 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::Set(_))) + { + 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::Set(_)) + && !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}; + + #[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([ + ("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![], + integer_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/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index f5af15abaf1..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 @@ -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 { @@ -30,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( @@ -45,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 @@ -92,6 +115,100 @@ impl IdentityManager { Ok(()) } + /// 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 + /// 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`. 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, + ) -> Result<(), PlatformWalletError> { + let target = IdentityLocation::InWallet { + wallet_id, + registration_index: identity_index, + }; + let current = self + .location_index() + .get(identity_id) + .copied() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + if current != target { + if let Some(other) = self + .wallet_identities + .get(&wallet_id) + .and_then(|bucket| bucket.get(&identity_index)) + .filter(|other| other.identity.id() != *identity_id) + { + return Err(PlatformWalletError::IdentityIndexOccupied { + identity_id: *identity_id, + identity_index, + occupant: other.identity.id(), + }); + } + 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)), + } + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + 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); + } + Ok(()) + } + /// 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..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 @@ -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,6 +271,173 @@ mod tests { assert_eq!(manager.highest_registration_index(&wallet_id), Some(0)); } + #[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 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 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_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)); + 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(); + 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. + manager + .adopt_into_wallet(&placeholder, wallet_id, 7) + .unwrap(); + + // 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(); + 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!(matches!( + manager.adopt_into_wallet(&Identifier::from([4u8; 32]), wallet_id, 9), + Err(PlatformWalletError::IdentityNotFound(_)) + )); + } + + /// 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(); 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/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 ab661278f99..9aedcac1f94 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,17 @@ 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). + /// 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). + #[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 +90,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 7cbe111776d..64b18e9563f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -809,12 +809,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()); @@ -832,11 +849,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) { @@ -926,9 +938,25 @@ impl PlatformWallet { "persister load failed while rebinding shielded viewing keys: {e}" )) })?; + 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 + .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); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index df2b4fa1ddc..fd5a4810d04 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -46,6 +46,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..bccc4489d71 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -0,0 +1,235 @@ +//! 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) +} + +/// 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; + 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. + /// 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], + 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(), + )); + } + 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() { + return Err(PlatformWalletError::ShieldedNotBound); + } + 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 670eaef352a..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 @@ -38,6 +38,8 @@ use crate::wallet::shielded::{ #[derive(Default)] struct CapturingPersistence { stored: Mutex>, + durability_events: Mutex>, + fail_flush: std::sync::atomic::AtomicBool, serve: Mutex>>, serve_subwallets: Mutex>, load_calls: Mutex, @@ -112,11 +114,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(()) } @@ -916,3 +925,407 @@ async fn should_keep_wallet_and_coordinator_keys_when_guarded_registration_is_re 1 ); } + +/// 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; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .adopt_into_wallet(&id, wallet.wallet_id(), 5) + .unwrap(); + } + 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; + 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 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: seed_master_key(&seed, 3), + 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()); + // 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 + .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 + .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()); + // 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 + .unwrap(); + assert!(wallet + .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 + .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))); +} + +/// 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/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b6b36194a90..3d91dff54ed 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 67bfc71e13f..b1ef623f365 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1253,3 +1253,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 fea73af90dc..357dcf0966b 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() @@ -2092,6 +2128,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` @@ -2120,6 +2157,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: @@ -2305,6 +2357,7 @@ fn seal_wallet_entries(staged: Vec) -> Vec = keys .into_iter() @@ -2377,26 +2430,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 @@ -3181,6 +3220,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, @@ -3201,6 +3252,7 @@ fn build_identity_restore( ignored_senders_count: 0, contact_profiles: ptr::null(), contact_profiles_count: 0, + dashpay_profile: ptr::null(), }; Ok(IdentityRestoreStaged { entry, @@ -3209,6 +3261,7 @@ fn build_identity_restore( ignored_senders, payments, contact_profiles, + dashpay_profile, }) } @@ -3277,6 +3330,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, @@ -3287,6 +3358,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, }, @@ -3629,6 +3706,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( @@ -4551,13 +4638,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", IDENTITY_KEY_UPSERT_DESCRIPTOR), ("onPersistIdentityKeyRemoval", "([B[BI)I"), 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/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 2183bfbe76d..ca59ecc2c09 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -4,8 +4,11 @@ SwiftData schemas become supported history when a build reaches App Store distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the -model checksum match the observed App Store 9.0.2 database. Active models are -V3. Other intermediate development shapes remain unsupported. +model checksum match the observed App Store 9.0.2 database. App Store schema +3.0.0 is published and `DashSchemaV3` is bound to its release snapshot +(`DashSchemaSnapshotV3`). Active models are V4, which adds the +`PersistentDashpayPaymentAddresses` entity. Other intermediate development +shapes remain unsupported. The old V2 fixture was generated from September 8 sources containing 13 properties added on August 28, after the August 27 App Store release. The @@ -15,26 +18,24 @@ separate from archive-captured releases. This identifies a matching model source, not the confirmed build commit of Apple's binary. V2 is reserved: automated release capture must not register another shape under that number. -The main migration plan is historical V2 → V3. Accepted V1 has a separate -V1 → V3 plan: V1 already contains the 13 properties missing from historical +The main migration plan is historical V2 → V3 → V4. Accepted V1 has a separate +V1 → V3 → V4 plan: V1 already contains the 13 properties missing from historical V2, so a V1 → V2 → V3 chain could discard values. Routing uses model metadata and runs after recovery; a version label alone never selects an unknown beta schema. The former live V2 is accepted only when its complete graph matches -current V3 exactly, entity hashes and checksum alike. That alias therefore -lasts only until the next live-graph change: after it, every store still -labelled `2.0.0` with the former live shape becomes `unsupported-v2` and fails -closed. Check internal devices still carrying that label before the next -shape change and migrate or deliberately reset them then, rather than -discovering them afterwards as failed opens. +the published V3 snapshot exactly, entity hashes and checksum alike; any other +store labelled `2.0.0` is `unsupported-v2` and fails closed. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, `accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the -bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds -a temporary store, so it is memoized per process and consulted only where a -label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the -default plan without any probe, and a probe failure on the undecidable labels +`unsupported-v2`, `released-v3-to-v4`, `labelled-current-v4`, +`ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3` (each +route continues through the registered stages to the live version). Resolving +a frozen schema's identity builds a temporary store, so it is memoized per +process and consulted only where a label is undecidable without it (`1.0.0`, +`2.0.0`); `3.0.0` and `4.0.0` labels take the default plan without any probe, +and a probe failure on the undecidable labels refuses the open with the probe's own error while leaving the store untouched. ## Legacy stores before the release registry @@ -137,9 +138,9 @@ The run failed before upload, so this provenance establishes a tested source layout rather than proof of publication. Regression tests exercise the public factory, data/default preservation, writes, reopen, and failure recovery. -Keep the bridge for installations that skip the V3 app release. When advancing -to V4, bind `DashSchemaV3` to its released snapshot and retain the legacy-to-V3 -step before the normal V3-to-current plan. The bridge must never automatically +Keep the bridge for installations that skip the V3 app release. `DashSchemaV3` +is bound to its released snapshot, and the bridge keeps it as its fixed target +before the normal V3-to-current plan. The bridge must never automatically follow the latest live model graph. The release observer's one-time `bootstrap` only records its observation baseline; it neither runs this migration nor proves V1's App Store provenance. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ea878415c36..cd079db2e4c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -54,9 +54,10 @@ public enum DashModelContainer { frozenModelGraph(assetLock: DashSchemaV1.PersistentAssetLock.self) } - /// Live models for the next App Store schema. A release snapshot does not - /// replace these types until a subsequent shape change introduces a new live - /// version; callers must continue fetching the top-level model types. + /// Live models for the next App Store schema (V4). Published V3 is bound to + /// its generated release snapshot (`DashSchemaSnapshotV3`), so changing these + /// types never changes a released version in place; callers must continue + /// fetching the top-level model types. public static var modelTypes: [any PersistentModel.Type] { [ PersistentIdentity.self, @@ -94,13 +95,14 @@ public enum DashModelContainer { PersistentInvitation.self, PersistentMasternode.self, PersistentTrackedMasternode.self, - PersistentIdentityBalanceMetadata.self + PersistentIdentityBalanceMetadata.self, + PersistentDashpayPaymentAddresses.self ] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data. @@ -172,7 +174,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText(liveVersionLabel)]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +187,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText(liveVersionLabel)]) return container } catch { logMigrationFailure(error) @@ -201,9 +203,12 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("3.0.0")]) + "target_version": .publicText(liveVersionLabel)]) } + /// The live schema's version, as logged in `target_version`. + static let liveVersionLabel = "4.0.0" + /// Select by the complete stored model identity, after journal recovery. /// Accepted V1 contains fields absent from the real historical V2; sending /// it through V2 would delete those values before V3 adds the fields again. @@ -218,7 +223,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("3.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText(liveVersionLabel)]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +241,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("3.0.0")]) + "route": .publicText(route), "target_version": .publicText(liveVersionLabel)]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -263,10 +268,14 @@ public enum DashModelContainer { } logRoute(historical ? "historical-v2-to-v3" : "previous-live-v2-current-shape") } else if versions == ["3.0.0"] { + // Published V3 is registered from its release snapshot, so the + // default plan identifies it by hash; no probe is needed either. + logRoute("released-v3-to-v4") + } else if versions == [liveVersionLabel] { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("labelled-current-v3") + logRoute("labelled-current-v4") } else { logRoute("ordinary-current-plan") } @@ -287,12 +296,13 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -300,9 +310,10 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self] } static var stages: [MigrationStage] { - [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self)] + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] } } @@ -372,9 +383,22 @@ public enum DashSchemaV2: VersionedSchema { } } -/// Current working schema. A later shape change must preserve this graph as -/// the fixed legacy-bridge target before introducing another live version. +/// App Store schema 3.0.0, bound to its generated release snapshot +/// (`FrozenSchemas/DashSchemaSnapshotV3+*.swift`, `schema-releases.json`). It +/// is also the fixed legacy-bridge target: installations that skip the V3 app +/// still reach exactly this graph before the V3 -> V4 stage. public enum DashSchemaV3: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } +} + +/// Current working schema. V4 adds `PersistentDashpayPaymentAddresses`: the +/// DashPay payment addresses (Core, Platform, shielded) of an owned or cached +/// contact profile, keyed by `(network, owner identity, profile identity)`. They +/// live in their own entity rather than as columns on the profile entities, so +/// no V3 entity changes shape and V3 -> V4 is a purely additive lightweight stage. +/// Until V4 is published, later shape changes may still edit it in place. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(4, 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..3bf115ecba2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift @@ -0,0 +1,63 @@ +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) } + } + + /// 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/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/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/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 e8762235538..b0d88e7d93c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2906,37 +2906,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 97d86c8dfb9..4638451a54b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -193,6 +193,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 @@ -1545,3 +1562,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 d9ffdea6697..7048db1b83a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3490,12 +3490,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( @@ -3589,7 +3591,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. @@ -3601,7 +3603,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 ) @@ -3658,11 +3660,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 } @@ -3796,7 +3806,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 @@ -3811,6 +3821,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( @@ -3828,6 +3839,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 @@ -3857,7 +3872,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 @@ -3868,6 +3883,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 { @@ -3882,6 +3900,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 { @@ -3901,6 +3920,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) } } @@ -5031,6 +5054,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 @@ -5054,6 +5080,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 @@ -6442,6 +6471,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() @@ -6984,6 +7015,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 @@ -7138,6 +7179,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } let identitiesBuffer = buildIdentityRestoreBuffer( identities: sortedIdentities, + addressesByOwner: addressesByOwner, allocation: allocation ) @@ -8137,6 +8179,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func buildIdentityRestoreBuffer( identities: [PersistentIdentity], + addressesByOwner: [Data: [PersistentDashpayPaymentAddresses]], allocation: LoadAllocation ) -> UnsafeMutablePointer? { if identities.isEmpty { @@ -8448,6 +8491,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 } @@ -8459,6 +8558,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 { @@ -8487,6 +8587,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) @@ -9842,7 +9954,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 @@ -9879,6 +9994,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 ) ) @@ -9910,12 +10029,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 7c467d4c1c8..6ba1071ef44 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -697,3 +697,30 @@ key that carries no budget or that the identity does not have. - [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/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/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/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 057fca7a0dd..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,42 @@ 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 + 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 = Self.ordinaryBindRequest(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 @@ -274,15 +310,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 @@ -297,7 +333,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( @@ -549,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/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index e6c47d0a07f..5f831ef5181 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -1145,7 +1145,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 @@ -1527,7 +1527,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/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/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/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 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/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..5d0c30110d2 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,36 @@ 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] + + 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 }) + } + } + + /// 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 = tipAccount 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 +95,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 +169,12 @@ struct DashPayProfileView: View { } } } + .sheet(isPresented: $showSpendTips) { + if let walletId = identity.wallet?.walletId, + let account = tipAccount { + 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..60459935b40 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,136 @@ 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 resolving = false + @State private var error: String? + @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 { + 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, !submitted { + 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 \(tipAmount?.dashString ?? "—") DASH from your \(sourceLabel).") + } + } + if let error { Text(error).foregroundStyle(.red) } + 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) + } + 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 } + 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 { resolving = false } + do { + 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 + 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/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 95f8479e58c..e031baa2efb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -1163,6 +1163,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? @@ -1220,6 +1222,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) @@ -1255,11 +1273,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 @@ -1311,11 +1345,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..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 @@ -361,15 +366,19 @@ 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. + let bound = shieldedService.rebindAfterIdentityDiscovery( + walletManager: walletManager, walletId: walletId, + network: platformState.currentNetwork, resolver: MnemonicResolver()) result = WalletFinding( walletId: walletId, label: label, foundCount: found.count, - error: nil + error: nil, + bindingWarning: bound ? nil : "Identities were discovered successfully. Shielded wallet binding could not complete; retry from the Sync tab." ) - // Zero hits → ask Rust for the preview keypairs the - // scan walked so the user can eyeball / copy them. if found.isEmpty { await loadPreviewKeys(on: managed) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index a567917dedf..ed1114e3a22 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -62,6 +62,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", @@ -289,6 +296,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 b4627318cf0..4ae893d5e42 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -507,6 +507,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 7fe6ed1cd28..4ff05975c12 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -360,6 +360,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/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..? + 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 {}) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 982d9ab0afd..477e3ee865d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,15 +11,20 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV4: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +// A version after the live one. Its custom stage starts from live V4, whose +// registered types are the live models the transformation fetches. +private enum BridgeFutureV5: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, BridgeFutureV4.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .custom(fromVersion: DashSchemaV3.self, toVersion: BridgeFutureV4.self, + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -29,8 +34,9 @@ private enum BridgeFuturePlan: SchemaMigrationPlan { } } -// The candidate immediately before restoring historical V2 used today's -// complete graph with a V2 version label. This is one exact supported alias. +// The candidate immediately before restoring historical V2 used the graph that +// was later published as V3, with a V2 version label. This is one exact +// supported alias; its types are the V3 release snapshot, not the live models. private enum PreviousLiveV2: VersionedSchema { static var versionIdentifier: Schema.Version { Schema.Version(2, 0, 0) } static var models: [any PersistentModel.Type] { DashSchemaV3.models } @@ -770,7 +776,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV4.self) + let schema = Schema(versionedSchema: BridgeFutureV5.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( @@ -887,7 +893,9 @@ final class DashLegacySchemaMigrationTests: XCTestCase { let container = try ModelContainer(for: schema, configurations: [ ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) ]) - try verifyRows(container.mainContext) + // This container registers the V3 snapshot types, not the live ones. + XCTAssertEqual(try container.mainContext.fetchCount( + FetchDescriptor()), 1) } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) let container = try open(url, hooks: .init(visit: { _, _ in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1ac6248f4cc..aa76d92c54c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -78,8 +78,8 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"3.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v3\"")) + XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV3RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["3.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = Schema(versionedSchema: DashSchemaV4.self) let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = Schema(versionedSchema: DashSchemaV4.self) let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, @@ -627,11 +627,35 @@ final class DashModelMigrationTests: XCTestCase { func testV3AddsKeyColumnsWithoutChangingTheFrozenBaseline() throws { let baseline = Schema(versionedSchema: DashSchemaV1.self) let live = Schema(versionedSchema: DashSchemaV3.self) + // V3 is the published snapshot; the live types must still carry the columns. + let current = Schema(versionedSchema: DashSchemaV4.self) let oldKey = try XCTUnwrap(baseline.entities.first { $0.name == "PersistentPublicKey" }) let newKey = try XCTUnwrap(live.entities.first { $0.name == "PersistentPublicKey" }) + let currentKey = try XCTUnwrap(current.entities.first { $0.name == "PersistentPublicKey" }) for column in ["totalBudget", "expiresAt", "contractBoundsKind"] { XCTAssertNil(oldKey.attributesByName[column]) XCTAssertNotNil(newKey.attributesByName[column]) + XCTAssertNotNil(currentKey.attributesByName[column]) + } + } + + /// What makes V3 -> V4 lightweight: V4 adds exactly one entity and changes + /// none. The payment addresses deliberately live in their own table rather + /// than as columns on the profile entities, so the published V3 profile + /// shapes are untouched. + func testV4AddsOnlyThePaymentAddressesEntityToPublishedV3() throws { + let v3 = Schema(versionedSchema: DashSchemaV3.self) + let v4 = Schema(versionedSchema: DashSchemaV4.self) + XCTAssertEqual( + Set(v4.entities.map(\.name)).subtracting(v3.entities.map(\.name)), + ["PersistentDashpayPaymentAddresses"]) + XCTAssertEqual(Set(v3.entities.map(\.name)).subtracting(v4.entities.map(\.name)), []) + for name in ["PersistentDashpayProfile", "PersistentDashpayContactProfile"] { + let published = try XCTUnwrap(v3.entities.first { $0.name == name }) + let live = try XCTUnwrap(v4.entities.first { $0.name == name }) + XCTAssertEqual( + live.attributesByName.keys.sorted(), published.attributesByName.keys.sorted(), + "\(name): V4 adds no column; addresses live in their own entity") } } 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) + } +} 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/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 439a181f8e3..91a1b564684 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -36,7 +36,8 @@ "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", - "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift", + "PersistentDashpayPaymentAddresses": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift" }, "value_types": [ { diff --git a/scripts/check-storage-explorer.sh b/scripts/check-storage-explorer.sh index 55e150dc9d3..3f21948187c 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) @@ -53,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 @@ -66,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 @@ -78,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