diff --git a/packages/dashpay-contract/schema/v2/dashpay.schema.json b/packages/dashpay-contract/schema/v2/dashpay.schema.json index db5ce9b4dcd..74ea27bdf9c 100644 --- a/packages/dashpay-contract/schema/v2/dashpay.schema.json +++ b/packages/dashpay-contract/schema/v2/dashpay.schema.json @@ -74,6 +74,14 @@ "maxItems": 21, "description": "Platform address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by the 20-byte HASH160, i.e. RIPEMD160 of SHA256, of the public key or redeem script) for public payments. The type byte is consensus-enforced by a data trigger.", "position": 6 + }, + "shieldedAddress": { + "type": "array", + "byteArray": true, + "minItems": 43, + "maxItems": 43, + "description": "Raw Orchard receiving address: 11-byte diversifier followed by 32-byte diversified transmission key. Clients validate before payment; wallets should use a dedicated tip account.", + "position": 7 } }, "minProperties": 1, diff --git a/packages/dashpay-contract/src/v2/mod.rs b/packages/dashpay-contract/src/v2/mod.rs index 131fecb1769..a9611b407fb 100644 --- a/packages/dashpay-contract/src/v2/mod.rs +++ b/packages/dashpay-contract/src/v2/mod.rs @@ -3,7 +3,8 @@ use serde_json::Value; // Document-type name and property constants live in `crate::v1::document_types`; // v2 does not change any names v1 defined, it only adds the optional -// `corePaymentAddress` / `platformPaymentAddress` properties to `profile`. +// `corePaymentAddress`, `platformPaymentAddress`, and `shieldedAddress` +// properties to `profile`. pub fn load_documents_schemas() -> Result { serde_json::from_str(include_str!("../../schema/v2/dashpay.schema.json")) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index fe919a0d872..df91d5cb210 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -33,6 +33,9 @@ class AppContainer(private val context: Context) { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val shieldedTipSubmissions = + org.dashfoundation.example.ui.dashpay.ShieldedTipSubmissions(applicationScope) + val database: DashDatabase = DashDatabase.create(context) val dataStore = context.preferencesStore diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt index cab86124046..274cdd57f01 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt @@ -23,6 +23,9 @@ data class DashPayProfile( val displayName: String?, val publicMessage: String?, val avatarUrl: String?, + val corePaymentAddress: String? = null, + val platformPaymentAddress: String? = null, + val shieldedAddress: String? = null, ) /** Parse a `getProfile` / `getContactProfile` JSON object, or null. */ @@ -32,6 +35,9 @@ fun parseDashPayProfile(json: String?): DashPayProfile? { displayName = obj.optStringOrNull("displayName"), publicMessage = obj.optStringOrNull("publicMessage"), avatarUrl = obj.optStringOrNull("avatarUrl"), + corePaymentAddress = obj.optStringOrNull("corePaymentAddress"), + platformPaymentAddress = obj.optStringOrNull("platformPaymentAddress"), + shieldedAddress = obj.optStringOrNull("shieldedAddress"), ) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt index 2f81559246e..6e85c35d62b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,14 +40,20 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import java.math.BigDecimal import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.tokens.PaymentAddressUpdate import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.ui.components.FormSection import org.dashfoundation.example.ui.components.LabeledContent import org.dashfoundation.example.ui.components.SubmitButton import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.DashAddress +import org.dashfoundation.example.util.DashAddressType import org.dashfoundation.example.util.generateQrBitmap import org.dashfoundation.example.util.hexToBytes @@ -72,7 +79,19 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val walletId = identity?.walletId val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + val tipAccount = remember(manager, identity?.identityIndex) { + identity?.identityIndex?.let { index -> runCatching { manager?.shieldedTipAccountIndex(index) }.getOrNull() } + } + val tipBalance by remember(walletId, tipAccount) { + if (walletId == null || tipAccount == null) flowOf(0L) + else container.database.shieldedDao().observeNotesByWalletAccount(walletId, tipAccount) + .map { notes -> notes.filter { !it.isSpent }.sumOf { it.value } } + }.collectAsStateWithLifecycle(initialValue = 0L) + var profile by remember { mutableStateOf(null) } + val publishedTipAddress = profile?.shieldedAddress?.let { raw -> + manager?.let { m -> runCatching { DashAddress.encodeOrchard(raw.hexToBytes(), m.network) }.getOrNull() } + } var profileExists by remember { mutableStateOf(false) } var qrUri by remember { mutableStateOf(null) } var qrError by remember { mutableStateOf(null) } @@ -87,6 +106,7 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController var displayNameField by remember { mutableStateOf("") } var publicMessageField by remember { mutableStateOf("") } var avatarUrlField by remember { mutableStateOf("") } + var shieldedAddressField by remember { mutableStateOf("") } var isSaving by remember { mutableStateOf(false) } var saveError by remember { mutableStateOf(null) } @@ -132,6 +152,7 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController displayNameField = profile?.displayName.orEmpty() publicMessageField = profile?.publicMessage.orEmpty() avatarUrlField = profile?.avatarUrl.orEmpty() + shieldedAddressField = publishedTipAddress.orEmpty() saveError = null } isEditing = !isEditing @@ -172,6 +193,31 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController label = { Text("Avatar URL") }, singleLine = true, ) + TextButton(enabled = !isSaving && container.shieldedService.isAvailable, onClick = { + val m = manager ?: return@TextButton + val wid = walletId ?: return@TextButton + isSaving = true + saveError = null + scope.launch { + try { + shieldedAddressField = requireNotNull(DashAddress.encodeOrchard(m.prepareShieldedTipAddress(wid, idBytes), m.network)) + } catch (e: Exception) { + saveError = e.message ?: "Could not prepare tip account" + } finally { + isSaving = false + } + } + }, modifier = Modifier.testTag("dashpay.profile.useTipAccount")) { + Text("Use this wallet’s dedicated tip account") + } + Text("The address is published only when you save.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = shieldedAddressField, + onValueChange = { shieldedAddressField = it }, + modifier = Modifier.fillMaxWidth().testTag("dashpay.profile.shieldedAddress"), + label = { Text("Shielded tip address") }, + supportingText = { Text("Paste an external receiving address, or leave blank to disable tips. External funds are managed by the receiving wallet.") }, + ) saveError?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } @@ -194,6 +240,15 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController avatarUrl = avatarUrlField.trim().ifEmpty { null }, doCreate = !profileExists, signerHandle = m.signerHandle, + shieldedAddress = when (val address = shieldedAddressField.trim()) { + publishedTipAddress.orEmpty() -> PaymentAddressUpdate.Keep + "" -> PaymentAddressUpdate.Remove + else -> { + val parsed = DashAddress.parse(address, m.network) as? DashAddressType.Orchard + ?: throw IllegalArgumentException("Enter a shielded address for this network") + PaymentAddressUpdate.Set(parsed.raw43) + } + }, ) loadProfile() isEditing = false @@ -224,6 +279,19 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController } } + if (!isEditing) { + FormSection(title = "Shielded tips") { + Text("This wallet’s tip balance: ${BigDecimal.valueOf(tipBalance, 11).stripTrailingZeros().toPlainString()} DASH") + val address = publishedTipAddress + if (address == null) { + Text("Tips are not enabled.") + } else { + SelectionContainer { Text(address, style = MaterialTheme.typography.bodySmall) } + Text("This receiving address is public and associated with your username. Removing it does not revoke previously shared copies.", style = MaterialTheme.typography.bodySmall) + } + } + } + FormSection(title = "Identity") { Text( Base58.encode(idBytes), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt index 983beec2f0e..21f91263e3c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -38,6 +40,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -159,6 +162,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 +324,41 @@ fun DashPayTabScreen(navController: NavHostController) { onError = { unlockError = it }, ) + val tipManager = manager + val tipWalletId = identity.walletId + val tipAccountResult = remember(tipManager, identity.identityIndex) { + runCatching { requireNotNull(tipManager).shieldedTipAccountIndex(identity.identityIndex) } + } + 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 = { + tipAccountResult.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..2386e995efc 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt @@ -140,6 +140,15 @@ fun SearchWalletsForIdentitiesScreen(navController: NavHostController) { walletHandle = wallet.handle, mnemonicResolverHandle = mgr.mnemonicResolverHandle, ) + if (container.shieldedService.isAvailable && found.isNotEmpty()) { + 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/12.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json new file mode 100644 index 00000000000..b00de9abc58 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json @@ -0,0 +1,4192 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "099a0638297ed00c932013944578313f", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `corePaymentAddress` BLOB, `platformPaymentAddress` BLOB, `shieldedAddress` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "corePaymentAddress", + "columnName": "corePaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "platformPaymentAddress", + "columnName": "platformPaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "shieldedAddress", + "columnName": "shieldedAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `corePaymentAddress` BLOB, `platformPaymentAddress` BLOB, `shieldedAddress` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "corePaymentAddress", + "columnName": "corePaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "platformPaymentAddress", + "columnName": "platformPaymentAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "shieldedAddress", + "columnName": "shieldedAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '099a0638297ed00c932013944578313f')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index ef90b3804a1..bcf28f8455b 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -28,6 +28,56 @@ class DashDatabaseMigrationTest { private val dbName = "migration-test.db" + @Test + fun migrate11To12PreservesProfilesAndSweepState() { + migrateProfilesAndSweepState(11) + } + + @Test + fun migrate10To12PreservesProfilesThroughBothMigrations() { + migrateProfilesAndSweepState(10) + } + + private fun migrateProfilesAndSweepState(fromVersion: Int) { + helper.createDatabase(dbName, fromVersion).apply { + execSQL("INSERT INTO identities (identityId, balance, revision, isLocal, identityType, createdAt, lastUpdated, networkRaw, identityIndex) VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0)") + execSQL("INSERT INTO dashpay_profiles (networkRaw, identityId, displayName, createdAt, lastUpdated) VALUES (1, x'0A', 'Alice', 0, 0)") + execSQL("INSERT INTO dashpay_contact_profiles (networkRaw, ownerIdentityId, contactIdentityId, displayName, checkedAtMs, createdAt, lastUpdated) VALUES (1, x'0A', x'0B', 'Bob', 123, 0, 0)") + execSQL("INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, syncedHeight, lastSynced, isImported, createdAt, lastUpdated) VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)") + execSQL("INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, walletId, createdAt) VALUES (x'0301', 0, x'02', x'01', 0)") + if (fromVersion == 11) { + execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321") + execSQL("UPDATE pending_inputs SET isSweptTombstone = 1, winnerMinedHeight = 1234") + } + close() + } + val db = helper.runMigrationsAndValidate( + dbName, 12, true, DashDatabase.MIGRATION_10_11, DashDatabase.MIGRATION_11_12, + ) + for ((table, name) in listOf("dashpay_profiles" to "Alice", "dashpay_contact_profiles" to "Bob")) { + db.query("SELECT displayName, corePaymentAddress, platformPaymentAddress, shieldedAddress FROM $table").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(name, cursor.getString(0)) + assertTrue(cursor.isNull(1) && cursor.isNull(2) && cursor.isNull(3)) + } + db.execSQL("UPDATE $table SET shieldedAddress = ?", arrayOf(ByteArray(43) { 0x45 })) + db.query("SELECT shieldedAddress FROM $table").use { cursor -> + assertTrue(cursor.moveToFirst()) + org.junit.Assert.assertArrayEquals(ByteArray(43) { 0x45 }, cursor.getBlob(0)) + } + } + db.query("SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(if (fromVersion == 11) 1 else 0, cursor.getInt(0)) + if (fromVersion == 11) assertEquals(1234, cursor.getInt(1)) else assertTrue(cursor.isNull(1)) + } + db.query("SELECT lastAppliedChainLockHeight FROM wallets").use { cursor -> + assertTrue(cursor.moveToFirst()) + if (fromVersion == 11) assertEquals(4321, cursor.getInt(0)) else assertTrue(cursor.isNull(0)) + } + db.close() + } + /** * v2 → v3 adds the `dashpay_contact_profiles` and `dashpay_payments` * tables (additive — no reshapes). Pre-existing v2 data must survive @@ -476,7 +526,7 @@ class DashDatabaseMigrationTest { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 11, + 12, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -485,16 +535,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).close() } - /** The full chain from v1 must also land on a valid v11 schema. */ + /** The full chain from v1 must also land on a valid v12 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 11, + 12, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -506,6 +557,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 1144dad7add..612ebd5e612 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -139,6 +139,12 @@ internal object DashpayNative { avatarBytes: ByteArray?, doCreate: Boolean, signerHandle: Long, + coreAddressAction: Int, + coreAddress: ByteArray?, + platformAddressAction: Int, + platformAddress: ByteArray?, + shieldedAddressAction: Int, + shieldedAddress: ByteArray?, ): String? /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt index c1f9a6803d5..5e177af5082 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -16,6 +16,17 @@ package org.dashfoundation.dashsdk.ffi * non-shielded build throws [UnsatisfiedLinkError]. */ internal object FundingNative { + external fun tipAccountIndex(identityIndex: Int): Int + external fun prepareShieldedTipAddress( + manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray, + ): ByteArray + external fun resolveShieldedTip(wallet: Long, username: String): ByteArray + external fun sendShieldedTip( + manager: Long, walletId: ByteArray, resolver: Long, account: Int, + username: String, expectedId: ByteArray, expectedAddress: ByteArray, + amount: Long, memo: String?, + ) + /** Kick the ~30s Halo 2 proving-key build onto a background thread. Idempotent. */ external fun warmUpProver() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index d199a07ee36..c404d00ef65 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -402,7 +402,7 @@ abstract class NativePersistenceBridge { /** * One `IdentityEntryFFI` upsert. DPNS labels + acquired-at timestamps * ride as parallel arrays. Descriptor - * `([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;)I`. + * `([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I`. */ @Suppress("LongParameterList") open fun onPersistIdentityUpsert( @@ -426,6 +426,9 @@ abstract class NativePersistenceBridge { dashpayAvatarFingerprint: ByteArray, dashpayAvatarFingerprintPresent: Boolean, dashpayPublicMessage: String?, + dashpayCorePaymentAddress: ByteArray? = null, + dashpayPlatformPaymentAddress: ByteArray? = null, + dashpayShieldedAddress: ByteArray? = null, ): Int = 0 /** One identity-id removal. Descriptor `([B[B)I`. */ @@ -573,7 +576,7 @@ abstract class NativePersistenceBridge { /** * One `ContactProfileRowFFI` delta riding an identity upsert * (`IdentityEntryFFI.contact_profiles`). Descriptor - * `([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;J)I`. + * `([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;J[B[B[B)I`. * * [isPresent] `true` ⇒ upsert the cached contact-profile row for * `(ownerId, contactId)`; `false` ⇒ tombstone — the contact removed @@ -597,6 +600,9 @@ abstract class NativePersistenceBridge { avatarFingerprintPresent: Boolean, publicMessage: String?, checkedAtMs: Long, + corePaymentAddress: ByteArray? = null, + platformPaymentAddress: ByteArray? = null, + shieldedAddress: ByteArray? = null, ): Int = 0 // ── Asset locks ─────────────────────────────────────────────────── @@ -1139,6 +1145,7 @@ class IdentityRestoreData( * re-fetches every contact. */ @JvmField val contactProfiles: Array, + @JvmField val dashpayProfile: ContactProfileRestoreData? = null, ) /** @@ -1171,6 +1178,9 @@ class ContactProfileRestoreData( @JvmField val avatarFingerprint: ByteArray?, @JvmField val publicMessage: String?, @JvmField val checkedAtMs: Long, + @JvmField val corePaymentAddress: ByteArray? = null, + @JvmField val platformPaymentAddress: ByteArray? = null, + @JvmField val shieldedAddress: ByteArray? = null, ) /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 822c08a242a..fd6032f9c56 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -142,9 +142,12 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * pre-migration row reads back as an ordinary, unstamped, non-tombstone * entry, and a wallet with no recorded chainlock height has no boundary * at all (nothing collects). + * + * Version 12 adds nullable Core, Platform, and shielded payment addresses + * to cached owner and contact profiles, preserving the version-11 sweep state. */ @Database( - version = 11, + version = 12, exportSchema = true, entities = [ WalletEntity::class, @@ -579,6 +582,17 @@ abstract class DashDatabase : RoomDatabase() { } } + /** v11 → v12: optional public payment addresses on cached profiles. */ + val MIGRATION_11_12: Migration = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + for (table in listOf("dashpay_profiles", "dashpay_contact_profiles")) { + for (column in listOf("corePaymentAddress", "platformPaymentAddress", "shieldedAddress")) { + db.execSQL("ALTER TABLE `$table` ADD COLUMN `$column` BLOB") + } + } + } + } + /** * v10 → v11: the four additive sweep-hold columns and the two * `pending_inputs` indexes — see the version-11 class doc above. @@ -635,6 +649,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, + MIGRATION_11_12, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index d68d1758f93..58719c447b9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1626,6 +1626,9 @@ class PlatformWalletPersistenceHandler( dashpayAvatarFingerprint: ByteArray, dashpayAvatarFingerprintPresent: Boolean, dashpayPublicMessage: String?, + dashpayCorePaymentAddress: ByteArray?, + dashpayPlatformPaymentAddress: ByteArray?, + dashpayShieldedAddress: ByteArray?, ): Int = guarded { stage(walletId) { db -> val ownerWallet = if (walletIdIsSome) identityWalletId else walletId @@ -1740,6 +1743,9 @@ class PlatformWalletPersistenceHandler( avatarHash = if (dashpayAvatarHashPresent) dashpayAvatarHash else null, avatarFingerprint = if (dashpayAvatarFingerprintPresent) dashpayAvatarFingerprint else null, + corePaymentAddress = dashpayCorePaymentAddress, + platformPaymentAddress = dashpayPlatformPaymentAddress, + shieldedAddress = dashpayShieldedAddress, lastUpdated = now(), ), ) @@ -2185,6 +2191,9 @@ class PlatformWalletPersistenceHandler( avatarFingerprintPresent: Boolean, publicMessage: String?, checkedAtMs: Long, + corePaymentAddress: ByteArray?, + platformPaymentAddress: ByteArray?, + shieldedAddress: ByteArray?, ): Int = guarded { stage(walletId) { db -> // Owner identity must exist (networkRaw is read off it). In the @@ -2207,6 +2216,9 @@ class PlatformWalletPersistenceHandler( avatarUrl = avatarUrl, avatarHash = avatarHash.takeIf { avatarHashPresent }, avatarFingerprint = avatarFingerprint.takeIf { avatarFingerprintPresent }, + corePaymentAddress = corePaymentAddress, + platformPaymentAddress = platformPaymentAddress, + shieldedAddress = shieldedAddress, checkedAtMs = checkedAtMs, lastUpdated = now(), ), @@ -2940,6 +2952,9 @@ class PlatformWalletPersistenceHandler( avatarFingerprint = cp.avatarFingerprint, publicMessage = cp.publicMessage, checkedAtMs = cp.checkedAtMs, + corePaymentAddress = cp.corePaymentAddress, + platformPaymentAddress = cp.platformPaymentAddress, + shieldedAddress = cp.shieldedAddress, ) }.toTypedArray() IdentityRestoreData( @@ -2956,6 +2971,17 @@ class PlatformWalletPersistenceHandler( ignoredSenders = ignoredRows, payments = paymentRows, contactProfiles = contactProfileRows, + dashpayProfile = database.dashpayDao().getProfile(idRow.networkRaw, idRow.identityId)?.let { p -> + ContactProfileRestoreData( + contactId = idRow.identityId, + displayName = p.displayName, bio = p.bio, avatarUrl = p.avatarUrl, + avatarHash = p.avatarHash, avatarFingerprint = p.avatarFingerprint, + publicMessage = p.publicMessage, checkedAtMs = 0, + corePaymentAddress = p.corePaymentAddress, + platformPaymentAddress = p.platformPaymentAddress, + shieldedAddress = p.shieldedAddress, + ) + }, ) }.toTypedArray() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt index bb72b816457..669f5274936 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt @@ -67,6 +67,9 @@ data class DashpayContactProfileEntity( val avatarHash: ByteArray? = null, /** 8-byte perceptual hash. */ val avatarFingerprint: ByteArray? = null, + val corePaymentAddress: ByteArray? = null, + val platformPaymentAddress: ByteArray? = null, + val shieldedAddress: ByteArray? = null, /** * Wall-clock ms of the last fetch attempt on the Rust side * (`ContactProfileEntry.checked_at_ms`) — drives the self-heal diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt index 6d8a8806839..a956ef22cc8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt @@ -44,6 +44,9 @@ data class DashpayProfileEntity( val avatarHash: ByteArray? = null, /** 8-byte perceptual hash. */ val avatarFingerprint: ByteArray? = null, + val corePaymentAddress: ByteArray? = null, + val platformPaymentAddress: ByteArray? = null, + val shieldedAddress: ByteArray? = null, val createdAt: Date = Date(), val lastUpdated: Date = Date(), ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt index 1dfb441d1b0..0733d30a557 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt @@ -208,7 +208,9 @@ class ShieldedService(private val database: DashDatabase) { _state.value = ShieldedSyncState() _shieldedBalance.value = database.shieldedDao() .observeUnspentNotesByWallet(walletId) - .map { notes -> notes.sumOf { it.value } } + // Rust also scans dedicated tip accounts; ordinary wallet balances only + // include the accounts selected by this service's caller. + .map { notes -> notes.filter { it.accountIndex in sortedAccounts }.sumOf { it.value } } try { manager.configureShielded(dbPath) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 62eb7d33457..504481bd847 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -37,6 +37,13 @@ class Dashpay internal constructor(private val walletHandle: Long, private val gate: org.dashfoundation.dashsdk.wallet.TeardownGate? = null, ) { + /** Fetch and validate the current recipient through proof-verified DPNS and profile reads. */ + suspend fun resolveShieldedTip(username: String): ShieldedTipRecipient = gate.op { + val bytes = mapNativeErrors { org.dashfoundation.dashsdk.ffi.FundingNative.resolveShieldedTip(walletHandle, username) } + check(bytes.size == 75) { "Invalid recipient response" } + ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75)) + } + /** * Send a contact request to [recipientIdentityId], signing the document * state-transition with [signerHandle] and keying the contact crypto @@ -476,11 +483,17 @@ class Dashpay internal constructor(private val walletHandle: Long, avatarBytes: ByteArray? = null, doCreate: Boolean, signerHandle: Long, + corePaymentAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, + platformPaymentAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, + shieldedAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, ): String? = gate.op { mapNativeErrors { DashpayNative.createOrUpdateProfile( walletHandle, identityId, displayName, publicMessage, avatarUrl, avatarBytes, doCreate, signerHandle, + corePaymentAddress.action, corePaymentAddress.bytes, + platformPaymentAddress.action, platformPaymentAddress.bytes, + shieldedAddress.action, shieldedAddress.bytes, ) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt new file mode 100644 index 00000000000..2284dfa2b0c --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt @@ -0,0 +1,8 @@ +package org.dashfoundation.dashsdk.tokens + +/** Explicit partial-update semantics for a public profile payment address. */ +sealed class PaymentAddressUpdate(internal val action: Int, internal val bytes: ByteArray?) { + data object Keep : PaymentAddressUpdate(0, null) + class Set(bytes: ByteArray) : PaymentAddressUpdate(1, bytes.copyOf()) + data object Remove : PaymentAddressUpdate(2, null) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt new file mode 100644 index 00000000000..5ba1d253d31 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt @@ -0,0 +1,16 @@ +package org.dashfoundation.dashsdk.tokens + +/** Verified recipient snapshot to bind payment confirmation to an identity and address. */ +class ShieldedTipRecipient(identityId: ByteArray, address: ByteArray) { + private val identityBytes = identityId.copyOf() + private val addressBytes = address.copyOf() + val identityId: ByteArray get() = identityBytes.copyOf() + val address: ByteArray get() = addressBytes.copyOf() + init { + require(identityBytes.size == 32) + require(addressBytes.size == 43) + } + override fun equals(other: Any?): Boolean = other is ShieldedTipRecipient && + identityBytes.contentEquals(other.identityBytes) && addressBytes.contentEquals(other.addressBytes) + override fun hashCode(): Int = 31 * identityBytes.contentHashCode() + addressBytes.contentHashCode() +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt new file mode 100644 index 00000000000..ad29af2cf00 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt @@ -0,0 +1,40 @@ +package org.dashfoundation.dashsdk.tokens + +import android.content.SharedPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.persistence.normalizeDpnsLabel + +/** + * Local record of recipients explicitly confirmed by this wallet's user. + * Mirrors the Swift tip sheet's persistent recipient-change warning. This + * record only informs confirmation; Rust still verifies the recipient before sending. + */ +class ShieldedTipRecipientHistory(private val preferences: SharedPreferences) { + fun hasChanged( + network: Network, walletId: ByteArray, username: String, recipient: ShieldedTipRecipient, + ): Boolean { + val previous = preferences.getString(key(network, walletId, username), null) ?: return false + return previous != snapshot(recipient) + } + + /** Call only after explicit confirmation, before submitting the payment. */ + suspend fun confirm( + network: Network, walletId: ByteArray, username: String, recipient: ShieldedTipRecipient, + ) = withContext(Dispatchers.IO) { + check(preferences.edit().putString(key(network, walletId, username), snapshot(recipient)).commit()) { + "Could not save recipient confirmation" + } + } + + private fun key(network: Network, walletId: ByteArray, username: String): String { + val label = username.trim().lowercase().removeSuffix(".dash") + return "${network.ffiValue}:${walletId.hex()}:${normalizeDpnsLabel(label)}.dash" + } + + private fun snapshot(recipient: ShieldedTipRecipient): String = + "${recipient.identityId.hex()}:${recipient.address.hex()}" + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index aba0c4ceb2d..6094224b0eb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1768,6 +1768,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 524f6d057e8..3018c163edb 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,12 +233,12 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion11WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion12WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. - assertEquals(11, db.openHelper.readableDatabase.version) + assertEquals(12, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index e9f941bcfd1..7ae21c3c336 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -905,6 +905,9 @@ class PlatformWalletPersistenceHandlerTest { dashpayAvatarFingerprint = ByteArray(8), dashpayAvatarFingerprintPresent = false, dashpayPublicMessage = "hi", + dashpayCorePaymentAddress = ByteArray(21) { 1 }, + dashpayPlatformPaymentAddress = ByteArray(21) { 2 }, + dashpayShieldedAddress = ByteArray(43) { 3 }, ) handler.onChangesetEnd(walletId, success = true) @@ -923,6 +926,9 @@ class PlatformWalletPersistenceHandlerTest { assertNotNull(profile) assertEquals("Alice", profile!!.displayName) assertEquals("hi", profile.publicMessage) + assertTrue(ByteArray(21) { 1 }.contentEquals(profile.corePaymentAddress)) + assertTrue(ByteArray(21) { 2 }.contentEquals(profile.platformPaymentAddress)) + assertTrue(ByteArray(43) { 3 }.contentEquals(profile.shieldedAddress)) assertNotNull(profile.avatarHash) assertNull(profile.avatarFingerprint) } @@ -4700,6 +4706,9 @@ class PlatformWalletPersistenceHandlerTest { avatarFingerprintPresent = false, publicMessage = "yo", checkedAtMs = 1_700_000_111_000, + corePaymentAddress = ByteArray(21) { 1 }, + platformPaymentAddress = ByteArray(21) { 2 }, + shieldedAddress = ByteArray(43) { 3 }, ) handler.onChangesetEnd(walletId, success = true) } @@ -4828,9 +4837,18 @@ class PlatformWalletPersistenceHandlerTest { ), ) + db.dashpayDao().upsertProfile( + org.dashfoundation.dashsdk.persistence.entities.DashpayProfileEntity( + networkRaw = testnet, identityId = ownerId, + displayName = "Alice", shieldedAddress = ByteArray(43) { 7 }, + ), + ) val list = handler.onLoadWalletList() assertEquals(1, list.size) val identity = list[0].identities.single() + assertEquals("Alice", identity.dashpayProfile?.displayName) + assertTrue(ByteArray(43) { 7 }.contentEquals(identity.dashpayProfile?.shieldedAddress)) + assertEquals(1, identity.payments.size) val payment = identity.payments[0] @@ -4843,6 +4861,9 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(1, identity.contactProfiles.size) val profile = identity.contactProfiles[0] + assertTrue(ByteArray(21) { 1 }.contentEquals(profile.corePaymentAddress)) + assertTrue(ByteArray(21) { 2 }.contentEquals(profile.platformPaymentAddress)) + assertTrue(ByteArray(43) { 3 }.contentEquals(profile.shieldedAddress)) assertTrue(contactId.contentEquals(profile.contactId)) assertEquals("Bob", profile.displayName) assertNull(profile.bio) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt new file mode 100644 index 00000000000..a5ebd51c380 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistoryTest.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.dashsdk.tokens + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.Network +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ShieldedTipRecipientHistoryTest { + private val preferences = ApplicationProvider.getApplicationContext() + .getSharedPreferences("tip-recipient-history-test", Context.MODE_PRIVATE) + private val walletId = ByteArray(32) { 1 } + private val recipient = ShieldedTipRecipient(ByteArray(32) { 2 }, ByteArray(43) { 3 }) + private val history get() = ShieldedTipRecipientHistory(preferences) + + @Before + fun reset() { + preferences.edit().clear().commit() + } + + @Test + fun confirmationSurvivesReopeningAndCanonicalSpellings() = runTest { + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Alice", recipient)) + history.confirm(Network.TESTNET, walletId, " Alice.DASH ", recipient) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "a11ce", recipient)) + val changed = ShieldedTipRecipient(recipient.identityId, ByteArray(43) { 4 }) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "a11ce.dash", changed)) + } + + @Test + fun detectsBothIdentityAndAddressReplacementWithoutChangingThePin() = runTest { + history.confirm(Network.TESTNET, walletId, "Alice", recipient) + val newIdentity = ShieldedTipRecipient(ByteArray(32) { 4 }, recipient.address) + val newAddress = ShieldedTipRecipient(recipient.identityId, ByteArray(43) { 5 }) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", newIdentity)) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", newAddress)) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Alice", recipient)) + history.confirm(Network.TESTNET, walletId, "Alice", newAddress) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Alice", newAddress)) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", recipient)) + } + + @Test + fun isolatesNetworksWalletsAndUsernames() = runTest { + history.confirm(Network.TESTNET, walletId, "Alice", recipient) + val changed = ShieldedTipRecipient(ByteArray(32) { 4 }, recipient.address) + assertFalse(history.hasChanged(Network.MAINNET, walletId, "Alice", changed)) + assertFalse(history.hasChanged(Network.TESTNET, ByteArray(32) { 6 }, "Alice", changed)) + assertFalse(history.hasChanged(Network.TESTNET, walletId, "Bob", changed)) + assertTrue(history.hasChanged(Network.TESTNET, walletId, "Alice", changed)) + } +} diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 682291a6ce0..f41aa690884 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -645,7 +645,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002816630 + 24002927540 ); let check_result = platform @@ -1160,7 +1160,7 @@ mod tests { // Plus we have 24_000_000_000 in base costs assert_eq!( processing_result.aggregated_fees().processing_fee, - 24005633260 + 24005855080 ); let check_result = platform @@ -1635,7 +1635,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002816630 + 24002927540 ); platform @@ -1722,7 +1722,7 @@ mod tests { assert_eq!( update_processing_result.aggregated_fees().processing_fee, - 27002879350 + 27003006640 ); let check_result = platform @@ -2094,7 +2094,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002816630 + 24002927540 ); platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 4f66d59dce8..b1849e305c7 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -1117,6 +1117,8 @@ mod tests { "profile must not carry platformPaymentAddress before transition_to_version_14" ); + assert!(!pre_profile.iter().any(|p| p == "shieldedAddress")); + let result = platform.transition_to_version_14(&block_info, &transaction, platform_version); assert!(result.is_ok(), "transition failed: {:?}", result.err()); @@ -1153,6 +1155,7 @@ mod tests { profile.iter().any(|p| p == "platformPaymentAddress"), "profile must carry platformPaymentAddress after transition_to_version_14" ); + assert!(profile.iter().any(|p| p == "shieldedAddress")); } /// The v13→v14 boundary through the production dispatcher @@ -1286,7 +1289,11 @@ mod tests { .keys() .cloned() .collect::>(); - for field in ["corePaymentAddress", "platformPaymentAddress"] { + for field in [ + "corePaymentAddress", + "platformPaymentAddress", + "shieldedAddress", + ] { assert!( !pre_profile_properties.iter().any(|p| p == field), "profile must not carry {field} before the upgrade" @@ -1338,7 +1345,11 @@ mod tests { .keys() .cloned() .collect::>(); - for field in ["corePaymentAddress", "platformPaymentAddress"] { + for field in [ + "corePaymentAddress", + "platformPaymentAddress", + "shieldedAddress", + ] { assert!( post_profile_properties.iter().any(|p| p == field), "profile must carry {field} after the upgrade" @@ -2643,3 +2654,47 @@ mod tests { ); } } + +#[cfg(test)] +mod shielded_profile_schema_tests { + use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; + use dpp::platform_value::{platform_value, Value}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + #[test] + fn should_validate_shielded_profile_address_boundaries() { + for version in [13, 14] { + let pv = PlatformVersion::get(version).unwrap(); + let contract = load_system_data_contract(SystemDataContract::Dashpay, pv).unwrap(); + for length in [0, 42, 43, 44] { + let properties = + platform_value!({ "shieldedAddress": Value::Bytes(vec![0; length]) }); + let result = contract + .validate_document_properties("profile", properties, pv) + .unwrap(); + assert_eq!( + result.is_valid(), + version == 14 && length == 43, + "protocol {version}, address length {length}: {result:?}" + ); + } + let result = contract + .validate_document_properties( + "profile", + platform_value!({"shieldedAddress": "not bytes"}), + pv, + ) + .unwrap(); + assert!(!result.is_valid()); + let legacy = contract + .validate_document_properties( + "profile", + platform_value!({"displayName": "Alice"}), + pv, + ) + .unwrap(); + assert!(legacy.is_valid()); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index 2a14056d93e..f587d106691 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -11,8 +11,9 @@ mod deletion_tests { run_document_delete_on_document_type_that_is_mutable_and_can_be_deleted_at_protocol_version( PlatformVersion::latest().protocol_version, // v14: the deleted document carries the contract-version stamp - // (one stored byte, five estimated), shifting processing costs - 1699620, + // (one stored byte, five estimated); the larger DashPay v2 schema + // also increases byte-billed contract-tree reads. + 1720780, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 5b9c07a62c0..cf58d46aaca 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -498,8 +498,8 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_mutable() { run_document_replace_on_document_type_that_is_mutable_at_protocol_version( PlatformVersion::latest().protocol_version, - // v14: replaced documents carry the contract-version stamp - 1433220, + // v14: contract-version stamp plus the larger DashPay v2 schema + 1454380, ) .await; } @@ -1986,7 +1986,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let documents_batch_update_transition_1 = BatchTransition::new_document_replacement_transition_from_document( @@ -2067,7 +2067,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive @@ -2210,7 +2210,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2292,7 +2292,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_600_000_000, 902, 44, 1, false); //next epoch @@ -2336,7 +2336,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive @@ -2475,7 +2475,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2557,7 +2557,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_600_000_000, 902, 44, 1, false); //next epoch @@ -2601,7 +2601,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive @@ -2744,7 +2744,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2826,7 +2826,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); fast_forward_to_block(&platform, 1_600_000_000, 905, 44, 2, true); //next epoch @@ -2870,7 +2870,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 shieldedAddress:bytes b3bb8852a93313580b0f9cef98328f2fa69b49e87f74043b160f4edb43e8cbe62c4985b6ec6094dd7554da "); let issues = platform .drive diff --git a/packages/rs-drive/tests/deterministic_root_hash.rs b/packages/rs-drive/tests/deterministic_root_hash.rs index 63b362aecf1..38cb27f5b95 100644 --- a/packages/rs-drive/tests/deterministic_root_hash.rs +++ b/packages/rs-drive/tests/deterministic_root_hash.rs @@ -306,7 +306,7 @@ mod tests { // payment address fields, changing the contract's serialized bytes // and therefore the root hash after insertion. 9..=13 => "14d9e2cdc3f25d1dfd079c1f9dd0d44db5bf73d397b04258449231a2d5bafda7", - _ => "02a78b45ecac17a6c08ce352c22b888fcd044de9f4ff82983c5d8fd97e6e8793", + _ => "1c97b429d1be04f623b6fdd33561393e463922c17d38355549280aed513d9313", }; assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs index 6c83f4d459e..5ac265a66c7 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay_profile.rs @@ -4,7 +4,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use std::ptr; -use platform_wallet::{DashPayProfile, ProfileUpdate}; +use platform_wallet::{DashPayProfile, PaymentAddressUpdate, ProfileUpdate}; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -14,6 +14,35 @@ use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// Payment address update: action 0 keeps, 1 sets, and 2 removes the property. +/// For action 1, `bytes` must point to `len` readable bytes for the call duration. +#[repr(C)] +pub struct PaymentAddressUpdateFFI { + pub action: u32, + pub bytes: *const u8, + pub len: usize, +} + +unsafe fn decode_address_update( + input: *const PaymentAddressUpdateFFI, +) -> Result { + if input.is_null() { + return Ok(PaymentAddressUpdate::Keep); + } + let input = &*input; + match input.action { + 0 => Ok(PaymentAddressUpdate::Keep), + 2 => Ok(PaymentAddressUpdate::Remove), + 1 if !input.bytes.is_null() && matches!(input.len, 21 | 43) => Ok( + PaymentAddressUpdate::Set(std::slice::from_raw_parts(input.bytes, input.len).to_vec()), + ), + _ => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "Invalid payment address update".to_string(), + )), + } +} + /// Flat FFI view of a [`DashPayProfile`]. #[repr(C)] pub struct DashPayProfileFFI { @@ -24,6 +53,12 @@ pub struct DashPayProfileFFI { pub avatar_hash: [u8; 32], pub avatar_fingerprint_is_some: bool, pub avatar_fingerprint: [u8; 8], + pub core_payment_address_is_some: bool, + pub core_payment_address: [u8; 21], + pub platform_payment_address_is_some: bool, + pub platform_payment_address: [u8; 21], + pub shielded_address_is_some: bool, + pub shielded_address: [u8; 43], } impl DashPayProfileFFI { @@ -36,6 +71,12 @@ impl DashPayProfileFFI { avatar_hash: [0u8; 32], avatar_fingerprint_is_some: false, avatar_fingerprint: [0u8; 8], + core_payment_address_is_some: false, + core_payment_address: [0; 21], + platform_payment_address_is_some: false, + platform_payment_address: [0; 21], + shielded_address_is_some: false, + shielded_address: [0; 43], } } @@ -61,6 +102,33 @@ impl DashPayProfileFFI { avatar_hash, avatar_fingerprint_is_some, avatar_fingerprint, + core_payment_address_is_some: profile + .core_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + core_payment_address: profile + .core_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + platform_payment_address_is_some: profile + .platform_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + platform_payment_address: profile + .platform_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + shielded_address_is_some: profile + .shielded_address + .as_ref() + .is_some_and(|a| a.len() == 43), + shielded_address: profile + .shielded_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 43]), } } } @@ -331,14 +399,49 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s do_create: bool, signer_handle: *mut SignerHandle, out_profile: *mut DashPayProfileFFI, +) -> PlatformWalletFFIResult { + platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( + wallet_handle, + identity_id, + display_name, + public_message, + avatar_url, + avatar_bytes, + avatar_bytes_len, + ptr::null(), + ptr::null(), + ptr::null(), + do_create, + signer_handle, + out_profile, + ) +} + +/// Create or update a profile with explicit keep/set/remove payment address operations. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + display_name: *const c_char, + public_message: *const c_char, + avatar_url: *const c_char, + avatar_bytes: *const u8, + avatar_bytes_len: usize, + core_payment_address: *const PaymentAddressUpdateFFI, + platform_payment_address: *const PaymentAddressUpdateFFI, + shielded_address: *const PaymentAddressUpdateFFI, + do_create: bool, + signer_handle: *mut SignerHandle, + out_profile: *mut DashPayProfileFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_profile); - check_ptr!(signer_handle); // `DashPayProfileFFI` owns heap C-string pointer fields freed by // `dashpay_profile_ffi_free`; publish the empty sentinel before any // fallible work so an error path never leaves uninitialized stack bytes // in those pointer fields. Matches the read-side helpers in this file. *out_profile = DashPayProfileFFI::empty(); + check_ptr!(signer_handle); let id = unwrap_result_or_return!(read_identifier(identity_id)); @@ -352,6 +455,12 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s Some(std::slice::from_raw_parts(avatar_bytes, avatar_bytes_len).to_vec()) }; + let core_payment_address = + unwrap_result_or_return!(decode_address_update(core_payment_address)); + let platform_payment_address = + unwrap_result_or_return!(decode_address_update(platform_payment_address)); + let shielded_address = unwrap_result_or_return!(decode_address_update(shielded_address)); + let signer_addr = signer_handle as usize; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { @@ -361,6 +470,9 @@ pub unsafe extern "C" fn platform_wallet_create_or_update_dashpay_profile_with_s public_message, avatar_url, avatar_bytes: avatar_bytes_vec, + core_payment_address, + platform_payment_address, + shielded_address, }; block_on_worker(async move { @@ -401,6 +513,79 @@ mod tests { }) } + #[test] + fn null_signer_leaves_profile_safe_to_free() { + unsafe { + let mut out = std::mem::MaybeUninit::::uninit(); + let result = platform_wallet_create_or_update_dashpay_profile_with_signer( + 0, + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + 0, + false, + ptr::null_mut(), + out.as_mut_ptr(), + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + let mut out = out.assume_init(); + assert!(out.display_name.is_null()); + assert!(!out.shielded_address_is_some); + dashpay_profile_ffi_free(&mut out); + } + } + + #[test] + fn payment_address_updates_preserve_distinct_operations() { + unsafe { + assert!(matches!( + decode_address_update(ptr::null()).unwrap(), + PaymentAddressUpdate::Keep + )); + let mut update = PaymentAddressUpdateFFI { + action: 2, + bytes: ptr::null(), + len: 0, + }; + assert!(matches!( + decode_address_update(&update).unwrap(), + PaymentAddressUpdate::Remove + )); + update.action = 1; + assert!(decode_address_update(&update).is_err()); + let address = [7u8; 43]; + update.bytes = address.as_ptr(); + update.len = address.len(); + match decode_address_update(&update).unwrap() { + PaymentAddressUpdate::Set(bytes) => assert_eq!(bytes, address), + _ => panic!("expected set operation"), + } + update.action = 3; + assert!(decode_address_update(&update).is_err()); + } + } + + #[test] + fn profile_addresses_copy_to_owned_fixed_buffers() { + let profile = DashPayProfile { + core_payment_address: Some(vec![1; 21]), + platform_payment_address: Some(vec![2; 21]), + shielded_address: Some(vec![3; 43]), + ..Default::default() + }; + let mut ffi = DashPayProfileFFI::from_profile(&profile); + assert!(ffi.core_payment_address_is_some); + assert!(ffi.platform_payment_address_is_some); + assert!(ffi.shielded_address_is_some); + drop(profile); + assert_eq!(ffi.core_payment_address, [1; 21]); + assert_eq!(ffi.platform_payment_address, [2; 21]); + assert_eq!(ffi.shielded_address, [3; 43]); + unsafe { dashpay_profile_ffi_free(&mut ffi) }; + } + #[test] fn test_get_profile_absent_returns_false_flag() { unsafe { @@ -437,6 +622,7 @@ mod tests { avatar_hash: Some(hash), avatar_fingerprint: Some([1, 2, 3, 4, 5, 6, 7, 8]), public_message: Some("Hello world".to_string()), + ..Default::default() }); let handle = MANAGED_IDENTITY_STORAGE.insert(managed); diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 2d0c526e034..793c9cb625f 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -137,6 +137,13 @@ pub struct IdentityEntryFFI { pub dashpay_profile_avatar_fingerprint: [u8; 8], /// `true` iff the source `avatar_fingerprint` was `Some(_)`. pub dashpay_profile_avatar_fingerprint_present: bool, + pub dashpay_profile_core_payment_address: [u8; 21], + pub dashpay_profile_core_payment_address_present: bool, + pub dashpay_profile_platform_payment_address: [u8; 21], + pub dashpay_profile_platform_payment_address_present: bool, + pub dashpay_profile_shielded_address: [u8; 43], + pub dashpay_profile_shielded_address_present: bool, + /// Heap-allocated NUL-terminated UTF-8 C string for the DashPay /// profile's public message. `null` when the source field was /// `None`. Owned by this FFI struct; freed in @@ -213,6 +220,13 @@ pub struct ContactProfileRowFFI { pub avatar_fingerprint: [u8; 8], /// `true` iff the source `avatar_fingerprint` was `Some(_)`. pub avatar_fingerprint_present: bool, + pub core_payment_address: [u8; 21], + pub core_payment_address_present: bool, + pub platform_payment_address: [u8; 21], + pub platform_payment_address_present: bool, + pub shielded_address: [u8; 43], + pub shielded_address_present: bool, + /// Heap-allocated `publicMessage`; `null` when `None`. Freed in /// [`free_identity_entry_ffi`]. pub public_message: *const c_char, @@ -356,38 +370,8 @@ const _: [u8; 8] = [0u8; std::mem::align_of::()]; // than a build error. Pin the expected size here so any reshape // fails the cargo build first. // -// Expected layout on 64-bit targets (all fields in declaration -// order under `#[repr(C)]`): -// -// 0..=31 identity_id [u8; 32] -// 32..=39 balance u64 -// 40..=47 revision u64 -// 48 identity_index_is_some bool -// 49..=51 (padding to 4) -// 52..=55 identity_index u32 -// 56 status u8 -// 57 wallet_id_is_some bool -// 58..=89 wallet_id [u8; 32] -// 90..=95 (padding to 8 for pointer alignment) -// 96..=103 dpns_names *const *const c_char -// 104..=111 dpns_names_count usize -// 112..=119 dpns_names_acquired_at *const u64 -// 120 dashpay_profile_present bool -// 121..=127 (padding to 8 for pointer alignment) -// 128..=135 dashpay_profile_display_name *const c_char -// 136..=143 dashpay_profile_bio *const c_char -// 144..=151 dashpay_profile_avatar_url *const c_char -// 152..=183 dashpay_profile_avatar_hash [u8; 32] -// 184 dashpay_profile_avatar_hash_present bool -// 185..=192 dashpay_profile_avatar_fingerprint [u8; 8] -// 193 dashpay_profile_avatar_fingerprint_present bool -// 194..=199 (padding to 8 for pointer alignment) -// 200..=207 dashpay_profile_public_message *const c_char -// 208..=215 contact_profiles *const ContactProfileRowFFI -// 216..=223 contact_profiles_count usize -// -// Total size = 224, alignment = 8 (from u64 / pointer). -const _: [u8; 224] = [0u8; std::mem::size_of::()]; +// Includes three fixed-size payment addresses and their presence flags. +const _: [u8; 312] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // --------------------------------------------------------------------------- @@ -450,6 +434,15 @@ impl IdentityEntryFFI { dashpay_profile_avatar_hash_present: profile_fields.avatar_hash_present, dashpay_profile_avatar_fingerprint: profile_fields.avatar_fingerprint, dashpay_profile_avatar_fingerprint_present: profile_fields.avatar_fingerprint_present, + dashpay_profile_core_payment_address: profile_fields.core_payment_address, + dashpay_profile_core_payment_address_present: profile_fields + .core_payment_address_present, + dashpay_profile_platform_payment_address: profile_fields.platform_payment_address, + dashpay_profile_platform_payment_address_present: profile_fields + .platform_payment_address_present, + dashpay_profile_shielded_address: profile_fields.shielded_address, + dashpay_profile_shielded_address_present: profile_fields.shielded_address_present, + dashpay_profile_public_message: profile_fields.public_message, contact_profiles, contact_profiles_count, @@ -472,6 +465,13 @@ struct DashPayProfileFields { avatar_hash_present: bool, avatar_fingerprint: [u8; 8], avatar_fingerprint_present: bool, + core_payment_address: [u8; 21], + core_payment_address_present: bool, + platform_payment_address: [u8; 21], + platform_payment_address_present: bool, + shielded_address: [u8; 43], + shielded_address_present: bool, + public_message: *const c_char, } @@ -487,6 +487,13 @@ impl DashPayProfileFields { avatar_hash_present: false, avatar_fingerprint: [0u8; 8], avatar_fingerprint_present: false, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [0; 43], + shielded_address_present: false, + public_message: ptr::null(), } } @@ -514,6 +521,34 @@ impl DashPayProfileFields { avatar_hash_present, avatar_fingerprint, avatar_fingerprint_present, + core_payment_address: profile + .core_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + core_payment_address_present: profile + .core_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + platform_payment_address: profile + .platform_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + platform_payment_address_present: profile + .platform_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + shielded_address: profile + .shielded_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 43]), + shielded_address_present: profile + .shielded_address + .as_ref() + .is_some_and(|a| a.len() == 43), + public_message: optional_c_string(profile.public_message.as_deref()), } } @@ -613,6 +648,13 @@ fn allocate_contact_profile_rows( avatar_hash_present: false, avatar_fingerprint: [0u8; 8], avatar_fingerprint_present: false, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [0; 43], + shielded_address_present: false, + public_message: ptr::null(), checked_at_ms: entry.checked_at_ms, }); @@ -636,6 +678,34 @@ fn allocate_contact_profile_rows( avatar_hash_present, avatar_fingerprint, avatar_fingerprint_present, + core_payment_address: profile + .core_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + core_payment_address_present: profile + .core_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + platform_payment_address: profile + .platform_payment_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 21]), + platform_payment_address_present: profile + .platform_payment_address + .as_ref() + .is_some_and(|a| a.len() == 21), + shielded_address: profile + .shielded_address + .as_deref() + .and_then(|a| a.try_into().ok()) + .unwrap_or([0; 43]), + shielded_address_present: profile + .shielded_address + .as_ref() + .is_some_and(|a| a.len() == 43), + public_message: optional_c_string(profile.public_message.as_deref()), checked_at_ms: entry.checked_at_ms, }); @@ -1015,6 +1085,7 @@ mod tests { avatar_hash: Some([0xAB; 32]), avatar_fingerprint: Some([0xCD; 8]), public_message: None, + ..Default::default() }), dashpay_payments: Default::default(), contact_profiles: Default::default(), @@ -1067,6 +1138,7 @@ mod tests { avatar_hash: Some([0x11; 32]), avatar_fingerprint: None, public_message: None, + ..Default::default() }), checked_at_ms: 111, }, diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2b0b23dbd49..a4992a5e37d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5523,6 +5523,9 @@ fn build_wallet_identity_bucket( unsafe { restore_dashpay_payments(spec, &mut managed) }; unsafe { restore_dashpay_ignored(spec, &mut managed) }; unsafe { restore_contact_profiles(spec, &mut managed) }; + if let Some(profile) = unsafe { spec.dashpay_profile.as_ref() } { + *managed.dashpay_profile_mut() = Some(unsafe { profile_from_restore_row(profile) }); + } bucket.insert(spec.identity_index, managed); } @@ -5683,6 +5686,42 @@ fn is_valid_avatar_url(url: &str) -> bool { !url.is_empty() && url.len() <= MAX_AVATAR_URL_LEN && url.starts_with("https://") } +/// Copy a host-owned profile row, retaining the avatar URL validation used for +/// cached contact profiles. The host frees the buffers after restoration returns. +/// +/// # Safety +/// The row's string pointers must be null or readable NUL-terminated strings. +unsafe fn profile_from_restore_row( + row: &ContactProfileRestoreEntryFFI, +) -> platform_wallet::DashPayProfile { + let opt_string = |ptr: *const std::os::raw::c_char| -> Option { + if ptr.is_null() { + None + } else { + CStr::from_ptr(ptr).to_str().ok().map(str::to_string) + } + }; + platform_wallet::DashPayProfile { + display_name: opt_string(row.display_name), + bio: opt_string(row.bio), + avatar_url: opt_string(row.avatar_url).filter(|u| is_valid_avatar_url(u)), + avatar_hash: row.avatar_hash_present.then_some(row.avatar_hash), + avatar_fingerprint: row + .avatar_fingerprint_present + .then_some(row.avatar_fingerprint), + public_message: opt_string(row.public_message), + core_payment_address: row + .core_payment_address_present + .then(|| row.core_payment_address.to_vec()), + platform_payment_address: row + .platform_payment_address_present + .then(|| row.platform_payment_address.to_vec()), + shielded_address: row + .shielded_address_present + .then(|| row.shielded_address.to_vec()), + } +} + /// Fold a slice of [`ContactProfileRestoreEntryFFI`] rows into /// the managed identity's contact-profile cache. Split out from /// [`restore_contact_profiles`] so the c-string decode + avatar-url @@ -5696,43 +5735,11 @@ unsafe fn apply_contact_profile_rows( rows: &[ContactProfileRestoreEntryFFI], managed: &mut ManagedIdentity, ) { - use platform_wallet::{ContactProfileEntry, DashPayProfile}; - - let opt_string = |ptr: *const std::os::raw::c_char| -> Option { - if ptr.is_null() { - None - } else { - CStr::from_ptr(ptr).to_str().ok().map(str::to_string) - } - }; - for row in rows { - let avatar_hash = if row.avatar_hash_present { - Some(row.avatar_hash) - } else { - None - }; - let avatar_fingerprint = if row.avatar_fingerprint_present { - Some(row.avatar_fingerprint) - } else { - None - }; - // Re-validate the public, attacker-controlled avatar URL; drop - // just the URL field (keep the rest of the profile) if it no - // longer passes the `https://` / length rule. - let avatar_url = opt_string(row.avatar_url).filter(|u| is_valid_avatar_url(u)); - managed.dashpay_contact_profiles_mut().insert( Identifier::from(row.contact_id), - ContactProfileEntry { - profile: Some(DashPayProfile { - display_name: opt_string(row.display_name), - bio: opt_string(row.bio), - avatar_url, - avatar_hash, - avatar_fingerprint, - public_message: opt_string(row.public_message), - }), + platform_wallet::ContactProfileEntry { + profile: Some(profile_from_restore_row(row)), checked_at_ms: row.checked_at_ms, }, ); @@ -8704,6 +8711,13 @@ mod tests { avatar_hash_present: true, avatar_fingerprint: [0x22; 8], avatar_fingerprint_present: true, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [3; 43], + shielded_address_present: true, + public_message: public_message.as_ptr(), checked_at_ms: 1_700_000_000_000, }, @@ -8716,6 +8730,13 @@ mod tests { avatar_hash_present: false, avatar_fingerprint: [0u8; 8], avatar_fingerprint_present: false, + core_payment_address: [0; 21], + core_payment_address_present: false, + platform_payment_address: [0; 21], + platform_payment_address_present: false, + shielded_address: [0; 43], + shielded_address_present: false, + public_message: std::ptr::null(), checked_at_ms: 1_700_000_000_001, }, @@ -8740,6 +8761,7 @@ mod tests { ); assert_eq!(alice_profile.avatar_hash, Some([0x11; 32])); assert_eq!(alice_profile.avatar_fingerprint, Some([0x22; 8])); + assert_eq!(alice_profile.shielded_address, Some(vec![3; 43])); assert!(alice_profile.bio.is_none()); let bob = managed diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c1cec289755..978e01ddb8c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2256,6 +2256,23 @@ mod tests { ); } + #[test] + fn should_contain_tip_worker_panic_as_uncertain_spend() { + let result = catch_spend_panic("shielded tip", || { + let result = block_on_worker(async { + panic!("tip worker failed after possible broadcast"); + #[allow(unreachable_code)] + Ok(()) + }); + map_spend_result(result, "shielded tip") + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed + ); + assert!(message_of(&result).contains("do NOT retry")); + } + /// A panic inside the CoinJoin-drain funding export must NOT unwind into the `extern "C"` /// frame (that aborts the Android process before the JNI layer's own guard can translate it /// into a Java exception). It becomes `ErrorTransactionBroadcastUnconfirmed` — the @@ -2606,3 +2623,189 @@ mod tests { ); } } + +/// Derive and register the identity's dedicated tip account, without publishing it. +/// All ID pointers must reference 32 bytes; `out_address` must reference 43 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_prepare_shielded_tip_address( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + identity_id_bytes: *const u8, + out_address: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(identity_id_bytes); + check_ptr!(out_address); + std::ptr::write_bytes(out_address, 0, 43); + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let identity_id = match crate::types::read_identifier(identity_id_bytes) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(value) => value, + Err(e) => return e, + }; + match block_on_worker(async move { + wallet + .prepare_shielded_tip_address(seed.as_ref(), &identity_id, &coordinator) + .await + }) { + Ok(address) => { + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } +} + +/// Resolve a username using verified current DPNS and profile documents. +/// Outputs are 32-byte identity ID and 43-byte Orchard address buffers. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_resolve_shielded_tip( + wallet_handle: Handle, + username: *const c_char, + out_identity_id: *mut u8, + out_address: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(username); + check_ptr!(out_identity_id); + check_ptr!(out_address); + std::ptr::write_bytes(out_identity_id, 0, 32); + std::ptr::write_bytes(out_address, 0, 43); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let result = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { identity.dashpay().resolve_shielded_tip(&username).await }) + }); + match result { + Some(Ok(recipient)) => { + std::ptr::copy_nonoverlapping( + recipient.identity_id.to_buffer().as_ptr(), + out_identity_id, + 32, + ); + std::ptr::copy_nonoverlapping(recipient.address.as_ptr(), out_address, 43); + PlatformWalletFFIResult::ok() + } + Some(Err(e)) => e.into(), + None => { + PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::NotFound, "Wallet not found") + } + } +} + +/// Send a tip only if fresh resolution matches the recipient the user confirmed. +/// ID/address pointers must reference 32/43 readable bytes respectively. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + username: *const c_char, + expected_identity_id: *const u8, + expected_address: *const u8, + amount: u64, + memo_text: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(username); + check_ptr!(expected_identity_id); + check_ptr!(expected_address); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let identity_id = match crate::types::read_identifier(expected_identity_id) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let address: [u8; 43] = std::slice::from_raw_parts(expected_address, 43) + .try_into() + .unwrap(); + let memo_str = match crate::dashpay_profile::decode_opt_c_str(memo_text) { + Ok(value) => value, + Err(e) => return e, + }; + let memo = match encode_memo_text(memo_str.as_deref()) { + Ok(value) => value, + Err(e) => return e, + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(value) => value, + Err(e) => return e, + }; + catch_spend_panic("shielded tip", || { + let result = block_on_worker(async move { + let recipient = platform_wallet::ShieldedTipRecipient { + identity_id, + address, + }; + let prover = CachedOrchardProver::new(); + let result = wallet + .send_shielded_tip( + &coordinator, + seed.as_ref(), + account, + &username, + &recipient, + amount, + memo, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + map_spend_result(result, "shielded tip") + }) +} + +/// Return the dedicated ZIP-32 tip account for a wallet identity derivation index. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_shielded_tip_account_index( + identity_index: u32, + out_account: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_account); + *out_account = 0; + match platform_wallet::wallet::shielded::tips::shielded_tip_account_index(identity_index) { + Ok(account) => { + *out_account = account; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } +} + +/// Whether an account is reserved for explicitly selected DashPay tip activity. +#[no_mangle] +pub extern "C" fn platform_wallet_is_shielded_tip_account(account: u32) -> bool { + platform_wallet::wallet::shielded::is_shielded_tip_account(account) +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 8b41f702b8e..e0d3074935d 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -568,3 +568,86 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( ), } } + +/// Snapshot the effective bound accounts, including discovered and restored tip +/// accounts. An unbound wallet returns an empty array. The caller must release +/// a nonempty result with `platform_wallet_manager_free_shielded_account_indices`. +/// +/// # Safety +/// `wallet_id_bytes` must point to 32 readable bytes; both output pointers must +/// be writable. Returned indices are valid until freed by the caller. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_account_indices( + handle: Handle, + wallet_id_bytes: *const u8, + out_indices: *mut *mut u32, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_indices); + check_ptr!(out_count); + *out_indices = std::ptr::null_mut(); + *out_count = 0; + check_ptr!(wallet_id_bytes); + let mut wallet_id = [0; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + match manager.get_wallet(&wallet_id).await { + Some(wallet) => Some(wallet.shielded_account_indices().await), + None => None, + } + }) + }); + let Some(indices) = unwrap_option_or_return!(option) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "wallet not found", + ); + }; + if !indices.is_empty() { + *out_count = indices.len(); + *out_indices = Box::into_raw(indices.into_boxed_slice()) as *mut u32; + } + PlatformWalletFFIResult::ok() +} + +/// Release an account snapshot returned by the matching getter. Null is a no-op. +/// +/// # Safety +/// A nonnull pointer and count must be an unfreed pair from +/// `platform_wallet_manager_shielded_account_indices`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_shielded_account_indices( + indices: *mut u32, + count: usize, +) { + if !indices.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + indices, count, + ))); + } +} + +#[cfg(test)] +mod account_indices_tests { + use super::*; + + #[test] + fn invalid_handle_clears_account_snapshot_outputs() { + let mut indices = std::ptr::NonNull::::dangling().as_ptr(); + let mut count = 99; + let wallet_id = [0; 32]; + unsafe { + let result = platform_wallet_manager_shielded_account_indices( + 0, + wallet_id.as_ptr(), + &mut indices, + &mut count, + ); + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert!(indices.is_null()); + assert_eq!(count, 0); + platform_wallet_manager_free_shielded_account_indices(indices, count); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c49be7de1b7..edeaabbaf48 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -348,6 +348,8 @@ pub struct IdentityRestoreEntryFFI { /// identity has no cached contact profiles. pub contact_profiles: *const ContactProfileRestoreEntryFFI, pub contact_profiles_count: usize, + /// Optional owned DashPay profile; contact_id is ignored. + pub dashpay_profile: *const ContactProfileRestoreEntryFFI, } /// One DashPay payment-history row to rehydrate into @@ -378,7 +380,7 @@ pub struct PaymentRestoreEntryFFI { /// the managed identity's contact-profile cache (keyed by the contact's identity /// id) at load. Mirrors the persist-side /// [`crate::identity_persistence::ContactProfileRowFFI`] field-for-field -/// (the leading `contact_id` key, the five public profile fields with +/// (the leading `contact_id` key, the public profile fields with /// their `_present` byte-array flags, and the trailing `checked_at_ms` /// self-heal timestamp). /// @@ -410,6 +412,13 @@ pub struct ContactProfileRestoreEntryFFI { pub avatar_fingerprint: [u8; 8], /// `true` iff the source `avatar_fingerprint` was `Some(_)`. pub avatar_fingerprint_present: bool, + pub core_payment_address: [u8; 21], + pub core_payment_address_present: bool, + pub platform_payment_address: [u8; 21], + pub platform_payment_address_present: bool, + pub shielded_address: [u8; 43], + pub shielded_address_present: bool, + /// NUL-terminated `publicMessage`, or null when `None`. pub public_message: *const std::os::raw::c_char, /// Wall-clock ms of the last fetch attempt — the diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 830b7a366ef..a9dd1cee79d 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -148,6 +148,7 @@ erDiagram BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities" BLOB entry_blob "bincode-encoded IdentityEntry" + INTEGER entry_format "0 = legacy profile shape; 1 = payment addresses" INTEGER tombstoned "0 | 1 (logical delete)" } @@ -168,6 +169,7 @@ erDiagram DASHPAY_PROFILES { BLOB identity_id PK "one row per identity" BLOB profile_blob "bincode-encoded DashPayProfile" + INTEGER profile_format "0 = legacy profile shape; 1 = payment addresses" } DASHPAY_PAYMENTS_OVERLAY { diff --git a/packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs b/packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs new file mode 100644 index 00000000000..2d679b5348c --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V008__profile_address_encoding.rs @@ -0,0 +1,7 @@ +//! Profile addresses extend a positional bincode record embedded in identities. +//! Tag existing rows as format 0 and new writes as format 1; legacy decoders keep +//! old databases readable without treating a corrupt new record as an old one. +pub fn migration() -> String { + "ALTER TABLE identities ADD COLUMN entry_format INTEGER NOT NULL DEFAULT 0 CHECK(entry_format IN (0, 1)); + ALTER TABLE dashpay_profiles ADD COLUMN profile_format INTEGER NOT NULL DEFAULT 0 CHECK(profile_format IN (0, 1));".to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs index c669e885dd6..18d5be669d8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -36,3 +36,6 @@ pub use config::{ pub use error::{AutoBackupOperation, WalletStorageError}; pub use persister::{PruneReport, RetentionPolicy, SqlitePersister}; pub use reports::{CommitReport, DeleteWalletReport}; + +// Versioned blob readers are useful to hosts inspecting pre-migration backups. +pub use schema::{dashpay::decode_profile, identities::decode_identity}; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index 502d984116d..ed01b742239 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -3,7 +3,8 @@ //! Thin error-mapping wrappers around `bincode::serde` so every //! `_blob` column in the SQLite schema uses one encoding path. Schema //! evolution is gated by the refinery migration version on the -//! database as a whole — there is no per-blob revision tag. +//! database; identity/profile rows also carry an encoding column so older +//! positional records remain readable until rewritten. //! //! [`encode_outpoint`] / [`decode_outpoint`] encode a `dashcore::OutPoint` //! the same way — via bincode-serde — for the `outpoint` PRIMARY KEY diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index b6caa52fb11..e85890ad9c1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -23,6 +23,8 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +pub use super::identity_profile_encoding::decode_profile; + /// Both dashpay tables are keyed by identity only; their FK targets /// `identities(identity_id)` so cascade flows through the /// `wallet_metadata → identities` chain. @@ -50,9 +52,9 @@ pub fn apply( let mut delete_stmt = tx.prepare_cached("DELETE FROM dashpay_profiles WHERE identity_id = ?1")?; let mut insert_stmt = tx.prepare_cached( - "INSERT INTO dashpay_profiles (identity_id, profile_blob) \ - VALUES (?1, ?2) \ - ON CONFLICT(identity_id) DO UPDATE SET profile_blob = excluded.profile_blob", + "INSERT INTO dashpay_profiles (identity_id, profile_blob, profile_format) \ + VALUES (?1, ?2, 1) \ + ON CONFLICT(identity_id) DO UPDATE SET profile_blob = excluded.profile_blob, profile_format = 1", )?; for (identity_id, profile) in profiles { match profile { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 72b22435f27..ea4ccdc09e3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -12,6 +12,8 @@ use {platform_wallet::changeset::IdentityEntry, rusqlite::Connection}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +pub use super::identity_profile_encoding::decode_identity; + pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -31,13 +33,13 @@ pub fn apply( // by the per-entry cross-check below. let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); let mut stmt = tx.prepare_cached( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ - VALUES (?1, ?2, ?3, ?4, 0) \ + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned, entry_format) \ + VALUES (?1, ?2, ?3, ?4, 0, 1) \ ON CONFLICT(identity_id) DO UPDATE SET \ wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ wallet_index = excluded.wallet_index, \ entry_blob = excluded.entry_blob, \ - tombstoned = 0", + tombstoned = 0, entry_format = 1", )?; let wallet_id_param = wallet_id_to_param(wallet_id); for (id, entry) in &cs.identities { @@ -118,17 +120,19 @@ pub fn fetch( // wallet_id); a real WalletId matches only that wallet's rows. // `IS` is NULL-safe equality so the NULL branch works uniformly. let wallet_id_param = wallet_id_to_param(wallet_id); - let row: Option<(Vec, i64)> = conn + let row: Option<(Vec, i64, i64)> = conn .query_row( - "SELECT entry_blob, tombstoned FROM identities \ + "SELECT entry_blob, tombstoned, entry_format FROM identities \ WHERE identity_id = ?1 AND wallet_id IS ?2", params![&identity_id[..], wallet_id_param], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; match row { None => Ok(None), - Some((payload, tombstoned)) => Ok(Some((blob::decode(&payload)?, tombstoned != 0))), + Some((payload, tombstoned, format)) => { + Ok(Some((decode_identity(&payload, format)?, tombstoned != 0))) + } } } @@ -156,7 +160,7 @@ pub fn load_state( // clause matches by wallet_id (orphan identities — wallet_id NULL — // are out of scope for this per-wallet loader). let mut stmt = conn.prepare( - "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", + "SELECT identity_id, entry_blob, tombstoned, entry_format FROM identities WHERE wallet_id = ?1", )?; // The ignored-senders TABLE is the authoritative ignore record (every // ignore/un-ignore maintains it transactionally); the `entry_blob`'s @@ -172,7 +176,7 @@ pub fn load_state( if tombstoned != 0 { continue; } - let entry: IdentityEntry = blob::decode(&payload)?; + let entry = super::identity_profile_encoding::decode_identity(&payload, row.get(3)?)?; let ignored = ignored_by_owner.remove(&entry.id).unwrap_or_default(); let managed = managed_identity_from_entry(&entry, wallet_id, ignored); match entry.identity_index { @@ -283,8 +287,8 @@ pub fn ensure_exists( let wallet_id_param = wallet_id_to_param(wallet_id); conn.execute( "INSERT OR IGNORE INTO identities \ - (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ - VALUES (?1, ?2, NULL, ?3, 0)", + (identity_id, wallet_id, wallet_index, entry_blob, tombstoned, entry_format) \ + VALUES (?1, ?2, NULL, ?3, 0, 1)", params![&identity_id[..], wallet_id_param, payload], )?; Ok(()) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs new file mode 100644 index 00000000000..d801f4f1d1b --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs @@ -0,0 +1,276 @@ +//! Frozen pre-address profile records. Do not edit their field order. +use super::blob; +use crate::sqlite::error::WalletStorageError; +use dpp::fee::Credits; +use dpp::prelude::{Identifier, Revision}; +use platform_wallet::changeset::IdentityEntry; +use platform_wallet::wallet::identity::types::block_time::BlockTime; +use platform_wallet::wallet::identity::PaymentEntry; +use platform_wallet::{ContactProfileEntry, DashPayProfile, DpnsNameInfo, IdentityStatus}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyProfile { + display_name: Option, + bio: Option, + avatar_url: Option, + avatar_hash: Option<[u8; 32]>, + avatar_fingerprint: Option<[u8; 8]>, + public_message: Option, +} +impl From for DashPayProfile { + fn from(p: LegacyProfile) -> Self { + Self { + display_name: p.display_name, + bio: p.bio, + avatar_url: p.avatar_url, + avatar_hash: p.avatar_hash, + avatar_fingerprint: p.avatar_fingerprint, + public_message: p.public_message, + ..Default::default() + } + } +} +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyContactProfile { + profile: Option, + checked_at_ms: u64, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyIdentityEntry { + pub id: Identifier, + pub balance: Credits, + pub revision: Revision, + pub identity_index: Option, + pub last_updated_balance_block_time: Option, + pub last_synced_keys_block_time: Option, + pub dpns_names: Vec, + pub contested_dpns_names: Vec, + pub status: IdentityStatus, + pub wallet_id: Option<[u8; 32]>, + pub dashpay_profile: Option, + pub dashpay_payments: BTreeMap, + pub contact_profiles: BTreeMap, + pub ignored_senders: BTreeSet, +} + +/// Decode a `dashpay_profiles.profile_blob` using its V008 `profile_format` +/// stamp: zero is the pre-address shape, one includes payment addresses. Never +/// fall back to the legacy shape after a current-format decoding error. +pub fn decode_profile(payload: &[u8], format: i64) -> Result { + match format { + 0 => blob::decode::(payload).map(Into::into), + 1 => blob::decode(payload), + _ => Err(WalletStorageError::blob_decode( + "unsupported profile encoding", + )), + } +} + +/// Decode an `identities.entry_blob` using its `entry_format` stamp, preserving +/// the pre-address owned and contact profile shapes for format zero. +pub fn decode_identity(payload: &[u8], format: i64) -> Result { + match format { + 1 => blob::decode(payload), + 0 => { + let old: LegacyIdentityEntry = blob::decode(payload)?; + Ok(IdentityEntry { + id: old.id, + balance: old.balance, + revision: old.revision, + identity_index: old.identity_index, + last_updated_balance_block_time: old.last_updated_balance_block_time, + last_synced_keys_block_time: old.last_synced_keys_block_time, + dpns_names: old.dpns_names, + contested_dpns_names: old.contested_dpns_names, + status: old.status, + wallet_id: old.wallet_id, + dashpay_profile: old.dashpay_profile.map(Into::into), + dashpay_payments: old.dashpay_payments, + contact_profiles: old + .contact_profiles + .into_iter() + .map(|(id, entry)| { + ( + id, + ContactProfileEntry { + profile: entry.profile.map(Into::into), + checked_at_ms: entry.checked_at_ms, + }, + ) + }) + .collect(), + ignored_senders: old.ignored_senders, + }) + } + _ => Err(WalletStorageError::blob_decode( + "unsupported identity profile encoding", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn old_profile() -> LegacyProfile { + LegacyProfile { + display_name: Some("Alice".into()), + bio: Some("hello".into()), + avatar_url: None, + avatar_hash: None, + avatar_fingerprint: None, + public_message: Some("hello".into()), + } + } + #[test] + fn should_decode_standalone_profiles_by_format_without_fallback() { + let legacy = blob::encode(&old_profile()).unwrap(); + let mut profile = super::super::dashpay::decode_profile(&legacy, 0).unwrap(); + assert_eq!(profile.display_name.as_deref(), Some("Alice")); + assert_eq!(profile.public_message.as_deref(), Some("hello")); + assert!(profile.core_payment_address.is_none()); + assert!(profile.platform_payment_address.is_none()); + assert!(profile.shielded_address.is_none()); + assert!(super::super::dashpay::decode_profile(&legacy, 1).is_err()); + profile.shielded_address = Some(vec![9; 43]); + let current = blob::encode(&profile).unwrap(); + assert_eq!( + super::super::dashpay::decode_profile(¤t, 1).unwrap(), + profile + ); + assert!(super::super::dashpay::decode_profile(¤t, 0).is_err()); + assert!(super::super::dashpay::decode_profile(¤t, 2).is_err()); + assert!(super::super::dashpay::decode_profile(¤t[..current.len() - 1], 1).is_err()); + } + + #[test] + fn should_read_old_owned_and_contact_profiles_without_losing_following_fields() { + let id = Identifier::from([1; 32]); + let old = LegacyIdentityEntry { + id, + balance: 123, + revision: 2, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: vec![], + contested_dpns_names: vec![], + status: IdentityStatus::Unknown, + wallet_id: Some([2; 32]), + dashpay_profile: Some(old_profile()), + dashpay_payments: BTreeMap::new(), + contact_profiles: [( + id, + LegacyContactProfile { + profile: Some(old_profile()), + checked_at_ms: 321, + }, + )] + .into(), + ignored_senders: [Identifier::from([3; 32])].into(), + }; + let encoded = blob::encode(&old).unwrap(); + // Apply the real V7 -> V8 migration around an existing binary record. + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::runner() + .set_target(refinery::Target::Version(7)) + .run(&mut conn) + .unwrap(); + conn.execute("INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", rusqlite::params![[2u8; 32].as_slice()]).unwrap(); + conn.execute("INSERT INTO identities (identity_id, wallet_id, entry_blob, tombstoned) VALUES (?1, ?2, ?3, 0)", rusqlite::params![id.as_slice(), [2u8; 32].as_slice(), &encoded]).unwrap(); + conn.execute( + "INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, ?2)", + rusqlite::params![id.as_slice(), blob::encode(&old_profile()).unwrap()], + ) + .unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let (profile_blob, profile_format): (Vec, i64) = conn + .query_row( + "SELECT profile_blob, profile_format FROM dashpay_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(profile_format, 0); + assert_eq!( + super::super::dashpay::decode_profile(&profile_blob, profile_format).unwrap(), + DashPayProfile::from(old_profile()), + ); + let (restored, tombstoned) = + super::super::identities::fetch(&conn, &[2; 32], &id.to_buffer()) + .unwrap() + .unwrap(); + assert!(!tombstoned); + assert_eq!( + conn.query_row("SELECT entry_format FROM identities", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + assert_eq!(restored.balance, 123); + assert_eq!( + restored + .dashpay_profile + .as_ref() + .unwrap() + .display_name + .as_deref(), + Some("Alice") + ); + assert!(restored + .dashpay_profile + .as_ref() + .unwrap() + .shielded_address + .is_none()); + assert_eq!(restored.contact_profiles[&id].checked_at_ms, 321); + assert!(restored + .ignored_senders + .contains(&Identifier::from([3; 32]))); + let mut current = restored; + current.dashpay_profile.as_mut().unwrap().shielded_address = Some(vec![9; 43]); + let tx = conn.transaction().unwrap(); + let changes = platform_wallet::changeset::IdentityChangeSet { + identities: [(id, current.clone())].into(), + ..Default::default() + }; + super::super::identities::apply(&tx, &[2; 32], &changes).unwrap(); + super::super::dashpay::apply( + &tx, + &[2; 32], + Some(&BTreeMap::from([(id, current.dashpay_profile.clone())])), + None, + ) + .unwrap(); + tx.commit().unwrap(); + let (profile_blob, profile_format): (Vec, i64) = conn + .query_row( + "SELECT profile_blob, profile_format FROM dashpay_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(profile_format, 1); + assert_eq!( + super::super::dashpay::decode_profile(&profile_blob, profile_format).unwrap(), + current.dashpay_profile.clone().unwrap(), + ); + assert_eq!( + conn.query_row("SELECT entry_format FROM identities", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + super::super::identities::fetch(&conn, &[2; 32], &id.to_buffer()) + .unwrap() + .unwrap() + .0, + current + ); + let encoded = blob::encode(¤t).unwrap(); + assert_eq!(decode_identity(&encoded, 1).unwrap(), current); + assert!(decode_identity(&encoded[..encoded.len() - 1], 1).is_err()); + assert!(decode_identity(&encoded, 2).is_err()); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index b545be7a9af..aade8ce2901 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -10,8 +10,9 @@ //! `_blob` columns carry the full sub-changeset entry encoded with //! `bincode::serde::encode_to_vec` against the serde-derived types in //! `platform-wallet` — see [`blob::encode`] / [`blob::decode`]. -//! Schema evolution is gated by the refinery migration version on -//! the database; individual blobs have no inline revision tag. +//! Schema evolution is gated by refinery migrations. Identity and profile rows +//! additionally carry an encoding column because legacy positional bincode +//! records remain readable until the row is rewritten. pub mod accounts; pub mod asset_locks; @@ -95,3 +96,5 @@ pub(crate) fn assert_identities_belong_to_wallet( } Ok(()) } + +mod identity_profile_encoding; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 3f05d82c766..b798a0578ac 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -681,6 +681,7 @@ fn tc012_dashpay_overlay_roundtrip() { avatar_hash: None, avatar_fingerprint: None, public_message: Some("public".into()), + ..Default::default() }; let payment = PaymentEntry::new_sent(Identifier::from([0x66; 32]), 7_500, Some("lunch".into())); diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md new file mode 100644 index 00000000000..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/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 2fedc74a1a8..3e65bcd7b69 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -83,8 +83,9 @@ pub use wallet::identity::{ derive_contact_payment_addresses, derive_contact_xpub, pubkey_binds_expected_key_data, unmask_account_reference, BlockTime, ContactProfileEntry, ContactRequest, ContactXpubData, DashPayProfile, DashPayState, DpnsNameInfo, EstablishedContact, IdentityLocation, - IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, - RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, + IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PaymentAddressUpdate, + PrivateKeyData, ProfileUpdate, RegistrationIndex, ShieldedTipRecipient, + DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::masternode_withdrawal::{ MasternodeWithdrawalKey, MasternodeWithdrawalKeys, MasternodeWithdrawalRequest, @@ -114,3 +115,12 @@ pub use key_wallet_manager; // the crate can pass it to `ManagedIdentity` mutation methods // (`set_dashpay_profile`, `record_dashpay_payment`, `add_identity`, …). pub use wallet::persister::WalletPersister; + +pub use wallet::identity::types::dashpay::profile::{ + valid_transparent_payment_address, validated_shielded_address, +}; + +#[cfg(feature = "shielded")] +pub use wallet::shielded::tips::{ + is_shielded_tip_account, shielded_tip_account_index, SHIELDED_TIP_ACCOUNT_BASE, +}; diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index abd89c534ec..3a0f7b946ad 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -1458,6 +1458,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/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 623a67cd2c8..ec9810e9eb0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -132,6 +132,7 @@ impl DashPayView<'_, B> { // 1. The DashPay data contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; + self.validate_payment_address_update(&input).await?; // 2. Compute avatar hashes when raw bytes are provided. let (avatar_hash, avatar_fingerprint) = if let Some(ref bytes) = input.avatar_bytes { @@ -144,22 +145,9 @@ impl DashPayView<'_, B> { }; // 3. Build the document property map. - let mut properties = std::collections::BTreeMap::new(); - if let Some(ref name) = input.display_name { - properties.insert("displayName".to_string(), Value::Text(name.clone())); - } - if let Some(ref msg) = input.public_message { - properties.insert("publicMessage".to_string(), Value::Text(msg.clone())); - } - if let Some(ref url) = input.avatar_url { - properties.insert("avatarUrl".to_string(), Value::Text(url.clone())); - } - if let Some(hash) = avatar_hash { - properties.insert("avatarHash".to_string(), Value::Bytes32(hash)); - } - if let Some(fp) = avatar_fingerprint { - properties.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); - } + let properties = + merge_profile_properties(Default::default(), &input, avatar_hash, avatar_fingerprint); + let profile = profile_from_properties(&properties); // 4. Look up identity + signing key. The identity_index is not // needed here — the signer is supplied externally. @@ -233,15 +221,6 @@ impl DashPayView<'_, B> { }) .await?; - let profile = crate::wallet::identity::DashPayProfile { - display_name: input.display_name, - bio: input.public_message.clone(), - avatar_url: input.avatar_url, - avatar_hash, - avatar_fingerprint, - public_message: input.public_message, - }; - { let mut wm = self.wallet_manager.write().await; if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { @@ -275,6 +254,7 @@ impl DashPayView<'_, B> { // 1. The DashPay contract (process-wide cache). let dashpay_contract = super::dashpay_contract()?; + self.validate_payment_address_update(&input).await?; // 2. Fetch existing profile document for ID + revision + its // current property map (seed for the read-modify-write merge). @@ -445,9 +425,30 @@ fn merge_profile_properties( if let Some(fp) = avatar_fingerprint { existing.insert("avatarFingerprint".to_string(), Value::Bytes(fp.to_vec())); } + for (field, patch) in payment_address_updates(input) { + match patch { + crate::PaymentAddressUpdate::Keep => {} + crate::PaymentAddressUpdate::Set(bytes) => { + existing.insert(field.to_string(), Value::Bytes(bytes.clone())); + } + crate::PaymentAddressUpdate::Remove => { + existing.remove(field); + } + } + } existing } +fn payment_address_updates( + input: &crate::ProfileUpdate, +) -> [(&'static str, &crate::PaymentAddressUpdate); 3] { + [ + ("corePaymentAddress", &input.core_payment_address), + ("platformPaymentAddress", &input.platform_payment_address), + ("shieldedAddress", &input.shielded_address), + ] +} + /// Parse a profile document's property map into a [`DashPayProfile`]. /// Empty strings are normalized to `None`. `avatarHash`/`avatarFingerprint` /// are read via `as_bytes_slice` so both `Bytes` and the sized `Bytes32` @@ -479,6 +480,21 @@ fn profile_from_properties( avatar_hash, avatar_fingerprint, public_message, + core_payment_address: props + .get("corePaymentAddress") + .and_then(|v| v.as_bytes_slice().ok()) + .filter(|bytes| crate::valid_transparent_payment_address(bytes)) + .map(|bytes| bytes.to_vec()), + platform_payment_address: props + .get("platformPaymentAddress") + .and_then(|v| v.as_bytes_slice().ok()) + .filter(|bytes| crate::valid_transparent_payment_address(bytes)) + .map(|bytes| bytes.to_vec()), + shielded_address: props + .get("shieldedAddress") + .and_then(|v| v.as_bytes_slice().ok()) + .and_then(crate::validated_shielded_address) + .map(|bytes| bytes.to_vec()), } } @@ -1020,3 +1036,441 @@ 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![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![], + limit: 1, + offset: None, + start: None, + sub_queries: vec![], + }; + sdk.mock() + .expect_fetch_many::( + query, + Some([(domain.id(), Some(domain))].into()), + ) + .await + .unwrap(); + let profile = Document::V0(DocumentV0 { + id: Identifier::from([4; 32]), + owner_id: owner, + revision: Some(1), + properties: address + .map(|bytes| ("shieldedAddress".into(), Value::Bytes(bytes))) + .into_iter() + .collect(), + ..Default::default() + }); + let contract = super::super::dashpay_contract().unwrap(); + sdk.mock() + .expect_fetch_many::( + single_profile_query(&contract, &owner), + Some([(profile.id(), Some(profile))].into()), + ) + .await + .unwrap(); + let sdk = Arc::new(sdk); + let (wm, wallet_id, generation, _) = crate::test_support::funded_wallet_manager( + key_wallet::account::StandardAccountType::BIP44Account, + ) + .await; + let spv = Arc::new(crate::spv::SpvRuntime::new( + wm.clone(), + Arc::new(crate::events::PlatformEventManager::new(vec![])), + )); + let wallet = crate::PlatformWallet::new( + sdk.clone(), + wallet_id, + wm, + generation, + Arc::new(tokio::sync::Notify::new()), + Arc::new(crate::wallet::persister::NoPlatformPersistence), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + ); + let path = dir.join("tree.sqlite"); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + sdk, + key_wallet::Network::Testnet, + path, + store, + )); + let confirmed = crate::ShieldedTipRecipient { + identity_id: original_owner, + address: original_address, + }; + let error = wallet + .send_shielded_tip( + &coordinator, + &[42; 64], + 0, + "Alice.dash", + &confirmed, + 1000, + [0; 36], + &CachedOrchardProver::new(), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains(expected_error), + "expected {expected_error}, got {error}" + ); + drop(coordinator); + std::fs::remove_dir_all(dir).unwrap(); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs index 339520651d7..a0bd1440c38 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/mod.rs @@ -10,5 +10,5 @@ pub use established_contact::EstablishedContact; pub use payment::{DashpayAddressMatch, PaymentDirection, PaymentEntry, PaymentStatus}; pub use profile::{ calculate_avatar_hash, calculate_dhash_fingerprint, ContactProfileEntry, DashPayProfile, - ProfileUpdate, + PaymentAddressUpdate, ProfileUpdate, ShieldedTipRecipient, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs index 83fc5f9a5dc..529d2dced12 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs @@ -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 dad43b97310..b924320540c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -794,12 +794,29 @@ impl PlatformWallet { // snapshot predates a Clear. let snapshot_generation = coordinator.clear_generation(); let network = self.sdk.network; + let mut accounts: std::collections::BTreeSet = accounts.iter().copied().collect(); + accounts.extend(self.discovered_tip_accounts().await?); + // Keep retired tip accounts scanning, including identities removed from + // the local manager after their address was published. + let start = self + .persister + .load() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + accounts.extend( + start + .shielded + .viewing_keys + .keys() + .filter(|id| { + id.wallet_id == self.wallet_id + && super::shielded::is_shielded_tip_account(id.account_index) + }) + .map(|id| id.account_index), + ); let mut account_views: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for &account in accounts { - // `accounts` may contain duplicates; the BTreeMap - // dedups by definition. The full keyset (with its - // `SpendAuthorizingKey`) is dropped at the end of + for &account in &accounts { + // The full keyset (with its `SpendAuthorizingKey`) is dropped at the end of // this iteration — only the viewing half survives. let ks = OrchardKeySet::from_seed(seed, network, account)?; account_views.insert(account, ks.viewing_keys()); @@ -817,11 +834,6 @@ impl PlatformWallet { // treat it like the malformed-row case below: surface it rather // than silently mixing two keys' state. The recovery is a // shielded Clear, which drops both sides at once. - let start = self.persister.load().map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!( - "persister load failed while binding shielded viewing keys: {e}" - )) - })?; for (account, views) in &account_views { let id = SubwalletId::new(self.wallet_id, *account); if let Some(persisted) = start.shielded.viewing_keys.get(&id) { @@ -911,9 +923,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); @@ -2302,22 +2330,22 @@ mod shield_input_selection_tests { #[test] fn regression_reports_max_from_usable_suffix_not_total_account_balance() { - // Real account snapshot: the leading address is below the reserve, so - // capacity must come from the usable suffix, not the account total. - assert!( - 297_264_780 <= reserve(), - "regression shape requires the leading address to stay below the reserve; \ - re-seed the balances if the versioned reserve drops under 297_264_780" - ); + // Keep the leading address below the versioned reserve: PV14's lower + // fees made the historical 297_264_780-credit fixture spendable. + // Capacity must still come from the usable suffix, not the total. + let leading_balance = reserve() / 2; let candidates = vec![ - (addr(1), 297_264_780), + (addr(1), leading_balance), (addr(2), 2_000_000_000), (addr(3), 1_623_849_220), ]; let plan = plan(candidates).unwrap(); let expected_max = 3_623_849_220 - reserve(); - assert_eq!(plan.preflight.account_balance_credits, 3_921_114_000); + assert_eq!( + plan.preflight.account_balance_credits, + 3_623_849_220 + leading_balance + ); assert_eq!(plan.preflight.usable_balance_credits, 3_623_849_220); assert_eq!(plan.preflight.fee_reserve_credits, reserve()); assert_eq!(plan.preflight.max_shieldable_credits, expected_max); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 7685f44b883..d1f1dc2095e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -43,6 +43,8 @@ pub mod prover; pub mod seed_pool; pub mod store; pub mod sync; +pub mod tips; +pub use tips::{is_shielded_tip_account, shielded_tip_account_index, SHIELDED_TIP_ACCOUNT_BASE}; #[cfg(test)] mod viewing_key_bind_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs index 6cdb39536f8..010015e32e9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -281,3 +281,33 @@ fn shield_memo_round_trips_through_ovk_recovery() { "the OVK-recovered memo must decode back to the original text" ); } + +#[test] +fn should_restore_tip_notes_without_exposing_personal_notes_to_tip_viewing_key() { + use crate::wallet::shielded::shielded_tip_account_index; + let seed = [0x67; 64]; + let tip_index = shielded_tip_account_index(0).unwrap(); + let tips = OrchardKeySet::from_seed(&seed, Network::Testnet, tip_index).unwrap(); + let restored = OrchardKeySet::from_seed(&seed, Network::Testnet, tip_index).unwrap(); + let personal = OrchardKeySet::from_seed(&seed, Network::Testnet, 0).unwrap(); + // Rotation is just another address of the dedicated account. Both remain + // discoverable without a saved diversifier index or a current profile. + for recipient in [tips.default_address, tips.address_at(17)] { + let wire = make_own_ovk_wire_note( + recipient, + tips.outgoing_viewing_key.clone(), + 123_456, + [0; 36], + ); + assert!(try_decrypt_note_with_memo(&restored.prepared_ivk(), &wire).is_some()); + assert!(try_decrypt_note_with_memo(&personal.prepared_ivk(), &wire).is_none()); + } + let personal_wire = make_own_ovk_wire_note( + personal.default_address, + personal.outgoing_viewing_key.clone(), + 456_789, + [0; 36], + ); + assert!(try_decrypt_note_with_memo(&tips.prepared_ivk(), &personal_wire).is_none()); + assert!(try_recover_outgoing_note(&tips.outgoing_viewing_key, &personal_wire).is_none()); +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs new file mode 100644 index 00000000000..e6098dc6eb1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs @@ -0,0 +1,190 @@ +//! Dedicated DashPay tip accounts. Account selection is wallet policy, not +//! consensus: external Orchard addresses remain valid profile values. +//! +//! Reserve the upper half of the ZIP-32 account space for tips. A wallet-owned +//! identity at index i uses account 0x40000000 + i. Ordinary accounts use the +//! lower half. This mapping never depends on a username, the current profile, +//! or local allocation history: identity discovery followed by shielded bind +//! reconstructs it even after publication was removed. Address rotation uses +//! another diversifier in the same account, so all old addresses remain covered. + +use super::NetworkShieldedCoordinator; +use crate::wallet::platform_wallet::PlatformWallet; +use crate::{PlatformWalletError, ShieldedTipRecipient}; +use dpp::prelude::Identifier; +use std::sync::Arc; + +pub const SHIELDED_TIP_ACCOUNT_BASE: u32 = 0x4000_0000; + +pub fn shielded_tip_account_index(identity_index: u32) -> Result { + if identity_index >= SHIELDED_TIP_ACCOUNT_BASE { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "Identity index exceeds the dedicated tip account range".to_string(), + )); + } + Ok(SHIELDED_TIP_ACCOUNT_BASE + identity_index) +} + +pub fn is_shielded_tip_account(account: u32) -> bool { + (SHIELDED_TIP_ACCOUNT_BASE..0x8000_0000).contains(&account) +} + +impl PlatformWallet { + pub(crate) async fn discovered_tip_accounts(&self) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; + // An identity outside this convention can still use ordinary accounts + // or publish an external address; it must not block wallet-wide sync. + Ok(info + .identity_manager + .managed_identities() + .filter(|identity| identity.wallet_id == Some(self.wallet_id())) + .filter_map(|identity| identity.identity_index) + .filter_map(|index| shielded_tip_account_index(index).ok()) + .collect()) + } + + /// Prepare a dedicated receiving account without publishing anything. Full + /// bind persists its viewing key and registers it with the coordinator before + /// callers can publish the returned address. Repeated calls are idempotent. + /// 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(), + )); + } + shielded_tip_account_index(identity.identity_index.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Tip account requires a recoverable identity index".to_string(), + ) + })?)? + }; + let mut accounts = self.shielded_account_indices().await; + 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 600c5fbbbde..8882d294088 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -33,6 +33,8 @@ use crate::wallet::shielded::{FileBackedShieldedStore, NetworkShieldedCoordinato #[derive(Default)] struct CapturingPersistence { stored: Mutex>, + durability_events: Mutex>, + fail_flush: std::sync::atomic::AtomicBool, serve: Mutex>>, serve_subwallets: Mutex>, load_calls: Mutex, @@ -106,11 +108,18 @@ impl PlatformWalletPersistence for CapturingPersistence { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + self.durability_events.lock().unwrap().push("store"); self.stored.lock().expect("stored lock").push(changeset); Ok(()) } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.durability_events.lock().unwrap().push("flush"); + if self.fail_flush.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend(std::io::Error::other( + "injected flush failure", + ))); + } Ok(()) } @@ -750,3 +759,212 @@ async fn rebind_without_persisted_rows_reports_false_and_binds_nothing() { "malformed persisted viewing key must surface as an error" ); } + +/// A profile is not a recovery record. Identity discovery alone must restore +/// the reserved account, even when the owner removed the tip address entirely. +#[tokio::test] +async fn should_restore_tip_account_from_identity_discovery_and_register_for_sync() { + use crate::shielded_tip_account_index; + use dpp::identity::{Identity, IdentityV0}; + use dpp::prelude::Identifier; + let phrase = crate::test_support::MESSAGE_SIGNING_TEST_MNEMONIC; + let seed = key_wallet::Mnemonic::from_phrase(phrase) + .unwrap() + .to_seed(""); + let id = Identifier::from([0x33; 32]); + let account = shielded_tip_account_index(3).unwrap(); + let mut original = None; + for session in 0..2 { + let persister = Arc::new(CapturingPersistence::default()); + let (wallet_manager, wallet_id, _, _) = + crate::test_support::mnemonic_wallet_manager(phrase).await; + let generation = wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .unwrap() + .generation + .clone(); + let spv = Arc::new(crate::spv::SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(crate::events::PlatformEventManager::new(Vec::new())), + )); + let sdk = dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .build() + .unwrap(); + let wallet = PlatformWallet::new( + Arc::new(sdk), + wallet_id, + wallet_manager, + generation, + Arc::new(tokio::sync::Notify::new()), + persister.clone(), + Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + ); + { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 3, + wallet.wallet_id(), + wallet.persister(), + ) + .unwrap(); + } + { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: Identifier::from([0x44; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + crate::SHIELDED_TIP_ACCOUNT_BASE, + wallet.wallet_id(), + wallet.persister(), + ) + .unwrap(); + } + let coordinator = coordinator_at(&temp_dir(&format!("tips_restore_{session}"))); + // Fresh database, no viewing keys, no published profile, and caller + // only knows about ordinary account zero. + assert!(!wallet + .bind_shielded_from_persisted(&[0], &coordinator) + .await + .unwrap()); + // 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))); +} diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index f7831f29c4b..93be353459c 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -418,12 +418,13 @@ impl Sdk { // Extract the identity from records.identity if let Some(Value::Map(records)) = doc.properties().get("records") { for (key, value) in records { - if let (Value::Text(k), Value::Identifier(id_bytes)) = (key, value) { - if k == "identity" { - return Ok(Some(Identifier::from_bytes(id_bytes).map_err(|e| { - Error::Generic(format!("Invalid identifier: {}", e)) - })?)); - } + if key.as_text() == Some("identity") { + // CBOR and document decoding can represent the same + // identifier as Bytes/Bytes32 instead of Identifier. + return value + .to_identifier() + .map(Some) + .map_err(|e| Error::Generic(format!("Invalid identifier: {e}"))); } } } diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 57557093aea..edb64744665 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -653,6 +653,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createO avatar_bytes: JByteArray, do_create: jboolean, signer_handle: jlong, + core_action: jint, + core_address: JByteArray, + platform_action: jint, + platform_address: JByteArray, + shielded_action: jint, + shielded_address: JByteArray, ) -> jstring { guard(&mut env, ptr::null_mut(), |env| { let Some(id) = read_id32(env, &identity_id, "identityId") else { @@ -683,9 +689,34 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createO } }; + let address_bytes = [&core_address, &platform_address, &shielded_address] + .into_iter() + .map(|address| { + if address.is_null() { + Ok(Vec::new()) + } else { + env.convert_byte_array(address) + } + }) + .collect::, _>>(); + let address_bytes = match address_bytes { + Ok(bytes) => bytes, + Err(_) => { + crate::support::throw_sdk_exception(env, 1, "Unreadable payment address"); + return ptr::null_mut(); + } + }; + let actions = [core_action, platform_action, shielded_action]; + let updates: [_; 3] = std::array::from_fn(|index| { + platform_wallet_ffi::dashpay_profile::PaymentAddressUpdateFFI { + action: actions[index] as u32, + bytes: address_bytes[index].as_ptr(), + len: address_bytes[index].len(), + } + }); let mut profile = DashPayProfileFFI::empty(); let result = unsafe { - platform_wallet_ffi::platform_wallet_create_or_update_dashpay_profile_with_signer( + platform_wallet_ffi::platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( wallet_handle as Handle, id.as_ptr(), display.as_ref().map_or(ptr::null(), |c| c.as_ptr()), @@ -693,6 +724,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createO url.as_ref().map_or(ptr::null(), |c| c.as_ptr()), avatar.as_ref().map_or(ptr::null(), |v| v.as_ptr()), avatar.as_ref().map_or(0, |v| v.len()), + &updates[0], + &updates[1], + &updates[2], do_create != 0, signer_handle as *mut SignerHandle, &mut profile as *mut DashPayProfileFFI, diff --git a/packages/rs-unified-sdk-jni/src/funding.rs b/packages/rs-unified-sdk-jni/src/funding.rs index 1a53abf9652..1b518fb8453 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1019,3 +1019,155 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let _ = take_pwffi_error(env, result); }) } + +/// Dedicated tip account preparation; returns the complete raw Orchard address. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_prepareShieldedTipAddress( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, + identity_id: JByteArray, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let mut address = [0; 43]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_prepare_shielded_tip_address( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + id.as_ptr(), + address.as_mut_ptr(), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&address) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Current verified recipient: 32-byte identity ID followed by 43-byte address. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_resolveShieldedTip( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + username: JString, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return ptr::null_mut(); + } + }; + let mut recipient = [0; 75]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_resolve_shielded_tip( + wallet as Handle, + username.as_ptr(), + recipient.as_mut_ptr(), + recipient.as_mut_ptr().add(32), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&recipient) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Revalidates the confirmed recipient before spending. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShieldedTip( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, + account: jint, + username: JString, + expected_id: JByteArray, + expected_address: JByteArray, + amount: jlong, + memo: JString, +) { + guard(&mut env, (), |env| { + if account < 0 || amount <= 0 { + throw_sdk_exception(env, 1, "Invalid account or amount"); + return; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return; + }; + let Some(id) = read_id32(env, &expected_id, "expectedIdentityId") else { + return; + }; + let Some(address) = read_recipient43(env, &expected_address) else { + return; + }; + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return; + } + }; + let memo = match read_cstring_opt(env, &memo, "memo") { + Ok(value) => value, + Err(()) => return, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_send_shielded_tip( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + account as u32, + username.as_ptr(), + id.as_ptr(), + address.as_ptr(), + amount as u64, + memo.as_ref().map_or(ptr::null(), |m| m.as_ptr()), + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_tipAccountIndex( + mut env: JNIEnv, + _class: JClass, + identity_index: jint, +) -> jint { + guard(&mut env, -1, |env| { + if identity_index < 0 { + throw_sdk_exception(env, 1, "identity index must be non-negative"); + return -1; + } + let mut account = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_shielded_tip_account_index( + identity_index as u32, + &mut account, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + account as jint + }) +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 732534d25f0..e60d5343908 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1183,13 +1183,28 @@ unsafe fn persist_identity_upsert( let public_message = cstr_opt(env, e.dashpay_profile_public_message)?; let avatar_hash = env.byte_array_from_slice(&e.dashpay_profile_avatar_hash)?; let avatar_fp = env.byte_array_from_slice(&e.dashpay_profile_avatar_fingerprint)?; + let core_payment_address = if e.dashpay_profile_core_payment_address_present { + JObject::from(env.byte_array_from_slice(&e.dashpay_profile_core_payment_address)?) + } else { + JObject::null() + }; + let platform_payment_address = if e.dashpay_profile_platform_payment_address_present { + JObject::from(env.byte_array_from_slice(&e.dashpay_profile_platform_payment_address)?) + } else { + JObject::null() + }; + let shielded_address = if e.dashpay_profile_shielded_address_present { + JObject::from(env.byte_array_from_slice(&e.dashpay_profile_shielded_address)?) + } else { + JObject::null() + }; let code = env .call_method( bridge, "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ - Ljava/lang/String;[BZ[BZLjava/lang/String;)I", + Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I", &[ wid.into(), (&identity_id).into(), @@ -1211,6 +1226,9 @@ unsafe fn persist_identity_upsert( (&avatar_fp).into(), JValue::Bool(e.dashpay_profile_avatar_fingerprint_present as u8), (&public_message).into(), + (&core_payment_address).into(), + (&platform_payment_address).into(), + (&shielded_address).into(), ], )? .i()?; @@ -1234,11 +1252,26 @@ unsafe fn persist_identity_upsert( let public_message = cstr_opt(env, row.public_message)?; let avatar_hash = env.byte_array_from_slice(&row.avatar_hash)?; let avatar_fp = env.byte_array_from_slice(&row.avatar_fingerprint)?; + let core_payment_address = if row.core_payment_address_present { + JObject::from(env.byte_array_from_slice(&row.core_payment_address)?) + } else { + JObject::null() + }; + let platform_payment_address = if row.platform_payment_address_present { + JObject::from(env.byte_array_from_slice(&row.platform_payment_address)?) + } else { + JObject::null() + }; + let shielded_address = if row.shielded_address_present { + JObject::from(env.byte_array_from_slice(&row.shielded_address)?) + } else { + JObject::null() + }; env.call_method( bridge, "onPersistContactProfileDelta", "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ - [BZ[BZLjava/lang/String;J)I", + [BZ[BZLjava/lang/String;J[B[B[B)I", &[ wid.into(), (&identity_id).into(), @@ -1253,6 +1286,9 @@ unsafe fn persist_identity_upsert( JValue::Bool(row.avatar_fingerprint_present as u8), (&public_message).into(), JValue::Long(row.checked_at_ms as i64), + (&core_payment_address).into(), + (&platform_payment_address).into(), + (&shielded_address).into(), ], )? .i() @@ -2080,6 +2116,7 @@ struct IdentityRestoreStaged { ignored_senders: Vec<[u8; 32]>, payments: Vec, contact_profiles: Vec, + dashpay_profile: Option, } /// Staged DashPay payment-history row: FFI struct with `txid` / `memo` @@ -2108,6 +2145,21 @@ struct ContactProfileRestoreStaged { public_message: Option, } +fn seal_profile(profile: ContactProfileRestoreStaged) -> ContactProfileRestoreEntryFFI { + let ContactProfileRestoreStaged { + mut row, + display_name, + bio, + avatar_url, + public_message, + } = profile; + row.display_name = opt_cstring_into_raw(display_name); + row.bio = opt_cstring_into_raw(bio); + row.avatar_url = opt_cstring_into_raw(avatar_url); + row.public_message = opt_cstring_into_raw(public_message); + row +} + /// Staged DashPay contact-restore row: FFI struct with every pointer /// field still null / 0 until sealed, plus the owned buffers that back /// them. Mirrors the Swift `buildIdentityRestoreBuffer` contact block: @@ -2293,6 +2345,7 @@ fn seal_wallet_entries(staged: Vec) -> Vec = keys .into_iter() @@ -2365,26 +2418,12 @@ fn seal_wallet_entries(staged: Vec) -> Vec = - contact_profiles - .into_iter() - .map( - |ContactProfileRestoreStaged { - mut row, - display_name, - bio, - avatar_url, - public_message, - }| { - row.display_name = opt_cstring_into_raw(display_name); - row.bio = opt_cstring_into_raw(bio); - row.avatar_url = opt_cstring_into_raw(avatar_url); - row.public_message = - opt_cstring_into_raw(public_message); - row - }, - ) - .collect(); + contact_profiles.into_iter().map(seal_profile).collect(); (entry.contact_profiles, entry.contact_profiles_count) = vec_into_raw(contact_profiles); entry @@ -3164,6 +3203,18 @@ fn build_identity_restore( contact_profiles.push(cp); } + let owned_profile = env + .get_field( + holder, + "dashpayProfile", + "Lorg/dashfoundation/dashsdk/ffi/ContactProfileRestoreData;", + )? + .l()?; + let dashpay_profile = if owned_profile.is_null() { + None + } else { + Some(build_contact_profile_restore(env, &owned_profile)?) + }; let entry = IdentityRestoreEntryFFI { identity_id, balance, @@ -3184,6 +3235,7 @@ fn build_identity_restore( ignored_senders_count: 0, contact_profiles: ptr::null(), contact_profiles_count: 0, + dashpay_profile: ptr::null(), }; Ok(IdentityRestoreStaged { entry, @@ -3192,6 +3244,7 @@ fn build_identity_restore( ignored_senders, payments, contact_profiles, + dashpay_profile, }) } @@ -3260,6 +3313,24 @@ fn build_contact_profile_restore( Err(_) => ([0u8; 8], false), }; + let bytes = read_bytes_field_vec(env, holder, "corePaymentAddress")?; + let (core_payment_address, core_payment_address_present) = + match <[u8; 21]>::try_from(bytes.as_slice()) { + Ok(value) => (value, true), + Err(_) => ([0; 21], false), + }; + let bytes = read_bytes_field_vec(env, holder, "platformPaymentAddress")?; + let (platform_payment_address, platform_payment_address_present) = + match <[u8; 21]>::try_from(bytes.as_slice()) { + Ok(value) => (value, true), + Err(_) => ([0; 21], false), + }; + let bytes = read_bytes_field_vec(env, holder, "shieldedAddress")?; + let (shielded_address, shielded_address_present) = match <[u8; 43]>::try_from(bytes.as_slice()) + { + Ok(value) => (value, true), + Err(_) => ([0; 43], false), + }; Ok(ContactProfileRestoreStaged { row: ContactProfileRestoreEntryFFI { contact_id, @@ -3270,6 +3341,12 @@ fn build_contact_profile_restore( avatar_hash_present, avatar_fingerprint, avatar_fingerprint_present, + core_payment_address, + core_payment_address_present, + platform_payment_address, + platform_payment_address_present, + shielded_address, + shielded_address_present, public_message: ptr::null(), checked_at_ms, }, @@ -3603,6 +3680,16 @@ unsafe extern "C" fn tramp_load_wallet_list_free( e.identities_count, )); for ident in idents.iter() { + if !ident.dashpay_profile.is_null() { + let profile = Box::from_raw( + ident.dashpay_profile as *mut ContactProfileRestoreEntryFFI, + ); + free_raw_cstring(profile.display_name); + free_raw_cstring(profile.bio); + free_raw_cstring(profile.avatar_url); + free_raw_cstring(profile.public_message); + } + if !ident.keys.is_null() && ident.keys_count > 0 { let keys: Box<[IdentityKeyRestoreFFI]> = Box::from_raw(std::ptr::slice_from_raw_parts_mut( @@ -4525,13 +4612,13 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ - Ljava/lang/String;[BZ[BZLjava/lang/String;)I", + Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I", ), ("onPersistIdentityRemoval", "([B[B)I"), ( "onPersistContactProfileDelta", "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ - [BZ[BZLjava/lang/String;J)I", + [BZ[BZLjava/lang/String;J[B[B[B)I", ), ( "onPersistIdentityKeyUpsert", diff --git a/packages/rs-unified-sdk-jni/src/tokens.rs b/packages/rs-unified-sdk-jni/src/tokens.rs index d16b4cc9081..b2488d52ba5 100644 --- a/packages/rs-unified-sdk-jni/src/tokens.rs +++ b/packages/rs-unified-sdk-jni/src/tokens.rs @@ -1583,6 +1583,42 @@ pub(crate) fn profile_to_json(profile: &DashPayProfileFFI) -> String { .collect(); fields.push(format!("\"avatarFingerprint\":{}", json_string(&hex))); } + if profile.core_payment_address_is_some { + fields.push(format!( + "\"corePaymentAddress\":{}", + json_string( + &profile + .core_payment_address + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + )); + } + if profile.platform_payment_address_is_some { + fields.push(format!( + "\"platformPaymentAddress\":{}", + json_string( + &profile + .platform_payment_address + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + )); + } + if profile.shielded_address_is_some { + fields.push(format!( + "\"shieldedAddress\":{}", + json_string( + &profile + .shielded_address + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + )); + } format!("{{{}}}", fields.join(",")) } @@ -2006,7 +2042,19 @@ mod tests { profile.avatar_hash[31] = 0x01; profile.avatar_fingerprint_is_some = true; profile.avatar_fingerprint = [0x10, 0x20, 0, 0, 0, 0, 0, 0xFF]; + profile.core_payment_address_is_some = true; + profile.core_payment_address = [1; 21]; + profile.platform_payment_address_is_some = true; + profile.platform_payment_address = [2; 21]; + profile.shielded_address_is_some = true; + profile.shielded_address = [3; 43]; let json = profile_to_json(&profile); + assert!(json.contains(&format!("\"corePaymentAddress\":\"{}\"", "01".repeat(21)))); + assert!(json.contains(&format!( + "\"platformPaymentAddress\":\"{}\"", + "02".repeat(21) + ))); + assert!(json.contains(&format!("\"shieldedAddress\":\"{}\"", "03".repeat(43)))); // 32-byte hash: 0xAB … 0x01 → "ab" prefix, "01" suffix (64 hex chars). assert!(json.contains("\"avatarHash\":\"ab"), "got {json}"); assert!(json.contains("01\""), "hash suffix; got {json}"); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index eaa0ff44317..54b62862057 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -123,17 +123,59 @@ public enum DashModelContainer { + [PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V4). + /// Historical V4 shape, independent of every live model definition. + fileprivate static var v4ModelTypes: [any PersistentModel.Type] { + [ + DashSchemaV4.PersistentIdentity.self, + DashSchemaV4.PersistentDPNSName.self, + DashSchemaV4.PersistentDashpayProfile.self, + DashSchemaV4.PersistentDashpayContactProfile.self, + DashSchemaV4.PersistentDashpayContactRequest.self, + DashSchemaV4.PersistentDashpayPayment.self, + DashSchemaV4.PersistentDashpayIgnoredSender.self, + DashSchemaV4.PersistentDocument.self, + DashSchemaV4.PersistentDataContract.self, + DashSchemaV4.PersistentPublicKey.self, + DashSchemaV4.PersistentTokenBalance.self, + DashSchemaV4.PersistentKeyword.self, + DashSchemaV4.PersistentToken.self, + DashSchemaV4.PersistentDocumentType.self, + DashSchemaV4.PersistentIndex.self, + DashSchemaV4.PersistentProperty.self, + DashSchemaV4.PersistentTokenHistoryEvent.self, + DashSchemaV4.PersistentPlatformAddress.self, + DashSchemaV4.PersistentPlatformAddressesSyncState.self, + DashSchemaV4.PersistentWallet.self, + DashSchemaV4.PersistentAccount.self, + DashSchemaV4.PersistentCoreAddress.self, + DashSchemaV4.PersistentTransaction.self, + DashSchemaV4.PersistentTxo.self, + DashSchemaV4.PersistentPendingInput.self, + DashSchemaV4.PersistentWalletManagerMetadata.self, + DashSchemaV4.PersistentShieldedNote.self, + DashSchemaV4.PersistentShieldedOutgoingNote.self, + DashSchemaV4.PersistentShieldedSyncState.self, + DashSchemaV4.PersistentShieldedActivity.self, + DashSchemaV4.PersistentShieldedViewingKey.self, + DashSchemaV4.PersistentAssetLock.self, + DashSchemaV4.PersistentInvitation.self, + DashSchemaV4.PersistentMasternode.self, + DashSchemaV4.PersistentTrackedMasternode.self + ] + } + + /// All persistent model types in the current Dash SDK schema (V5). /// Unlike the lists above this one tracks the LIVE models, so it moves /// whenever a model gains a property — which is exactly why the /// released versions must not. public static var modelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] + allModelTypes(assetLock: PersistentAssetLock.self) + + [PersistentTrackedMasternode.self, PersistentDashpayPaymentAddresses.self] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV5.self) } /// Create a persistent model container for storing data @@ -181,14 +223,15 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -391,6 +434,12 @@ public enum DashSchemaV4: VersionedSchema { } public static var models: [any PersistentModel.Type] { - DashModelContainer.modelTypes + DashModelContainer.v4ModelTypes } } + +/// Version 5 adds a separate payment-address metadata table for DashPay profiles. +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index 1b535ec001d..bc1601a879f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -29,13 +29,12 @@ import SwiftData // // - `PersistentAssetLock`, frozen at its V2 shape (everything the live // model has EXCEPT `recipientIsExternal`, which V3 added). Referenced by -// `DashSchemaV1.models` and `DashSchemaV2.models`; V3 and V4 reference -// the live type. +// `DashSchemaV1.models` and `DashSchemaV2.models`; V3 references the live type. V4 uses its own frozen copy. // - The 24 models of the relationship component that contains // `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput` and // `PersistentWallet`, frozen at their V3 shape (everything the live // models had before V4's sweep columns). Referenced by V1, V2 and V3; -// V4 references the live types. The component travels as a whole +// V4 has its own copies in `DashSchemaV4FrozenModels.swift`. The component travels as a whole // because a frozen model must declare its relationships against frozen // counterparts (an `inverse:` key path is typed on the destination // model), and following those relationships in both directions closes @@ -45,7 +44,7 @@ import SwiftData // The ten models outside the component (shielded storage, invitations, // masternodes, the tracked-masternode registry, wallet-manager metadata, // the platform-addresses sync state) are still referenced live by every -// version and still carry the latent defect described above. When the +// V1–V3 version and still carry the latent defect described above. When the // next change touches one of them, freeze it here too — and if it sits in // a relationship component, freeze that component with it — then add a // version and a stage. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift new file mode 100644 index 00000000000..f059fe757f7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaV4FrozenModels.swift @@ -0,0 +1,1857 @@ +import Foundation +import SwiftData + +// Historical V4 definitions. Preserve stored properties, defaults, indexes and +// relationship topology exactly. Constructors only support migration fixtures; +// application behavior belongs to the live models. All relationship targets are +// nested here so SwiftData cannot pull a live entity into the historical schema. +extension DashSchemaV4 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + @Attribute(.unique) var identityId: Data + + var balance: Int64 + var revision: Int64 + + var isLocal: Bool + var alias: String? + + var dpnsName: String? + var mainDpnsName: String? + + var identityType: String + var votingPrivateKeyIdentifier: String? + + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + var createdAt: Date + + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + var wallet: PersistentWallet? + + var identityIndex: UInt32 = 0 + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + var ownedDataContracts: [PersistentDataContract] + + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + } + + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + var networkRaw: UInt32 + + var label: String + var normalizedLabel: String + + var parentDomainName: String + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + var isOwned: Bool = true + + var documentIdBase58: String? + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = label.lowercased().replacingOccurrences(of: "o", with: "0").replacingOccurrences(of: "i", with: "1").replacingOccurrences(of: "l", with: "1") + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = parentDomainName.lowercased().replacingOccurrences(of: "o", with: "0").replacingOccurrences(of: "i", with: "1").replacingOccurrences(of: "l", with: "1") + self.acquiredAt = acquiredAt + self.isOwned = isOwned + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + var networkRaw: UInt32 + + var displayName: String? + var publicMessage: String? + + var bio: String? + var avatarUrl: String? + + var avatarHash: Data? + var avatarFingerprint: Data? + + var identity: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var contactIdentityId: Data + var displayName: String? + + var publicMessage: String? + var bio: String? + + var avatarUrl: String? + var avatarHash: Data? + + var avatarFingerprint: Data? + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var contactIdentityId: Data + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + var contactAlias: String? + + var contactNote: String? + var contactHidden: Bool = false + + var contactAccountLabel: String? + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + var amountDuffs: UInt64 + + var directionRaw: UInt8 + var statusRaw: UInt8 + + var txid: String + var memo: String? + + var owner: PersistentIdentity + var createdAt: Date + + var lastUpdated: Date + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + var ownerIdentityId: Data + + var ignoredSenderId: Data + var owner: PersistentIdentity + + var ignoredAt: Date + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } + + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + + var data: Data + var contractId: String + + var ownerId: String + var contractIdData: Data + + var ownerIdData: Data + var createdAt: Date + + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + + var transferredAtBlockHeight: Int64? + var createdAtCoreBlockHeight: Int64? + + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + } + + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + @Attribute(.unique) var id: Data + + var name: String + var serializedContract: Data + + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + var version: Int? + + var ownerId: Data? + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + var networkRaw: UInt32 + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + + var documentsCanBeDeletedContractDefault: Bool + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + var hasTokens: Bool + + var tokensData: Data? + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + } + + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + + var securityLevel: String + var keyType: String + + var readOnly: Bool + var disabledAt: Int64? + + var publicKeyData: Data + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + + var lastAccessed: Date? + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + } + + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + var tokenId: String + + var identityId: Data + var balance: Int64 + + var frozen: Bool + var createdAt: Date + + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + + var tokenDecimals: Int32? + var networkRaw: UInt32 + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + } + + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + + var contractId: String + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } + + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + + var decimals: Int + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + + var distributionChangeRules: TokenDistributionChangeRules? + var tradeMode: TokenTradeMode + + var tradeModeChangeRules: ChangeControlRules? + var mainControlGroupPosition: Int? + + var mainControlGroupCanBeModified: String? + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + var dataContract: PersistentDataContract? + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } + + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + + var name: String + var schemaJSON: Data + + var propertiesJSON: Data + var documentsKeepHistory: Bool + + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + + var documentsTransferable: Bool + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } + + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + + var contested: Bool + var countable: String? + + var rangeCountable: Bool = false + var summable: String? + + var rangeSummable: Bool = false + var averageable: String? + + var rangeAverageable: Bool = false + var rankedCountable: Bool = false + + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + var preallocated: Bool = false + + var timeRangeJSON: Data? + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + var createdAt: Date + + var documentType: PersistentDocumentType? + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } + + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + + var documentTypeName: String + var name: String + + var type: String + var format: String? + + var contentMediaType: String? + var byteArray: Bool + + var minItems: Int? + var maxItems: Int? + + var pattern: String? + var minLength: Int? + + var maxLength: Int? + var minValue: Int? + + var maxValue: Int? + var fieldDescription: String? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } + + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + var eventType: String + + var transactionId: Data? + var blockHeight: Int64? + + var coreBlockHeight: Int64? + var fromIdentity: Data? + + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + + var balanceAfter: String? + var additionalDataJSON: Data? + + var eventDescription: String? + var createdAt: Date + + var eventTimestamp: Date + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + } + + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + @Attribute(.unique) var address: String + + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + + var publicKey: Data + var accountIndex: UInt32 + + var addressIndex: UInt32 + var derivationPath: String + + var isUsed: Bool + var balance: UInt64 + + var nonce: UInt32 + var firstSeenHeight: UInt32 + + var lastSeenHeight: UInt64 + var walletId: Data + + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var syncHeight: UInt64 + var syncTimestamp: UInt64 + + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } + + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + + var networkRaw: UInt32? + var name: String? + + var walletDescription: String? + var birthHeight: UInt32 + + var syncedHeight: UInt32 + var lastSynced: UInt64 + + var lastAppliedChainLockBytes: Data? + var lastAppliedChainLockHeight: UInt32? + + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } + + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + + var accountTypeName: String + var balanceConfirmed: UInt64 + + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + + var internalHighestUsed: Int32 + var standardTag: UInt8 + + var registrationIndex: UInt32 + var keyClass: UInt32 + + var userIdentityId: Data + var friendIdentityId: Data + + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + + var lastUpdated: Date + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } + + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + + var addressIndex: UInt32 + var derivationPath: String + + var isUsed: Bool + var firstSeenHeight: UInt32 + + var lastSeenHeight: UInt32 + var balance: UInt64 + + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + @Attribute(.unique) var txid: Data + + var transactionData: Data + var context: UInt32 + + var blockHeight: UInt32 + var blockHash: Data? + + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + + var hasBlockPosition: Bool = false + var direction: UInt32 + + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + + var netAmount: Int64 + var fee: UInt64? + + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentTxo { + #Index([\.walletId]) + @Attribute(.unique) var outpoint: Data + + var vout: UInt32 + var amount: UInt64 + + var address: String + var scriptPubKey: Data + + var height: UInt32 + var isCoinbase: Bool + + var isConfirmed: Bool + var isInstantLocked: Bool + + var isLocked: Bool + var isSpent: Bool + + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + var supersededByTxid: Data? + + var spendingInputIndex: UInt32? = nil + var account: PersistentAccount? + + var coreAddress: PersistentCoreAddress? + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + var outpoint = transaction.txid + var outputIndex = vout.littleEndian + withUnsafeBytes(of: &outputIndex) { outpoint.append(contentsOf: $0) } + self.outpoint = outpoint + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + } + + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) + var outpoint: Data + + var inputIndex: UInt32 + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + var walletId: Data + + var createdAt: Date + var isSweptTombstone: Bool = false + + var winnerMinedHeight: UInt32? + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } + + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + + var combinedSyncBlockHash: Data? + var walletCount: Int + + var createdAt: Date + var lastUpdated: Date + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + var walletId: Data + + var accountIndex: UInt32 + var position: UInt64 + + var cmx: Data + @Attribute(.unique) var nullifier: Data + + var blockHeight: UInt64 + var isSpent: Bool + + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } + + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + + var cmx: Data + var recipient: Data + + var value: UInt64 + var memo: Data + + var blockHeight: UInt64 + var createdAt: Date + + var lastUpdated: Date + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } + + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + + var lastSyncedIndex: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } + + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + + var entryId: Data + var kindTag: Int + + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + + var hasFee: Bool + var blockHeight: UInt64 + + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + + var memo: Data + var noteCmxs: Data + + var spentNullifiers: Data + var createdAt: Date + + var lastUpdated: Date + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } + + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + + var fvkBytes: Data + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } + + @Model + final class PersistentInvitation { + #Index([\.walletId]) + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + var walletId: Data + + var fundingIndexRaw: Int + var amountDuffs: Int64 + + var expiryUnix: Int + var createdAtSecs: Int + + var hasInviter: Bool + var statusRaw: Int + + var reclaimInFlight: Bool = false + var createdAt: Date + + var updatedAt: Date + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } + + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + var walletId: Data + + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + + var ownerAddress: String? + var votingAddress: String? + + var operatorPublicKey: Data? + var platformNodeId: Data? + + var payoutAddress: String? + var operatorPseudoAddress: String? + + var platformNodeAddress: String? + var ownerInWallet: Bool = false + + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + + var platformKeyIndex: UInt32 = 0 + var collateralTxid: Data? + + var collateralVout: UInt32 + var revoked: Bool + + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + + var txCount: UInt32 + var orderIndex: UInt32 + + var typeIndex: UInt32 = 0 + var createdAt: Date + + var lastUpdated: Date + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + @Attribute(.unique) var outPointHex: String + + var walletId: Data + var transactionBytes: Data + + var fundingTypeRaw: Int + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + var amountDuffs: Int64 + + var statusRaw: Int + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + var createdAt: Date + + var updatedAt: Date + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } + + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + + var label: String? + var addedAt: UInt64 + + var snapshotJSON: String + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift index 3106c69cc04..5bbbf4173b3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift @@ -11,7 +11,7 @@ import SwiftData /// without re-fetching on every launch. The cache is /// relationship-independent — it serves established contacts, pending /// incoming-request senders, and (later) ignored senders from one table, -/// matching the Rust map. It holds **only the five public profile +/// matching the Rust map. It holds **only the public profile /// fields** parsed from the on-chain `profile` document; it must never /// receive anything derived from the encrypted `contactInfo` path. /// @@ -176,3 +176,16 @@ extension PersistentDashpayContactProfile { } } } + +// Address metadata is stored separately so released profile/identity relationships +// retain their SwiftData schema identity across upgrades. +extension PersistentDashpayContactProfile { + private var paymentAddresses: PersistentDashpayPaymentAddresses? { + guard let modelContext else { return nil } + return try? PersistentDashpayPaymentAddresses.fetch(in: modelContext, networkRaw: networkRaw, + ownerIdentityId: ownerIdentityId, profileIdentityId: contactIdentityId) + } + public var corePaymentAddress: Data? { paymentAddresses?.corePaymentAddress } + public var platformPaymentAddress: Data? { paymentAddresses?.platformPaymentAddress } + public var shieldedAddress: Data? { paymentAddresses?.shieldedAddress } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift new file mode 100644 index 00000000000..8d484ecbb36 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPaymentAddresses.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftData + +/// Raw payment addresses for an owned or cached contact profile. Keeping this +/// metadata independent preserves the released profile/identity relationship schema. +@Model +public final class PersistentDashpayPaymentAddresses { + #Unique([\.networkRaw, \.ownerIdentityId, \.profileIdentityId]) + + public var networkRaw: UInt32 + public var ownerIdentityId: Data + public var profileIdentityId: Data + public var corePaymentAddress: Data? + public var platformPaymentAddress: Data? + public var shieldedAddress: Data? + + public init(networkRaw: UInt32, ownerIdentityId: Data, profileIdentityId: Data, + corePaymentAddress: Data? = nil, platformPaymentAddress: Data? = nil, + shieldedAddress: Data? = nil) { + self.networkRaw = networkRaw + self.ownerIdentityId = ownerIdentityId + self.profileIdentityId = profileIdentityId + self.corePaymentAddress = corePaymentAddress + self.platformPaymentAddress = platformPaymentAddress + self.shieldedAddress = shieldedAddress + } + + static func fetch(in context: ModelContext, networkRaw: UInt32, + ownerIdentityId: Data, profileIdentityId: Data, fetcher: any ModelFetching = LiveModelFetcher()) throws -> PersistentDashpayPaymentAddresses? { + let query = FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerIdentityId && $0.profileIdentityId == profileIdentityId + }) + return try fetcher.fetch(query, in: context).first + } + + /// Called within the persister's transaction; empty metadata removes the row. + static func replace(in context: ModelContext, networkRaw: UInt32, + ownerIdentityId: Data, profileIdentityId: Data, + core: Data?, platform: Data?, shielded: Data?, + fetcher: any ModelFetching = LiveModelFetcher()) throws { + let existing = try fetch(in: context, networkRaw: networkRaw, + ownerIdentityId: ownerIdentityId, profileIdentityId: profileIdentityId, fetcher: fetcher) + if core == nil && platform == nil && shielded == nil { + if let existing { context.delete(existing) } + return + } + let row = existing ?? PersistentDashpayPaymentAddresses(networkRaw: networkRaw, + ownerIdentityId: ownerIdentityId, profileIdentityId: profileIdentityId) + row.corePaymentAddress = core + row.platformPaymentAddress = platform + row.shieldedAddress = shielded + if existing == nil { context.insert(row) } + } + + static func removeOwned(in context: ModelContext, networkRaw: UInt32, ownerIdentityId: Data) throws { + try context.delete(model: PersistentDashpayPaymentAddresses.self, where: #Predicate { + $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerIdentityId + }) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift index 3e6eb2dd126..f0cdf470a70 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift @@ -128,3 +128,16 @@ extension PersistentDashpayProfile { } } } + +// Address metadata is stored separately so released profile/identity relationships +// retain their SwiftData schema identity across upgrades. +extension PersistentDashpayProfile { + private var paymentAddresses: PersistentDashpayPaymentAddresses? { + guard let modelContext else { return nil } + return try? PersistentDashpayPaymentAddresses.fetch(in: modelContext, networkRaw: networkRaw, + ownerIdentityId: identity.identityId, profileIdentityId: identity.identityId) + } + public var corePaymentAddress: Data? { paymentAddresses?.corePaymentAddress } + public var platformPaymentAddress: Data? { paymentAddresses?.platformPaymentAddress } + public var shieldedAddress: Data? { paymentAddresses?.shieldedAddress } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift index e7208cbe53b..5b9f17eb610 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/DashPayProfile.swift @@ -26,19 +26,33 @@ public struct DashPayProfile: Sendable, Equatable { /// Perceptual dHash (8 bytes / 64 bits) of the avatar. Present /// whenever the on-chain document carried an `avatarFingerprint`. public let avatarFingerprint: Data? + /// Core address storage encoding: type byte followed by HASH160 (21 bytes). + public let corePaymentAddress: Data? + /// Platform address storage encoding: type byte followed by HASH160 (21 bytes). + public let platformPaymentAddress: Data? + /// Complete raw Orchard address: diversifier (11 bytes) and public key (32 bytes). + public let shieldedAddress: Data? + public init( displayName: String? = nil, publicMessage: String? = nil, avatarUrl: String? = nil, avatarHash: Data? = nil, - avatarFingerprint: Data? = nil + avatarFingerprint: Data? = nil, + corePaymentAddress: Data? = nil, + platformPaymentAddress: Data? = nil, + shieldedAddress: Data? = nil ) { self.displayName = displayName self.publicMessage = publicMessage self.avatarUrl = avatarUrl self.avatarHash = avatarHash self.avatarFingerprint = avatarFingerprint + self.corePaymentAddress = corePaymentAddress + self.platformPaymentAddress = platformPaymentAddress + self.shieldedAddress = shieldedAddress + } /// Copy a `DashPayProfileFFI` into a Swift-owned value. The @@ -55,12 +69,16 @@ public struct DashPayProfile: Sendable, Equatable { self.avatarFingerprint = ffi.avatar_fingerprint_is_some ? Data(fromTuple8: ffi.avatar_fingerprint) : nil + self.corePaymentAddress = ffi.core_payment_address_is_some ? Swift.withUnsafeBytes(of: ffi.core_payment_address) { Data($0) } : nil + self.platformPaymentAddress = ffi.platform_payment_address_is_some ? Swift.withUnsafeBytes(of: ffi.platform_payment_address) { Data($0) } : nil + self.shieldedAddress = ffi.shielded_address_is_some ? Swift.withUnsafeBytes(of: ffi.shielded_address) { Data($0) } : nil + } } /// Input for `ManagedPlatformWallet.createDashPayProfile` / -/// `updateDashPayProfile`. Every field is optional; fields left as -/// `nil` are simply omitted from the outgoing document. +/// `updateDashPayProfile`. Optional text/avatar fields left as `nil` +/// are omitted. Payment addresses explicitly distinguish keep, set, and remove. /// /// `avatarBytes` is the raw image payload pre-downloaded by the app /// layer. When provided, platform-wallet computes the SHA-256 hash @@ -71,17 +89,28 @@ public struct DashPayProfileUpdate: Sendable { public var publicMessage: String? public var avatarUrl: String? public var avatarBytes: Data? + public var corePaymentAddress: DashPayPaymentAddressUpdate + public var platformPaymentAddress: DashPayPaymentAddressUpdate + public var shieldedAddress: DashPayPaymentAddressUpdate + public init( displayName: String? = nil, publicMessage: String? = nil, avatarUrl: String? = nil, - avatarBytes: Data? = nil + avatarBytes: Data? = nil, + corePaymentAddress: DashPayPaymentAddressUpdate = .keep, + platformPaymentAddress: DashPayPaymentAddressUpdate = .keep, + shieldedAddress: DashPayPaymentAddressUpdate = .keep ) { self.displayName = displayName self.publicMessage = publicMessage self.avatarUrl = avatarUrl self.avatarBytes = avatarBytes + self.corePaymentAddress = corePaymentAddress + self.platformPaymentAddress = platformPaymentAddress + self.shieldedAddress = shieldedAddress + } } @@ -122,3 +151,24 @@ private extension Data { self = Swift.withUnsafeBytes(of: &value) { Data($0) } } } + +/// Explicit operation on a payment address property. +public enum DashPayPaymentAddressUpdate: Sendable, Equatable { + case keep + case set(Data) + case remove + + func withFFI(_ body: (UnsafePointer) throws -> T) rethrows -> T { + let action: UInt32 + let data: Data + switch self { + case .keep: action = 0; data = Data() + case .set(let bytes): action = 1; data = bytes + case .remove: action = 2; data = Data() + } + return try data.withUnsafeBytes { bytes in + var value = PaymentAddressUpdateFFI(action: action, bytes: bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), len: UInt(data.count)) + return try withUnsafePointer(to: &value, body) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 8664e1dfb90..19a76588d1d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2763,37 +2763,19 @@ extension ManagedPlatformWallet { publicMessage, avatarUrl ) { namePtr, msgPtr, urlPtr -> PlatformWalletFFIResult in - let bytes = avatarBytes ?? Data() - if let avatarBytes, !avatarBytes.isEmpty { - return avatarBytes.withUnsafeBytes { rawBuf -> PlatformWalletFFIResult in - let bytesPtr = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) - return platform_wallet_create_or_update_dashpay_profile_with_signer( - handle, - idPtr, - namePtr, - msgPtr, - urlPtr, - bytesPtr, - UInt(avatarBytes.count), - doCreate, - signerHandle, - &outProfile - ) + return update.corePaymentAddress.withFFI { core in + update.platformPaymentAddress.withFFI { platform in + update.shieldedAddress.withFFI { shielded in + (avatarBytes ?? Data()).withUnsafeBytes { bytes in + platform_wallet_create_or_update_dashpay_profile_with_addresses_with_signer( + handle, idPtr, namePtr, msgPtr, urlPtr, + bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(bytes.count), core, platform, shielded, + doCreate, signerHandle, &outProfile + ) + } + } } - } else { - _ = bytes - return platform_wallet_create_or_update_dashpay_profile_with_signer( - handle, - idPtr, - namePtr, - msgPtr, - urlPtr, - nil, - 0, - doCreate, - signerHandle, - &outProfile - ) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index ed60ab38dfd..2b690250416 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -154,6 +154,23 @@ extension PlatformWalletManager { currentShieldedTreeTotal = total } + /// Effective engine account set, including automatically discovered tip accounts. + public func shieldedAccountIndices(walletId: Data) throws -> [UInt32] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("Configured manager and 32-byte walletId required") + } + var indices: UnsafeMutablePointer? + var count: UInt = 0 + try walletId.withUnsafeBytes { bytes in + try platform_wallet_manager_shielded_account_indices( + handle, bytes.bindMemory(to: UInt8.self).baseAddress!, &indices, &count + ).check() + } + defer { platform_wallet_manager_free_shielded_account_indices(indices, count) } + guard let indices else { return [] } + return Array(UnsafeBufferPointer(start: indices, count: Int(count))) + } + /// Bind `walletId`'s multi-account shielded sub-wallet to the /// `PlatformWallet` — from viewing keys the persister already /// holds when possible, deriving from the mnemonic only when it @@ -1236,3 +1253,88 @@ func shieldedTreeProgressCallback( ) } } + +public struct ShieldedTipRecipient: Sendable, Equatable { + public let identityId: Data + public let address: Data +} + +extension ManagedPlatformWallet { + /// Fetch and verify the current DPNS identity and shielded tip address. + public func resolveShieldedTip(username: String) async throws -> ShieldedTipRecipient { + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + var identityId = [UInt8](repeating: 0, count: 32) + var address = [UInt8](repeating: 0, count: 43) + try username.withCString { + try platform_wallet_resolve_shielded_tip(handle, $0, &identityId, &address).check() + } + return ShieldedTipRecipient(identityId: Data(identityId), address: Data(address)) + }.value + } +} + +extension PlatformWalletManager { + /// Prepare the dedicated tip account. Publication is a separate profile update. + public func prepareShieldedTipAddress(walletId: Data, identityId: Data, resolver: MnemonicResolver) async throws -> Data { + guard walletId.count == 32, identityId.count == 32, let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter("Expected wallet/identity IDs and mnemonic resolver") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { + try withExtendedLifetime(resolver) { + var address = [UInt8](repeating: 0, count: 43) + try walletId.withUnsafeBytes { wallet in + try identityId.withUnsafeBytes { identity in + try platform_wallet_manager_prepare_shielded_tip_address( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle, + identity.baseAddress!.assumingMemoryBound(to: UInt8.self), &address + ).check() + } + } + return Data(address) + } + }.value + } + + /// Recheck the confirmed recipient before building and broadcasting the payment. + public func sendShieldedTip(walletId: Data, resolver: MnemonicResolver, account: UInt32 = 0, + username: String, recipient: ShieldedTipRecipient, amount: UInt64) async throws { + guard walletId.count == 32, recipient.identityId.count == 32, recipient.address.count == 43, + let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter("Invalid shielded tip recipient or wallet") + } + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try withExtendedLifetime(resolver) { + try walletId.withUnsafeBytes { wallet in + try recipient.identityId.withUnsafeBytes { identity in + try recipient.address.withUnsafeBytes { address in + try username.withCString { name in + try platform_wallet_manager_send_shielded_tip( + handle, wallet.baseAddress!.assumingMemoryBound(to: UInt8.self), resolverHandle, + account, name, identity.baseAddress!.assumingMemoryBound(to: UInt8.self), + address.baseAddress!.assumingMemoryBound(to: UInt8.self), amount, nil + ).check() + } + } + } + } + } + }.value + } +} + +extension PlatformWalletManager { + public static func shieldedTipAccountIndex(identityIndex: UInt32) throws -> UInt32 { + var account: UInt32 = 0 + try platform_wallet_shielded_tip_account_index(identityIndex, &account).check() + return account + } +} + +extension PlatformWalletManager { + public static func isShieldedTipAccount(_ account: UInt32) -> Bool { + platform_wallet_is_shielded_tip_account(account) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 49e97964602..eebbf7bfecd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3326,12 +3326,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// were dropped from the Rust side — the former moved to the UI /// layer, the latter is now derived from /// `IdentityManager.highestRegistrationIndex(...)` at read time. + @discardableResult func persistIdentities( walletId: Data, upserts: [IdentityEntrySnapshot], removed: [Data] - ) { + ) -> Bool { onQueue { + do { for entry in upserts { let identityId = entry.identityId let descriptor = FetchDescriptor( @@ -3425,7 +3427,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // shape: a missing snapshot leaves any existing row // intact. if let profile = entry.dashpayProfile { - upsertDashpayProfile(identityRow: row, profile: profile) + try upsertDashpayProfile(identityRow: row, profile: profile) } // Upsert the cached contact-profile rows for this identity. @@ -3437,7 +3439,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // contact simply MISSING from this flush is "no update" (not a // delete). An empty array leaves any existing rows intact. if !entry.contactProfiles.isEmpty { - upsertDashpayContactProfiles( + try upsertDashpayContactProfiles( identityRow: row, profiles: entry.contactProfiles ) @@ -3494,11 +3496,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.identityId == identityId } ) if let existing = try? backgroundContext.fetch(descriptor).first { + try PersistentDashpayPaymentAddresses.removeOwned(in: backgroundContext, + networkRaw: existing.networkRaw, ownerIdentityId: identityId) backgroundContext.delete(existing) } } // No save() — bracketed by changesetBegin/End. + return true + } catch { + SDKLogger.event("persistence_profile_addresses_failed", category: .persistence, + severity: .error, error: error) + return false + } } // onQueue } @@ -3632,7 +3642,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func upsertDashpayProfile( identityRow: PersistentIdentity, profile: DashpayProfileSnapshot - ) { + ) throws { if let existing = identityRow.dashpayProfile { // Field-level refresh. Every column is overwritten on // every flush — the FFI snapshot is authoritative for @@ -3647,6 +3657,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.avatarUrl = profile.avatarUrl existing.avatarHash = profile.avatarHash existing.avatarFingerprint = profile.avatarFingerprint + existing.lastUpdated = Date() } else { let row = PersistentDashpayProfile( @@ -3664,6 +3675,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `PersistentIdentity.dashpayProfile`, so we don't need // to assign `identityRow.dashpayProfile = row` here. } + try PersistentDashpayPaymentAddresses.replace(in: backgroundContext, + networkRaw: identityRow.networkRaw, ownerIdentityId: identityRow.identityId, + profileIdentityId: identityRow.identityId, core: profile.corePaymentAddress, + platform: profile.platformPaymentAddress, shielded: profile.shieldedAddress, fetcher: modelFetcher) } /// Upsert one `PersistentDashpayContactProfile` row per cached @@ -3693,7 +3708,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func upsertDashpayContactProfiles( identityRow: PersistentIdentity, profiles: [ContactProfileSnapshot] - ) { + ) throws { let ownerIdentityId = identityRow.identityId for profile in profiles { let contactIdentityId = profile.contactIdentityId @@ -3704,6 +3719,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) ) guard profile.isPresent else { + try PersistentDashpayPaymentAddresses.replace(in: backgroundContext, + networkRaw: identityRow.networkRaw, ownerIdentityId: ownerIdentityId, + profileIdentityId: contactIdentityId, core: nil, platform: nil, shielded: nil, fetcher: modelFetcher) // Confirmed-absent: delete the stale row if one exists; a // never-persisted contact is a no-op. if let existing = try? backgroundContext.fetch(descriptor).first { @@ -3718,6 +3736,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { existing.avatarUrl = profile.avatarUrl existing.avatarHash = profile.avatarHash existing.avatarFingerprint = profile.avatarFingerprint + existing.checkedAtMs = profile.checkedAtMs existing.lastUpdated = Date() } else { @@ -3737,6 +3756,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // collection from the `inverse:` declaration on // `PersistentIdentity.contactProfiles`. } + try PersistentDashpayPaymentAddresses.replace(in: backgroundContext, + networkRaw: identityRow.networkRaw, ownerIdentityId: ownerIdentityId, + profileIdentityId: contactIdentityId, core: profile.corePaymentAddress, + platform: profile.platformPaymentAddress, shielded: profile.shieldedAddress, fetcher: modelFetcher) } } @@ -4796,6 +4819,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// 8-byte DHash perceptual fingerprint. `nil` when the source /// `avatar_fingerprint_present == false`. let avatarFingerprint: Data? + var corePaymentAddress: Data? = nil + var platformPaymentAddress: Data? = nil + var shieldedAddress: Data? = nil /// Wall-clock ms of the last fetch attempt on the Rust side /// (`ContactProfileEntry.checked_at_ms`). let checkedAtMs: UInt64 @@ -4819,6 +4845,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `avatarFingerprint`). `nil` when the source /// `avatar_fingerprint_present == false`. let avatarFingerprint: Data? + var corePaymentAddress: Data? = nil + var platformPaymentAddress: Data? = nil + var shieldedAddress: Data? = nil } /// Swift-side snapshot of `IdentityKeyEntryFFI` — public-key @@ -6154,6 +6183,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // that their problematic cascade children are // gone from the store. for identity in identitiesToDelete { + try PersistentDashpayPaymentAddresses.removeOwned(in: backgroundContext, + networkRaw: identity.networkRaw, ownerIdentityId: identity.identityId) backgroundContext.delete(identity) } try backgroundContext.save() @@ -6696,6 +6727,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + let paymentAddressRows: [PersistentDashpayPaymentAddresses] + do { + paymentAddressRows = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + } catch { + SDKLogger.event("persistence_profile_addresses_load_failed", category: .persistence, + severity: .error, error: error) + return (nil, 0, true) + } + let addressesByOwner = Dictionary(grouping: paymentAddressRows, by: \.ownerIdentityId) + // Allocate `entriesPtr` and the `LoadAllocation` here — past // the fallible SwiftData fetch above — so an early-error path // doesn't leak the entries buffer (LoadAllocation only gets @@ -6850,6 +6891,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } let identitiesBuffer = buildIdentityRestoreBuffer( identities: sortedIdentities, + addressesByOwner: addressesByOwner, allocation: allocation ) @@ -7712,6 +7754,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func buildIdentityRestoreBuffer( identities: [PersistentIdentity], + addressesByOwner: [Data: [PersistentDashpayPaymentAddresses]], allocation: LoadAllocation ) -> UnsafeMutablePointer? { if identities.isEmpty { @@ -7987,6 +8030,62 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // under a wrong key (matching the abort-on-corrupt convention the // UTXO restore uses). Filtering up front also keeps the fixed- // capacity buffer fully initialized so the count stays exact. + let addressRows = (addressesByOwner[identity.identityId] ?? []).filter { $0.networkRaw == identity.networkRaw } + if let profile = identity.dashpayProfile { + let addresses = addressRows.first { $0.profileIdentityId == identity.identityId } + var row = ContactProfileRestoreEntryFFI() + copyBytes(identity.identityId, into: &row.contact_id) + if let displayName = profile.displayName, !displayName.isEmpty { + row.display_name = UnsafePointer( + duplicateCString(displayName, allocation: allocation)) + } + if let bio = profile.bio, !bio.isEmpty { + row.bio = UnsafePointer( + duplicateCString(bio, allocation: allocation)) + } + if let avatarUrl = profile.avatarUrl, !avatarUrl.isEmpty { + row.avatar_url = UnsafePointer( + duplicateCString(avatarUrl, allocation: allocation)) + } + if let publicMessage = profile.publicMessage, !publicMessage.isEmpty { + row.public_message = UnsafePointer( + duplicateCString(publicMessage, allocation: allocation)) + } + // Gate the byte arrays on presence — an absent hash / + // fingerprint must round-trip as `_present == false`, + // not as an all-zero value (which Rust would otherwise + // restore as a real `Some([0u8; N])`). + if let avatarHash = profile.avatarHash, avatarHash.count == 32 { + copyBytes(avatarHash, into: &row.avatar_hash) + row.avatar_hash_present = true + } else { + row.avatar_hash_present = false + } + if let address = addresses?.corePaymentAddress, address.count == 21 { + copyBytes(address, into: &row.core_payment_address) + row.core_payment_address_present = true + } + if let address = addresses?.platformPaymentAddress, address.count == 21 { + copyBytes(address, into: &row.platform_payment_address) + row.platform_payment_address_present = true + } + if let address = addresses?.shieldedAddress, address.count == 43 { + copyBytes(address, into: &row.shielded_address) + row.shielded_address_present = true + } + if let avatarFingerprint = profile.avatarFingerprint, + avatarFingerprint.count == 8 { + copyBytes(avatarFingerprint, into: &row.avatar_fingerprint) + row.avatar_fingerprint_present = true + } else { + row.avatar_fingerprint_present = false + } + let own = UnsafeMutablePointer.allocate(capacity: 1) + own.initialize(to: row) + entry.dashpay_profile = UnsafePointer(own) + allocation.contactProfileArrays.append((own, 1)) + } + let contactProfileRows = identity.contactProfiles.filter { $0.contactIdentityId.count == 32 } @@ -7998,6 +8097,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { capacity: contactProfileRows.count ) for (c, profile) in contactProfileRows.enumerated() { + let addresses = addressRows.first { $0.profileIdentityId == profile.contactIdentityId } var row = ContactProfileRestoreEntryFFI() copyBytes(profile.contactIdentityId, into: &row.contact_id) if let displayName = profile.displayName, !displayName.isEmpty { @@ -8026,6 +8126,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } else { row.avatar_hash_present = false } + if let address = addresses?.corePaymentAddress, address.count == 21 { + copyBytes(address, into: &row.core_payment_address) + row.core_payment_address_present = true + } + if let address = addresses?.platformPaymentAddress, address.count == 21 { + copyBytes(address, into: &row.platform_payment_address) + row.platform_payment_address_present = true + } + if let address = addresses?.shieldedAddress, address.count == 43 { + copyBytes(address, into: &row.shielded_address) + row.shielded_address_present = true + } if let avatarFingerprint = profile.avatarFingerprint, avatarFingerprint.count == 8 { copyBytes(avatarFingerprint, into: &row.avatar_fingerprint) @@ -9343,7 +9455,10 @@ private func persistIdentitiesCallback( publicMessage: e.dashpay_profile_public_message.map { String(cString: $0) }, avatarUrl: e.dashpay_profile_avatar_url.map { String(cString: $0) }, avatarHash: avatarHash, - avatarFingerprint: avatarFingerprint + avatarFingerprint: avatarFingerprint, + corePaymentAddress: e.dashpay_profile_core_payment_address_present ? Swift.withUnsafeBytes(of: e.dashpay_profile_core_payment_address) { Data($0) } : nil, + platformPaymentAddress: e.dashpay_profile_platform_payment_address_present ? Swift.withUnsafeBytes(of: e.dashpay_profile_platform_payment_address) { Data($0) } : nil, + shieldedAddress: e.dashpay_profile_shielded_address_present ? Swift.withUnsafeBytes(of: e.dashpay_profile_shielded_address) { Data($0) } : nil ) } else { dashpayProfile = nil @@ -9380,6 +9495,10 @@ private func persistIdentitiesCallback( avatarUrl: row.avatar_url.map { String(cString: $0) }, avatarHash: avatarHash, avatarFingerprint: avatarFingerprint, + corePaymentAddress: row.core_payment_address_present ? Swift.withUnsafeBytes(of: row.core_payment_address) { Data($0) } : nil, + platformPaymentAddress: row.platform_payment_address_present ? Swift.withUnsafeBytes(of: row.platform_payment_address) { Data($0) } : nil, + shieldedAddress: row.shielded_address_present ? Swift.withUnsafeBytes(of: row.shielded_address) { Data($0) } : nil, + checkedAtMs: row.checked_at_ms ) ) @@ -9411,12 +9530,12 @@ private func persistIdentitiesCallback( } } - handler.persistIdentities( + let success = handler.persistIdentities( walletId: walletId, upserts: upserts, removed: removed ) - return 0 + return success ? 0 : 1 } /// C shim for `on_persist_identity_keys_fn`. Same snapshot + cast diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index a08b9d2bc7d..b10ac59322e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -598,3 +598,30 @@ do { - [SwiftExampleApp Integration](../../../SwiftExampleApp/SwiftExampleApp/Services/DashPayService.swift) - Real-world usage example - [Unit Tests](../../../SwiftTests/SwiftDashSDKTests/PlatformWalletTests.swift) - Comprehensive test examples - [Integration Tests](../../../SwiftTests/SwiftDashSDKTests/PlatformWalletIntegrationTests.swift) - Full workflow examples + +### Shielded DashPay tips + +DashPay profiles can publish `shieldedAddress`, a complete 43-byte raw Orchard +address. `DashPayProfileUpdate` uses `.keep`, `.set(Data)`, and `.remove` for +payment address changes; unrelated profile edits preserve the published address. +Wallet-generated tip addresses use a dedicated shielded account for each local +identity. Call `prepareShieldedTipAddress` and then explicitly publish the returned +address through a signed profile update. External receiving addresses may also be +published; their funds are managed and recovered by the external wallet. + +For seed restoration, discover the wallet's identities and call `bindShielded` +again. The next shielded sync automatically scans historical notes for newly +bound tip accounts; no reset is needed. Rust derives the reserved tip accounts +from the recovered identity indices, including accounts whose profile addresses +were subsequently removed. Removing a published address does not revoke copies +already shared or stop monitoring previously received tips. + +To pay a username, call `resolveShieldedTip`, show the returned identity and +address for confirmation, then call `sendShieldedTip` with that recipient. The +send operation verifies fresh resolution still matches the confirmed recipient. +A `shieldedSpendUnconfirmed` error must not be retried automatically: the payment +may already have been accepted. + +The published address is publicly associated with the username. Dedicated +accounts isolate viewing keys and ordinary receiving activity; transfers between +accounts can still introduce correlations. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/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 982d064f4df..1af66b383b3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -186,6 +186,32 @@ class ShieldedService: ObservableObject { // MARK: - Lifecycle + /// Re-register newly discovered identity accounts while retaining the engine's + /// existing ordinary accounts. Binding remains independent of discovery success. + @discardableResult + func rebindAfterIdentityDiscovery( + walletManager: PlatformWalletManager, + walletId: Data, + network: Network, + resolver: MnemonicResolver + ) -> Bool { + let existing: [UInt32] + do { + existing = try walletManager.shieldedAccountIndices(walletId: walletId) + } catch { + lastError = error.localizedDescription + return false + } + let accounts = existing.isEmpty ? [0] : existing + if boundWalletId == walletId || boundWalletId == nil { + bind(walletManager: walletManager, walletId: walletId, network: network, + resolver: resolver, accounts: accounts) + return isBound + } + return bindEngine(walletManager: walletManager, walletId: walletId, network: network, + resolver: resolver, accounts: accounts) + } + /// Bind the service to a wallet. Drives `bindShielded` on the /// Rust side first (resolver-driven mnemonic lookup, ZIP-32 /// derivation per `accounts`, per-network commitment tree @@ -275,15 +301,15 @@ class ShieldedService: ObservableObject { resolver: resolver, accounts: sortedAccounts ) + boundAccounts = try walletManager.shieldedAccountIndices(walletId: walletId) isBound = true lastError = nil - boundAccounts = sortedAccounts // Populate per-account default addresses. Best-effort — // a failure on any one account leaves that entry // missing from `addressesByAccount` (the row in the UI // shows blank) but doesn't unbind the wallet. - for account in sortedAccounts { + for account in boundAccounts { if let raw = try? walletManager.shieldedDefaultAddress( walletId: walletId, account: account @@ -298,7 +324,7 @@ class ShieldedService: ObservableObject { // the existing Receive sheet which only renders one // address. Use account 0 if bound, else the lowest // bound account. - let primary = sortedAccounts.contains(0) ? 0 : (sortedAccounts.first ?? 0) + let primary = boundAccounts.contains(0) ? 0 : (boundAccounts.first ?? 0) orchardDisplayAddress = addressesByAccount[primary] SDKLogger.event( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index c4376359aaf..368f77cb40c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -1146,7 +1146,7 @@ struct WalletRowView: View { /// as `platformBalance` (1e11 credits/DASH), so it folds into /// the same divisor in [`combinedDashAmount(coreTotal:)`]. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(UInt64(0)) { $0 + $1.value } + shieldedNotes.filter { !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) }.reduce(UInt64(0)) { $0 + $1.value } } /// Combined wallet balance expressed in DASH for a precomputed @@ -1528,7 +1528,8 @@ private struct ShieldedNetworkSummaryRows: View { /// Sum of `value` over this network's unspent notes, in credits. private var totalUnspentCredits: UInt64 { allNotes.lazy - .filter { !$0.isSpent && walletIds.contains($0.walletId) } + .filter { !$0.isSpent && walletIds.contains($0.walletId) + && !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) } .reduce(UInt64(0)) { $0 &+ $1.value } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index de48fdf33bb..c543bc43064 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -493,7 +493,7 @@ struct SendTransactionView: View { /// send source is correct for a non-`firstWallet` wallet whose /// engine binding is live but whose UI mirror is pointed elsewhere. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(0) { $0 + $1.value } + shieldedNotes.filter { !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) }.reduce(0) { $0 + $1.value } } /// Mirrors `WalletDetailView.platformBalance`: BLAST-synced diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 04b69f05cd3..bc7289dacec 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -1047,7 +1047,7 @@ struct BalanceCardView: View { /// non-`firstWallet` wallet whose engine binding is live but whose UI /// mirror is pointed elsewhere. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(0) { $0 + $1.value } + shieldedNotes.filter { !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) }.reduce(0) { $0 + $1.value } } /// Core-chain balance summed from one Rust in-memory account snapshot. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index d47274cd161..9502c749810 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -1834,7 +1834,7 @@ struct CreateIdentityView: View { /// FetchDescriptor round-trip. private func shieldedPoolBalance(for walletId: Data) -> UInt64 { unspentShieldedNotes - .filter { $0.walletId == walletId } + .filter { $0.walletId == walletId && !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) } .reduce(0) { $0 + $1.value } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index b3b9aad1a98..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..276e0f966bc 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,93 @@ private struct AddViaQRSheet: View { } } } + +/// Resolve first, then explicitly confirm the identity, address, and amount. +struct SendShieldedTipSheet: View { + let walletId: Data + var account: UInt32 = 0 + var sourceLabel: String = "ordinary shielded account" + @EnvironmentObject private var walletManager: PlatformWalletManager + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var username = "" + @State private var amount = "" + @State private var recipient: ShieldedTipRecipient? + @State private var busy = false + @State private var error: String? + @State private var submitted = false + @State private var showRecipientChanged = false + + + private var tipAmount: ShieldedTipAmount? { ShieldedTipAmount(amount) } + private var credits: UInt64? { tipAmount?.credits } + + var body: some View { + NavigationStack { + Form { + TextField("Username", text: $username) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .disabled(busy || submitted) + .onChange(of: username) { _, _ in recipient = nil } + TextField("Amount in DASH", text: $amount).keyboardType(.decimalPad) + .disabled(busy || submitted) + if let recipient { + Section("Confirm recipient") { + Text(username) + Text(recipient.identityId.toBase58String()).font(.caption).textSelection(.enabled) + Text(DashAddress.encodeOrchard(rawBytes: recipient.address, network: appState.currentNetwork) ?? "") + .font(.caption2).textSelection(.enabled) + Text("Send \(tipAmount?.dashString ?? "—") DASH from your \(sourceLabel).") + } + } + if let error { Text(error).foregroundStyle(.red) } + if submitted { + Text("Tip submitted. Check shielded activity for confirmation.") + } else { + Button(recipient == nil ? "Review recipient" : "Confirm and send tip") { submit() } + .disabled(busy || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || credits == nil) + } + if busy { ProgressView() } + } + .navigationTitle("Shielded tip") + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() }.disabled(busy) } } + .interactiveDismissDisabled(busy) + .alert("Tip recipient changed", isPresented: $showRecipientChanged) { + Button("Review new recipient") {} + Button("Cancel", role: .cancel) { recipient = nil } + } message: { + Text("This username now resolves to a different identity or shielded address than your last confirmation. Verify the change with the recipient before sending.") + } + } + } + + private func submit() { + guard let wallet = walletManager.wallet(for: walletId), let credits else { return } + busy = true + error = nil + Task { @MainActor in + defer { busy = false } + do { + if let recipient { + ShieldedTipRecipientHistory().confirm(network: appState.currentNetwork, walletId: walletId, + username: username, recipient: recipient) + try await walletManager.sendShieldedTip(walletId: walletId, resolver: MnemonicResolver(), + account: account, username: username, recipient: recipient, amount: credits) + submitted = true + } else { + let resolved = try await wallet.resolveShieldedTip(username: username) + if ShieldedTipRecipientHistory().hasChanged(network: appState.currentNetwork, walletId: walletId, + username: username, recipient: resolved) { + showRecipientChanged = true + } + recipient = resolved + } + } catch { + self.error = error.localizedDescription + // A relay-accepted payment may already exist; prevent a second send. + if case PlatformWalletError.shieldedSpendUnconfirmed = error { submitted = true } + recipient = nil + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index edc3ed10dbf..b3cf96dff6c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -1146,6 +1146,8 @@ struct DashPayProfileEditorView: View { @State private var displayName: String = "" @State private var publicMessage: String = "" @State private var avatarUrl: String = "" + @State private var shieldedTipAddress: String = "" + @EnvironmentObject private var appState: AppState @State private var isSaving = false @State private var errorMessage: String? @@ -1203,6 +1205,22 @@ struct DashPayProfileEditorView: View { .foregroundColor(.secondary) } + Section { + TextField("Shielded receiving address", text: $shieldedTipAddress, axis: .vertical) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("dashpay.profile.shieldedAddress") + Button("Use a dedicated tip account") { prepareTipAddress() } + .disabled(isSaving) + if !shieldedTipAddress.isEmpty { + Button("Remove tip address", role: .destructive) { shieldedTipAddress = "" } + } + } header: { + Text("Shielded tips") + } footer: { + Text("This address is public. Use a separate account for tips, or paste an address from another wallet. Save to publish. Removing it does not revoke copies already shared.") + } + if let err = errorMessage { Section { Text(err) @@ -1238,11 +1256,27 @@ struct DashPayProfileEditorView: View { displayName = existing.displayName ?? "" publicMessage = existing.publicMessage ?? "" avatarUrl = existing.avatarUrl ?? "" + shieldedTipAddress = existing.shieldedAddress.flatMap { DashAddress.encodeOrchard(rawBytes: $0, network: appState.currentNetwork) } ?? "" } } } } + private func prepareTipAddress() { + guard let walletId else { errorMessage = "This identity has no local wallet."; return } + isSaving = true + errorMessage = nil + Task { @MainActor in + defer { isSaving = false } + do { + let address = try await walletManager.prepareShieldedTipAddress( + walletId: walletId, identityId: identityId, resolver: MnemonicResolver() + ) + shieldedTipAddress = DashAddress.encodeOrchard(rawBytes: address, network: appState.currentNetwork) ?? "" + } catch { errorMessage = error.localizedDescription } + } + } + /// Submit the create / update transition. /// /// When the user enters an avatar URL, we fetch the image bytes @@ -1294,11 +1328,22 @@ struct DashPayProfileEditorView: View { avatarBytes = nil } + let tipUpdate: DashPayPaymentAddressUpdate + let tipText = shieldedTipAddress.trimmingCharacters(in: .whitespacesAndNewlines) + if tipText.isEmpty { + tipUpdate = existing?.shieldedAddress == nil ? .keep : .remove + } else if case .orchard(let address) = DashAddress.parse(tipText, network: appState.currentNetwork).type { + tipUpdate = address == existing?.shieldedAddress ? .keep : .set(address) + } else { + errorMessage = "Enter a valid shielded address for this network." + return + } let update = DashPayProfileUpdate( displayName: cleanedDisplay.isEmpty ? nil : cleanedDisplay, publicMessage: cleanedMsg.isEmpty ? nil : cleanedMsg, avatarUrl: cleanedUrl.isEmpty ? nil : cleanedUrl, - avatarBytes: avatarBytes + avatarBytes: avatarBytes, + shieldedAddress: tipUpdate ) // Resolve the wallet via the identity's `walletId`; diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/SearchWalletsForIdentitiesView.swift index b30bdecf72c..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 64bd42c8867..10277115e06 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -55,6 +55,13 @@ struct StorageExplorerView: View { ) { DashpayContactProfileStorageListView(network: network) } + modelRow( + "DashPay Payment Addresses", + icon: "qrcode", + type: PersistentDashpayPaymentAddresses.self + ) { + DashpayPaymentAddressesStorageListView(network: network) + } modelRow( "DashPay Payments", icon: "arrow.left.arrow.right.circle", @@ -281,6 +288,7 @@ struct StorageExplorerView: View { directCount(PersistentDashpayProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactRequest.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayContactProfile.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentDashpayPaymentAddresses.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayPayment.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayIgnoredSender.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDocument.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 6a735390481..dd822002e59 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -474,6 +474,40 @@ struct DashpayContactProfileStorageListView: View { } } +// MARK: - PersistentDashpayPaymentAddresses + +struct DashpayPaymentAddressesStorageListView: View { + let network: Network + @Query private var records: [PersistentDashpayPaymentAddresses] + + private var filtered: [PersistentDashpayPaymentAddresses] { + records.filter { $0.networkRaw == network.rawValue } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: DashpayPaymentAddressesStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.profileIdentityId.toHexString()) + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Owner: \(record.ownerIdentityId.toHexString())") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .navigationTitle("Payment Addresses (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Records", systemImage: "qrcode") + } + } + } +} + // MARK: - PersistentDashpayContactRequest /// Storage-explorer list of every DashPay contact-request row. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index d5394d9436a..d6b4ced129e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -337,6 +337,29 @@ struct DashpayContactProfileStorageDetailView: View { } } +// MARK: - PersistentDashpayPaymentAddresses + +struct DashpayPaymentAddressesStorageDetailView: View { + let record: PersistentDashpayPaymentAddresses + + var body: some View { + Form { + Section("Profile") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? String(record.networkRaw)) + FieldRow(label: "Owner ID (Hex)", value: hexString(record.ownerIdentityId)) + FieldRow(label: "Profile ID (Hex)", value: hexString(record.profileIdentityId)) + } + Section("Payment Addresses (Hex)") { + FieldRow(label: "Core (21 B)", value: record.corePaymentAddress.map(hexString) ?? "—") + FieldRow(label: "Platform (21 B)", value: record.platformPaymentAddress.map(hexString) ?? "—") + FieldRow(label: "Shielded (43 B)", value: record.shieldedAddress.map(hexString) ?? "—") + } + } + .navigationTitle("Payment Addresses") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentDashpayPayment /// Detail view for one DashPay payment-history row. Read-only dump diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 08d1f84c383..aeb0b891ad9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -5,6 +5,38 @@ import XCTest @testable import SwiftDashSDK final class DashModelMigrationTests: XCTestCase { + /// The PR only adds a new entity to the live models. Removing that entity + /// reconstructs the exact pre-freeze V4 definition, independently of the + /// new frozen classes. A store written with it must match frozen V4. + @MainActor + func testPreFreezeV4StoreMigratesUsingFrozenV4() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("historical-v4.store") + let historicalModels = DashModelContainer.modelTypes.filter { + $0 != PersistentDashpayPaymentAddresses.self + } + let historical = Schema(historicalModels, version: .init(4, 0, 0)) + let oldConfiguration = ModelConfiguration(schema: historical, url: url, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer(for: historical, configurations: [oldConfiguration]) + let identityId = Data(repeating: 0x62, count: 32) + do { + let identity = PersistentIdentity(identityId: identityId, network: .testnet) + oldContainer!.mainContext.insert(identity) + oldContainer!.mainContext.insert(PersistentDashpayProfile(identity: identity, displayName: "Historical V4")) + try oldContainer!.mainContext.save() + } + oldContainer = nil + let current = DashModelContainer.schema + let configuration = ModelConfiguration(schema: current, url: url, cloudKitDatabase: .none) + let migrated = try ModelContainer(for: current, migrationPlan: DashMigrationPlan.self, + configurations: [configuration]) + let profile = try XCTUnwrap(migrated.mainContext.fetch(FetchDescriptor()).first) + XCTAssertEqual(profile.identity.identityId, identityId) + XCTAssertEqual(profile.displayName, "Historical V4") + } + @MainActor func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { let directory = FileManager.default.temporaryDirectory @@ -134,18 +166,18 @@ final class DashModelMigrationTests: XCTestCase { configurations: [v4Configuration]) let wallets = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") XCTAssertNil( wallets.first?.lastAppliedChainLockHeight, "a wallet migrated from V3 has no chainlock boundary yet, so no " + "tombstone it later takes can be collected on a fabricated one") let pending = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") - let coins = try migrated.mainContext.fetch(FetchDescriptor()) + let coins = try migrated.mainContext.fetch(FetchDescriptor()) XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") XCTAssertNil( @@ -153,7 +185,7 @@ final class DashModelMigrationTests: XCTestCase { "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " + "so the release and re-delivery rules see an ordinary spent coin") let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") } @@ -218,15 +250,15 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v4Configuration]) - let wallets = try migrated.mainContext.fetch(FetchDescriptor()) + let wallets = try migrated.mainContext.fetch(FetchDescriptor()) XCTAssertEqual(wallets.map(\.walletId), [walletId]) XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(transactions.map(\.txid), [txid]) XCTAssertEqual(transactions.first?.context, 3) XCTAssertEqual(transactions.first?.netAmount, 2_000) - let coins = try migrated.mainContext.fetch(FetchDescriptor()) + let coins = try migrated.mainContext.fetch(FetchDescriptor()) XCTAssertEqual(coins.count, 1) XCTAssertEqual(coins.first?.vout, 1) XCTAssertEqual(coins.first?.amount, 2_000) @@ -240,7 +272,7 @@ final class DashModelMigrationTests: XCTestCase { /// What makes the V3 -> V4 stage lightweight: the two versions name the /// same entity set, and V4 only widens three of them. Also pins that - /// `PersistentTransaction` is NOT one of the three — a swept row is + /// `DashSchemaV4.PersistentTransaction` is NOT one of the three — a swept row is /// deleted outright, so the transaction entity carries no sweep marker, /// and one that came back would silently change V4's checksum. func testV3AndV4NameTheSameEntitySet() throws { @@ -281,7 +313,8 @@ final class DashModelMigrationTests: XCTestCase { Schema(versionedSchema: DashSchemaV1.self), Schema(versionedSchema: DashSchemaV2.self), Schema(versionedSchema: DashSchemaV3.self), - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV4.self), + Schema(versionedSchema: DashSchemaV5.self) ] { let names = schema.entities.map(\.name) XCTAssertTrue( @@ -381,3 +414,107 @@ final class DashModelMigrationTests: XCTestCase { true) } } + +extension DashModelMigrationTests { + @MainActor + func testV4StoreMigratesToV5PreservingSweepStateAndProfiles() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("sweep-profile.store") + let oldSchema = Schema(versionedSchema: DashSchemaV4.self) + let oldConfig = ModelConfiguration(schema: oldSchema, url: storeURL, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer(for: oldSchema, configurations: [oldConfig]) + let identityId = Data(repeating: 0x21, count: 32) + let walletId = Data(repeating: 0x31, count: 32) + let winnerTxid = Data(repeating: 0x41, count: 32) + do { + let context = oldContainer!.mainContext + let identity = DashSchemaV4.PersistentIdentity(identityId: identityId, isLocal: true, network: .testnet) + context.insert(identity) + context.insert(DashSchemaV4.PersistentDashpayProfile(identity: identity, displayName: "Preserved profile")) + let wallet = DashSchemaV4.PersistentWallet(walletId: walletId, network: .testnet) + wallet.lastAppliedChainLockHeight = 4321 + context.insert(wallet) + let pending = DashSchemaV4.PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, + spendingTxid: winnerTxid, spendingTransaction: nil, walletId: walletId) + pending.isSweptTombstone = true + pending.winnerMinedHeight = 1234 + context.insert(pending) + let funding = DashSchemaV4.PersistentTransaction(txid: Data(repeating: 0x51, count: 32), + transactionData: Data([0x03, 0x00]), context: 2, blockHeight: 100) + context.insert(funding) + let coin = DashSchemaV4.PersistentTxo(transaction: funding, vout: 0, amount: 1000, address: "yV4Coin", height: 100) + coin.walletId = walletId + coin.isSpent = true + coin.supersededByTxid = winnerTxid + context.insert(coin) + try context.save() + } + oldContainer = nil + + let schema = Schema(versionedSchema: DashSchemaV5.self) + let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none) + var container: ModelContainer? = try ModelContainer( + for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + do { + let context = container!.mainContext + let profiles = try context.fetch(FetchDescriptor()) + XCTAssertEqual(profiles.map(\.displayName), ["Preserved profile"]) + XCTAssertEqual(profiles.first?.identity.identityId, identityId) + XCTAssertNil(profiles.first?.shieldedAddress) + let wallets = try context.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.map(\.lastAppliedChainLockHeight), [4321]) + let pending = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertTrue(pending.isSweptTombstone) + XCTAssertEqual(pending.winnerMinedHeight, 1234) + XCTAssertEqual(pending.spendingTxid, winnerTxid) + let coin = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertEqual(coin.transaction?.blockHeight, 100) + try PersistentDashpayPaymentAddresses.replace(in: context, + networkRaw: Network.testnet.rawValue, ownerIdentityId: identityId, profileIdentityId: identityId, + core: nil, platform: nil, shielded: Data(repeating: 0x45, count: 43)) + try context.save() + } + container = nil + let reopened = try ModelContainer(for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + let profile = try XCTUnwrap(reopened.mainContext.fetch(FetchDescriptor()).first) + XCTAssertEqual(profile.shieldedAddress, Data(repeating: 0x45, count: 43)) + } + + @MainActor + func testV3ProfileStoreMigratesToPaymentAddressesWithoutLosingProfile() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("profile.store") + let oldSchema = Schema(versionedSchema: DashSchemaV3.self) + let oldConfig = ModelConfiguration(schema: oldSchema, url: storeURL, cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer(for: oldSchema, configurations: [oldConfig]) + let identityId = Data(repeating: 0x21, count: 32) + do { + let identity = DashSchemaV1.PersistentIdentity(identityId: identityId, isLocal: true, network: .testnet) + oldContainer!.mainContext.insert(identity) + oldContainer!.mainContext.insert(DashSchemaV1.PersistentDashpayProfile( + identity: identity, displayName: "Preserved profile")) + try oldContainer!.mainContext.save() + } + oldContainer = nil + let schema = Schema(versionedSchema: DashSchemaV5.self) + let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + let profiles = try container.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(profiles.count, 1) + XCTAssertEqual(profiles[0].displayName, "Preserved profile") + XCTAssertNil(profiles[0].shieldedAddress) + try PersistentDashpayPaymentAddresses.replace(in: container.mainContext, + networkRaw: Network.testnet.rawValue, ownerIdentityId: identityId, profileIdentityId: identityId, + core: nil, platform: nil, shielded: Data(repeating: 0x45, count: 43)) + try container.mainContext.save() + XCTAssertEqual(profiles[0].identity.identityId, identityId) + XCTAssertEqual(profiles[0].shieldedAddress, Data(repeating: 0x45, count: 43)) + } +} 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/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