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..dd9bf8a0a12 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -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,23 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val walletId = identity?.walletId val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + // Derived from the live identity's index, not Room's placeholder 0 (see DashPayTabScreen). + val tipAccount by produceState(initialValue = null, wallet, manager, identityIdHex) { + value = wallet?.let { w -> + runCatching { w.identityIndex(idBytes)?.let { index -> manager?.shieldedTipAccountIndex(index) } } + .getOrNull() + } + } + val tipBalance by remember(walletId, tipAccount) { + 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 +110,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 +156,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 +197,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 +244,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 +283,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..9e79c4547f8 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -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,54 @@ fun DashPayTabScreen(navController: NavHostController) { onError = { unlockError = it }, ) + val tipManager = manager + val tipWalletId = identity.walletId + // The tip account follows the live identity's derivation index, not + // Room's non-null `identityIndex`: its 0 is a placeholder for an identity + // with no recoverable index and would alias identity 0's tip pool. + val tipAccountResult by produceState?>( + initialValue = null, managed, tipManager, identityHex, + ) { + value = runCatching { + val index = requireNotNull(managed) { "Wallet is not loaded" } + .identityIndex(identity.identityId) + ?: error("Tip account requires a recoverable identity index") + requireNotNull(tipManager).shieldedTipAccountIndex(index) + } + } + val tipAccount = tipAccountResult?.getOrNull() + val tipSubmission = tipWalletId?.let { + container.shieldedTipSubmissions.forWallet(network.ffiValue, it.toHex()) + } + val tipSending by rememberUpdatedState(tipSubmission?.busy == true) + val tipSheetState = rememberModalBottomSheetState( + confirmValueChange = { value -> value != SheetValue.Hidden || !tipSending }, + ) + if (showTipSheet && managed != null && tipManager != null && tipWalletId != null && tipAccount != null && tipSubmission != null) { + ModalBottomSheet( + sheetState = tipSheetState, + onDismissRequest = { if (!tipSubmission.busy) showTipSheet = false }, + ) { + ShieldedTipSheet(tipManager, managed, tipWalletId, tipAccount, tipSubmission) + } + } FormSection(title = "DashPay") { + if (container.shieldedService.isAvailable) { + EntityRow( + icon = Icons.AutoMirrored.Filled.Send, + title = "Send shielded tip", + onClick = { + when (val result = tipAccountResult) { + null -> unlockError = "The tip account is still loading" + else -> result.fold( + onSuccess = { showTipSheet = true }, + onFailure = { unlockError = it.message ?: "Could not open shielded tips" }, + ) + } + }, + modifier = Modifier.testTag("dashpay.sendShieldedTip"), + ) + } EntityRow( icon = Icons.Default.Group, title = "Contacts", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt new file mode 100644 index 00000000000..e21e1ca8522 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipFailure.kt @@ -0,0 +1,24 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError + +/** + * Permit a fresh user review only for failures known not to have executed a tip. + * On the tip path, Rust maps selection/build/recipient-check failures to + * WalletOperation. Broadcast ambiguity maps to ShieldedSpendUnconfirmed, and + * successful post-broadcast bookkeeping is best-effort (never WalletOperation). + * Unknown exceptions, including JNI failures and cancellation, remain locked. + */ +internal fun canReviewShieldedTipAfterFailure(error: Exception): Boolean = when (error) { + is IllegalArgumentException, + is DashSdkError.InvalidParameter, + is DashSdkError.PlatformWallet.InvalidHandle, + is DashSdkError.PlatformWallet.NotFound, + is DashSdkError.PlatformWallet.SigningKeyUnavailable, + is DashSdkError.PlatformWallet.WalletOperation, + is DashSdkError.PlatformWallet.ShieldedNoRecordedAnchor, + is DashSdkError.PlatformWallet.ShieldedBroadcastFailed -> true + // ErrorInvalidParameter is a preflight-only FFI failure on this call path. + is DashSdkError.PlatformWallet.Generic -> error.nativeCode == 2 + else -> false +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt new file mode 100644 index 00000000000..c5418cdde79 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSheet.kt @@ -0,0 +1,123 @@ +package org.dashfoundation.example.ui.dashpay + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import java.math.BigDecimal +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient +import org.dashfoundation.dashsdk.tokens.ShieldedTipRecipientHistory +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet +import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.DashAddress + +/** Username tipping uses the same verified recipient and confirmation boundary as Swift. */ +@Composable +fun ShieldedTipSheet(manager: PlatformWalletManager, wallet: ManagedPlatformWallet, walletId: ByteArray, tipAccount: Int, submission: ShieldedTipSubmission) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val history = remember(context) { + ShieldedTipRecipientHistory(context.getSharedPreferences("dashpay.tipRecipients", Context.MODE_PRIVATE)) + } + var changedRecipient by remember { mutableStateOf(null) } + var username by remember { mutableStateOf("") } + var amount by remember { mutableStateOf("") } + var recipient by remember { mutableStateOf(null) } + var confirmedAmount by remember { mutableStateOf(null) } + var resolving by remember { mutableStateOf(false) } + val busy = resolving || submission.busy + val submitted = submission.submitted + var spendTips by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + changedRecipient?.let { changed -> + AlertDialog( + onDismissRequest = { changedRecipient = null }, + title = { Text("Tip recipient changed") }, + text = { Text("The identity or shielded address for this username differs from your previous confirmation. Verify the change with the recipient before continuing.") }, + confirmButton = { + TextButton(onClick = { recipient = changed; changedRecipient = null }) { Text("Review new recipient") } + }, + dismissButton = { + TextButton(onClick = { changedRecipient = null }) { Text("Cancel") } + }, + ) + } + + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Send a shielded tip") + OutlinedTextField(username, { username = it; recipient = null }, label = { Text("Username") }, enabled = !busy && !submitted) + OutlinedTextField(amount, { amount = it; recipient = null }, label = { Text("Amount (DASH)") }, enabled = !busy && !submitted) + Row { + Checkbox(checked = spendTips, enabled = !busy && !submitted, + onCheckedChange = { spendTips = it; recipient = null }) + Text("Spend from my dedicated tip account") + } + recipient?.let { + Text("Recipient: ${Base58.encode(it.identityId)}") + DashAddress.encodeOrchard(it.address, manager.network)?.let { address -> + SelectionContainer { Text(address) } + } + Text("Send $amount DASH from your ${if (spendTips) "tip" else "main shielded"} account to $username?") + } + (message ?: submission.message)?.let { Text(it) } + if (submission.status == ShieldedTipSubmission.Status.Sent) { + TextButton(onClick = { submission.startNewTip() }) { Text("Start a new tip") } + } + SubmitButton( + text = if (recipient == null) "Review tip" else "Confirm and send", + isLoading = busy, enabled = !busy && !submitted && changedRecipient == null, modifier = Modifier.fillMaxWidth(), + ) { + val selected = recipient + if (selected != null) { + val sendUsername = username.trim() + val sendAmount = requireNotNull(confirmedAmount) + val sendAccount = if (spendTips) tipAccount else 0 + recipient = null + submission.submit { + history.confirm(manager.network, walletId, sendUsername, selected) + manager.sendShieldedTip(walletId, sendUsername, selected, sendAmount, account = sendAccount) + } + } else { + resolving = true + message = null + scope.launch { + try { + val credits = BigDecimal(amount.trim()).movePointRight(11).longValueExact() + require(credits > 0) { "Enter a positive amount" } + confirmedAmount = credits + val resolved = wallet.dashpay.resolveShieldedTip(username.trim()) + if (history.hasChanged(manager.network, walletId, username, resolved)) { + changedRecipient = resolved + } else { + recipient = resolved + } + } catch (e: Exception) { + message = e.message ?: "Unable to review tip" + recipient = null + } finally { resolving = false } + } + } + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt new file mode 100644 index 00000000000..af37ff54ca6 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ShieldedTipSubmission.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** Application-owned submissions survive sheet dismissal, navigation and activity recreation. */ +class ShieldedTipSubmissions(private val scope: CoroutineScope) { + private val wallets = mutableMapOf() + + /** Called on the main thread; one guard per network/wallet, across all of its identities. */ + fun forWallet(network: Int, walletId: String): ShieldedTipSubmission = + wallets.getOrPut("$network:$walletId") { ShieldedTipSubmission(scope) } +} + +/** Main-thread state. A cancelled UI must never imply that blocking JNI stopped broadcasting. */ +class ShieldedTipSubmission(private val scope: CoroutineScope) { + enum class Status { Ready, Sending, Sent, Uncertain } + + var status by mutableStateOf(Status.Ready) + private set + var message by mutableStateOf(null) + private set + val busy: Boolean get() = status == Status.Sending + val submitted: Boolean get() = status != Status.Ready + + fun submit(send: suspend () -> Unit) { + if (submitted) return + // Lock synchronously, before launching, so two UI events cannot submit twice. + status = Status.Sending + message = null + scope.launch { + try { + send() + status = Status.Sent + message = "Shielded tip sent." + } catch (error: Exception) { + status = if (canReviewShieldedTipAfterFailure(error)) Status.Ready else Status.Uncertain + message = if (status == Status.Uncertain) { + "The tip may have been sent. Check shielded activity before sending again. ${error.message.orEmpty()}" + } else { + error.message ?: "Unable to send tip" + } + if (error is kotlinx.coroutines.CancellationException) throw error + } + } + } + + /** Starting another payment requires an explicit action after a confirmed successful return. */ + fun startNewTip() { + if (status != Status.Sent) return + status = Status.Ready + message = null + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/SearchWalletsForIdentitiesScreen.kt index a92013c0bd8..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 fb82e03ccb3..070df65f3fe 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt @@ -16,6 +16,17 @@ package org.dashfoundation.dashsdk.ffi * non-shielded build throws [UnsatisfiedLinkError]. */ internal object FundingNative { + external fun tipAccountIndex(identityIndex: Int): Int + external fun prepareShieldedTipAddress( + manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray, + ): ByteArray + external fun resolveShieldedTip(wallet: Long, username: String): ByteArray + external fun sendShieldedTip( + manager: Long, walletId: ByteArray, resolver: Long, account: Int, + username: String, expectedId: ByteArray, expectedAddress: ByteArray, + amount: Long, memo: String?, + ) + external fun shieldedIdentityDebitRecoveryRecords( managerHandle: Long, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 1426fc60dea..a728bc88cab 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -442,7 +442,7 @@ abstract class NativePersistenceBridge { /** * One `IdentityEntryFFI` upsert. DPNS labels + acquired-at timestamps * ride as parallel arrays. Descriptor - * `([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;)I`. + * `([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZ[BZLjava/lang/String;[B[B[B)I`. */ @Suppress("LongParameterList") open fun onPersistIdentityUpsert( @@ -466,6 +466,9 @@ abstract class NativePersistenceBridge { dashpayAvatarFingerprint: ByteArray, dashpayAvatarFingerprintPresent: Boolean, dashpayPublicMessage: String?, + dashpayCorePaymentAddress: ByteArray? = null, + dashpayPlatformPaymentAddress: ByteArray? = null, + dashpayShieldedAddress: ByteArray? = null, ): Int = 0 /** One identity-id removal. Descriptor `([B[B)I`. */ @@ -613,7 +616,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 @@ -637,6 +640,9 @@ abstract class NativePersistenceBridge { avatarFingerprintPresent: Boolean, publicMessage: String?, checkedAtMs: Long, + corePaymentAddress: ByteArray? = null, + platformPaymentAddress: ByteArray? = null, + shieldedAddress: ByteArray? = null, ): Int = 0 // ── Asset locks ─────────────────────────────────────────────────── @@ -1183,6 +1189,7 @@ class IdentityRestoreData( * re-fetches every contact. */ @JvmField val contactProfiles: Array, + @JvmField val dashpayProfile: ContactProfileRestoreData? = null, ) /** @@ -1215,6 +1222,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 88a080308a0..03d5f77be7c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1635,6 +1635,9 @@ class PlatformWalletPersistenceHandler( dashpayAvatarFingerprint: ByteArray, dashpayAvatarFingerprintPresent: Boolean, dashpayPublicMessage: String?, + dashpayCorePaymentAddress: ByteArray?, + dashpayPlatformPaymentAddress: ByteArray?, + dashpayShieldedAddress: ByteArray?, ): Int = guarded { stage(walletId) { db -> val ownerWallet = if (walletIdIsSome) identityWalletId else walletId @@ -1749,6 +1752,9 @@ class PlatformWalletPersistenceHandler( avatarHash = if (dashpayAvatarHashPresent) dashpayAvatarHash else null, avatarFingerprint = if (dashpayAvatarFingerprintPresent) dashpayAvatarFingerprint else null, + corePaymentAddress = dashpayCorePaymentAddress, + platformPaymentAddress = dashpayPlatformPaymentAddress, + shieldedAddress = dashpayShieldedAddress, lastUpdated = now(), ), ) @@ -2194,6 +2200,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 @@ -2216,6 +2225,9 @@ class PlatformWalletPersistenceHandler( avatarUrl = avatarUrl, avatarHash = avatarHash.takeIf { avatarHashPresent }, avatarFingerprint = avatarFingerprint.takeIf { avatarFingerprintPresent }, + corePaymentAddress = corePaymentAddress, + platformPaymentAddress = platformPaymentAddress, + shieldedAddress = shieldedAddress, checkedAtMs = checkedAtMs, lastUpdated = now(), ), @@ -2953,6 +2965,9 @@ class PlatformWalletPersistenceHandler( avatarFingerprint = cp.avatarFingerprint, publicMessage = cp.publicMessage, checkedAtMs = cp.checkedAtMs, + corePaymentAddress = cp.corePaymentAddress, + platformPaymentAddress = cp.platformPaymentAddress, + shieldedAddress = cp.shieldedAddress, ) }.toTypedArray() IdentityRestoreData( @@ -2969,6 +2984,17 @@ class PlatformWalletPersistenceHandler( ignoredSenders = ignoredRows, payments = paymentRows, contactProfiles = contactProfileRows, + dashpayProfile = database.dashpayDao().getProfile(idRow.networkRaw, idRow.identityId)?.let { p -> + ContactProfileRestoreData( + contactId = idRow.identityId, + displayName = p.displayName, bio = p.bio, avatarUrl = p.avatarUrl, + avatarHash = p.avatarHash, avatarFingerprint = p.avatarFingerprint, + publicMessage = p.publicMessage, checkedAtMs = 0, + corePaymentAddress = p.corePaymentAddress, + platformPaymentAddress = p.platformPaymentAddress, + shieldedAddress = p.shieldedAddress, + ) + }, ) }.toTypedArray() } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt index bb72b816457..669f5274936 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactProfileEntity.kt @@ -67,6 +67,9 @@ data class DashpayContactProfileEntity( val avatarHash: ByteArray? = null, /** 8-byte perceptual hash. */ val avatarFingerprint: ByteArray? = null, + val corePaymentAddress: ByteArray? = null, + val platformPaymentAddress: ByteArray? = null, + val shieldedAddress: ByteArray? = null, /** * Wall-clock ms of the last fetch attempt on the Rust side * (`ContactProfileEntry.checked_at_ms`) — drives the self-heal diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt index 6d8a8806839..a956ef22cc8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayProfileEntity.kt @@ -44,6 +44,9 @@ data class DashpayProfileEntity( val avatarHash: ByteArray? = null, /** 8-byte perceptual hash. */ val avatarFingerprint: ByteArray? = null, + val corePaymentAddress: ByteArray? = null, + val platformPaymentAddress: ByteArray? = null, + val shieldedAddress: ByteArray? = null, val createdAt: Date = Date(), val lastUpdated: Date = Date(), ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt index 1dfb441d1b0..0733d30a557 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/ShieldedService.kt @@ -208,7 +208,9 @@ class ShieldedService(private val database: DashDatabase) { _state.value = ShieldedSyncState() _shieldedBalance.value = database.shieldedDao() .observeUnspentNotesByWallet(walletId) - .map { notes -> notes.sumOf { it.value } } + // Rust also scans dedicated tip accounts; ordinary wallet balances only + // include the accounts selected by this service's caller. + .map { notes -> notes.filter { it.accountIndex in sortedAccounts }.sumOf { it.value } } try { manager.configureShielded(dbPath) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 62eb7d33457..504481bd847 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -37,6 +37,13 @@ class Dashpay internal constructor(private val walletHandle: Long, private val gate: org.dashfoundation.dashsdk.wallet.TeardownGate? = null, ) { + /** Fetch and validate the current recipient through proof-verified DPNS and profile reads. */ + suspend fun resolveShieldedTip(username: String): ShieldedTipRecipient = gate.op { + val bytes = mapNativeErrors { org.dashfoundation.dashsdk.ffi.FundingNative.resolveShieldedTip(walletHandle, username) } + check(bytes.size == 75) { "Invalid recipient response" } + ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75)) + } + /** * Send a contact request to [recipientIdentityId], signing the document * state-transition with [signerHandle] and keying the contact crypto @@ -476,11 +483,17 @@ class Dashpay internal constructor(private val walletHandle: Long, avatarBytes: ByteArray? = null, doCreate: Boolean, signerHandle: Long, + corePaymentAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, + platformPaymentAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, + shieldedAddress: PaymentAddressUpdate = PaymentAddressUpdate.Keep, ): String? = gate.op { mapNativeErrors { DashpayNative.createOrUpdateProfile( walletHandle, identityId, displayName, publicMessage, avatarUrl, avatarBytes, doCreate, signerHandle, + corePaymentAddress.action, corePaymentAddress.bytes, + platformPaymentAddress.action, platformPaymentAddress.bytes, + shieldedAddress.action, shieldedAddress.bytes, ) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt new file mode 100644 index 00000000000..2284dfa2b0c --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/PaymentAddressUpdate.kt @@ -0,0 +1,8 @@ +package org.dashfoundation.dashsdk.tokens + +/** Explicit partial-update semantics for a public profile payment address. */ +sealed class PaymentAddressUpdate(internal val action: Int, internal val bytes: ByteArray?) { + data object Keep : PaymentAddressUpdate(0, null) + class Set(bytes: ByteArray) : PaymentAddressUpdate(1, bytes.copyOf()) + data object Remove : PaymentAddressUpdate(2, null) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt new file mode 100644 index 00000000000..5ba1d253d31 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipient.kt @@ -0,0 +1,16 @@ +package org.dashfoundation.dashsdk.tokens + +/** Verified recipient snapshot to bind payment confirmation to an identity and address. */ +class ShieldedTipRecipient(identityId: ByteArray, address: ByteArray) { + private val identityBytes = identityId.copyOf() + private val addressBytes = address.copyOf() + val identityId: ByteArray get() = identityBytes.copyOf() + val address: ByteArray get() = addressBytes.copyOf() + init { + require(identityBytes.size == 32) + require(addressBytes.size == 43) + } + override fun equals(other: Any?): Boolean = other is ShieldedTipRecipient && + identityBytes.contentEquals(other.identityBytes) && addressBytes.contentEquals(other.addressBytes) + override fun hashCode(): Int = 31 * identityBytes.contentHashCode() + addressBytes.contentHashCode() +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt new file mode 100644 index 00000000000..ad29af2cf00 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/ShieldedTipRecipientHistory.kt @@ -0,0 +1,40 @@ +package org.dashfoundation.dashsdk.tokens + +import android.content.SharedPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.persistence.normalizeDpnsLabel + +/** + * Local record of recipients explicitly confirmed by this wallet's user. + * Mirrors the Swift tip sheet's persistent recipient-change warning. This + * record only informs confirmation; Rust still verifies the recipient before sending. + */ +class ShieldedTipRecipientHistory(private val preferences: SharedPreferences) { + fun hasChanged( + network: Network, walletId: ByteArray, username: String, recipient: ShieldedTipRecipient, + ): Boolean { + val previous = preferences.getString(key(network, walletId, username), null) ?: return false + return previous != snapshot(recipient) + } + + /** Call only after explicit confirmation, before submitting the payment. */ + suspend fun confirm( + network: Network, walletId: ByteArray, username: String, recipient: ShieldedTipRecipient, + ) = withContext(Dispatchers.IO) { + check(preferences.edit().putString(key(network, walletId, username), snapshot(recipient)).commit()) { + "Could not save recipient confirmation" + } + } + + private fun key(network: Network, walletId: ByteArray, username: String): String { + val label = username.trim().lowercase().removeSuffix(".dash") + return "${network.ffiValue}:${walletId.hex()}:${normalizeDpnsLabel(label)}.dash" + } + + private fun snapshot(recipient: ShieldedTipRecipient): String = + "${recipient.identityId.hex()}:${recipient.address.hex()}" + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt index 9cee58497f9..88b5c4f8567 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/ManagedPlatformWallet.kt @@ -1073,6 +1073,29 @@ class ManagedPlatformWallet internal constructor( } } + /** + * The HD derivation index of one managed identity, or null when the wallet + * holds no recoverable index for it (a watched or index-less identity). + * Per-identity state derived from the index — the dedicated shielded tip + * account — must treat null as "unavailable" rather than fall back to 0, + * which is identity 0's slot. Room's `IdentityEntity.identityIndex` is + * non-null and stores 0 as a placeholder, so it cannot answer this. + */ + suspend fun identityIndex(identityId: ByteArray): Int? = withContext(Dispatchers.IO) { + mapNativeErrors { + val identityHandle = translateManagedIdentityNotFoundToZero { + TokensNative.getManagedIdentity(handle, identityId) + } + if (identityHandle == 0L) return@mapNativeErrors null + try { + val index = WalletManagerNative.managedIdentityGetIdentityIndex(identityHandle) + if (index < 0) null else index.toInt() + } finally { + TokensNative.managedIdentityDestroy(identityHandle) + } + } + } + /** * The advisory "why not" reason a withdrawal preflight records when the * account can't fund one — port of the `success_with_message` reason the diff --git a/packages/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 7ef17c2d23e..e0364b797ad 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 @@ -1857,6 +1857,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 0aaacf099e1..2a679b56083 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 bdd3734e17a..71ee29e85c4 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 @@ -688,8 +688,9 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - // from protocol version 14 the contract's version item is stored beside the contract - 24002877830 + // from protocol version 14 the contract's version item is stored beside the contract, + // and the larger DashPay v2 schema adds byte-billed contract bytes + 24002988740 ); let check_result = platform @@ -1353,8 +1354,9 @@ mod tests { // Plus we have 24_000_000_000 in base costs assert_eq!( processing_result.aggregated_fees().processing_fee, - // from protocol version 14 the contract's version item is stored beside the contract - 24005755660 + // from protocol version 14 the contract's version item is stored beside the contract, + // and the larger DashPay v2 schema adds byte-billed contract bytes + 24005977480 ); let check_result = platform @@ -1829,8 +1831,9 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - // from protocol version 14 the contract's version item is stored beside the contract - 24002877830 + // from protocol version 14 the contract's version item is stored beside the contract, + // and the larger DashPay v2 schema adds byte-billed contract bytes + 24002988740 ); platform @@ -1917,8 +1920,9 @@ mod tests { assert_eq!( update_processing_result.aggregated_fees().processing_fee, - // from protocol version 14 the contract's version item is stored beside the contract - 27002932650 + // from protocol version 14 the contract's version item is stored beside the contract, + // and the larger DashPay v2 schema adds byte-billed contract bytes + 27003059940 ); let check_result = platform @@ -2506,8 +2510,9 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - // from protocol version 14 the contract's version item is stored beside the contract - 24002877830 + // from protocol version 14 the contract's version item is stored beside the contract, + // and the larger DashPay v2 schema adds byte-billed contract bytes + 24002988740 ); 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 b67ea4808ad..67a7ffa2dbe 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 @@ -1141,6 +1141,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()); @@ -1177,6 +1179,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")); } /// Reads the `status` enum of the stored withdrawals contract's `withdrawal` document @@ -1387,7 +1390,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" @@ -1439,7 +1446,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" @@ -2816,3 +2827,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 5d9165a286b..ae3871935b8 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 @@ -12,7 +12,10 @@ mod deletion_tests { PlatformVersion::latest().protocol_version, // v14: the deleted document carries the contract-version stamp // (one stored byte, five estimated), shifting processing costs - 1700360, // +740 per document write from protocol version 14: the contract's version item is one more node to rehash + // Protocol version 14 adds +740 per document write (the contract's version + // item is one more node to rehash) and the larger DashPay v2 schema + // increases byte-billed contract-tree reads. + 1721520, ) .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 0dae7a18c40..e70c5fc5227 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 @@ -501,7 +501,10 @@ mod replacement_tests { // v14: replaced documents carry the contract-version stamp, and // GroveDB V4 writes through the Merk node it retains from reading // the old value, billing slightly fewer reads than the V3 path - 1429060, // +740 per document write from protocol version 14: the contract's version item is one more node to rehash + // Protocol version 14 adds +740 per document write (the contract's version + // item is one more node to rehash) and the larger DashPay v2 schema + // increases byte-billed contract-tree reads. + 1450220, ) .await; } @@ -1988,7 +1991,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( @@ -2069,7 +2072,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 @@ -2212,7 +2215,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 @@ -2294,7 +2297,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 @@ -2338,7 +2341,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 @@ -2477,7 +2480,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 @@ -2559,7 +2562,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 @@ -2603,7 +2606,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 @@ -2746,7 +2749,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 @@ -2828,7 +2831,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 @@ -2872,7 +2875,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 20cd3437577..47bbc37a297 100644 --- a/packages/rs-drive/tests/deterministic_root_hash.rs +++ b/packages/rs-drive/tests/deterministic_root_hash.rs @@ -308,8 +308,9 @@ mod tests { 9..=13 => "14d9e2cdc3f25d1dfd079c1f9dd0d44db5bf73d397b04258449231a2d5bafda7", // Protocol version 14 also stores the contract's version as a four-byte // item beside the contract (`[64, id] / 2`), one more element under the - // contract's root subtree. - _ => "c5f12fcb423d17a25ea7969d3d51ce83f48a796392b7ceaf195f162647ec8206", + // contract's root subtree, and ships DashPay contract v2 with the + // optional `shieldedAddress` profile field. + _ => "3edbd4a7467ce38dcff24e517310ca8c66a096980669d0791f533ecd807203b4", }; 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 7491999e283..36408bcee3e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5859,6 +5859,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); } @@ -6019,6 +6022,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 @@ -6032,43 +6071,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, }, ); @@ -9361,6 +9368,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, }, @@ -9373,6 +9387,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, }, @@ -9397,6 +9418,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 e5c086a1cca..8250a63d09f 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -754,6 +754,48 @@ fn catch_spend_panic( ) } +/// Post-panic guidance for exports that move no funds. Paired with +/// `ErrorWalletOperation` in [`catch_query_panic`]. +const QUERY_PANIC_GUIDANCE: &str = "No funds were moved; the call may be retried."; + +/// [`catch_panic_to_code`] specialized for exports that spend nothing (address +/// preparation, recipient resolution). A panic there is a plain failure of this +/// call, so it maps to [`PlatformWalletFFIResultCode::ErrorWalletOperation`] and +/// the host may retry. The boundary matters as much as on the spend path: an +/// unwind that reaches the `extern "C"` frame aborts the process before the JNI +/// guard on the far side can translate it. +fn catch_query_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorWalletOperation, + QUERY_PANIC_GUIDANCE, + body, + ) +} + +/// Test-only fault injection for the tip exports: while set, the worker future +/// of each guarded tip call panics before it touches the wallet, so a test can +/// prove the real `extern "C"` entry point (not just the helper) contains the +/// unwind. Process-global because the future runs on a tokio worker thread; +/// consulted only by the three tip exports. +#[cfg(test)] +static INJECT_TIP_WORKER_PANIC: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(test)] +fn maybe_inject_tip_worker_panic() { + if INJECT_TIP_WORKER_PANIC.load(std::sync::atomic::Ordering::SeqCst) { + panic!("injected tip worker panic"); + } +} + +#[cfg(not(test))] +#[inline(always)] +fn maybe_inject_tip_worker_panic() {} + /// Post-panic guidance for the asset-lock funding exports. Paired with /// `ErrorTransactionBroadcastUnconfirmed` in [`catch_funding_panic`]. const FUNDING_PANIC_GUIDANCE: &str = "The asset lock may or may not have been broadcast — do \ @@ -2281,6 +2323,214 @@ fn resolve_wallet_and_coordinator( Ok((wallet, coordinator)) } +/// Derive and register the identity's dedicated tip account, without publishing it. +/// All ID pointers must reference 32 bytes; `out_address` must reference 43 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_prepare_shielded_tip_address( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + identity_id_bytes: *const u8, + out_address: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(identity_id_bytes); + check_ptr!(out_address); + std::ptr::write_bytes(out_address, 0, 43); + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let identity_id = match crate::types::read_identifier(identity_id_bytes) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(value) => value, + Err(e) => return e, + }; + // The worker call and its result mapping sit inside the panic boundary; + // the raw output write happens outside it, only on success, so a panic + // leaves the zeroed buffer untouched. + let mut address = [0u8; 43]; + let result = catch_query_panic( + "shielded tip address preparation", + || match block_on_worker(async move { + maybe_inject_tip_worker_panic(); + wallet + .prepare_shielded_tip_address(seed.as_ref(), &identity_id, &coordinator) + .await + }) { + Ok(bytes) => { + address = bytes; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + }, + ); + if result.code == PlatformWalletFFIResultCode::Success { + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); + } + result +} + +/// Resolve a username using verified current DPNS and profile documents. +/// Outputs are 32-byte identity ID and 43-byte Orchard address buffers. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_resolve_shielded_tip( + wallet_handle: Handle, + username: *const c_char, + out_identity_id: *mut u8, + out_address: *mut u8, +) -> PlatformWalletFFIResult { + check_ptr!(username); + check_ptr!(out_identity_id); + check_ptr!(out_address); + std::ptr::write_bytes(out_identity_id, 0, 32); + std::ptr::write_bytes(out_address, 0, 43); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let mut identity_id = [0u8; 32]; + let mut address = [0u8; 43]; + let result = catch_query_panic("shielded tip resolution", || { + let result = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + maybe_inject_tip_worker_panic(); + identity.dashpay().resolve_shielded_tip(&username).await + }) + }); + match result { + Some(Ok(recipient)) => { + identity_id = recipient.identity_id.to_buffer(); + address = recipient.address; + PlatformWalletFFIResult::ok() + } + Some(Err(e)) => e.into(), + None => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "Wallet not found", + ), + } + }); + if result.code == PlatformWalletFFIResultCode::Success { + std::ptr::copy_nonoverlapping(identity_id.as_ptr(), out_identity_id, 32); + std::ptr::copy_nonoverlapping(address.as_ptr(), out_address, 43); + } + result +} + +/// Send a tip only if fresh resolution matches the recipient the user confirmed. +/// ID/address pointers must reference 32/43 readable bytes respectively. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_send_shielded_tip( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + username: *const c_char, + expected_identity_id: *const u8, + expected_address: *const u8, + amount: u64, + memo_text: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(username); + check_ptr!(expected_identity_id); + check_ptr!(expected_address); + let username = match CStr::from_ptr(username).to_str() { + Ok(s) => s.to_owned(), + Err(e) => return e.into(), + }; + let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) + .try_into() + .unwrap(); + let identity_id = match crate::types::read_identifier(expected_identity_id) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let address: [u8; 43] = std::slice::from_raw_parts(expected_address, 43) + .try_into() + .unwrap(); + let memo_str = match crate::dashpay_profile::decode_opt_c_str(memo_text) { + Ok(value) => value, + Err(e) => return e, + }; + let memo = match encode_memo_text(memo_str.as_deref()) { + Ok(value) => value, + Err(e) => return e, + }; + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(value) => value, + Err(e) => return e, + }; + let seed = match crate::identity_keys_from_mnemonic::resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + ) { + Ok(value) => value, + Err(e) => return e, + }; + catch_spend_panic("shielded tip", || { + let result = block_on_worker(async move { + maybe_inject_tip_worker_panic(); + let recipient = platform_wallet::ShieldedTipRecipient { + identity_id, + address, + }; + let prover = CachedOrchardProver::new(); + let result = wallet + .send_shielded_tip( + &coordinator, + seed.as_ref(), + account, + &username, + &recipient, + amount, + memo, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + map_spend_result(result, "shielded tip") + }) +} + +/// Return the dedicated ZIP-32 tip account for a wallet identity derivation index. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_shielded_tip_account_index( + identity_index: u32, + out_account: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_account); + *out_account = 0; + match platform_wallet::wallet::shielded::tips::shielded_tip_account_index(identity_index) { + Ok(account) => { + *out_account = account; + PlatformWalletFFIResult::ok() + } + Err(e) => e.into(), + } +} + +/// Whether an account is reserved for explicitly selected DashPay tip activity. +#[no_mangle] +pub extern "C" fn platform_wallet_is_shielded_tip_account(account: u32) -> bool { + platform_wallet::wallet::shielded::is_shielded_tip_account(account) +} + #[cfg(test)] mod tests { use super::*; @@ -2831,6 +3081,208 @@ mod tests { ); } + /// A fixed BIP-39 phrase the test resolver answers for every wallet id; + /// the wallet under test is created from the same phrase so the resolved + /// seed matches it. + const TIP_TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about"; + + unsafe extern "C" fn resolve_tip_test_mnemonic( + _ctx: *const std::ffi::c_void, + _wallet_id: *const u8, + out: *mut c_char, + cap: usize, + out_len: *mut usize, + ) -> i32 { + let phrase = TIP_TEST_MNEMONIC.as_bytes(); + assert!(cap >= phrase.len()); + std::ptr::copy_nonoverlapping(phrase.as_ptr(), out as *mut u8, phrase.len()); + *out_len = phrase.len(); + rs_sdk_ffi::mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn destroy_tip_test_resolver(_ctx: *mut std::ffi::c_void) {} + + /// A manager with a configured shielded store and one wallet created from + /// `TIP_TEST_MNEMONIC`: the state a tip export needs to get past every + /// argument check and into its guarded worker call. + fn tip_test_manager() -> (Handle, [u8; 32], std::path::PathBuf) { + let handle = recovery_test_manager(); + let path = + std::env::temp_dir().join(format!("shielded-ffi-tip-{}-{handle}", std::process::id())); + std::fs::create_dir_all(&path).expect("temporary store directory"); + let wallet_id = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| { + runtime().block_on(async { + manager + .configure_shielded(path.join("tree.sqlite")) + .await + .expect("configure shielded store"); + manager + .create_wallet_from_mnemonic( + TIP_TEST_MNEMONIC, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("register wallet") + .wallet_id() + }) + }) + .expect("live manager"); + (handle, wallet_id, path) + } + + /// The real exports contain a worker panic instead of letting it unwind into + /// the `extern "C"` frame: this drives the exported entry points themselves, + /// so deleting a guard at a call site fails here even while the helper-level + /// tests below stay green. One test covers all three exports because the + /// injection flag is process-global. + #[test] + fn exported_tip_entry_points_contain_a_worker_panic() { + use std::sync::atomic::Ordering::SeqCst; + + let (handle, wallet_id, path) = tip_test_manager(); + let mut vtable = rs_sdk_ffi::MnemonicResolverVTable { + resolve: resolve_tip_test_mnemonic, + destroy: destroy_tip_test_resolver, + }; + let mut resolver = MnemonicResolverHandle { + ctx: std::ptr::null_mut(), + vtable: &mut vtable, + }; + let username = std::ffi::CString::new("alice").expect("username"); + let identity_id = [0x21u8; 32]; + let address = [0x43u8; 43]; + + INJECT_TIP_WORKER_PANIC.store(true, SeqCst); + + // Spending export: the ambiguous, do-not-retry contract. + let mut send = unsafe { + platform_wallet_manager_send_shielded_tip( + handle, + wallet_id.as_ptr(), + &mut resolver, + 0, + username.as_ptr(), + identity_id.as_ptr(), + address.as_ptr(), + 1_000, + std::ptr::null(), + ) + }; + assert_eq!( + send.code, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed + ); + let message = message_of(&send); + assert!( + message.contains("injected tip worker panic") && message.contains("do NOT retry"), + "{message}" + ); + + // Non-spending exports: a retryable failure with the outputs left zeroed. + let mut out_address = [0xAAu8; 43]; + let mut prepare = unsafe { + platform_wallet_manager_prepare_shielded_tip_address( + handle, + wallet_id.as_ptr(), + &mut resolver, + identity_id.as_ptr(), + out_address.as_mut_ptr(), + ) + }; + assert_eq!( + prepare.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = message_of(&prepare); + assert!( + message.contains("injected tip worker panic") && message.contains("may be retried"), + "{message}" + ); + assert_eq!( + out_address, [0u8; 43], + "no address may escape a failed call" + ); + + let mut wallet_handle = NULL_HANDLE; + let mut got = unsafe { + crate::manager::platform_wallet_manager_get_wallet( + handle, + &wallet_id, + &mut wallet_handle, + ) + }; + assert_eq!(got.code, PlatformWalletFFIResultCode::Success); + let mut out_identity = [0xAAu8; 32]; + let mut out_recipient = [0xAAu8; 43]; + let mut resolve = unsafe { + platform_wallet_resolve_shielded_tip( + wallet_handle, + username.as_ptr(), + out_identity.as_mut_ptr(), + out_recipient.as_mut_ptr(), + ) + }; + assert_eq!( + resolve.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + assert!(message_of(&resolve).contains("injected tip worker panic")); + assert_eq!(out_identity, [0u8; 32]); + assert_eq!(out_recipient, [0u8; 43]); + + INJECT_TIP_WORKER_PANIC.store(false, SeqCst); + unsafe { + crate::platform_wallet_ffi_result_free(&mut send); + crate::platform_wallet_ffi_result_free(&mut prepare); + crate::platform_wallet_ffi_result_free(&mut resolve); + crate::platform_wallet_ffi_result_free(&mut got); + let mut destroyed = crate::wallet::platform_wallet_destroy(wallet_handle); + crate::platform_wallet_ffi_result_free(&mut destroyed); + let mut destroyed = platform_wallet_manager_destroy(handle); + crate::platform_wallet_ffi_result_free(&mut destroyed); + } + let _ = std::fs::remove_dir_all(path); + } + + #[test] + fn catch_query_panic_maps_a_panic_to_a_retryable_wallet_operation() { + let result = catch_query_panic("shielded tip resolution", || { + panic!("tokio worker panicked"); + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = message_of(&result); + assert!( + message.contains("shielded tip resolution panicked") + && message.contains("tokio worker panicked") + && message.contains("may be retried"), + "{message}" + ); + } + + #[test] + fn should_contain_tip_worker_panic_as_uncertain_spend() { + let result = catch_spend_panic("shielded tip", || { + let result = block_on_worker(async { + panic!("tip worker failed after possible broadcast"); + #[allow(unreachable_code)] + Ok(()) + }); + map_spend_result(result, "shielded tip") + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed + ); + assert!(message_of(&result).contains("do NOT retry")); + } + /// A panic inside the CoinJoin-drain funding export must NOT unwind into the `extern "C"` /// frame (that aborts the Android process before the JNI layer's own guard can translate it /// into a Java exception). It becomes `ErrorTransactionBroadcastUnconfirmed` — the diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index dfda7eeb4c1..04943fae428 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -739,6 +739,89 @@ fn map_shielded_error(error: PlatformWalletError, operation: &str) -> PlatformWa } } +/// Snapshot the effective bound accounts, including discovered and restored tip +/// accounts. An unbound wallet returns an empty array. The caller must release +/// a nonempty result with `platform_wallet_manager_free_shielded_account_indices`. +/// +/// # Safety +/// `wallet_id_bytes` must point to 32 readable bytes; both output pointers must +/// be writable. Returned indices are valid until freed by the caller. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_account_indices( + handle: Handle, + wallet_id_bytes: *const u8, + out_indices: *mut *mut u32, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_indices); + check_ptr!(out_count); + *out_indices = std::ptr::null_mut(); + *out_count = 0; + check_ptr!(wallet_id_bytes); + let mut wallet_id = [0; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + match manager.get_wallet(&wallet_id).await { + Some(wallet) => Some(wallet.shielded_account_indices().await), + None => None, + } + }) + }); + let Some(indices) = unwrap_option_or_return!(option) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "wallet not found", + ); + }; + if !indices.is_empty() { + *out_count = indices.len(); + *out_indices = Box::into_raw(indices.into_boxed_slice()) as *mut u32; + } + PlatformWalletFFIResult::ok() +} + +/// Release an account snapshot returned by the matching getter. Null is a no-op. +/// +/// # Safety +/// A nonnull pointer and count must be an unfreed pair from +/// `platform_wallet_manager_shielded_account_indices`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_shielded_account_indices( + indices: *mut u32, + count: usize, +) { + if !indices.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + indices, count, + ))); + } +} + +#[cfg(test)] +mod account_indices_tests { + use super::*; + + #[test] + fn invalid_handle_clears_account_snapshot_outputs() { + let mut indices = std::ptr::NonNull::::dangling().as_ptr(); + let mut count = 99; + let wallet_id = [0; 32]; + unsafe { + let result = platform_wallet_manager_shielded_account_indices( + 0, + wallet_id.as_ptr(), + &mut indices, + &mut count, + ); + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert!(indices.is_null()); + assert_eq!(count, 0); + platform_wallet_manager_free_shielded_account_indices(indices, count); + } + } +} + #[cfg(test)] mod recovery_error_tests { use super::*; diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 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 769378695c1..68ba5003f62 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -132,6 +132,7 @@ erDiagram BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" INTEGER identity_index "BIP-32 index; NULL for out-of-wallet identities" BLOB entry_blob "bincode-encoded IdentityEntry" + INTEGER entry_format "V019: 0 = pre-payment-address record, 1 = current" } IDENTITY_KEYS { @@ -153,6 +154,7 @@ erDiagram DASHPAY_PROFILES { BLOB identity_id PK "one row per identity" BLOB profile_blob "bincode-encoded DashPayProfile" + INTEGER profile_format "V019: 0 = pre-payment-address record, 1 = current" } DASHPAY_PAYMENTS_OVERLAY { @@ -486,6 +488,13 @@ every identity-scoped child writer, so a changeset carrying both an upsert and a removal for one identity commits instead of pulling the FK parent out from under its own child inserts. +`entry_format` (V019) stamps the bincode shape of `entry_blob`: 0 is the +record written before `DashPayProfile` gained its payment addresses, 1 the +current one. The writer always stamps 1; the reader dispatches on the stamp +(`schema::identity_profile_encoding::decode_identity`), so rows written +before V019 stay readable until they are rewritten. The DEFAULT is 1 and the +migration stamps the rows it finds 0, so only pre-V019 rows are ever legacy. + - PK: `identity_id`. - FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE` (nullable). - Index: `idx_identities_wallet(wallet_id)`. @@ -618,6 +627,11 @@ DELETE rather than a NULL blob — the row is absent, not nulled. `apply` writes these rows, but `load()` does not return them today; `DashPaySyncManager` rebuilds the canonical profile from Platform. +`profile_format` (V019) stamps the bincode shape of `profile_blob` the same +way `identities.entry_format` does (0 = pre-payment-address record, 1 = +current); `schema::identity_profile_encoding::decode_profile` is the stamped +reader for the day `load()` grows one. + - PK: `identity_id` (single-row-per-identity). - FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. @@ -847,3 +861,4 @@ table-rebuild migration, as V004 does. | V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | | V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | | V018 | `V018__identity_hard_delete.rs` | Retires identity tombstoning. Adds `cascade_children_on_identity_delete` (brooms `identity_keys` / `contacts` / `ignored_senders` / `pending_contact_crypto` by the deleted identity id, covering the rows no live FK reaches) plus its access-path indexes `idx_contacts_owner`, `idx_ignored_senders_owner`, and `idx_pending_contact_crypto_owner`; purges every already-tombstoned identity and its dependents; drops `identities.tombstoned`. | +| V019 | `V019__profile_address_encoding.rs` | Adds the encoding stamps `identities.entry_format` and `dashpay_profiles.profile_format` (`INTEGER NOT NULL DEFAULT 1 CHECK IN (0, 1)`) and stamps every row present at migration time 0. `DashPayProfile` gained the core, platform and shielded payment addresses, which widens the positional bincode record embedded in both blobs; the stamp routes each row to the decoder for the shape it carries (`schema::identity_profile_encoding`). | diff --git a/packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs b/packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs new file mode 100644 index 00000000000..2b98b77cd6a --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V019__profile_address_encoding.rs @@ -0,0 +1,19 @@ +//! Stamp the bincode encoding of `identities.entry_blob` and +//! `dashpay_profiles.profile_blob`. +//! +//! Payment addresses (core, platform, shielded) extend `DashPayProfile`, which +//! is embedded positionally in both blobs, so a record written before this +//! migration decodes only against the pre-address shape. The stamp says which +//! shape a row carries: 0 is the pre-address record, 1 the current one, and +//! `schema::identity_profile_encoding` holds the decoder for each. +//! +//! Every row present when the migration runs is stamped 0. The column DEFAULT +//! is 1, so a later insert that does not name the column is read as the shape +//! the current writer produces; the CHECK refuses a stamp no decoder knows. +pub fn migration() -> String { + "ALTER TABLE identities ADD COLUMN entry_format INTEGER NOT NULL DEFAULT 1 CHECK(entry_format IN (0, 1)); + UPDATE identities SET entry_format = 0; + ALTER TABLE dashpay_profiles ADD COLUMN profile_format INTEGER NOT NULL DEFAULT 1 CHECK(profile_format IN (0, 1)); + UPDATE dashpay_profiles SET profile_format = 0;" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs index 6ed1f6a26ee..a179d6336a7 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -51,3 +51,6 @@ pub use persister::{prune_backups_in, PruneReport, RetentionPolicy, SqlitePersis pub use reports::{CommitReport, DeleteWalletReport}; #[doc(inline)] pub use schema::core_pool::OwningAccount; + +// Versioned blob readers are useful to hosts inspecting pre-migration backups. +pub use schema::{dashpay::decode_profile, identities::decode_identity}; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index c53f2584be2..164d8236743 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -1,6 +1,9 @@ //! BLOB-column codec helpers: thin `bincode::serde` wrappers so every //! `_blob` column uses one encoding path. Schema evolution is gated by the -//! refinery migration version — no per-blob revision tag. +//! refinery migration version — no per-blob revision tag, except the +//! `entry_format` / `profile_format` row stamps V019 added for the two +//! blobs that embed the widened `DashPayProfile` record (see +//! `identity_profile_encoding`). //! //! [`encode_outpoint`] / [`decode_outpoint`] encode `dashcore::OutPoint` //! the same way for the `outpoint` PK columns. The key is variable-width, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index e9dd5cdb6ae..f03490c5008 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -30,6 +30,13 @@ use crate::sqlite::schema::blob::impl_persistable_blob; // PUBLIC material only: DashPay overlay types reaching `_blob` columns. impl_persistable_blob!(DashPayProfile, PaymentEntry); +// `profile_blob` carries an encoding stamp (`profile_format`, V019): 0 is the +// pre-payment-address `DashPayProfile` shape, 1 the current one. Every write +// stamps 1; nothing in the crate reads the column back yet (see the module +// doc), so the stamped decoder is exported for hosts and for the day `load()` +// grows a reader. +pub use super::identity_profile_encoding::decode_profile; + /// Both tables are keyed by identity only; their FK to /// `identities(identity_id)` cascades via the `wallets → identities` chain. /// `wallet_id` feeds the precondition check only — no column. @@ -50,9 +57,10 @@ pub fn apply( let mut delete_stmt = tx.prepare_cached("DELETE FROM dashpay_profiles WHERE identity_id = ?1")?; let mut insert_stmt = tx.prepare_cached( - "INSERT INTO dashpay_profiles (identity_id, profile_blob) \ - VALUES (?1, ?2) \ - ON CONFLICT(identity_id) DO UPDATE SET profile_blob = excluded.profile_blob", + "INSERT INTO dashpay_profiles (identity_id, profile_blob, profile_format) \ + VALUES (?1, ?2, 1) \ + ON CONFLICT(identity_id) DO UPDATE SET \ + profile_blob = excluded.profile_blob, profile_format = 1", )?; for (identity_id, profile) in profiles { match profile { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index d29c622f5eb..e40e80b04b6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -22,6 +22,11 @@ use crate::sqlite::schema::blob::impl_persistable_blob; // PUBLIC material only: identity snapshot reaching the `entry_blob` column. impl_persistable_blob!(IdentityEntry); +// `entry_blob` carries an encoding stamp (`entry_format`, V019): 0 is the +// pre-payment-address `IdentityEntry` shape, 1 the current one. Every write +// stamps 1; the stamped reader keeps rows written before V019 decodable. +pub use super::identity_profile_encoding::decode_identity; + /// Write the changeset's inserted / updated identities. /// /// The insert half of a two-part apply: [`apply_removals`] must run for @@ -49,12 +54,13 @@ pub fn apply_upserts( // upsert without erroring), preserving the resident blob and index. // `IS` is the NULL-safe match for the nullable column. let mut stmt = tx.prepare_cached( - "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob) \ - VALUES (?1, ?2, ?3, ?4) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, entry_format) \ + VALUES (?1, ?2, ?3, ?4, 1) \ ON CONFLICT(identity_id) DO UPDATE SET \ wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ identity_index = excluded.identity_index, \ - entry_blob = excluded.entry_blob \ + entry_blob = excluded.entry_blob, \ + entry_format = 1 \ WHERE identities.wallet_id IS NULL OR identities.wallet_id IS excluded.wallet_id", )?; let wallet_id_param = wallet_id_to_param(wallet_id); @@ -280,7 +286,7 @@ pub fn fetch( // the identity-id row can't leak through; sentinel matches orphan rows. let wallet_id_param = wallet_id_to_param(wallet_id); let mut stmt = conn.prepare( - "SELECT length(entry_blob), entry_blob FROM identities \ + "SELECT length(entry_blob), entry_blob, entry_format FROM identities \ WHERE identity_id = ?1 AND wallet_id IS ?2", )?; let mut rows = stmt.query(params![&identity_id[..], wallet_id_param])?; @@ -289,7 +295,7 @@ pub fn fetch( Some(row) => { blob::check_size(row.get::<_, i64>(0)?)?; let payload: Vec = row.get(1)?; - Ok(Some(blob::decode(&payload)?)) + Ok(Some(decode_identity(&payload, row.get(2)?)?)) } } } @@ -351,7 +357,7 @@ pub fn load_state_with_ctx( // unowned bucket. A plain `=` could not express the second case at all. let wallet_id_param = wallet_id_to_param(wallet_id); let mut stmt = conn.prepare( - "SELECT identity_id, length(entry_blob), entry_blob, identity_index \ + "SELECT identity_id, length(entry_blob), entry_blob, identity_index, entry_format \ FROM identities WHERE wallet_id IS ?1 ORDER BY identity_id", )?; // The ignored-senders TABLE is the authoritative ignore record (every @@ -366,7 +372,7 @@ pub fn load_state_with_ctx( blob::check_size(row.get::<_, i64>(1)?)?; let payload: Vec = row.get(2)?; let typed_identity_index: Option = row.get(3)?; - let entry: IdentityEntry = blob::decode(&payload)?; + let entry: IdentityEntry = decode_identity(&payload, row.get(4)?)?; // Cross-check the decoded blob against the typed columns it was // selected by (mirrors the accounts / identity_keys readers): the // blob must name the same identity, and its own wallet_id (when set) @@ -572,8 +578,9 @@ pub fn ensure_exists( let payload = blob::encode(&stub)?; let wallet_id_param = wallet_id_to_param(wallet_id); conn.execute( - "INSERT OR IGNORE INTO identities (identity_id, wallet_id, identity_index, entry_blob) \ - VALUES (?1, ?2, NULL, ?3)", + "INSERT OR IGNORE INTO identities \ + (identity_id, wallet_id, identity_index, entry_blob, entry_format) \ + VALUES (?1, ?2, NULL, ?3, 1)", params![&identity_id[..], wallet_id_param, payload], )?; Ok(()) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs new file mode 100644 index 00000000000..8cc869fc11d --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_profile_encoding.rs @@ -0,0 +1,259 @@ +//! Frozen pre-V019 profile records, and the stamped decoders that route each +//! `identities.entry_blob` / `dashpay_profiles.profile_blob` row to the shape +//! it carries. Do not edit the legacy field order. +//! +//! `DashPayProfile` gained its payment addresses as trailing `Option` fields. +//! Under `bincode::serde` a struct is a fixed-arity positional record, so a +//! record written before the widening is never "short": the trailing fields +//! are simply absent, and decoding it against the current shape reads the +//! next field's bytes as an address. The row stamp (V019) is what says which +//! shape to decode; `serde(default)` on the new fields serves map formats +//! such as JSON only. +use super::blob; +use crate::sqlite::error::WalletStorageError; +use dpp::fee::Credits; +use dpp::prelude::{Identifier, Revision}; +use platform_wallet::changeset::IdentityEntry; +use platform_wallet::wallet::identity::types::block_time::BlockTime; +use platform_wallet::wallet::identity::PaymentEntry; +use platform_wallet::{ContactProfileEntry, DashPayProfile, DpnsNameInfo, IdentityStatus}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyProfile { + display_name: Option, + bio: Option, + avatar_url: Option, + avatar_hash: Option<[u8; 32]>, + avatar_fingerprint: Option<[u8; 8]>, + public_message: Option, +} +impl From for DashPayProfile { + fn from(p: LegacyProfile) -> Self { + Self { + display_name: p.display_name, + bio: p.bio, + avatar_url: p.avatar_url, + avatar_hash: p.avatar_hash, + avatar_fingerprint: p.avatar_fingerprint, + public_message: p.public_message, + ..Default::default() + } + } +} +impl From for LegacyProfile { + fn from(p: DashPayProfile) -> Self { + Self { + display_name: p.display_name, + bio: p.bio, + avatar_url: p.avatar_url, + avatar_hash: p.avatar_hash, + avatar_fingerprint: p.avatar_fingerprint, + public_message: p.public_message, + } + } +} +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyContactProfile { + profile: Option, + checked_at_ms: u64, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct LegacyIdentityEntry { + pub id: Identifier, + pub balance: Credits, + pub revision: Revision, + pub identity_index: Option, + pub last_updated_balance_block_time: Option, + pub last_synced_keys_block_time: Option, + pub dpns_names: Vec, + pub contested_dpns_names: Vec, + pub status: IdentityStatus, + pub wallet_id: Option<[u8; 32]>, + pub dashpay_profile: Option, + pub dashpay_payments: BTreeMap, + pub contact_profiles: BTreeMap, + pub ignored_senders: BTreeSet, +} +impl From for IdentityEntry { + fn from(old: LegacyIdentityEntry) -> Self { + IdentityEntry { + id: old.id, + balance: old.balance, + revision: old.revision, + identity_index: old.identity_index, + last_updated_balance_block_time: old.last_updated_balance_block_time, + last_synced_keys_block_time: old.last_synced_keys_block_time, + dpns_names: old.dpns_names, + contested_dpns_names: old.contested_dpns_names, + status: old.status, + wallet_id: old.wallet_id, + dashpay_profile: old.dashpay_profile.map(Into::into), + dashpay_payments: old.dashpay_payments, + contact_profiles: old + .contact_profiles + .into_iter() + .map(|(id, entry)| { + ( + id, + ContactProfileEntry { + profile: entry.profile.map(Into::into), + checked_at_ms: entry.checked_at_ms, + }, + ) + }) + .collect(), + ignored_senders: old.ignored_senders, + } + } +} +impl From for LegacyIdentityEntry { + fn from(entry: IdentityEntry) -> Self { + Self { + id: entry.id, + balance: entry.balance, + revision: entry.revision, + identity_index: entry.identity_index, + last_updated_balance_block_time: entry.last_updated_balance_block_time, + last_synced_keys_block_time: entry.last_synced_keys_block_time, + dpns_names: entry.dpns_names, + contested_dpns_names: entry.contested_dpns_names, + status: entry.status, + wallet_id: entry.wallet_id, + dashpay_profile: entry.dashpay_profile.map(Into::into), + dashpay_payments: entry.dashpay_payments, + contact_profiles: entry + .contact_profiles + .into_iter() + .map(|(id, entry)| { + ( + id, + LegacyContactProfile { + profile: entry.profile.map(Into::into), + checked_at_ms: entry.checked_at_ms, + }, + ) + }) + .collect(), + ignored_senders: entry.ignored_senders, + } + } +} + +// Test-only: production never writes the legacy shapes, but migration +// fixtures have to produce the bytes a pre-V019 writer produced. PUBLIC +// material only, like the current shapes they mirror. +#[cfg(any(test, feature = "__test-helpers"))] +super::blob::impl_persistable_blob!(LegacyProfile, LegacyIdentityEntry); + +/// The bytes a pre-V019 writer stored for `profile`: the record without the +/// payment-address fields. For fixtures of databases written before V019. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn encode_legacy_profile(profile: &DashPayProfile) -> Result, WalletStorageError> { + blob::encode(&LegacyProfile::from(profile.clone())) +} + +/// The bytes a pre-V019 writer stored for `entry`: its own and its contacts' +/// profiles in the pre-payment-address shape. For fixtures of databases +/// written before V019. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn encode_legacy_identity(entry: &IdentityEntry) -> Result, WalletStorageError> { + blob::encode(&LegacyIdentityEntry::from(entry.clone())) +} + +/// Decode a `dashpay_profiles.profile_blob` using its V019 `profile_format` +/// stamp: zero is the pre-address shape, one includes payment addresses. Never +/// fall back to the legacy shape after a current-format decoding error. +pub fn decode_profile(payload: &[u8], format: i64) -> Result { + match format { + 0 => blob::decode::(payload).map(Into::into), + 1 => blob::decode(payload), + _ => Err(WalletStorageError::blob_decode( + "unsupported profile encoding", + )), + } +} + +/// Decode an `identities.entry_blob` using its `entry_format` stamp, preserving +/// the pre-address owned and contact profile shapes for format zero. +pub fn decode_identity(payload: &[u8], format: i64) -> Result { + match format { + 1 => blob::decode(payload), + 0 => blob::decode::(payload).map(Into::into), + _ => Err(WalletStorageError::blob_decode( + "unsupported identity profile encoding", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn old_profile() -> DashPayProfile { + DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("hello".into()), + public_message: Some("hello".into()), + ..Default::default() + } + } + + #[test] + fn should_decode_standalone_profiles_by_format_without_fallback() { + let legacy = encode_legacy_profile(&old_profile()).unwrap(); + let mut profile = decode_profile(&legacy, 0).unwrap(); + assert_eq!(profile, old_profile()); + assert!(profile.core_payment_address.is_none()); + assert!(profile.platform_payment_address.is_none()); + assert!(profile.shielded_address.is_none()); + assert!(decode_profile(&legacy, 1).is_err()); + profile.shielded_address = Some(vec![9; 43]); + let current = blob::encode(&profile).unwrap(); + assert_eq!(decode_profile(¤t, 1).unwrap(), profile); + assert!(decode_profile(¤t, 0).is_err()); + assert!(decode_profile(¤t, 2).is_err()); + assert!(decode_profile(¤t[..current.len() - 1], 1).is_err()); + } + + /// The legacy identity record round-trips through the stamped decoder + /// with every field after the embedded profiles intact: the profile is + /// positional, so a shape mismatch would shift `dashpay_payments`, + /// `contact_profiles` and `ignored_senders`, not just drop an address. + #[test] + fn should_read_old_owned_and_contact_profiles_without_losing_following_fields() { + let id = Identifier::from([1; 32]); + let entry = IdentityEntry { + id, + balance: 123, + revision: 2, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: vec![], + contested_dpns_names: vec![], + status: IdentityStatus::Unknown, + wallet_id: Some([2; 32]), + dashpay_profile: Some(old_profile()), + dashpay_payments: BTreeMap::new(), + contact_profiles: [( + id, + ContactProfileEntry { + profile: Some(old_profile()), + checked_at_ms: 321, + }, + )] + .into(), + ignored_senders: [Identifier::from([3; 32])].into(), + }; + let legacy = encode_legacy_identity(&entry).unwrap(); + assert_eq!(decode_identity(&legacy, 0).unwrap(), entry); + // Current bytes carry three more `None`s per embedded profile. + let current = blob::encode(&entry).unwrap(); + assert_eq!(current.len(), legacy.len() + 6); + assert_eq!(decode_identity(¤t, 1).unwrap(), entry); + assert!(decode_identity(¤t[..current.len() - 1], 1).is_err()); + assert!(decode_identity(¤t, 2).is_err()); + assert!(decode_identity(&legacy, 2).is_err()); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 88bff87e377..daaefce1a9d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -4,7 +4,10 @@ //! columns (heights, hashes, outpoints, flags); `_blob` columns carry the //! full sub-changeset entry via [`blob::encode`] / [`blob::decode`]. Schema //! evolution is gated by the refinery migration version — blobs carry no -//! inline revision tag. +//! inline revision tag. The two exceptions are the identity and DashPay +//! profile blobs, whose rows carry an encoding stamp column (V019) because +//! the positional `DashPayProfile` record grew payment addresses; see +//! [`identity_profile_encoding`]. pub mod accounts; pub mod asset_locks; @@ -16,6 +19,7 @@ pub mod dashpay; pub mod dpns_name_states; pub mod identities; pub mod identity_keys; +pub mod identity_profile_encoding; pub mod identity_scan_states; pub mod invitations; pub mod pending_contact_crypto; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 9757deee4de..910d8e5f1ff 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -674,6 +674,7 @@ fn tc012_dashpay_overlay_roundtrip() { avatar_hash: None, avatar_fingerprint: None, public_message: Some("public".into()), + ..Default::default() }; let payment = PaymentEntry::new_sent(Identifier::from([0x66; 32]), 7_500, Some("lunch".into())); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs new file mode 100644 index 00000000000..60432de4fc7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_profile_address_encoding.rs @@ -0,0 +1,233 @@ +#![allow(clippy::field_reassign_with_default)] + +//! A database written before V019 keeps its identity and DashPay profile rows +//! readable: the migration stamps the rows it finds 0, the stamped decoders +//! route them to the pre-payment-address record, and a rewrite stamps 1. +//! The fixture is written at V007 (the last published pre-rehydration +//! schema), so the whole V008 -> V019 chain runs over the legacy bytes. + +use std::collections::BTreeMap; + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; +use platform_wallet::{ContactProfileEntry, DashPayProfile, IdentityStatus}; +use platform_wallet_storage::sqlite::schema::identity_profile_encoding::{ + encode_legacy_identity, encode_legacy_profile, +}; +use platform_wallet_storage::sqlite::schema::{blob, dashpay, identities}; +use platform_wallet_storage::sqlite::{decode_identity, decode_profile, migrations}; +use rusqlite::{params, Connection}; + +const WALLET_ID: [u8; 32] = [0x62; 32]; + +fn old_profile() -> DashPayProfile { + DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("hello".into()), + public_message: Some("hello".into()), + ..Default::default() + } +} + +fn entry(id: Identifier) -> IdentityEntry { + IdentityEntry { + id, + balance: 123, + revision: 2, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: vec![], + contested_dpns_names: vec![], + status: IdentityStatus::Unknown, + wallet_id: Some(WALLET_ID), + dashpay_profile: Some(old_profile()), + dashpay_payments: BTreeMap::new(), + contact_profiles: [( + id, + ContactProfileEntry { + profile: Some(old_profile()), + checked_at_ms: 321, + }, + )] + .into(), + ignored_senders: [Identifier::from([3; 32])].into(), + } +} + +fn stamps(conn: &Connection) -> (i64, i64) { + let entry_format = conn + .query_row("SELECT entry_format FROM identities", [], |row| row.get(0)) + .unwrap(); + let profile_format = conn + .query_row("SELECT profile_format FROM dashpay_profiles", [], |row| { + row.get(0) + }) + .unwrap(); + (entry_format, profile_format) +} + +fn stored_profile(conn: &Connection) -> DashPayProfile { + let (payload, format): (Vec, i64) = conn + .query_row( + "SELECT profile_blob, profile_format FROM dashpay_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + decode_profile(&payload, format).unwrap() +} + +/// Rows a pre-V019 writer stored are stamped 0 by the migration and decode +/// to the pre-address shape through both readers; a rewrite stamps them 1 +/// and the new fields round-trip; an insert that omits the stamp is read as +/// the current shape. +#[test] +fn pre_v019_rows_stay_readable_and_rewrites_restamp() { + let id = Identifier::from([1; 32]); + let old = entry(id); + + let mut conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", true).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(7)) + .run(&mut conn) + .unwrap(); + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![WALLET_ID.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 0, ?3, 0)", + params![ + id.as_slice(), + WALLET_ID.as_slice(), + encode_legacy_identity(&old).unwrap() + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, ?2)", + params![ + id.as_slice(), + encode_legacy_profile(&old_profile()).unwrap() + ], + ) + .unwrap(); + + migrations::run(&mut conn).unwrap(); + + assert_eq!(stamps(&conn), (0, 0), "rows present at V019 are legacy"); + assert_eq!(stored_profile(&conn), old_profile()); + let restored = identities::fetch(&conn, &WALLET_ID, &id.to_buffer()) + .unwrap() + .unwrap(); + assert_eq!(restored, old); + assert!(restored + .dashpay_profile + .as_ref() + .unwrap() + .shielded_address + .is_none()); + // The reader the runtime restores from dispatches on the stamp too: a + // legacy row decoded against the current shape would fail this load. + let state = identities::load_state(&conn, &WALLET_ID).unwrap(); + let managed = &state.wallet_identities[&WALLET_ID][&0]; + assert_eq!(managed.identity.id(), id); + assert_eq!(managed.identity.balance(), 123); + + // Rewriting the rows through the live writers stamps them current. + let mut current = restored; + current.dashpay_profile.as_mut().unwrap().shielded_address = Some(vec![9; 43]); + let tx = conn.transaction().unwrap(); + identities::apply_upserts( + &tx, + &WALLET_ID, + &IdentityChangeSet { + identities: [(id, current.clone())].into(), + ..Default::default() + }, + ) + .unwrap(); + dashpay::apply( + &tx, + &WALLET_ID, + Some(&BTreeMap::from([(id, current.dashpay_profile.clone())])), + None, + ) + .unwrap(); + tx.commit().unwrap(); + assert_eq!(stamps(&conn), (1, 1)); + assert_eq!( + stored_profile(&conn), + current.dashpay_profile.clone().unwrap() + ); + assert_eq!( + identities::fetch(&conn, &WALLET_ID, &id.to_buffer()) + .unwrap() + .unwrap(), + current + ); + let encoded = blob::encode(¤t).unwrap(); + assert_eq!(decode_identity(&encoded, 1).unwrap(), current); + + // The stamp DEFAULT follows the writer: an insert that omits it reads as + // the current shape, so only rows the migration found are ever legacy. + let other = Identifier::from([7; 32]); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob) \ + VALUES (?1, ?2, 1, ?3)", + params![ + other.as_slice(), + WALLET_ID.as_slice(), + blob::encode(&IdentityEntry { + id: other, + identity_index: Some(1), + ..current.clone() + }) + .unwrap() + ], + ) + .unwrap(); + let format: i64 = conn + .query_row( + "SELECT entry_format FROM identities WHERE identity_id = ?1", + [other.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(format, 1); + assert_eq!( + identities::fetch(&conn, &WALLET_ID, &other.to_buffer()) + .unwrap() + .unwrap() + .dashpay_profile + .unwrap() + .shielded_address, + Some(vec![9; 43]) + ); +} + +/// A stamp no decoder knows is refused at the schema, not misread. +#[test] +fn unknown_stamp_is_rejected_by_the_check_constraint() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run(&mut conn).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![WALLET_ID.as_slice()], + ) + .unwrap(); + let id = Identifier::from([4; 32]); + let err = conn + .execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, entry_format) \ + VALUES (?1, ?2, NULL, ?3, 2)", + params![id.as_slice(), WALLET_ID.as_slice(), blob::encode(&entry(id)).unwrap()], + ) + .unwrap_err(); + assert!(err.to_string().contains("CHECK"), "{err}"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 282a2f5abf1..20593d4171c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "91f2fab573900a41066b8d237a29b83ca94b2fc730702389ab9c088271da522f"; + "e7451271a3b29686a7ca0d09bdc1e1bac5c02c70af98987448d6fd0ca7a7b047"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "0dbcfb2ab8d8362a206c0ab948051028c7eafc640861a823c4c50f13b0602be8"; + "0daf4e59295ca8f254fe5d402ab37290341bacccd9cb3727097ec9e4eb06171c"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md new file mode 100644 index 00000000000..c184b7b4ecb --- /dev/null +++ b/packages/rs-platform-wallet/docs/SHIELDED_TIPS.md @@ -0,0 +1,85 @@ +# DashPay shielded tips + +DashPay profiles can publish a reusable Orchard receiving address. A payer resolves +DPNS to an identity, fetches its profile with proof verification, confirms the +recipient, and sends an ordinary shielded transfer. No contact request or payment +notification document is necessary. + +## Address format and updates + +`profile.shieldedAddress` contains 43 raw bytes: the 11-byte diversifier followed +by the 32-byte diversified transmission key. Text encoding, network prefix, and +checksum belong to the user interface. External addresses with any valid +diversifier are supported. The profile does not prove ownership of the address. + +`DashPayProfile` exposes `core_payment_address`, `platform_payment_address`, and +`shielded_address`. The transparent fields use their existing 21-byte storage +form. `ProfileUpdate` uses `PaymentAddressUpdate::Keep`, `Set(bytes)`, or `Remove` +for each address. An unrelated edit preserves all payment addresses. Clients +validate Orchard decoding before publication and payment; an invalid shielded +address does not prevent displaying the rest of the profile. + +Publication checks the connected chain's DashPay contract before submitting an +address field. A bundled schema alone does not demonstrate network activation. + +## Dedicated local accounts + +The wallet reserves ZIP-32 account indices `0x40000000..0x80000000` for DashPay +tips. A wallet-owned identity at derivation index `i` uses account +`0x40000000 + i`; identity indices outside the lower half are rejected by the tip +helper. Ordinary account allocation must stay below `0x40000000`. Use +`shielded_tip_account_index` and `is_shielded_tip_account` instead of duplicating +these constants in applications. + +Call `PlatformWallet::prepare_shielded_tip_address(seed, identity_id, coordinator)` +to obtain the default address of this account. The helper binds its viewing keys +to the synchronization coordinator and flushes persistence before returning. It +does not publish the address; publication remains an explicit profile operation. +Repeated preparation derives the same account and address. An identity without a +wallet derivation index can publish an external address instead. + +Tip accounts have distinct viewing keys. Hosts must exclude them from ordinary +receive, balance, and automatic spending choices, as the example apps do. +`shielded_balances()` returns all bound accounts separately, including tip +accounts; it does not apply this policy. Spending a tip balance is an explicit +host action. +The generic shielded transfer API still accepts an explicitly selected account. +A published address is publicly associated with the profile; account separation +does not eliminate correlations introduced by later transfers or provide a +blanket guarantee against future cryptographic attacks. + +Address preparation requires an existing shielded bind. Bind the host's ordinary +accounts first; preparation preserves them while adding the dedicated account. + +## Restoration and address changes + +On seed restoration, discover the wallet's identities before binding and scanning +shielded accounts. Seed-backed `bind_shielded` adds each discovered identity's tip +account even if the caller supplies only account zero. This does not depend on the +current profile: removal or replacement of a published address does not remove +received funds from recovery. After discovering more identities in an existing +session, bind again. Newly bound accounts start with their own scan watermark at +zero, so the next synchronization scans their history while retaining the shared +commitment tree and previously synchronized accounts. + +Seedless binding includes persisted tip viewing keys, including retired accounts. +If a newly discovered identity's key is missing, it returns `false` so the host can +perform seed-backed binding. Address rotation within a tip account uses a new +diversifier; the account viewing key detects both old and new addresses. Removing +publication neither revokes old address copies nor stops their monitoring. + +An externally supplied address is not automatically owned by this wallet. Its +funds, viewing keys, and recovery belong to the external wallet. + +## Resolving and paying + +Use `wallet.identity().dashpay().resolve_shielded_tip(username)` to obtain a fresh +`ShieldedTipRecipient`. Display the resolved identity and address for confirmation. +`wallet.send_shielded_tip(...)` takes that confirmed recipient, re-resolves the +name and profile, and refuses payment if either changed. It never switches to a +transparent destination. This is a snapshot check: the submitted payment always +uses the address that was confirmed, even if the profile changes afterward. + +Tips to a public profile address are anonymous from the receiver's perspective +unless the payer provides additional context. This feature does not implement +per-contact addresses or authenticated sender attribution. diff --git a/packages/rs-platform-wallet/src/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 10780172b4a..b1c2fcbd55f 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -1457,6 +1457,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 1cce9634674..db7a7ef5594 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -143,6 +143,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 { @@ -155,22 +156,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. @@ -231,15 +219,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) { @@ -273,6 +252,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). @@ -430,9 +410,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` @@ -464,6 +465,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()), } } @@ -1085,3 +1101,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 6b69ae6042e..17ccb417189 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -798,12 +798,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()); @@ -821,11 +838,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) { @@ -915,9 +927,25 @@ impl PlatformWallet { "persister load failed while rebinding shielded viewing keys: {e}" )) })?; + let mut accounts: std::collections::BTreeSet = accounts.iter().copied().collect(); + // Newly discovered identities require their tip accounts too. Missing + // FVKs must trigger seed-backed binding: skipping them would report a + // successful restart while silently omitting recoverable tip history. + accounts.extend(self.discovered_tip_accounts().await?); + accounts.extend( + start + .shielded + .viewing_keys + .keys() + .filter(|id| { + id.wallet_id == self.wallet_id + && super::shielded::is_shielded_tip_account(id.account_index) + }) + .map(|id| id.account_index), + ); let mut account_views: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for &account in accounts { + for &account in &accounts { let id = SubwalletId::new(self.wallet_id, account); let Some(fvk_bytes) = start.shielded.viewing_keys.get(&id) else { return Ok(false); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index df2b4fa1ddc..fd5a4810d04 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -46,6 +46,8 @@ pub mod prover; pub mod seed_pool; pub mod store; pub mod sync; +pub mod tips; +pub use tips::{is_shielded_tip_account, shielded_tip_account_index, SHIELDED_TIP_ACCOUNT_BASE}; #[cfg(test)] mod viewing_key_bind_tests; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs index 6cdb39536f8..010015e32e9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -281,3 +281,33 @@ fn shield_memo_round_trips_through_ovk_recovery() { "the OVK-recovered memo must decode back to the original text" ); } + +#[test] +fn should_restore_tip_notes_without_exposing_personal_notes_to_tip_viewing_key() { + use crate::wallet::shielded::shielded_tip_account_index; + let seed = [0x67; 64]; + let tip_index = shielded_tip_account_index(0).unwrap(); + let tips = OrchardKeySet::from_seed(&seed, Network::Testnet, tip_index).unwrap(); + let restored = OrchardKeySet::from_seed(&seed, Network::Testnet, tip_index).unwrap(); + let personal = OrchardKeySet::from_seed(&seed, Network::Testnet, 0).unwrap(); + // Rotation is just another address of the dedicated account. Both remain + // discoverable without a saved diversifier index or a current profile. + for recipient in [tips.default_address, tips.address_at(17)] { + let wire = make_own_ovk_wire_note( + recipient, + tips.outgoing_viewing_key.clone(), + 123_456, + [0; 36], + ); + assert!(try_decrypt_note_with_memo(&restored.prepared_ivk(), &wire).is_some()); + assert!(try_decrypt_note_with_memo(&personal.prepared_ivk(), &wire).is_none()); + } + let personal_wire = make_own_ovk_wire_note( + personal.default_address, + personal.outgoing_viewing_key.clone(), + 456_789, + [0; 36], + ); + assert!(try_decrypt_note_with_memo(&tips.prepared_ivk(), &personal_wire).is_none()); + assert!(try_recover_outgoing_note(&tips.outgoing_viewing_key, &personal_wire).is_none()); +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/tips.rs b/packages/rs-platform-wallet/src/wallet/shielded/tips.rs new file mode 100644 index 00000000000..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 670eaef352a..2697172c74e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -38,6 +38,8 @@ use crate::wallet::shielded::{ #[derive(Default)] struct CapturingPersistence { stored: Mutex>, + durability_events: Mutex>, + fail_flush: std::sync::atomic::AtomicBool, serve: Mutex>>, serve_subwallets: Mutex>, load_calls: Mutex, @@ -112,11 +114,18 @@ impl PlatformWalletPersistence for CapturingPersistence { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + self.durability_events.lock().unwrap().push("store"); self.stored.lock().expect("stored lock").push(changeset); Ok(()) } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + self.durability_events.lock().unwrap().push("flush"); + if self.fail_flush.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend(std::io::Error::other( + "injected flush failure", + ))); + } Ok(()) } @@ -916,3 +925,212 @@ async fn should_keep_wallet_and_coordinator_keys_when_guarded_registration_is_re 1 ); } + +/// A profile is not a recovery record. Identity discovery alone must restore +/// the reserved account, even when the owner removed the tip address entirely. +#[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 67bfc71e13f..b1ef623f365 100644 --- a/packages/rs-unified-sdk-jni/src/funding.rs +++ b/packages/rs-unified-sdk-jni/src/funding.rs @@ -1253,3 +1253,155 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_shielde let _ = take_pwffi_error(env, result); }) } + +/// Dedicated tip account preparation; returns the complete raw Orchard address. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_prepareShieldedTipAddress( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, + identity_id: JByteArray, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return ptr::null_mut(); + }; + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let mut address = [0; 43]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_prepare_shielded_tip_address( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + id.as_ptr(), + address.as_mut_ptr(), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&address) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Current verified recipient: 32-byte identity ID followed by 43-byte address. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_resolveShieldedTip( + mut env: JNIEnv, + _class: JClass, + wallet: jlong, + username: JString, +) -> jni::sys::jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return ptr::null_mut(); + } + }; + let mut recipient = [0; 75]; + let result = unsafe { + platform_wallet_ffi::platform_wallet_resolve_shielded_tip( + wallet as Handle, + username.as_ptr(), + recipient.as_mut_ptr(), + recipient.as_mut_ptr().add(32), + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + env.byte_array_from_slice(&recipient) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Revalidates the confirmed recipient before spending. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_sendShieldedTip( + mut env: JNIEnv, + _class: JClass, + manager: jlong, + wallet_id: JByteArray, + resolver: jlong, + account: jint, + username: JString, + expected_id: JByteArray, + expected_address: JByteArray, + amount: jlong, + memo: JString, +) { + guard(&mut env, (), |env| { + if account < 0 || amount <= 0 { + throw_sdk_exception(env, 1, "Invalid account or amount"); + return; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return; + }; + let Some(id) = read_id32(env, &expected_id, "expectedIdentityId") else { + return; + }; + let Some(address) = read_recipient43(env, &expected_address) else { + return; + }; + let username = match read_cstring_opt(env, &username, "username") { + Ok(Some(name)) => name, + _ => { + throw_sdk_exception(env, 1, "username is required"); + return; + } + }; + let memo = match read_cstring_opt(env, &memo, "memo") { + Ok(value) => value, + Err(()) => return, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_send_shielded_tip( + manager as Handle, + wid.as_ptr(), + resolver as *mut MnemonicResolverHandle, + account as u32, + username.as_ptr(), + id.as_ptr(), + address.as_ptr(), + amount as u64, + memo.as_ref().map_or(ptr::null(), |m| m.as_ptr()), + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_FundingNative_tipAccountIndex( + mut env: JNIEnv, + _class: JClass, + identity_index: jint, +) -> jint { + guard(&mut env, -1, |env| { + if identity_index < 0 { + throw_sdk_exception(env, 1, "identity index must be non-negative"); + return -1; + } + let mut account = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_shielded_tip_account_index( + identity_index as u32, + &mut account, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + account as jint + }) +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 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 2d66c4b8ea3..3c021b72d3e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -85,7 +85,52 @@ public enum DashModelContainer { + [DashSchemaV2.PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V4). + /// The exact model set registered as schema V4: every model V3 registers, + /// with the sweep columns, frozen as a whole graph under `DashSchemaV4` + /// (see `FrozenSchemas/`, generated by `scripts/freeze_schema_models.py` + /// from the last commit before V5 added a model). Frozen for the same + /// reason as `v1ModelTypes`. + 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 released versions above this list tracks the LIVE models, /// so it moves whenever a model gains a property — which is exactly why /// the released versions must not. When the next property lands: freeze @@ -133,13 +178,14 @@ public enum DashModelContainer { PersistentAssetLock.self, PersistentInvitation.self, PersistentMasternode.self, - PersistentTrackedMasternode.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 @@ -206,14 +252,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) ] } } @@ -410,12 +457,31 @@ public enum DashSchemaV3: VersionedSchema { /// /// Registering it required freezing every model V1–V3 register — the /// generated copies under `FrozenSchemas/`, see -/// `scripts/freeze_schema_models.py`. +/// `scripts/freeze_schema_models.py`. V5 retired it in turn, so its own +/// model set is frozen the same way (`DashSchemaV4.*`). public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v4ModelTypes + } +} + +/// Version 5 adds `PersistentDashpayPaymentAddresses`: the DashPay payment +/// addresses (core, platform, shielded) of an owned or cached contact profile, +/// keyed by `(network, owner identity, profile identity)`. They live in their +/// own entity rather than as columns on `PersistentDashpayProfile` / +/// `PersistentDashpayContactProfile`, so no existing entity changes shape and +/// the stage is a pure additive lightweight migration. A store written by the +/// V4 build opens against the frozen `DashSchemaV4` graph and gains the empty +/// table. +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/FrozenSchemas/DashSchemaV4+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift new file mode 100644 index 00000000000..e5c5757dfca --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentAccount` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift new file mode 100644 index 00000000000..f693fa47678 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaV4.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift new file mode 100644 index 00000000000..f2949819ba4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// `PersistentCoreAddress` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentCoreAddress { + var poolTypeName: String { + switch poolTypeTag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Additional" + case 3: return "Additional (Hardened)" + default: return "Unknown(\(poolTypeTag))" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift new file mode 100644 index 00000000000..893dc47285e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData + +// `PersistentDPNSName` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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 = Self.normalize(label) + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = Self.normalize(parentDomainName) + 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() + } + } +} + +extension DashSchemaV4.PersistentDPNSName { + var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + +extension DashSchemaV4.PersistentDPNSName { + static func normalize(_ input: String) -> String { + String(input.map { c -> Character in + switch c { + case "o", "O": return "0" + case "i", "I": return "1" + case "l", "L": return "1" + default: return Character(c.lowercased()) + } + }) + } +} + +extension DashSchemaV4.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { name in + name.identity.identityId == target && name.isOwned == true + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..5053e0e2a28 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactProfile` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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() + } + } +} + +extension DashSchemaV4.PersistentDashpayContactProfile { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift new file mode 100644 index 00000000000..744c5572ddf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactRequest` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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() + } + } +} + +extension DashSchemaV4.PersistentDashpayContactRequest { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + isOutgoing: Bool + ) -> Predicate { + let target = ownerIdentityId + let direction = isOutgoing + return #Predicate { row in + row.ownerIdentityId == target && row.isOutgoing == direction + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..d332a6ffa47 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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() + } + } +} + +extension DashSchemaV4.PersistentDashpayIgnoredSender { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift new file mode 100644 index 00000000000..7dc4b69c6f1 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentDashpayPayment` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + + var amountDuffs: UInt64 + + var directionRaw: UInt8 + + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + var statusRaw: UInt8 + + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + 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() + } + } +} + +extension DashSchemaV4.PersistentDashpayPayment { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift new file mode 100644 index 00000000000..49ecf016725 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +// `PersistentDashpayProfile` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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() + } + } +} + +extension DashSchemaV4.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { profile in + profile.identity.identityId == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift new file mode 100644 index 00000000000..58068875996 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftData + +// `PersistentDataContract` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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? + + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + 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 + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { + guard let idData = Data.identifier(fromBase58: contractId) else { + return #Predicate { _ in false } + } + return #Predicate { contract in + contract.id == idData + } + } + + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in + contract.ownerId == ownerId + } + } + + static func predicate(name: String) -> Predicate { + #Predicate { contract in + contract.name.localizedStandardContains(name) + } + } + + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in + contract.hasTokens == true + } + } + + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in + contract.keywordRelations.contains { $0.keyword == keyword } + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in + contract.lastSyncedAt == nil || contract.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target + } + } + + static func contractsWithTokensPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift new file mode 100644 index 00000000000..896b6010580 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift @@ -0,0 +1,181 @@ +import Foundation +import SwiftData + +// `PersistentDocument` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + 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() + } + + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift new file mode 100644 index 00000000000..d6cbbf62bfd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftData + +// `PersistentDocumentType` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentDocumentType { + var contractIdBase58: String { + contractId.toBase58String() + } + + var schema: [String: Any]? { + try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] + } + + var persistentProperties: [DashSchemaV4.PersistentProperty]? { + return propertiesList + } + + var requiredFields: [String]? { + guard let data = requiredFieldsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] + } + + var documentCount: Int { + documents?.count ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift new file mode 100644 index 00000000000..9cf09acdedb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift @@ -0,0 +1,274 @@ +import Foundation +import SwiftData + +// `PersistentIdentity` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +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 network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + 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 + } + + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + var identityPublicKeys: [IdentityPublicKey] { + publicKeys.compactMap { $0.toIdentityPublicKey() } + } + + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in + identity.identityId == identityId + } + } + + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in + identity.wallet != nil + } + } + + static func predicate(type: IdentityType) -> Predicate { + let typeString = type.rawValue + return #Predicate { identity in + identity.identityType == typeString + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in + identity.lastSyncedAt == nil || identity.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target + } + } + + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.wallet != nil && identity.networkRaw == target + } + } + + static func fetch( + in context: ModelContext, + identityId: Data + ) -> DashSchemaV4.PersistentIdentity? { + let target = identityId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + return try? context.fetch(descriptor).first + } +} + +extension DashSchemaV4.PersistentIdentity { + @discardableResult + static func updateBalance( + in context: ModelContext, + identityId: Data, + balance: UInt64 + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.balance = Int64(bitPattern: balance) + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateDpnsName( + in context: ModelContext, + identityId: Data, + dpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.dpnsName = dpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateMainDpnsName( + in context: ModelContext, + identityId: Data, + mainDpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.mainDpnsName = mainDpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func remove( + in context: ModelContext, + identityId: Data + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + context.delete(row) + return true + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift new file mode 100644 index 00000000000..9b93607a0d4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +// `PersistentIndex` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentIndex { + var properties: [String]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] + } + + var contestedDetails: [String: Any]? { + guard let data = contestedDetailsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var timeRange: [String: Any]? { + guard let data = timeRangeJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift new file mode 100644 index 00000000000..71cc5bb3f33 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +// `PersistentInvitation` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift new file mode 100644 index 00000000000..b42f37707f7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +// `PersistentKeyword` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 + } + } +} + +extension DashSchemaV4.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in + item.keyword.localizedStandardContains(keyword) + } + } + + static func predicate(contractId: String) -> Predicate { + #Predicate { item in + item.contractId == contractId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift new file mode 100644 index 00000000000..bd028d65783 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData + +// `PersistentMasternode` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + + var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + + var displayNumber: Int { + Int(typeIndex) + } + + var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + var statusName: String { + status.displayName + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift new file mode 100644 index 00000000000..725f19910fd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData + +// `PersistentPendingInput` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift new file mode 100644 index 00000000000..90998081e7b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddress` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in + entry.balance > 0 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift new file mode 100644 index 00000000000..fbc04ff24cf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + 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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift new file mode 100644 index 00000000000..233efafe6bd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +// `PersistentProperty` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift new file mode 100644 index 00000000000..68c9d28cb32 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift @@ -0,0 +1,171 @@ +import Foundation +import SwiftData + +// `PersistentPublicKey` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + contractBoundsDocumentTypeName = nil + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } +} + +extension DashSchemaV4.PersistentPublicKey { + func toIdentityPublicKey() -> IdentityPublicKey? { + guard let purpose = purposeEnum, + let securityLevel = securityLevelEnum, + let keyType = keyTypeEnum else { + return nil + } + + let bounds: ContractBounds? + if let id = contractBounds?.first, id.count == 32 { + if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + + return IdentityPublicKey( + id: KeyID(keyId), + purpose: purpose, + securityLevel: securityLevel, + contractBounds: bounds, + keyType: keyType, + readOnly: readOnly, + data: publicKeyData, + disabledAt: disabledAt.map { TimestampMillis($0) } + ) + } + + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV4.PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + case .none: + boundsIds = nil + docTypeName = nil + } + return DashSchemaV4.PersistentPublicKey( + keyId: Int32(publicKey.id), + purpose: publicKey.purpose, + securityLevel: publicKey.securityLevel, + keyType: publicKey.keyType, + publicKeyData: publicKey.data, + readOnly: publicKey.readOnly, + disabledAt: publicKey.disabledAt.map { Int64($0) }, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, + identityId: identityId + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift new file mode 100644 index 00000000000..e916eb9174a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData + +// `PersistentShieldedActivity` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift new file mode 100644 index 00000000000..2646b312466 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData + +// `PersistentShieldedNote` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 + } + } +} + +extension DashSchemaV4.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift new file mode 100644 index 00000000000..5dd20e6b091 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..6c520c0f965 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedSyncState` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift new file mode 100644 index 00000000000..a51d1e46f9f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedViewingKey` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift new file mode 100644 index 00000000000..f06de9f8f3b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift @@ -0,0 +1,376 @@ +import Foundation +import SwiftData + +// `PersistentToken` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + } +} + +extension DashSchemaV4.PersistentToken { + var displayName: String { + if let desc = tokenDescription, !desc.isEmpty { + return desc + } + return getSingularForm() ?? name + } + + var formattedBaseSupply: String { + Self.formatSupply(baseSupply, decimals: decimals) + } + + static func formatSupply(_ raw: String, decimals: Int) -> String { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return raw + } + let normalized = String(raw.drop(while: { $0 == "0" })) + let digits = normalized.isEmpty ? "0" : normalized + let scale = max(0, decimals) + let integer: String + var fraction = "" + if scale == 0 { + integer = digits + } else if digits.count <= scale { + integer = "0" + fraction = String(repeating: "0", count: scale - digits.count) + digits + } else { + let split = digits.index(digits.endIndex, offsetBy: -scale) + integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } + grouped.append(character) + } + grouped = String(grouped.reversed()) + return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" + } + + var contractIdBase58: String { + contractId.toBase58String() + } + + var canManuallyMint: Bool { + manualMintingRules != nil + } + + var canManuallyBurn: Bool { + manualBurningRules != nil + } + + var canFreeze: Bool { + freezeRules != nil + } + + var canUnfreeze: Bool { + unfreezeRules != nil + } + + var canDestroyFrozenFunds: Bool { + destroyFrozenFundsRules != nil + } + + var hasEmergencyActions: Bool { + emergencyActionRules != nil + } + + var canChangeMaxSupply: Bool { + maxSupplyChangeRules != nil + } + + var canChangeConventions: Bool { + conventionsChangeRules != nil + } + + var hasDistribution: Bool { + perpetualDistribution != nil || preProgrammedDistribution != nil + } + + var canChangeTradeMode: Bool { + tradeModeChangeRules != nil + } + + var keepsAnyHistory: Bool { + keepsTransferHistory || + keepsFreezingHistory || + keepsMintingHistory || + keepsBurningHistory || + keepsDirectPricingHistory || + keepsDirectPurchaseHistory + } + + var totalSupply: String { + guard let balances = balances, !balances.isEmpty else { return baseSupply } + return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) + } + + var totalFrozenBalance: String { + guard let balances = balances else { return "0" } + return Self.sumUnsignedBalances( + balances.lazy.filter(\.frozen).map(\.unsignedBalance) + ) + } + + var activeHolders: Int { + balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 + } + + private static func sumUnsignedBalances(_ values: S) -> String + where S.Element == UInt64 { + var digits: [UInt8] = [0] // little-endian decimal digits + + for value in values { + var carry = 0 + let addend = String(value).utf8.reversed().map { Int($0 - 48) } + let width = max(digits.count, addend.count) + if digits.count < width { + digits.append(contentsOf: repeatElement(0, count: width - digits.count)) + } + + for index in 0.. 0 { + digits.append(UInt8(carry % 10)) + carry /= 10 + } + } + + return String(digits.reversed().map { Character(String($0)) }) + } + + var hasMaxSupply: Bool { + maxSupply != nil + } + + var isTradeable: Bool { + tradeMode != .notTradeable + } + + var newTokensDestinationIdentityBase58: String? { + newTokensDestinationIdentity?.toBase58String() + } +} + +extension DashSchemaV4.PersistentToken { + func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { + if localizations == nil { + localizations = [:] + } + localizations?[languageCode] = DashSchemaV4.TokenLocalization( + singularForm: singularForm, + pluralForm: pluralForm, + description: description + ) + lastUpdatedAt = Date() + } + + func getSingularForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm + } + + func getPluralForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm + } +} + +extension DashSchemaV4.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV4.ChangeControlRules? { + switch type { + case .conventions: return conventionsChangeRules + case .maxSupply: return maxSupplyChangeRules + case .manualMinting: return manualMintingRules + case .manualBurning: return manualBurningRules + case .freeze: return freezeRules + case .unfreeze: return unfreezeRules + case .destroyFrozenFunds: return destroyFrozenFundsRules + case .emergencyAction: return emergencyActionRules + case .tradeMode: return tradeModeChangeRules + } + } + + func setChangeControlRules(_ rules: DashSchemaV4.ChangeControlRules, for type: ChangeControlRuleType) { + switch type { + case .conventions: conventionsChangeRules = rules + case .maxSupply: maxSupplyChangeRules = rules + case .manualMinting: manualMintingRules = rules + case .manualBurning: manualBurningRules = rules + case .freeze: freezeRules = rules + case .unfreeze: unfreezeRules = rules + case .destroyFrozenFunds: destroyFrozenFundsRules = rules + case .emergencyAction: emergencyActionRules = rules + case .tradeMode: tradeModeChangeRules = rules + } + + lastUpdatedAt = Date() + } +} + +extension DashSchemaV4.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualMintingRules != nil + } + } + + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualBurningRules != nil + } + } + + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in + token.freezeRules != nil + } + } + + static func distributionTokensPredicate() -> Predicate { + #Predicate { token in + token.perpetualDistribution != nil || token.preProgrammedDistribution != nil + } + } + + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in + token.isPaused == true + } + } + + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in + token.contractId == contractId + } + } + + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + switch rule { + case .manualMinting: + return #Predicate { token in + token.manualMintingRules != nil + } + case .manualBurning: + return #Predicate { token in + token.manualBurningRules != nil + } + case .freeze: + return #Predicate { token in + token.freezeRules != nil + } + case .unfreeze: + return #Predicate { token in + token.unfreezeRules != nil + } + case .destroyFrozenFunds: + return #Predicate { token in + token.destroyFrozenFundsRules != nil + } + case .emergencyAction: + return #Predicate { token in + token.emergencyActionRules != nil + } + case .conventions: + return #Predicate { token in + token.conventionsChangeRules != nil + } + case .maxSupply: + return #Predicate { token in + token.maxSupplyChangeRules != nil + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift new file mode 100644 index 00000000000..31d7c67c1bc --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftData + +// `PersistentTokenBalance` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + @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 + } + + convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. (tokenId: String, balance: UInt64, frozen: Bool) { + return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) + } +} + +extension DashSchemaV4.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId && balance.identityId == identityId + } + } + + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in + balance.identityId == identityId + } + } + + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in + balance.balance != 0 + } + } + + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in + balance.frozen == true + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in + balance.lastSyncedAt == nil || balance.lastSyncedAt! < date + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift new file mode 100644 index 00000000000..5972942e7fb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + + var eventTypeEnum: TokenEventType { + TokenEventType(rawValue: eventType) ?? .unknown + } + + var fromIdentityBase58: String? { + fromIdentity?.toBase58String() + } + + var toIdentityBase58: String? { + toIdentity?.toBase58String() + } + + var performedByIdentityBase58: String { + performedByIdentity.toBase58String() + } + + var displayTitle: String { + switch eventTypeEnum { + case .mint: + return "Minted \(formattedAmount)" + case .burn: + return "Burned \(formattedAmount)" + case .transfer: + return "Transfer \(formattedAmount)" + case .freeze: + return "Frozen \(formattedAmount)" + case .unfreeze: + return "Unfrozen \(formattedAmount)" + case .destroyFrozenFunds: + return "Destroyed Frozen Funds \(formattedAmount)" + case .configUpdate: + return "Configuration Updated" + case .emergencyAction: + return "Emergency Action" + case .perpetualDistribution: + return "Perpetual Distribution \(formattedAmount)" + case .preProgrammedRelease: + return "Pre-programmed Release \(formattedAmount)" + case .directPricing: + return "Direct Pricing Updated" + case .directPurchase: + return "Direct Purchase \(formattedAmount)" + case .unknown: + return "Unknown Event" + } + } + + private var formattedAmount: String { + guard let amount = amount else { return "" } + return amount + } + + func setAdditionalData(_ data: [String: Any]) { + additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) + } + + func getAdditionalData() -> [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..4a2e857dbd9 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentTrackedMasternode` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + var label: String? + var addedAt: UInt64 + var snapshotJSON: String + + var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + 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/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift new file mode 100644 index 00000000000..9550581b615 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftData + +// `PersistentTransaction` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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() + } + + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + var isAssetLock: Bool { + typedKind == .assetLock + } + + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift new file mode 100644 index 00000000000..548d513f9b2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentTxo` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @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 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + 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 + } + + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift new file mode 100644 index 00000000000..8301f4bc246 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift @@ -0,0 +1,95 @@ +import Foundation +import SwiftData + +// `PersistentWallet` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + var networkRaw: UInt32? + + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + 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 = [] + } + } +} + +extension DashSchemaV4.PersistentWallet { + var label: String { + if let name = name, !name.isEmpty { + return name + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + +extension DashSchemaV4.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } + } + + static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift new file mode 100644 index 00000000000..66eeeabc41f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift new file mode 100644 index 00000000000..7e6f1bb0c13 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftData + +// Inline value types exactly as schema DashSchemaV4 stored them, generated +// by scripts/freeze_schema_models.py from TokenTypes.swift +// at commit 787cac09e7. SwiftData expands a stored Codable struct into composite +// attributes of the owning entity, so these shapes are inputs to that +// version's checksum just like the model's own properties. Do not edit. +extension DashSchemaV4 { + struct ChangeControlRules: Codable, Equatable, Sendable { + var authorizedToMakeChange: String + var adminActionTakers: String + var changingAuthorizedActionTakersToNoOneAllowed: Bool + var changingAdminActionTakersToNoOneAllowed: Bool + var selfChangingAdminActionTakersAllowed: Bool + + init( + authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, + adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, + changingAuthorizedActionTakersToNoOneAllowed: Bool = false, + changingAdminActionTakersToNoOneAllowed: Bool = false, + selfChangingAdminActionTakersAllowed: Bool = false + ) { + self.authorizedToMakeChange = authorizedToMakeChange + self.adminActionTakers = adminActionTakers + self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed + self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed + self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed + } + + static func mostRestrictive() -> ChangeControlRules { + return ChangeControlRules() + } + + static func contractOwnerControlled() -> ChangeControlRules { + return ChangeControlRules( + authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, + adminActionTakers: AuthorizedActionTakers.noOne.rawValue, + selfChangingAdminActionTakersAllowed: true + ) + } + } + + enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { + case noOne = "NoOne" + case contractOwner = "ContractOwner" + case mainGroup = "MainGroup" + + static func identity(_ id: Data) -> String { + return "Identity:\(id.toBase58String())" + } + + static func group(_ position: Int) -> String { + return "Group:\(position)" + } + } + + struct TokenPerpetualDistribution: Codable, Equatable, Sendable { + var distributionType: String + var distributionRecipient: String + var enabled: Bool + var lastDistributionTime: Date? + var nextDistributionTime: Date? + + init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { + self.distributionType = "{}" + self.distributionRecipient = distributionRecipient + self.enabled = enabled + } + } + + struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { + var distributionSchedule: [DistributionEvent] + var currentEventIndex: Int + var totalDistributed: String + var remainingToDistribute: String + var isActive: Bool + var isPaused: Bool + var isCompleted: Bool + + init() { + self.distributionSchedule = [] + self.currentEventIndex = 0 + self.totalDistributed = "0" + self.remainingToDistribute = "0" + self.isActive = true + self.isPaused = false + self.isCompleted = false + } + } + + struct DistributionEvent: Codable, Equatable, Sendable { + var id: UUID + var triggerType: String + var triggerTime: Date? + var triggerBlock: Int64? + var triggerCondition: String? + var amount: String + var recipient: String + var description: String? + + init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { + self.id = UUID() + self.triggerType = "Time" + self.triggerTime = triggerTime + self.amount = amount + self.recipient = recipient + self.description = description + } + } + + struct TokenDistributionChangeRules: Codable, Equatable, Sendable { + var perpetualDistributionRules: ChangeControlRules? + var newTokensDestinationIdentityRules: ChangeControlRules? + var mintingAllowChoosingDestinationRules: ChangeControlRules? + var changeDirectPurchasePricingRules: ChangeControlRules? + + init( + perpetualDistributionRules: ChangeControlRules? = nil, + newTokensDestinationIdentityRules: ChangeControlRules? = nil, + mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, + changeDirectPurchasePricingRules: ChangeControlRules? = nil + ) { + self.perpetualDistributionRules = perpetualDistributionRules + self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules + self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules + self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules + } + } + + enum TokenTradeMode: String, CaseIterable, Codable, Sendable { + case notTradeable = "NotTradeable" + + var displayName: String { + switch self { + case .notTradeable: + return "Not Tradeable" + } + } + } + + struct TokenLocalization: Codable, Equatable, Sendable { + let singularForm: String + let pluralForm: String + let description: String? + + init(singularForm: String, pluralForm: String, description: String? = nil) { + self.singularForm = singularForm + self.pluralForm = pluralForm + self.description = description + } + } +} 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 7268b081190..a93b04c0490 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -167,6 +167,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 @@ -1395,3 +1412,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 dbda3c8fefe..a672530057f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3492,12 +3492,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( @@ -3591,7 +3593,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. @@ -3603,7 +3605,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 ) @@ -3660,11 +3662,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 } @@ -3798,7 +3808,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 @@ -3813,6 +3823,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( @@ -3830,6 +3841,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 @@ -3859,7 +3874,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 @@ -3870,6 +3885,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 { @@ -3884,6 +3902,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 { @@ -3903,6 +3922,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) } } @@ -4962,6 +4985,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 @@ -4985,6 +5011,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 @@ -6320,6 +6349,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() @@ -6862,6 +6893,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 @@ -7016,6 +7057,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } let identitiesBuffer = buildIdentityRestoreBuffer( identities: sortedIdentities, + addressesByOwner: addressesByOwner, allocation: allocation ) @@ -7878,6 +7920,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func buildIdentityRestoreBuffer( identities: [PersistentIdentity], + addressesByOwner: [Data: [PersistentDashpayPaymentAddresses]], allocation: LoadAllocation ) -> UnsafeMutablePointer? { if identities.isEmpty { @@ -8153,6 +8196,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 } @@ -8164,6 +8263,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 { @@ -8192,6 +8292,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) @@ -9539,7 +9651,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 @@ -9576,6 +9691,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 ) ) @@ -9607,12 +9726,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 4dd0c8f1dcd..b273ab1929d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -642,3 +642,30 @@ instead of blindly submitting a replacement payment. - [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/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 9fbfa802dcc..3278c6072cd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -60,6 +60,9 @@ struct SwiftExampleAppApp: App { @StateObject private var platformBalanceSyncService = PlatformBalanceSyncService() @StateObject private var transitionState = TransitionState() @StateObject private var appUIState = AppUIState() + // Shielded tip sends outlive the sheet that starts them (see + // `ShieldedTipSubmissions`), so their state is owned here. + @StateObject private var shieldedTipSubmissions = ShieldedTipSubmissions() /// Current manager exposed to views via the env object pipeline. /// Reads from the published `activeManager` on every body @@ -144,6 +147,7 @@ struct SwiftExampleAppApp: App { .environmentObject(platformBalanceSyncService) .environmentObject(transitionState) .environmentObject(appUIState) + .environmentObject(shieldedTipSubmissions) .environment(\.modelContext, modelContainer.mainContext) .onOpenURL { url in // DashPay invitation deep link: route to the DashPay tab and diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index d47274cd161..9502c749810 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -1834,7 +1834,7 @@ struct CreateIdentityView: View { /// FetchDescriptor round-trip. private func shieldedPoolBalance(for walletId: Data) -> UInt64 { unspentShieldedNotes - .filter { $0.walletId == walletId } + .filter { $0.walletId == walletId && !PlatformWalletManager.isShieldedTipAccount($0.accountIndex) } .reduce(0) { $0 + $1.value } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift index b3b9aad1a98..5d0c30110d2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayProfileView.swift @@ -1,6 +1,7 @@ import CoreImage.CIFilterBuiltins import SwiftDashSDK import SwiftUI +import SwiftData /// Read-only DashPay profile sheet, promoted out of /// `IdentityDetailView`'s inline card: large avatar, display name, @@ -19,6 +20,36 @@ struct DashPayProfileView: View { @State private var qrImage: UIImage? @State private var qrURI: String? @State private var qrError: String? + @State private var showSpendTips = false + @Query private var shieldedNotes: [PersistentShieldedNote] + + init(identity: PersistentIdentity, profile: DashPayProfile?, onEdit: @escaping () -> Void) { + self.identity = identity + self.profile = profile + self.onEdit = onEdit + if let walletId = identity.wallet?.walletId { + _shieldedNotes = Query(filter: PersistentShieldedNote.unspentPredicate(walletId: walletId)) + } else { + _shieldedNotes = Query(filter: #Predicate { _ in false }) + } + } + + /// The persisted row uses zero as a historical placeholder. Only Rust's + /// optional derivation metadata can distinguish that from a real index zero. + private var tipAccount: UInt32? { + guard let walletId = identity.wallet?.walletId, + let wallet = walletManager.wallet(for: walletId), + let managed = try? wallet.managedIdentity(identityId: identity.identityId), + let index = try? managed.getIdentityIndex() else { return nil } + return try? PlatformWalletManager.shieldedTipAccountIndex(identityIndex: index) + } + + private var tipBalance: UInt64 { + guard let walletId = identity.wallet?.walletId, + let account = tipAccount else { return 0 } + return shieldedNotes.filter { $0.walletId == walletId && $0.accountIndex == account && !$0.isSpent } + .reduce(UInt64(0)) { $0 &+ $1.value } + } private var displayName: String { if let name = profile?.displayName? @@ -64,6 +95,20 @@ struct DashPayProfileView: View { .listRowBackground(Color.clear) } + Section("Shielded tips") { + if let address = profile?.shieldedAddress, + let display = DashAddress.encodeOrchard(rawBytes: address, network: identity.network) { + Text(display).font(.caption).textSelection(.enabled) + } else { + Text("No tip address published") + } + Text("Dedicated account balance: \(NSDecimalNumber(decimal: Decimal(tipBalance) / 100_000_000_000).stringValue) DASH") + .font(.caption) + Button("Send from dedicated tip account") { showSpendTips = true } + .disabled(tipBalance == 0) + Text("An external receiving address is managed by its own wallet.").font(.caption).foregroundStyle(.secondary) + } + Section("Identity") { Text(identity.identityIdBase58) .font(.caption) @@ -124,6 +169,12 @@ struct DashPayProfileView: View { } } } + .sheet(isPresented: $showSpendTips) { + if let walletId = identity.wallet?.walletId, + let account = tipAccount { + SendShieldedTipSheet(walletId: walletId, account: account, sourceLabel: "dedicated tip account") + } + } .navigationTitle("Your Profile") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index 1eb043a4c22..60459935b40 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -33,6 +33,7 @@ struct DashPayTabView: View { @State private var segment: DashPaySegment = .contacts @State private var showAddContact = false + @State private var showShieldedTip = false @State private var showAddViaQR = false /// Drives the claim sheet via `.sheet(item:)`. A fresh value (new `id`) @@ -162,6 +163,11 @@ struct DashPayTabView: View { content .navigationTitle("DashPay") .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShieldedTip = true } label: { Image(systemName: "gift") } + .accessibilityLabel("Send shielded tip") + .disabled(walletManager.firstWallet == nil) + } ToolbarItem(placement: .navigationBarTrailing) { Button { refresh() @@ -242,6 +248,11 @@ struct DashPayTabView: View { } } } + .sheet(isPresented: $showShieldedTip) { + if let walletId = activeIdentity?.wallet?.walletId ?? walletManager.firstWallet?.walletId { + SendShieldedTipSheet(walletId: walletId) + } + } .sheet(isPresented: $showAddViaQR) { if let identity = activeIdentity { AddViaQRSheet(identity: identity) @@ -818,7 +829,10 @@ struct DashPayTabView: View { publicMessage: persisted.publicMessage, avatarUrl: persisted.avatarUrl, avatarHash: persisted.avatarHash, - avatarFingerprint: persisted.avatarFingerprint + avatarFingerprint: persisted.avatarFingerprint, + corePaymentAddress: persisted.corePaymentAddress, + platformPaymentAddress: persisted.platformPaymentAddress, + shieldedAddress: persisted.shieldedAddress ) } } @@ -1019,3 +1033,136 @@ private struct AddViaQRSheet: View { } } } + +/// Resolve first, then explicitly confirm the identity, address, and amount. +/// The send itself is owned by the app-wide `ShieldedTipSubmissions`, so a +/// dismissed or reopened sheet finds the same in-flight or uncertain state +/// instead of a fresh, unlocked one. +struct SendShieldedTipSheet: View { + let walletId: Data + var account: UInt32 = 0 + var sourceLabel: String = "ordinary shielded account" + @EnvironmentObject private var submissions: ShieldedTipSubmissions + @EnvironmentObject private var appState: AppState + + var body: some View { + SendShieldedTipSheetContent( + walletId: walletId, account: account, sourceLabel: sourceLabel, + submission: submissions.forWallet(network: appState.currentNetwork, walletId: walletId)) + } +} + +private struct SendShieldedTipSheetContent: View { + let walletId: Data + let account: UInt32 + let sourceLabel: String + @ObservedObject var submission: ShieldedTipSubmission + @EnvironmentObject private var walletManager: PlatformWalletManager + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + @State private var username = "" + @State private var amount = "" + @State private var recipient: ShieldedTipRecipient? + @State private var resolving = false + @State private var error: String? + @State private var showRecipientChanged = false + + private var tipAmount: ShieldedTipAmount? { ShieldedTipAmount(amount) } + private var credits: UInt64? { tipAmount?.credits } + /// Recipient resolution is sheet-local; the send lock is the shared submission. + private var busy: Bool { resolving || submission.busy } + private var submitted: Bool { submission.submitted } + + var body: some View { + NavigationStack { + Form { + TextField("Username", text: $username) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .disabled(busy || submitted) + .onChange(of: username) { _, _ in recipient = nil } + TextField("Amount in DASH", text: $amount).keyboardType(.decimalPad) + .disabled(busy || submitted) + if let recipient, !submitted { + Section("Confirm recipient") { + Text(username) + Text(recipient.identityId.toBase58String()).font(.caption).textSelection(.enabled) + Text(DashAddress.encodeOrchard(rawBytes: recipient.address, network: appState.currentNetwork) ?? "") + .font(.caption2).textSelection(.enabled) + Text("Send \(tipAmount?.dashString ?? "—") DASH from your \(sourceLabel).") + } + } + if let error { Text(error).foregroundStyle(.red) } + switch submission.status { + case .sent: + Text(submission.message ?? "Tip submitted. Check shielded activity for confirmation.") + Button("Send another tip") { + submission.startNewTip() + recipient = nil + amount = "" + } + case .uncertain: + Text(submission.message + ?? "The tip may have been sent. Check shielded activity before sending again.") + .foregroundStyle(.orange) + case .ready, .sending: + if let message = submission.message, error == nil { + Text(message).foregroundStyle(.red) + } + Button(recipient == nil ? "Review recipient" : "Confirm and send tip") { submit() } + .disabled(busy || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || credits == nil) + } + if busy { ProgressView() } + } + .navigationTitle("Shielded tip") + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() }.disabled(busy) } } + .interactiveDismissDisabled(busy) + .alert("Tip recipient changed", isPresented: $showRecipientChanged) { + Button("Review new recipient") {} + Button("Cancel", role: .cancel) { recipient = nil } + } message: { + Text("This username now resolves to a different identity or shielded address than your last confirmation. Verify the change with the recipient before sending.") + } + } + } + + private func submit() { + guard let wallet = walletManager.wallet(for: walletId), let credits else { return } + error = nil + let network = appState.currentNetwork + let name = username + if let recipient { + let confirmed = recipient + let manager = walletManager + let account = account + let walletId = walletId + submission.submit { + ShieldedTipRecipientHistory().confirm(network: network, walletId: walletId, + username: name, recipient: confirmed) + do { + try await manager.sendShieldedTip(walletId: walletId, resolver: MnemonicResolver(), + account: account, username: name, recipient: confirmed, amount: credits) + } catch { + // Whatever the outcome, the next attempt re-reviews the recipient. + self.recipient = nil + throw error + } + } + return + } + resolving = true + Task { @MainActor in + defer { resolving = false } + do { + let resolved = try await wallet.resolveShieldedTip(username: name) + if ShieldedTipRecipientHistory().hasChanged(network: network, walletId: walletId, + username: name, recipient: resolved) { + showRecipientChanged = true + } + recipient = resolved + } catch { + self.error = error.localizedDescription + recipient = nil + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift new file mode 100644 index 00000000000..b4be45f6a91 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ShieldedTipSubmission.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftDashSDK + +/// Application-owned tip submissions. They outlive the sheet that started +/// them: a dismissed sheet must never imply the native send stopped +/// broadcasting, and reopening from either entry point (the DashPay tab or +/// the profile) has to find the same guard. Mirrors the Kotlin example app's +/// `ShieldedTipSubmissions`. +@MainActor +final class ShieldedTipSubmissions: ObservableObject { + private var wallets: [String: ShieldedTipSubmission] = [:] + + /// One guard per network and wallet, across all of its identities. + func forWallet(network: Network, walletId: Data) -> ShieldedTipSubmission { + let key = "\(network.rawValue):\(walletId.hexString)" + if let existing = wallets[key] { return existing } + let created = ShieldedTipSubmission() + wallets[key] = created + return created + } +} + +/// Main-actor state of one wallet's shielded tip submission. +@MainActor +final class ShieldedTipSubmission: ObservableObject { + enum Status: Equatable { case ready, sending, sent, uncertain } + + @Published private(set) var status: Status = .ready + @Published private(set) var message: String? + + var busy: Bool { status == .sending } + var submitted: Bool { status != .ready } + + /// Locks synchronously, before the task starts, so two UI events cannot + /// submit twice. Returns nil when a submission already holds the lock; the + /// task is returned so tests can await the outcome. + @discardableResult + func submit(_ send: @escaping @MainActor () async throws -> Void) -> Task? { + guard !submitted else { return nil } + status = .sending + message = nil + return Task { @MainActor in + do { + try await send() + status = .sent + message = "Shielded tip sent." + } catch { + if Self.canReviewAfterFailure(error) { + status = .ready + message = error.localizedDescription + } else { + status = .uncertain + message = "The tip may have been sent. Check shielded activity before sending " + + "again. \(error.localizedDescription)" + } + } + } + } + + /// Starting another payment is an explicit action after a confirmed success. + func startNewTip() { + guard status == .sent else { return } + status = .ready + message = nil + } + + /// Only failures known not to have executed a tip permit a fresh review. + /// On the tip path Rust maps selection, build and recipient-check failures + /// to `walletOperation`, and broadcast ambiguity to + /// `shieldedSpendUnconfirmed`; anything unknown, including cancellation, + /// stays locked until shielded activity shows the outcome. + static func canReviewAfterFailure(_ error: Error) -> Bool { + guard let error = error as? PlatformWalletError else { return false } + switch error { + case .nullPointer, .invalidHandle, .invalidParameter, .invalidIdentifier, .invalidNetwork, + .walletOperation, .identityNotFound, .contactNotFound, .utf8Conversion, + .noSelectableInputs, .shieldedBroadcastFailed, .shieldedNoRecordedAnchor, + .shieldedInsufficientBalance: + return true + default: + return false + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 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 e70f8ce4abe..950bb7ca477 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/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift new file mode 100644 index 00000000000..bf466fa55b1 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ShieldedTipSubmissionTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// The app-owned shielded tip guard: one submission per network and wallet, +/// locked from the first tap, and unlocked only by a confirmed result. A +/// dismissed sheet reopening on the same wallet must find the same guard. +@MainActor +final class ShieldedTipSubmissionTests: XCTestCase { + /// Parks a submission until the test releases it, like a native send in flight. + @MainActor + private final class Gate { + private var continuation: CheckedContinuation? + private var opened = false + func wait() async { + if opened { return } + await withCheckedContinuation { continuation = $0 } + } + func open() { + opened = true + continuation?.resume() + continuation = nil + } + } + + @MainActor + private final class Counter { + var sends = 0 + } + + func testInFlightSendRefusesASecondSubmissionAndReopeningFindsTheSameGuard() async { + let submissions = ShieldedTipSubmissions() + let walletId = Data(repeating: 0x31, count: 32) + let gate = Gate() + let counter = Counter() + + let submission = submissions.forWallet(network: .testnet, walletId: walletId) + let task = submission.submit { + counter.sends += 1 + await gate.wait() + } + XCTAssertNotNil(task) + XCTAssertTrue(submission.busy) + + // The sheet was dismissed and reopened: the owner hands back the same + // guard, which is still locked, so the second tap never sends. + let reopened = submissions.forWallet(network: .testnet, walletId: walletId) + XCTAssertTrue(reopened === submission) + XCTAssertNil(reopened.submit { counter.sends += 1 }) + + gate.open() + await task?.value + XCTAssertEqual(counter.sends, 1) + XCTAssertEqual(submission.status, .sent) + XCTAssertTrue(submission.submitted) + + // Another payment needs an explicit action after the confirmed result. + submission.startNewTip() + XCTAssertEqual(submission.status, .ready) + XCTAssertFalse(submission.submitted) + + // Another wallet on the same network has its own guard. + let other = submissions.forWallet(network: .testnet, walletId: Data(repeating: 0x32, count: 32)) + XCTAssertFalse(other === submission) + } + + func testUnconfirmedSpendStaysLockedAcrossReopenAndCannotStartANewTip() async { + let submission = ShieldedTipSubmission() + let task = submission.submit { + throw PlatformWalletError.shieldedSpendUnconfirmed("relay accepted the transition") + } + await task?.value + XCTAssertEqual(submission.status, .uncertain) + XCTAssertTrue(submission.submitted) + XCTAssertTrue(submission.message?.contains("may have been sent") == true) + + // Neither a fresh tap nor "send another" unlocks an uncertain outcome. + XCTAssertNil(submission.submit {}) + submission.startNewTip() + XCTAssertEqual(submission.status, .uncertain) + } + + func testPreflightFailureReturnsToReadyWhileUnknownFailuresLock() async { + let submission = ShieldedTipSubmission() + await submission.submit { + throw PlatformWalletError.walletOperation("insufficient shielded notes") + }?.value + XCTAssertEqual(submission.status, .ready) + XCTAssertEqual( + submission.message, + PlatformWalletError.walletOperation("insufficient shielded notes").localizedDescription) + + struct Unknown: Error {} + await submission.submit { throw Unknown() }?.value + XCTAssertEqual(submission.status, .uncertain) + XCTAssertNil(submission.submit {}) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 7daaabbe52d..118ba476d1d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -15,7 +15,8 @@ import XCTest /// the persistence sources as of commit 5f58417079 — the last state before /// V4, the state the frozen copies under `FrozenSchemas/` are generated /// from — through that build's own `DashSchemaV1` / `DashSchemaV2` / -/// `DashSchemaV3`, and `dash-v4` by the build that registered V4, through +/// `DashSchemaV3`, `dash-v4` by the build that registered V4, and `dash-v5` +/// by the build that registered V5 (the payment-address table), each through /// `DashModelContainer.create`. They pin the frozen copies as the pre-V4 /// build defined them, not what the original V1 release wrote (see the /// `DashSchemaV1` doc for why those stores are expected to fail open and @@ -69,6 +70,9 @@ final class DashModelMigrationTests: XCTestCase { Fixture( name: "dash-v4", version: DashSchemaV4.self, hasTrackedMasternode: true, assetLockRecipientIsExternal: true), + Fixture( + name: "dash-v5", version: DashSchemaV5.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: true), ] /// Every schema version that has ever shipped, oldest first, as @@ -80,7 +84,7 @@ final class DashModelMigrationTests: XCTestCase { /// give it a fixture store in `fixtures`, written by that build with /// `testWriteTheLiveSchemaFixtureStore`. Every entry has a fixture, /// the live one included. - private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0"] + private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0"] private static let fixtureWalletId = Data(repeating: 0x31, count: 32) private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) @@ -645,19 +649,21 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v4Configuration]) + // V4 registers the frozen `DashSchemaV4` graph, so the read side is + // that type: the same entity, one property wider than V3's copy. 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( @@ -665,7 +671,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") } @@ -730,15 +736,17 @@ 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) @@ -750,6 +758,27 @@ final class DashModelMigrationTests: XCTestCase { "the coin's relationship to its funding transaction survives three stages") } + /// What makes the V4 -> V5 stage lightweight: V5 adds exactly one + /// entity and changes none. The payment addresses deliberately live in + /// their own table rather than as columns on the profile entities, so + /// the frozen V4 profile shapes are untouched. + func testV5AddsOnlyThePaymentAddressesEntity() throws { + let v4 = Schema(versionedSchema: DashSchemaV4.self) + let v5 = Schema(versionedSchema: DashSchemaV5.self) + XCTAssertEqual( + Set(v5.entities.map(\.name)).subtracting(v4.entities.map(\.name)), + ["PersistentDashpayPaymentAddresses"]) + XCTAssertEqual( + Set(v4.entities.map(\.name)).subtracting(v5.entities.map(\.name)), []) + for name in ["PersistentDashpayProfile", "PersistentDashpayContactProfile"] { + let frozen = try XCTUnwrap(v4.entities.first { $0.name == name }) + let live = try XCTUnwrap(v5.entities.first { $0.name == name }) + XCTAssertEqual( + live.attributesByName.keys.sorted(), frozen.attributesByName.keys.sorted(), + "\(name): V5 adds no column; addresses live in their own entity") + } + } + /// 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 @@ -793,7 +822,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( @@ -895,3 +925,137 @@ final class DashModelMigrationTests: XCTestCase { true) } } + +extension DashModelMigrationTests { + /// The stage this change adds: a V4 store, with sweep state and a profile, + /// arrives at V5 with every row intact, and the new payment-address table + /// is writable for that profile. V4 registers the frozen graph, so rows go + /// in as `DashSchemaV4` types and come out live. + @MainActor + func testV4StoreMigratesToV5PreservingSweepStateAndProfiles() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + 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( + "DashTipMigrationTest", schema: oldSchema, url: storeURL, allowsSave: true, + 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 = try XCTUnwrap(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( + "DashTipMigrationTest", schema: schema, url: storeURL, allowsSave: true, + cloudKitDatabase: .none) + var container: ModelContainer? = try ModelContainer( + for: schema, migrationPlan: DashMigrationPlan.self, configurations: [config]) + do { + let context = try XCTUnwrap(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)) + } + + /// The whole chain for a profile: a V3 store (frozen V1 profile shape) + /// reaches V5 with the profile intact and the address table usable. + @MainActor + func testV3ProfileStoreMigratesToPaymentAddressesWithoutLosingProfile() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + 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( + "DashProfileChainMigrationTest", schema: oldSchema, url: storeURL, allowsSave: true, + cloudKitDatabase: .none) + var oldContainer: ModelContainer? = try ModelContainer( + for: oldSchema, configurations: [oldConfig]) + let identityId = Data(repeating: 0x21, count: 32) + do { + let context = try XCTUnwrap(oldContainer?.mainContext) + let identity = DashSchemaV1.PersistentIdentity( + identityId: identityId, isLocal: true, network: .testnet) + context.insert(identity) + context.insert(DashSchemaV1.PersistentDashpayProfile( + identity: identity, displayName: "Preserved profile")) + try context.save() + } + oldContainer = nil + let schema = Schema(versionedSchema: DashSchemaV5.self) + let config = ModelConfiguration( + "DashProfileChainMigrationTest", schema: schema, url: storeURL, allowsSave: true, + 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.first?.displayName, "Preserved profile") + XCTAssertNil(profiles.first?.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.first?.identity.identityId, identityId) + XCTAssertEqual(profiles.first?.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/Fixtures/SchemaStores/dash-v5.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store new file mode 100644 index 00000000000..735e14912b5 Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift new file mode 100644 index 00000000000..56598d8dabc --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedAccountSnapshotTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import SwiftDashSDK + +@MainActor +final class ShieldedAccountSnapshotTests: XCTestCase { + /// Identity discovery can precede configureShielded. Reading the current + /// account set must still succeed so the service can perform the first bind. + func testUnconfiguredShieldedWalletReturnsEmptySnapshot() async throws { + let sdk = try SDK(network: .testnet) + let manager = try PlatformWalletManager(sdk: sdk) + do { + let wallet = try await manager.createWallet( + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + network: .testnet, + createDefaultAccounts: false) + XCTAssertEqual(try manager.shieldedAccountIndices(walletId: wallet.walletId), []) + XCTAssertThrowsError(try manager.shieldedAccountIndices(walletId: Data(repeating: 0x71, count: 32))) + await manager.shutdown() + } catch { + await manager.shutdown() + throw error + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift new file mode 100644 index 00000000000..cb9d1374cd4 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ShieldedTipAmountTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import SwiftDashSDK + +final class ShieldedTipAmountTests: XCTestCase { + func testRejectsPartialLocaleAndMalformedAmounts() { + for text in ["1,5", "1abc", "1.5abc", "1,000.5", "1e2", "+1", "-1", " 1", "1 ", "", ".", "1.", ".5", "12", "0", "0.000000000001"] { + XCTAssertNil(ShieldedTipAmount(text), text) + } + } + + func testExactCreditsAndCanonicalConfirmation() throws { + for (input, credits, display) in [("1.5", UInt64(150_000_000_000), "1.5"), + ("001.500000000000", 150_000_000_000, "1.5"), + ("0.00000000001", 1, "0.00000000001"), + ("184467440.73709551615", UInt64.max, "184467440.73709551615")] { + let amount = try XCTUnwrap(ShieldedTipAmount(input)) + XCTAssertEqual(amount.credits, credits) + XCTAssertEqual(amount.dashString, display) + } + XCTAssertNil(ShieldedTipAmount("184467440.73709551616")) + XCTAssertNil(ShieldedTipAmount("184467441")) + XCTAssertNil(ShieldedTipAmount("18446744073709551616")) + } +} diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index baf78f0f269..f31ab7813f6 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -176,6 +176,17 @@ class Freeze: Freeze("DashSchemaV2", "5f58417079", ("PersistentTrackedMasternode",)), # V3 replaces the asset lock with the shape that has `recipientIsExternal`. Freeze("DashSchemaV3", "5f58417079", ("PersistentAssetLock",)), + # V4 registered the whole graph live (V3's models plus the sweep columns + # on the wallet transaction models). V5 added `PersistentDashpayPaymentAddresses`, + # so every model V4 registers is frozen at the last commit that touched + # the live models before that. + Freeze( + "DashSchemaV4", + "787cac09e7", + tuple(V1_GRAPH_MODELS) + ("PersistentAssetLock", "PersistentTrackedMasternode"), + TOKEN_TYPES_FILE, + tuple(TOKEN_VALUE_TYPES), + ), ] HEADER = "import Foundation\nimport SwiftData\n\n" diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 260c8670a05..e04a43e8a38 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -41,7 +41,8 @@ def setUp(self): ) def test_should_find_the_committed_files_are_the_generators_output(self): - self.assertEqual(len(self.files), 37) + # V1-V3 (37 files) plus the 36 frozen for V4 (35 models and the value types). + self.assertEqual(len(self.files), 73) self.assertEqual(gen.check_problems(ROOT, self.files), []) def test_should_report_a_hand_edit_to_a_frozen_file(self): 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