diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index aa26292d4b4..17e5a54f81c 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -8,6 +8,7 @@ "atomic_changesets", "invitations", "pending_contact_crypto", + "private_contact_request_scan_state", "provider_transactions", "shielded_viewing_keys", "tracked_asset_locks", @@ -30,18 +31,27 @@ "dpns_name_array_free": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_dashpay_payments": "packages/rs-platform-wallet-ffi/src/dashpay_payment.rs", + "managed_identity_get_private_contact": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "managed_identity_get_private_reply_pending": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "on_load_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_load_private_contact_scan_state_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_dashpay_payments_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_persist_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "platform_address_wallet_addresses_with_balances": "packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs", "platform_wallet_claim_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_create_document_with_signer": "packages/rs-platform-wallet-ffi/src/document.rs", "platform_wallet_create_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", + "platform_wallet_get_private_contact_readiness": "packages/rs-platform-wallet-ffi/src/contact_request.rs", + "platform_wallet_manager_accept_contact_request_replying_in_kind": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_manager_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", + "platform_wallet_manager_send_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_shielded_fund_from_wallet_drain": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_shielded_wallet_drain_preview": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_shielded_wallet_drain_status": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", @@ -49,6 +59,7 @@ "platform_wallet_manager_sync_fault_detected": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_resume_identity_with_existing_asset_lock_signer": "packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs", "platform_wallet_sync_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", + "platform_wallet_sync_private_contact_requests": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_token_purchase": "packages/rs-platform-wallet-ffi/src/tokens/purchase.rs", "platform_wallet_token_transfer": "packages/rs-platform-wallet-ffi/src/tokens/transfer.rs", "platform_wallet_token_update_config": "packages/rs-platform-wallet-ffi/src/tokens/update_config.rs", @@ -770,6 +781,215 @@ } ] }, + { + "id": "dashpay.private_contact_requests", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 73, readiness", + "area": "correctness", + "shared_apis": [ + "platform_wallet_sync_private_contact_requests", + "platform_wallet_manager_send_private_contact_request", + "platform_wallet_manager_accept_contact_request_replying_in_kind", + "platform_wallet_manager_accept_private_contact_request", + "managed_identity_get_private_contact", + "managed_identity_get_private_reply_pending", + "platform_wallet_get_private_contact_readiness" + ], + "required_persistence_capabilities": [ + "atomic_changesets", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 73 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur." + }, + "kotlin": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 73 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur." + } + }, + "verification": [ + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs", + "id": "should_send_read_and_accept_a_private_contact_request", + "command": "cargo test -p platform-wallet --lib should_send_read_and_accept_a_private_contact_request", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs", + "id": "should_accept_a_public_request_replying_in_kind", + "command": "cargo test -p platform-wallet --lib should_accept_a_public_request_replying_in_kind", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "id": "should_accept_publicly_without_shielded_support_or_a_mnemonic_read", + "command": "cargo test -p platform-wallet-ffi --lib should_accept_publicly_without_shielded_support_or_a_mnemonic_read", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "id": "should_read_whether_a_private_reply_is_pending", + "command": "cargo test -p platform-wallet-ffi --lib should_read_whether_a_private_reply_is_pending", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/error.rs", + "id": "should_tell_a_private_request_from_other_accept_failures_by_code", + "command": "cargo test -p platform-wallet-ffi --lib should_tell_a_private_request_from_other_accept_failures_by_code", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift", + "id": "testShouldRouteAPrivatelyReceivedRequestByItsOwnCode", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftDashSDKTests/ErrorHandlingTests", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldKeepAnUnconfirmedPrivateSubmissionPending", + "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt", + "id": "shouldRouteARequestThatNeedsAPrivateReplyByItsOwnCode", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*DashSdkErrorTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAnUnconfirmedPrivateSubmissionPending", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAPendingPrivateRequestPending", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/dashpay.rs", + "id": "should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", + "command": "cargo test -p rs-unified-sdk-jni --lib should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/dashpay.rs", + "id": "should_destroy_every_handle_of_the_array_and_free_it", + "command": "cargo test -p rs-unified-sdk-jni --lib should_destroy_every_handle_of_the_array_and_free_it", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt", + "id": "privateContactRequestSymbolsBindAndRejectInvalidInput", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + } + ] + }, + { + "id": "dashpay.private_contact_request_scan_state", + "title": "DashPay private contact request sync resumes after restart from its persisted read position", + "area": "persistence", + "shared_apis": [ + "on_persist_private_contact_scan_state_fn", + "on_load_private_contact_scan_state_fn", + "on_load_private_contact_scan_state_free_fn" + ], + "required_persistence_capabilities": [ + "atomic_changesets", + "private_contact_request_scan_state", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "SwiftData schema V5 stores each identity's read position and its private contacts' shielded addresses (PersistentPrivateContactRequestsPosition, PersistentPrivateShieldedAddress) inside the changeset round, clears the state of a removed identity, purges it with the wallet, hands it back through the load/free shims (a stored row that cannot cross fails the load) and declares the capability; the example app's Storage Explorer lists both models. No test restarts a process and resumes a private sync from the stored position." + }, + "kotlin": { + "sdk": "partial", + "example_app": "not-applicable", + "restart": "required", + "reason": "Room v16 (migration 15 to 16) stores the same rows in the round, clears a removed identity's state, purges it with the wallet and declares the capability; JNI wires the persist/load/free slots only for a bridge that overrides both callbacks and the contact upsert (the capability also needs contact persistence on the Rust side), rows cross as packed byte arrays, and a stored row that cannot cross fails the load. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." + } + }, + "verification": [ + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_restore_the_private_contact_request_scan_state_onto_its_identity", + "command": "cargo test -p platform-wallet-ffi --lib should_restore_the_private_contact_request_scan_state_onto_its_identity", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_leave_hosts_without_the_scan_state_capability_unchanged", + "command": "cargo test -p platform-wallet-ffi --lib should_leave_hosts_without_the_scan_state_capability_unchanged", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift", + "id": "testShouldDeclareTheCapabilityAndRoundTripThroughTheCallbackShims", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:SwiftDashSDKTests/PrivateContactScanStatePersistenceTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt", + "id": "shouldStoreReplaceAndRemoveTheScanStateWithItsRound", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*PrivateContactScanStatePersistenceTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/persistence.rs", + "id": "the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it", + "command": "cargo test -p rs-unified-sdk-jni --lib the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it", + "covers_restart": false + } + ] + }, { "id": "identity.asset_lock_resume", "title": "Identity registration and top-up resume from tracked locks", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index b2d0b1fed42..e8f13a71848 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 @@ -154,6 +154,22 @@ class AppContainer(private val context: Context) { loadWallets = { val activatedManager = walletManagerStore.activate(sdk.network, sdk) activatedManager.loadPersistedWallets() + // Open the shielded store right after the load: Rust then + // restores, with no keys, the unconfirmed private contact + // request payments of every wallet that bound a shielded + // account in an earlier process. Until it does, their public + // contact requests are refused (code 71), and a later bind + // may never come (no mnemonic, declined biometric). The same + // path the bind opens, so the bind's call is a no-op. + if (shieldedService.isAvailable) { + try { + activatedManager.configureShielded(shieldedDbPath(activatedManager.network)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w(TAG, "Failed to configure the shielded store after the load", e) + } + } org.dashfoundation.example.ui.dashpay.DashPayWalletLoad( value = activatedManager, loadedWalletIdsHex = activatedManager.wallets.value.keys, @@ -207,6 +223,10 @@ class AppContainer(private val context: Context) { * `bindShielded` and the start-if-not-running guards make repeated calls * (on every wallet-set change) safe. */ + /** The per-network commitment-tree store, as iOS `ShieldedService.dbPath(for:)` names it. */ + private fun shieldedDbPath(network: org.dashfoundation.dashsdk.Network): String = + java.io.File(context.filesDir, "shielded_tree_${network.networkName}.sqlite").absolutePath + private suspend fun rebindWalletScopedServices( manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager, ) { @@ -243,10 +263,7 @@ class AppContainer(private val context: Context) { // and doesn't throw. dbPath mirrors iOS's // `ShieldedService.dbPath(for:)` naming // (`shielded_tree_.sqlite`), rooted in filesDir. - val shieldedDbPath = java.io.File( - context.filesDir, - "shielded_tree_${manager.network.networkName}.sqlite", - ).absolutePath + val shieldedDbPath = shieldedDbPath(manager.network) shieldedService.bind( manager = manager, walletId = wallet.walletId, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 7a92748c67e..a7a72bc985c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -98,6 +98,18 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { var accountLabel by remember { mutableStateOf("") } var isSending by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + // Set when an accept's private reply may have gone through (its result + // could not be confirmed): the screen only offers to close, never to + // submit again, as the send screen does for an unconfirmed spend. + var unconfirmedNotice by remember { mutableStateOf(null) } + // Whether the wallet has a private request to the resolved recipient in + // flight, or paid and not yet read back (`privateRepliesPending`). While + // it does, Send and the collision accept are off and the screen offers a + // private sync, which settles it. Read from the wallet, so reopening this + // screen keeps it. A failed read keeps them off until a read succeeds. + var recipientPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } + var isSyncingPrivate by remember { mutableStateOf(false) } + var pendingReadVersion by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -107,7 +119,14 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { AddMode.IDENTITY_ID -> parsedIdentityId } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true - val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending + val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && + unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex()) + val recipientBlocked = resolvedRecipient != null && + ( + recipientPending.isRestoringPrivatePayments || + recipientPending.isPending(resolvedRecipient.toHex()) || + recipientPending.isUnavailable + ) // Scan-to-send: consume the QR string handed back by the scanner screen // (the house pattern — observe the saved-state flow, then null it out). @@ -168,6 +187,18 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // Cache-only profile for the preview card (local read; most unknown // identities won't have one). val recipientHex = resolvedRecipient?.toHex() + LaunchedEffect(recipientHex, wallet, pendingReadVersion) { + val contactIds = listOfNotNull(resolvedRecipient) + recipientPending = recipientPending.recorded( + privateRepliesPending(wallet, idBytes, contactIds), + contactIds.map { it.toHex() }, + ).withReadiness(privateContactReadiness(wallet, idBytes)) + } + // Binding the shielded account restores the wallet's private payments: + // read the readiness again then. + LaunchedEffect(container.shieldedService) { + container.shieldedService.isBound.collect { pendingReadVersion++ } + } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient val w = wallet @@ -204,9 +235,44 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { kickDashPaySync(scope, m) navController.popBackStack() } catch (e: Exception) { - errorMessage = e.message ?: "Send failed" + when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + // Nothing was sent, and the wallet may hold nothing for + // it: the screen shows it as pending and offers the + // private sync that settles it. + ContactRequestSubmissionOutcome.PrivateRequestPending -> + recipientPending = recipientPending.withRefusal(recipient.toHex()) + // Nothing was sent: the screen shows the wallet restoring + // its private payments until its readiness says restored. + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> + recipientPending = recipientPending.withRestoringRefusal() + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + kickDashPaySync(scope, m) + unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } + is ContactRequestSubmissionOutcome.Failed -> errorMessage = outcome.message + } } finally { isSending = false + pendingReadVersion++ + } + } + } + + /** + * Read the private store, which settles a pending private request, then + * re-read the recipient's pending state: Send comes back once nothing is + * pending, for the user to try again. + */ + fun syncPrivateRequests() { + val w = wallet ?: return + val m = manager ?: return + isSyncingPrivate = true + scope.launch { + try { + syncPrivateContactRequests(w, m)?.let { errorMessage = it } + } finally { + isSyncingPrivate = false + pendingReadVersion++ } } } @@ -214,15 +280,17 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return + // A private reply to this contact may be pending in the wallet. + if (!recipientPending.allowsSubmission(recipient.toHex())) return isSending = true errorMessage = null scope.launch { try { - val ok = w.dashpay.acceptIncomingRequest( + val ok = acceptIncomingContactRequest( + wallet = w, + manager = m, ourIdentityId = idBytes, senderId = recipient, - signerHandle = m.signerHandle, - coreSignerHandle = m.mnemonicResolverHandle, ) if (ok) { kickDashPaySync(scope, m) @@ -232,9 +300,23 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { "accept it from Requests." } } catch (e: Exception) { - errorMessage = "Accept failed: ${e.message ?: "unknown error"}" + when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + // Pending, not failed: the next sync settles it. + kickDashPaySync(scope, m) + unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } + // Nothing was sent: the screen offers the private sync. + ContactRequestSubmissionOutcome.PrivateRequestPending -> + recipientPending = recipientPending.withRefusal(recipient.toHex()) + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> + recipientPending = recipientPending.withRestoringRefusal() + is ContactRequestSubmissionOutcome.Failed -> + errorMessage = "Accept failed: ${outcome.message}" + } } finally { isSending = false + pendingReadVersion++ } } } @@ -334,6 +416,27 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { enabled = canSend, modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), ) { attemptSend() } + if (recipientBlocked) { + Text( + ContactRequestSubmissionOutcome.rowMessage( + pending = recipientPending.isPending(recipient.toHex()), + restoring = recipientPending.isRestoringPrivatePayments, + unavailable = recipientPending.isUnavailable, + error = null, + ).orEmpty(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("dashpay.addContact.pending"), + ) + // A private sync settles a pending request; it does not + // restore private payments, so it is not offered for that. + if (!recipientPending.isRestoringPrivatePayments) { + TextButton( + onClick = { syncPrivateRequests() }, + enabled = !isSyncingPrivate, + modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), + ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } + } + } } if (errorMessage != null) { @@ -366,6 +469,17 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { }, ) } + + unconfirmedNotice?.let { notice -> + AlertDialog( + onDismissRequest = { navController.popBackStack() }, + title = { Text("Submitted") }, + text = { Text(notice) }, + confirmButton = { + TextButton(onClick = { navController.popBackStack() }) { Text("Done") } + }, + ) + } } @Composable diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt new file mode 100644 index 00000000000..5afbfc297a6 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -0,0 +1,207 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.tokens.Dashpay + +/** + * How a contact request send or accept that threw ended, for the DashPay + * screens. Follows the send screen (and iOS `SendViewModel.swift`): a shielded + * spend whose result could not be confirmed may already be on chain (Rust + * keeps its notes reserved), so the screen keeps it pending and never offers a + * fresh submission; the next sync settles it. A private request Rust reports + * as pending (one to the same contact in flight, or paid and not yet read + * back), which a public send or accept can also meet, sent nothing new: the + * screen shows it as pending and offers a private sync, which settles it, + * after which the user may try again. Unrestored private payments (code 72) + * hold the wallet, not one contact: the screen shows "restoring private + * payments" until the wallet's readiness reads restored; no private sync + * lifts it. Every other error is an ordinary failure the user may retry. + * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). + */ +sealed interface ContactRequestSubmissionOutcome { + /** The private reply may have gone through: do not submit again. */ + data object MayHaveGoneThrough : ContactRequestSubmissionOutcome + + /** A private request to the contact is pending in the wallet; nothing was sent. */ + data object PrivateRequestPending : ContactRequestSubmissionOutcome + + /** The wallet's private payments are not restored yet; nothing was sent. */ + data object PrivatePaymentsNotRestored : ContactRequestSubmissionOutcome + + /** It failed; the message is the error's. */ + data class Failed(val message: String) : ContactRequestSubmissionOutcome + + companion object { + const val MAY_HAVE_GONE_THROUGH_MESSAGE = "This may have gone through. It shows up " + + "after the next sync confirms it; do not submit it again." + + const val PRIVATE_REQUEST_PENDING_MESSAGE = "A private request to this contact is " + + "pending. Sync private requests to settle it, then try again if it did not " + + "go through." + + const val RESTORING_PRIVATE_PAYMENTS_MESSAGE = "Restoring this wallet's private " + + "payments. Contact requests wait until they are restored." + + /** + * Code 73 is a configuration error of the app's storage, which + * declares what a private payment needs; shown as such. + */ + const val RECOVERY_UNSUPPORTED_MESSAGE = "This storage backend can't protect " + + "private payments, so private contact requests are off." + + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending + is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored + is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported -> + Failed(RECOVERY_UNSUPPORTED_MESSAGE) + else -> Failed(error.message ?: "unknown error") + } + + /** + * The message a request row shows: the restoring notice while the + * wallet's private payments are not restored, the pending notice + * while the wallet has a private request to the contact pending, the + * unavailable notice while the wallet could not be read, and + * whatever error an earlier attempt left otherwise. + */ + fun rowMessage( + pending: Boolean, + restoring: Boolean = false, + unavailable: Boolean = false, + error: String?, + ): String? = when { + restoring -> RESTORING_PRIVATE_PAYMENTS_MESSAGE + pending -> PRIVATE_REQUEST_PENDING_MESSAGE + unavailable -> PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE + else -> error + } + } +} + +/** + * Which contacts (lower hex ids) have a private request pending in the + * wallet, as the screens last read it (`privateRepliesPending`). While one + * is, Rust refuses another send or reply to that contact, so the screens turn + * off what would submit one (Accept, Ignore, Send). A failed read is not + * "nothing pending": the last successful snapshot stays, and those controls + * stay off for every contact until a read covering it succeeds. + * + * A send or accept Rust refused as pending (code 71) is kept apart from the + * read: code 71 also means the private store could not be read to its end + * before the first send or accept since load, with nothing reserved, so the + * read may find nothing pending. That contact stays pending, with the + * private sync offered, until the wallet's readiness says the store was + * read to its end. + * + * Whether the wallet's private payments are restored is the wallet's + * readiness (`Dashpay.privateContactReadiness`), not anything the screens + * conclude: a code 72 refusal, or a readiness read that says not restored, + * shows "restoring private payments" for every contact, and only a + * readiness read that says restored lifts it. A private sync changes + * neither on its own. + * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). + */ +data class PrivateRepliesPendingSnapshot( + /** The pending contacts the last successful read found. */ + val pending: Set = emptySet(), + /** The contacts the last successful read covered. */ + val covered: Set = emptySet(), + /** False after a failed read, until a read succeeds. */ + val available: Boolean = true, + /** Contacts a send or accept was refused for as pending (code 71), until the store is read to its end. */ + val refused: Set = emptySet(), + /** The wallet's readiness for the identity, as last read; null until a read succeeds. */ + val readiness: Dashpay.PrivateContactReadiness? = null, + /** Set by a code 72 refusal; cleared only by a readiness read that says restored. */ + val restoringRefused: Boolean = false, +) { + /** Whether the last read failed. */ + val isUnavailable: Boolean get() = !available + + /** + * This snapshot after a read of [contactIds]: [read] holds the pending + * ones, or is null when the read failed. + */ + fun recorded(read: Set?, contactIds: Collection): PrivateRepliesPendingSnapshot = + if (read == null) { + copy(available = false) + } else { + copy(pending = read, covered = contactIds.toSet(), available = true) + } + + /** This snapshot after a send or accept to [contactId] was refused as pending (code 71). */ + fun withRefusal(contactId: String): PrivateRepliesPendingSnapshot = copy(refused = refused + contactId) + + /** This snapshot after a send or accept refused because the private payments are not restored (code 72). */ + fun withRestoringRefusal(): PrivateRepliesPendingSnapshot = copy(restoringRefused = true) + + /** + * This snapshot after a read of the wallet's readiness, or null when it + * failed, which keeps what the last read said. A readiness that says + * restored lifts a code 72 refusal; one that says the private store was + * read to its end leaves a contact's pending state to the pending read. + */ + fun withReadiness(read: Dashpay.PrivateContactReadiness?): PrivateRepliesPendingSnapshot = + if (read == null) { + this + } else { + copy( + readiness = read, + restoringRefused = restoringRefused && !read.privatePaymentsRestored, + refused = if (read.privateStoreRead) emptySet() else refused, + ) + } + + /** + * Whether the wallet's private payments are being restored: a send or + * accept was refused with code 72, or the last readiness read says they + * are not restored. Every contact waits while they are. + */ + val isRestoringPrivatePayments: Boolean + get() = restoringRefused || readiness?.privatePaymentsRestored == false + + /** + * Whether [contactId] is pending: the last successful read found a + * private request to it, or a send or accept to it was refused as + * pending and the store has not been read to its end since. + */ + fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused + + /** + * Whether a send or reply to [contactId] may be offered: a successful + * read covered it and found nothing pending. + */ + fun allowsSubmission(contactId: String): Boolean = + available && contactId in covered && !isPending(contactId) && !isRestoringPrivatePayments + + companion object { + const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + + "contact is pending. Sync private requests to check again." + } +} + +/** + * What the request list's private-store refresh tells the user. A network + * that does not serve the store yet (before protocol version 15) is expected + * and stays quiet; an operational failure, or requests read but not saved, + * is shown so the user knows to refresh again. + * ← Swift `PrivateContactRefreshNotice` (`ContactRequestOutcomes.swift`). + */ +object PrivateContactRefreshNotice { + /** The notice a pass that read the store leaves, or null when every identity's requests reached disk. */ + fun notice(unpersistedIdentityCount: Long): String? { + if (unpersistedIdentityCount <= 0) return null + val who = if (unpersistedIdentityCount == 1L) "identity were" else "identities were" + return "Private requests for $unpersistedIdentityCount $who read but not saved. " + + "Pull to refresh again." + } + + /** The notice a pass that failed leaves, or null when the network does not serve the private store yet. */ + fun notice(error: Throwable): String? = + if (error is DashSdkError.PlatformWallet.ProtocolVersionNotActive) { + null + } else { + "Private requests could not be read: ${error.message ?: "unknown error"}" + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index b6057c802ed..addfee36b81 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -92,6 +92,18 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var removedOverlayIds by remember { mutableStateOf(emptySet()) } var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } + // Hex ids with a private reply in flight, or paid and not yet read back, + // as the wallet records it (`privateRepliesPending`): the row stays + // pending, without its buttons, until a private sync settles it. Read + // from the wallet, not kept by this screen, so leaving and reopening it + // keeps the state. Re-read on composition and row changes, after an + // accept, on refresh and after each sync pass. A failed read keeps the + // last snapshot and leaves every row without its buttons until a read + // succeeds. + var walletPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } + var pendingReadVersion by remember { mutableStateOf(0) } + // What the last private-store refresh has to tell the user, if anything. + var privateRefreshNotice by remember { mutableStateOf(null) } // Prune the optimistic-removal overlay against the backing query: keep a // hex only while its pair is STILL incoming-only, so an entry is dropped @@ -147,6 +159,25 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle .sortedByDescending { it.createdAtMillis } } + LaunchedEffect(wallet, rows, pendingReadVersion) { + val contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId } + val read = privateRepliesPending( + wallet = wallet, + ourIdentityId = idBytes, + contactIds = contactIds, + ) + walletPending = walletPending.recorded(read, contactIds.map { it.toHex() }) + .withReadiness(privateContactReadiness(wallet, idBytes)) + } + // Binding the shielded account restores the wallet's private payments: + // read the readiness again then. + LaunchedEffect(container.shieldedService) { + container.shieldedService.isBound.collect { pendingReadVersion++ } + } + LaunchedEffect(manager) { + manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } + } + fun accept(contactId: ByteArray) { val w = wallet ?: return val m = manager ?: return @@ -155,11 +186,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle inFlightIds = inFlightIds + hex scope.launch { try { - val ok = w.dashpay.acceptIncomingRequest( + val ok = acceptIncomingContactRequest( + wallet = w, + manager = m, ourIdentityId = idBytes, senderId = contactId, - signerHandle = m.signerHandle, - coreSignerHandle = m.mnemonicResolverHandle, ) if (ok) { removedOverlayIds = removedOverlayIds + hex @@ -168,9 +199,32 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle rowErrors = rowErrors + (hex to "Request not in local state — pull to refresh") } } catch (e: Exception) { - rowErrors = rowErrors + (hex to "Accept failed: ${e.message ?: "unknown error"}") + when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + // As the send screen treats an unconfirmed spend: not + // a failure to retry. The wallet keeps the reply + // pending, and the row reads that. + kickDashPaySync(scope, m) + } + ContactRequestSubmissionOutcome.PrivateRequestPending -> { + // Nothing was sent, and the wallet may hold nothing + // for it (the store was not read to its end): the row + // stays pending until the wallet's readiness says the + // store was read (pull to refresh reads it). + walletPending = walletPending.withRefusal(hex) + kickDashPaySync(scope, m) + } + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> { + // The wallet is held until its private payments are + // restored; every row waits for the readiness to say so. + walletPending = walletPending.withRestoringRefusal() + } + is ContactRequestSubmissionOutcome.Failed -> + rowErrors = rowErrors + (hex to "Accept failed: ${outcome.message}") + } } finally { inFlightIds = inFlightIds - hex + pendingReadVersion++ } } } @@ -209,7 +263,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle onRefresh = { scope.launch { isRefreshing = true - manager?.let { attachOrStartSync(it) } + manager?.let { m -> + attachOrStartSync(m) + wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } + pendingReadVersion++ + } isRefreshing = false } }, @@ -220,6 +278,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + privateRefreshNotice?.let { notice -> + item { + Text( + notice, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("dashpay.requests.privateRefreshNotice"), + ) + } + } if (incomingPending.isEmpty() && outgoingPending.isEmpty()) { item { DashPayEmptyRow( @@ -235,8 +303,13 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex), - errorMessage = rowErrors[hex], + isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), + errorMessage = ContactRequestSubmissionOutcome.rowMessage( + pending = walletPending.isPending(hex), + restoring = walletPending.isRestoringPrivatePayments, + unavailable = walletPending.isUnavailable, + error = rowErrors[hex], + ), onAccept = { accept(row.contactId) }, onIgnore = { ignore(row.contactId) }, ) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 0080bbdb3a7..4214772e305 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -1,9 +1,12 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.util.toHex /** * Shared DashPay sync helpers — port of the free functions in @@ -32,3 +35,91 @@ suspend fun attachOrStartSync(manager: PlatformWalletManager) { fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { scope.launch { runCatching { manager.dashPaySyncNow() } } } + +/** + * The hex ids of the contacts among [contactIds] that [ourIdentityId] has a + * private request to in flight, or paid and not yet read back, as the wallet + * records it (one snapshot). While one is, Rust refuses another send or reply + * to it, so the screens show it as pending and offer neither; a private sync + * settles it. Empty when the identity is not managed, as the wallet then + * holds nothing for it. Null when the wallet could not be read (no wallet, + * or a read failed), which the screens keep apart from "nothing pending" + * ([PrivateRepliesPendingSnapshot]). + * ← Swift `privateRepliesPending` (`ContactsView.swift`). + */ +suspend fun privateRepliesPending( + wallet: ManagedPlatformWallet?, + ourIdentityId: ByteArray, + contactIds: List, +): Set? { + if (contactIds.isEmpty()) return emptySet() + if (wallet == null) return null + return try { + wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } +} + +/** + * The wallet's readiness for [ourIdentityId]'s contact requests, or null + * when it could not be read ([PrivateRepliesPendingSnapshot.withReadiness] + * keeps the last one then). + * ← Swift `privateContactReadiness(wallet:identityId:)` (`ContactsView.swift`). + */ +suspend fun privateContactReadiness( + wallet: ManagedPlatformWallet?, + ourIdentityId: ByteArray, +): org.dashfoundation.dashsdk.tokens.Dashpay.PrivateContactReadiness? { + if (wallet == null) return null + return try { + wallet.dashpay.privateContactReadiness(ourIdentityId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } +} + +/** + * Read DashPay's private contact request store (protocol version 15) for + * [wallet], returning what the refresh should tell the user, or null. + * Private requests reach the wallet only through this read, so it runs with + * every pull-to-refresh of the request list. A network before protocol + * version 15 does not serve the store yet, which is expected and quiet; any + * other failure, or requests read but not saved, is reported. + * ← Swift `syncPrivateContactRequests` (`ContactsView.swift`). + */ +suspend fun syncPrivateContactRequests( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, +): String? = try { + val result = wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) + PrivateContactRefreshNotice.notice(result.unpersistedIdentityCount) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + PrivateContactRefreshNotice.notice(e) +} + +/** + * Accept the incoming request from [senderId] to [ourIdentityId] with the + * wallet's one accept, which replies the way the request came: privately + * (paid from the wallet's shielded balance) or with a document, signed by the + * manager's identity signer either way. The wallet decides; the app makes one + * call. Returns false when the request is not in local state. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`, as + * `ContactRequestsView.swift` and `AddContactView.swift` call it. + */ +suspend fun acceptIncomingContactRequest( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, + ourIdentityId: ByteArray, + senderId: ByteArray, +): Boolean { + val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) ?: return false + request.use { manager.acceptContactRequestReplyingInKind(wallet.walletId, it).close() } + return true +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt new file mode 100644 index 00000000000..0bac7ae22c4 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -0,0 +1,231 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Port of `ContactRequestOutcomesTests.swift`: an unconfirmed private reply + * stays pending (no fresh submission is offered), and a network that does + * not serve the private store yet is quiet while real failures are shown. + */ +class ContactRequestOutcomesTest { + + @Test + fun shouldKeepAnUnconfirmedPrivateSubmissionPending() { + val error = DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed("result could not be confirmed") + assertEquals( + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.of(error), + ) + } + + /** + * A pending private request (code 71), which a public send or accept can + * meet too, sent nothing: it is shown as pending, with a private sync to + * settle it, rather than as a submission that may have gone through. + */ + @Test + fun shouldKeepAPendingPrivateRequestPending() { + val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") + assertEquals( + ContactRequestSubmissionOutcome.PrivateRequestPending, + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldShowAWalletPendingReplyOverAnEarlierError() { + assertEquals( + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, error = "Accept failed: boom"), + ) + assertEquals( + "Accept failed: boom", + ContactRequestSubmissionOutcome.rowMessage(pending = false, error = "Accept failed: boom"), + ) + assertEquals( + PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + unavailable = true, + error = "Accept failed: boom", + ), + ) + assertNull(ContactRequestSubmissionOutcome.rowMessage(pending = false, error = null)) + } + + /** + * A read that found a private request pending, followed by a failed + * read: the contact stays pending and nothing is offered for any + * contact, until a read succeeds again. + */ + @Test + fun shouldKeepThePendingSnapshotAndOfferNothingAfterAFailedRead() { + val contacts = listOf("01", "02") + var snapshot = PrivateRepliesPendingSnapshot().recorded(setOf("01"), contacts) + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertTrue(snapshot.allowsSubmission("02")) + + snapshot = snapshot.recorded(null, contacts) + assertTrue(snapshot.isUnavailable) + assertTrue("the last snapshot stays", snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertFalse(snapshot.allowsSubmission("02")) + + snapshot = snapshot.recorded(emptySet(), contacts) + assertFalse(snapshot.isUnavailable) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** + * A send refused as pending (code 71) when the wallet reserved nothing + * (its store was not read to its end): the following read finds nothing + * pending, yet the contact stays pending, with the private sync offered, + * until the wallet's readiness says the store was read. + */ + @Test + fun shouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { + var snapshot = PrivateRepliesPendingSnapshot() + .withRefusal("01") + .recorded(emptySet(), listOf("01")) + assertFalse(snapshot.isUnavailable) + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = snapshot.isPending("01"), error = null), + ) + + // A pending read that finds nothing does not lift it; the wallet's + // readiness saying the store was read to its end does. + snapshot = snapshot.recorded(emptySet(), listOf("01")) + assertTrue(snapshot.isPending("01")) + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = true)) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + } + + private fun readiness(restored: Boolean, storeRead: Boolean, unresolved: Boolean = false) = + org.dashfoundation.dashsdk.tokens.Dashpay.PrivateContactReadiness( + privatePaymentsRestored = restored, + privateStoreRead = storeRead, + unresolvedPrivatePayment = unresolved, + ) + + /** A store that cannot protect private payments (code 73) is a configuration error, shown as such. */ + @Test + fun shouldShowUnsupportedPrivateRecoveryAsAStorageError() { + assertEquals( + ContactRequestSubmissionOutcome.Failed(ContactRequestSubmissionOutcome.RECOVERY_UNSUPPORTED_MESSAGE), + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported("no atomic scan state"), + ), + ) + } + + /** Unrestored private payments (code 72) are their own outcome, apart from a pending request (71). */ + @Test + fun shouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { + assertEquals( + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored, + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored("not restored"), + ), + ) + assertEquals( + ContactRequestSubmissionOutcome.RESTORING_PRIVATE_PAYMENTS_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, restoring = true, error = null), + ) + } + + /** + * A code 72 hold survives a successful pending read that finds nothing, + * and a readiness read after a private sync that still says not + * restored; only a readiness read that says restored lifts it. + */ + @Test + fun shouldHoldOnCode72UntilTheReadinessSaysRestored() { + var snapshot = PrivateRepliesPendingSnapshot().withRestoringRefusal().recorded(emptySet(), listOf("01")) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + + // A private sync read the store to its end; the payments are still not restored. + snapshot = snapshot.withReadiness(readiness(restored = false, storeRead = true, unresolved = true)) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + + // A failed readiness read keeps what the last one said. + snapshot = snapshot.withReadiness(null) + assertTrue(snapshot.isRestoringPrivatePayments) + + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = true)) + assertFalse(snapshot.isRestoringPrivatePayments) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** A readiness that says not restored holds every contact without any refusal. */ + @Test + fun shouldShowRestoringFromTheReadinessAlone() { + val snapshot = PrivateRepliesPendingSnapshot() + .recorded(emptySet(), listOf("01")) + .withReadiness(readiness(restored = false, storeRead = false)) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + } + + /** + * A successful read that found nothing pending offers every contact it + * covered, and nothing it did not. + */ + @Test + fun shouldOfferEveryContactASuccessfulEmptyReadCovered() { + val snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + assertFalse(snapshot.isUnavailable) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertFalse( + "a contact the read did not cover is not offered yet", + snapshot.allowsSubmission("02"), + ) + } + + @Test + fun shouldOfferARetryForAnOrdinaryFailure() { + val error = DashSdkError.PlatformWallet.ShieldedBroadcastFailed("rejected by the relay") + assertEquals( + ContactRequestSubmissionOutcome.Failed("rejected by the relay"), + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldStayQuietWhenTheNetworkDoesNotServeThePrivateStoreYet() { + assertNull( + PrivateContactRefreshNotice.notice( + DashSdkError.PlatformWallet.ProtocolVersionNotActive("needs protocol version 15"), + ), + ) + } + + @Test + fun shouldShowAPrivateRefreshThatFailed() { + assertEquals( + "Private requests could not be read: no node answered", + PrivateContactRefreshNotice.notice(DashSdkError.PlatformWallet.WalletOperation("no node answered")), + ) + } + + @Test + fun shouldShowRequestsThatWereReadButNotSaved() { + assertNull(PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 0)) + assertEquals( + "Private requests for 2 identities were read but not saved. Pull to refresh again.", + PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 2), + ) + } +} diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index ff6bef9ca6e..7bc14e553d1 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **27** +Capabilities tracked: **29** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 16 | 9 | 1 | 1 | -| Swift | Example app | 4 | 12 | 2 | 9 | -| Kotlin | SDK | 13 | 12 | 1 | 1 | -| Kotlin | Example app | 5 | 12 | 1 | 9 | +| Swift | SDK | 16 | 11 | 1 | 1 | +| Swift | Example app | 4 | 14 | 2 | 9 | +| Kotlin | SDK | 13 | 14 | 1 | 1 | +| Kotlin | Example app | 5 | 13 | 1 | 10 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 1 | 8 | 18 | -| Kotlin | 4 | 6 | 17 | +| Swift | 1 | 10 | 18 | +| Kotlin | 4 | 8 | 17 | ## Capability status @@ -33,6 +33,8 @@ Capabilities tracked: **27** | `dashpay.invitations` | supported / partial / required | supported / partial / required | | `core.provider_restore` | supported / partial / required | partial / partial / required | | `dashpay.deferred_contact_crypto` | partial / partial / required | partial / partial / required | +| `dashpay.private_contact_requests` | partial / partial / required | partial / partial / required | +| `dashpay.private_contact_request_scan_state` | partial / partial / required | partial / not-applicable / required | | `identity.asset_lock_resume` | supported / partial / required | partial / partial / required | | `core.compact_filter_rescan` | supported / partial / not_applicable | partial / partial / not_applicable | | `dpns.contested_names_by_identity` | supported / partial / not_applicable | partial / partial / required | diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json new file mode 100644 index 00000000000..fcb3c307fa0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json @@ -0,0 +1,4284 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "df09e705e8b63a04af564a1257b4a2cd", + "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, `dashPayBackfillFloor` INTEGER, `dashPayBackfillRewoundFrom` INTEGER, `dashPayBackfillCovered` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillFloor", + "columnName": "dashPayBackfillFloor", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillRewoundFrom", + "columnName": "dashPayBackfillRewoundFrom", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillCovered", + "columnName": "dashPayBackfillCovered", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `externalAccountReference` INTEGER, `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": "externalAccountReference", + "columnName": "externalAccountReference", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + }, + { + "tableName": "private_contact_requests_positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, `position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, `privatePaymentsUnresolved` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`walletId`, `identityId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyFingerprint", + "columnName": "keyFingerprint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "privatePaymentsUnresolved", + "columnName": "privatePaymentsUnresolved", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "identityId" + ] + } + }, + { + "tableName": "private_shielded_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `ownerId` BLOB NOT NULL, `contactId` BLOB NOT NULL, `address` BLOB NOT NULL, PRIMARY KEY(`walletId`, `ownerId`, `contactId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "ownerId", + "contactId" + ] + } + } + ], + "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, 'df09e705e8b63a04af564a1257b4a2cd')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index d9bfe5ee81b..70190a07dd7 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -4,6 +4,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.Dispatchers import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.DashpayNative +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.NativeLoader import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.ffi.SignerNative @@ -11,6 +13,7 @@ import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.persistence.DashDatabase import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -86,6 +89,55 @@ class FfiSmokeTest { assertEquals("compact recoverable ECDSA signature", 65, signature!!.size) } + /** + * DashPay private contact requests (protocol version 15): the + * `DashpayNative` symbols bind with the descriptors Kotlin declares, and + * bad input surfaces as a mapped [DashSDKException] rather than a crash. + * A null resolver is refused before any wallet lookup, and a shielded + * address that is not 43 bytes never reaches the FFI (which reads + * exactly 43). The explicit private send and private-reply accept are + * exported only by a shielded build, so they are probed only there; the + * sync, the reads and the in-kind accept are exported by every build. + * The flows themselves need a protocol version 15 network with shielded + * funds; their Rust side is covered by the platform-wallet and + * platform-wallet-ffi tests. + */ + @Test + fun privateContactRequestSymbolsBindAndRejectInvalidInput() { + NativeLoader.ensureLoaded() + assertThrows(DashSDKException::class.java) { + DashpayNative.syncPrivateContactRequests(0L, 0L, 1) + } + if (Sdk.hasShielded()) { + assertThrows(DashSDKException::class.java) { + DashpayNative.sendPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, ByteArray(32), ByteArray(32), + null, null, null, 0L, + ) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } + } + // An unknown wallet handle is NotFound. + assertThrows(DashSDKException::class.java) { + DashpayNative.privateContactReadiness(0L, ByteArray(32)) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptContactRequestReplyingInKind( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } + // The private contact read is benign on a miss (null, as Swift's + // nil); the pending read reports an unknown handle. + assertNull(DashpayNative.managedIdentityPrivateContact(0L, ByteArray(32))) + assertThrows(DashSDKException::class.java) { + DashpayNative.managedIdentityPrivateReplyPending(0L, ByteArray(32)) + } + } + /** * The #4069 watermark-freeze fault latch surfaced by PR #4314: the * `syncFaultDetected` JNI symbol binds, and an invalid manager handle @@ -102,6 +154,57 @@ class FfiSmokeTest { } } + /** + * The private contact request scan state needs every override it + * depends on, and atomic changesets: JNI wires its slots only for a + * bridge that overrides the contact upsert native calls, the + * scan-state store and the scan-state load (a read position vouches + * for the requests an inherited no-op contact writer never stored), and + * the wallet keeps the state only with `ATOMIC_CHANGESETS` too (an + * un-ignore stores the sender's removal and the position rewind in one + * changeset). JNI always wires the changeset begin and end callbacks, + * so the atomic half is the bridge's declaration. A bridge declaring + * both but leaving any override inherited, or declaring the scan state + * without atomic rounds, has neither; one implementing all three and + * declaring both, and the Room handler, keep both. + */ + @Test + fun privateContactScanStateCapabilityNeedsEveryOverride() { + NativeLoader.ensureLoaded() + val bits = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or + ATOMIC_CHANGESETS + fun effective(bridge: NativePersistenceBridge): Boolean = + WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bits == bits + + assertTrue("every override", effective(ScanStateBridge.Full())) + assertFalse("no contact upsert", effective(ScanStateBridge.WithoutContactUpsert())) + assertFalse("no scan-state store", effective(ScanStateBridge.WithoutScanStateStore())) + assertFalse("no scan-state load", effective(ScanStateBridge.WithoutScanStateLoad())) + assertFalse( + "every override, not declared", + effective(object : ScanStateBridge.Full() { + override fun persistenceCapabilitiesBits(): Long = 0L + }), + ) + assertFalse( + "every override, no atomic changesets", + effective(object : ScanStateBridge.Full() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + }), + ) + + val db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + try { + assertTrue( + "the Room handler", + effective(PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined)), + ) + } finally { + db.close() + } + } + /** * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve * each bridge method by (name, descriptor) only when its slot first @@ -127,3 +230,132 @@ class FfiSmokeTest { } } } + +/** + * Bridges declaring the private contact request scan state with some of the + * overrides it depends on, for + * [FfiSmokeTest.privateContactScanStateCapabilityNeedsEveryOverride]. Each + * concrete class declares exactly the overrides its name says. + */ +private abstract class ScanStateBridge : NativePersistenceBridge() { + override fun persistenceCapabilitiesVersion(): Int = 1 + + override fun persistenceCapabilitiesBits(): Long = + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or ATOMIC_CHANGESETS + + open class Full : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + } + + class WithoutContactUpsert : ScanStateBridge() { + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + } + + class WithoutScanStateStore : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + } + + class WithoutScanStateLoad : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + } +} + +/** `PersistenceCapabilities::ATOMIC_CHANGESETS` (bit 0). */ +private const val ATOMIC_CHANGESETS: Long = 1L 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 9a98201510b..19797f2678a 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 @@ -690,13 +690,63 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v15 -> v16 creates the two private contact request scan state tables + * empty (the first sync reads the store from its start) and leaves the + * existing rows alone; rows written afterwards keep every column, + * a full-width position included. + */ + @Test + fun migrate15To16AddsPrivateContactScanStateTables() { + helper.createDatabase(dbName, 15).apply { + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 2309809, 0, 0, 0, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 16, true, DashDatabase.MIGRATION_15_16) + + db.query("SELECT COUNT(*) FROM private_contact_requests_positions").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + db.query("SELECT syncedHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(2_309_809, c.getInt(0)) + } + db.execSQL( + "INSERT INTO private_contact_requests_positions (walletId, identityId, position, " + + "keyFingerprint) VALUES (x'01', x'0A', -1, x'03')", + ) + db.execSQL( + "INSERT INTO private_shielded_addresses (walletId, ownerId, contactId, address) " + + "VALUES (x'01', x'0A', x'0B', x'09')", + ) + db.query( + "SELECT position, keyFingerprint, privatePaymentsUnresolved FROM private_contact_requests_positions", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals("the u64 bit pattern survives", -1L, c.getLong(0)) + assertTrue(byteArrayOf(3).contentEquals(c.getBlob(1))) + assertEquals("the unresolved marker defaults to clear", 0, c.getInt(2)) + } + db.query("SELECT address FROM private_shielded_addresses WHERE contactId = x'0B'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(byteArrayOf(9).contentEquals(c.getBlob(0))) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -709,16 +759,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } - /** The full chain from v1 must also land on a valid v15 schema. */ + /** The full chain from v1 must also land on a valid v16 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -734,6 +785,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 9f19d1653a0..a929dacbd47 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -646,6 +646,85 @@ sealed class DashSdkError( override val userMessage: String get() = SPV_PROCESS_RESTART_REQUIRED_USER_MESSAGE } + /** + * `ErrorPrivateContactRequestNeedsPrivateReply` (native code 68). The + * contact requests with that identity came or went privately + * (protocol version 15), so a public request to it, in reply or as a + * rotation, would publish the relationship. The public accept and + * send refuse it and nothing was sent. Accept it with + * `PlatformWalletManager.acceptContactRequestReplyingInKind` (which + * picks the reply itself) or `acceptPrivateContactRequest`, or send + * with `PlatformWalletManager.sendPrivateContactRequest`. Not + * retryable as-is: the same public call is refused again. + * ← Swift `PlatformWalletError.privateContactRequestNeedsPrivateReply` + * (`PlatformWalletResult.swift`). + */ + class PrivateContactRequestNeedsPrivateReply(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorShieldedBuildRequired` (native code 69). The operation needs + * the shielded pool and the native library was built without the + * `shielded` feature (`./build_android.sh --no-shielded`): today, + * accepting a contact request that came privately with + * `PlatformWalletManager.acceptContactRequestReplyingInKind`. Nothing + * was sent; a public or adopted reply works on such a build. Not + * retryable on the same build. + * ← Swift `PlatformWalletError.shieldedBuildRequired`. + */ + class ShieldedBuildRequired(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorProtocolVersionNotActive` (native code 70). The SDK did not + * send the request: the network is proven at a protocol version below + * the one that serves it, for example DashPay's private contact + * request store before protocol version 15. Expected on such a + * network, so a host may stay quiet about it; it lifts when the + * network upgrades. A refusal that may lift sooner (the version could + * not be confirmed) arrives as [Generic] instead. + * ← Swift `PlatformWalletError.protocolVersionNotActive`. + */ + class ProtocolVersionNotActive(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactRequestPending` (native code 71). A private + * contact request to that identity is in flight (another private send + * or reply reserved it), or was paid for and is not yet read back from + * the store. Nothing new was paid; a later + * `Dashpay.syncPrivateContactRequests` settles it. Pending, not + * failed: do not submit it again. + * ← Swift `PlatformWalletError.privateContactRequestPending`. + */ + class PrivateContactRequestPending(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactPaymentsNotRestored` (native code 72). The + * wallet, not one contact, is held: its private payments from an + * earlier process are not restored from the shielded store yet, so + * no public contact request is sent or accepted, nothing signed or + * broadcast. `PlatformWalletManager.configureShielded` or binding + * the wallet's shielded account restores them; a private sync does + * not. `Dashpay.privateContactReadiness` reads whether they are. + * ← Swift `PlatformWalletError.privateContactPaymentsNotRestored`. + */ + class PrivateContactPaymentsNotRestored(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactRecoveryUnsupported` (native code 73). The + * wallet's storage cannot keep a private payment's recovery state + * across a restart (it does not declare the private contact request + * scan state together with atomic changesets), so a private send or + * reply is refused before anything is sealed or paid. A + * configuration error of the store, not something a retry fixes. + * ← Swift `PlatformWalletError.privateContactRecoveryUnsupported`. + */ + class PrivateContactRecoveryUnsupported(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -842,6 +921,14 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired + // ErrorPrivateContactRequestNeedsPrivateReply: the explicitly + // public accept or send refused a contact that went private. + 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) + 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired + 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive + 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending + 72 -> PlatformWallet.PrivateContactPaymentsNotRestored(message, cause) // ErrorPrivateContactPaymentsNotRestored + 73 -> PlatformWallet.PrivateContactRecoveryUnsupported(message, cause) // ErrorPrivateContactRecoveryUnsupported else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. 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..229eb35db2f 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 @@ -263,4 +263,115 @@ internal object DashpayNative { signerHandle: Long, nowUnix: Long, ): IdentityRegistrationNativeResult + + // ── DashPay private contact requests (protocol version 15) ──────── + // + // ← Swift `PrivateContactRequests.swift` + // (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + + /** + * Read DashPay's private contact request store for every identity of + * the wallet, at most [maxPages] pages, and ingest what it holds for + * them. The store is opened with keys derived inside + * [coreSignerHandle] (the manager's `MnemonicResolverHandle`). Ingested + * requests reach Room through the persister; no handles cross. Returns + * six longs: requests ingested, pages read, entries refused, identities + * whose ingest did not reach disk, the store position the pass stopped + * at, and 1 when it read to the store's last entry (else 0). Blocking. + * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. + */ + external fun syncPrivateContactRequests( + walletHandle: Long, + coreSignerHandle: Long, + maxPages: Int, + ): LongArray? + + /** + * Send a DashPay private contact request, paid from [account]'s + * shielded notes on [walletId]. [resolverHandle] supplies the Orchard + * spend authority and the DashPay keys; [signerHandle] is the identity + * signer [TokensNative.sendContactRequest] takes. [shieldedAddress] + * must be 43 raw Orchard bytes when set. Needs the `shielded` native + * feature. Returns the created `ContactRequest` handle (destroy via + * [TokensNative.contactRequestDestroy]). + * ← Swift `PlatformWalletManager.sendPrivateContactRequest`. + */ + @Suppress("LongParameterList") + external fun sendPrivateContactRequest( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + senderIdentityId: ByteArray, + recipientIdentityId: ByteArray, + accountLabel: String?, + autoAcceptProof: ByteArray?, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long + + /** + * Accept the incoming contact request [requestHandle] with a private + * reply, paid from [account]'s shielded notes. Handles as for + * [sendPrivateContactRequest]. Returns the `EstablishedContact` handle + * (destroy via [TokensNative.establishedContactDestroy]). + * ← Swift `PlatformWalletManager.acceptPrivateContactRequest`. + */ + @Suppress("LongParameterList") + external fun acceptPrivateContactRequest( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + requestHandle: Long, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long + + /** + * What a managed identity's private contact requests with [contactId] + * carry: a JSON object (`receivedPrivately`, `sentPrivately`, + * `shieldedAddress` lower hex or null), or null when neither tracked + * request between them came or went privately. + * ← Swift `ManagedIdentity.privateContact(contactId:)`. + */ + external fun managedIdentityPrivateContact(identityHandle: Long, contactId: ByteArray): String? + + /** + * Whether a private contact request from the managed identity to + * [contactId] is being paid for, or was paid for and is not yet read + * back from the store. A pure read; in memory only on the Rust side. + * ← Swift `ManagedIdentity.privateReplyPending(contactId:)`. + */ + external fun managedIdentityPrivateReplyPending(identityHandle: Long, contactId: ByteArray): Boolean + + /** + * What a contact request from [identityId], one of the wallet's + * identities, waits for, as the wallet decides it: three flags, + * `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment]` + * (`platform_wallet_get_private_contact_readiness`). An unknown wallet + * handle throws (NotFound), as does an identity the wallet does not + * hold. + * ← Swift `ManagedPlatformWallet.privateContactReadiness(identityId:)`. + */ + external fun privateContactReadiness(walletHandle: Long, identityId: ByteArray): BooleanArray? + + /** + * Accept the incoming contact request [requestHandle] replying the way + * it came: privately, paid from [account]'s shielded notes, when it came + * privately and no reply exists yet; otherwise with a document, or by + * adopting the reply already sent. The wallet decides. Arguments and + * result as for [acceptPrivateContactRequest]. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. + */ + @Suppress("LongParameterList") + external fun acceptContactRequestReplyingInKind( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + requestHandle: Long, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): 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 7d6ba3275c5..8f81f004a4e 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 @@ -99,6 +99,28 @@ abstract class NativePersistenceBridge { * it, so the declaration and the mirror can never drift apart. */ const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800 + + /** + * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE` + * (bit 14, `0x4000`): the private contact request sync's scan state + * is stored on its round and restored at load. The one Kotlin home + * of this bit, aliased by the public diagnostic mirror. + */ + const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = 1L shl 14 + + /** + * Packed row widths of [onPersistPrivateContactScanState] and + * [onLoadPrivateContactScanState]: a read position is the identity + * id (32), the position (u64, little-endian), the key fingerprint + * (32) and the unresolved private payment marker (1: 0 or 1); a + * shielded address the owner id (32), the contact id (32) and the + * address (43); an address removal the owner and contact ids; a + * cleared identity its id. + */ + const val PRIVATE_POSITION_ENTRY_SIZE: Int = 32 + 8 + 32 + 1 + const val PRIVATE_ADDRESS_ENTRY_SIZE: Int = 32 + 32 + 43 + const val PRIVATE_ADDRESS_REMOVAL_ENTRY_SIZE: Int = 32 + 32 + const val PRIVATE_CLEARED_IDENTITY_ENTRY_SIZE: Int = 32 } /** @@ -471,6 +493,30 @@ abstract class NativePersistenceBridge { coveredCount: Int, ): Int = 0 + /** + * The private contact request sync's scan state a round writes, inside + * the round after its contacts. Descriptor `([B[B[B[B[B)I`. Each array + * packs whole rows (see [PRIVATE_POSITION_ENTRY_SIZE] and its siblings): + * + * - [positions]: replace the identity's stored read position. + * - [addresses]: replace the stored address for `(owner, contact)`. + * - [removedAddresses]: delete the stored address for `(owner, contact)`. + * - [clearedIdentities]: identities the round removed from the wallet; + * delete each one's position and every address it owns. + * + * Delivered only to a bridge that overrides both this and + * [onLoadPrivateContactScanState]: an inherited no-op would acknowledge + * a read position it never kept. Without them each launch reads the + * private store from its start. + */ + open fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** @@ -907,6 +953,16 @@ abstract class NativePersistenceBridge { */ open fun onLoadShieldedViewingKeys(): Array = emptyArray() + /** + * `on_load_private_contact_scan_state_fn`: the stored scan state of + * [walletId] as two packed arrays, `[positions, addresses]`, in the + * layout [onPersistPrivateContactScanState] stores. Descriptor + * `([B)[[B`. Throw on a store that cannot be read: an empty answer + * would read the store from its start without saying why. + */ + open fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + /** * `on_get_core_tx_record_fn`. Returns the record for `txid` or `null` * if no row exists. Descriptor diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 58351c59fbb..1b887c5599b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -63,6 +63,16 @@ internal object WalletManagerNative { bridge: NativePersistenceBridge, ): String? + /** + * The persistence capability bits a manager created with [bridge] would + * attest, without creating one: its declaration intersected with the + * callbacks JNI wires for it, built exactly as [nativeCreate] builds + * them (which methods the bridge overrides decides some of those). The + * instrumented suite checks with it that a capability needs every + * override it depends on. + */ + external fun nativeEffectivePersistenceCapabilityBits(bridge: NativePersistenceBridge): Long + // ── Wallet creation / restore ───────────────────────────────────── /** 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 0283ac2904f..0b3445a5397 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 @@ -18,6 +18,7 @@ import org.dashfoundation.dashsdk.persistence.dao.DpnsNameDao import org.dashfoundation.dashsdk.persistence.dao.IdentityDao import org.dashfoundation.dashsdk.persistence.dao.InvitationDao import org.dashfoundation.dashsdk.persistence.dao.PlatformAddressDao +import org.dashfoundation.dashsdk.persistence.dao.PrivateContactScanStateDao import org.dashfoundation.dashsdk.persistence.dao.PublicKeyDao import org.dashfoundation.dashsdk.persistence.dao.ShieldedDao import org.dashfoundation.dashsdk.persistence.dao.StorageCountsDao @@ -45,6 +46,8 @@ import org.dashfoundation.dashsdk.persistence.entities.KeywordEntity import org.dashfoundation.dashsdk.persistence.entities.PendingInputEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressesSyncStateEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PropertyEntity import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedActivityEntity @@ -179,9 +182,17 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * treated the account as rotated, tore it down and rebuilt it — and that * rebuild was the per-launch cursor rewind. NULL for pre-migration rows: * native rebuilds once, stamps it, and never again. + * + * Version 16 (private contact request scan state): adds the + * `private_contact_requests_positions` and `private_shielded_addresses` + * tables, the scan state native writes through + * `onPersistPrivateContactScanState` and reads back through + * `onLoadPrivateContactScanState`, so a relaunch resumes the private contact + * request sync where it stopped. Empty after the migration: the first sync + * reads the store from its start, as every sync did before. */ @Database( - version = 15, + version = 16, exportSchema = true, entities = [ WalletEntity::class, @@ -218,6 +229,8 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti ShieldedSyncStateEntity::class, ShieldedViewingKeyEntity::class, WalletManagerMetadataEntity::class, + PrivateContactRequestsPositionEntity::class, + PrivateShieldedAddressEntity::class, ], ) @TypeConverters(Converters::class) @@ -241,6 +254,7 @@ abstract class DashDatabase : RoomDatabase() { abstract fun shieldedDao(): ShieldedDao abstract fun walletManagerMetadataDao(): WalletManagerMetadataDao abstract fun storageCountsDao(): StorageCountsDao + abstract fun privateContactScanStateDao(): PrivateContactScanStateDao companion object { const val DATABASE_NAME: String = "dash-sdk.db" @@ -716,6 +730,28 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v15 -> v16: the two private contact request scan state tables, see + * the version-16 class doc above. Created empty. + */ + val MIGRATION_15_16: Migration = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `private_contact_requests_positions` (" + + "`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, " + + "`position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, " + + "`privatePaymentsUnresolved` INTEGER NOT NULL DEFAULT 0, " + + "PRIMARY KEY(`walletId`, `identityId`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `private_shielded_addresses` (" + + "`walletId` BLOB NOT NULL, `ownerId` BLOB NOT NULL, " + + "`contactId` BLOB NOT NULL, `address` BLOB NOT NULL, " + + "PRIMARY KEY(`walletId`, `ownerId`, `contactId`))", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -746,6 +782,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) .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 4a5d469053d..b8c895071d6 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 @@ -47,6 +47,8 @@ import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity import org.dashfoundation.dashsdk.persistence.entities.InvitationEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressesSyncStateEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedActivityEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedNoteEntity @@ -154,7 +156,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_WALLET_RESTORE or CAPABILITY_DPNS_NAME_STATES or CAPABILITY_TRACKED_ASSET_LOCKS or - CAPABILITY_CORE_SWEEP_REMOVAL + CAPABILITY_CORE_SWEEP_REMOVAL or + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE /** * The single-thread executor created when no [dispatcher] is injected. @@ -808,6 +811,54 @@ class PlatformWalletPersistenceHandler( 0 } + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistPrivateContactScanState`: staged into the round, so the read + * position commits with, or rolls back with, the contact requests read + * before it. Identities the round removed lose theirs first, so a new + * position or address in the same round stands. + */ + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = guarded { + val positionRows = unpackPrivatePositions(walletId, positions) + val addressRows = unpackPrivateAddresses(walletId, addresses) + val removed = chunks(removedAddresses, NativePersistenceBridge.PRIVATE_ADDRESS_REMOVAL_ENTRY_SIZE) + .map { it.copyOfRange(0, 32) to it.copyOfRange(32, 64) } + val cleared = chunks(clearedIdentities, NativePersistenceBridge.PRIVATE_CLEARED_IDENTITY_ENTRY_SIZE) + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + db.walletDao().getByWalletId(walletId) ?: return@stage + val dao = db.privateContactScanStateDao() + for (identityId in cleared) { + dao.deletePositionsOfIdentity(walletId, identityId) + dao.deleteAddressesOfIdentity(walletId, identityId) + } + positionRows.forEach { dao.upsertPosition(it) } + addressRows.forEach { dao.upsertAddress(it) } + for ((ownerId, contactId) in removed) { + dao.deleteAddress(walletId, ownerId, contactId) + } + } + 0 + } + + /** The stored scan state of [walletId], packed as it was stored. */ + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + loadOrThrow { + runBlockingResult { + val dao = database.privateContactScanStateDao() + arrayOf( + packPrivatePositions(dao.positionsByWallet(walletId)), + packPrivateAddresses(dao.addressesByWallet(walletId)), + ) + } + } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { val round = openRound(walletId) round?.finalityAdvanced = true @@ -2180,9 +2231,16 @@ class PlatformWalletPersistenceHandler( externalAccountReference: Int, ): Int = guarded { stage(walletId) { db -> - // Owner identity must exist; skip silently otherwise (replayed - // next round), reading networkRaw off the owner. - val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + // The owner identity must exist (networkRaw is read off it). + // Within a round identities apply before contacts, and native + // stores contact requests only of identities it manages, so it + // is there; when it is not, the round fails rather than being + // acknowledged without the row: this store declares the private + // contact request scan state, so an acknowledged round would let + // native store a read position past a request that was never + // saved, and no later scan would read it again. + val owner = db.identityDao().getByIdentityId(ownerId) + ?: throw IllegalStateException("contact request owner identity is not persisted") db.dashpayDao().upsertContactRequest( DashpayContactRequestEntity( networkRaw = owner.networkRaw, @@ -2231,9 +2289,10 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> if (isIgnored) { db.dashpayDao().deleteContactRequest(ownerId, senderId, isOutgoing = false) - // Owner identity must exist (networkRaw is read off it); - // skip silently otherwise — replayed next round. - val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + // The owner identity must exist (networkRaw is read off it); + // the round fails otherwise, as for a contact upsert. + val owner = db.identityDao().getByIdentityId(ownerId) + ?: throw IllegalStateException("ignored sender owner identity is not persisted") db.dashpayDao().upsertIgnoredSender( DashpayIgnoredSenderEntity( networkRaw = owner.networkRaw, @@ -3650,6 +3709,8 @@ class PlatformWalletPersistenceHandler( database.shieldedDao().deleteActivityByWallet(walletId) database.shieldedDao().deleteSyncStatesByWallet(walletId) database.shieldedDao().deleteViewingKeysByWallet(walletId) + database.privateContactScanStateDao().deletePositionsByWallet(walletId) + database.privateContactScanStateDao().deleteAddressesByWallet(walletId) // The wallet row itself — cascades accounts → core / platform // addresses. @@ -4135,6 +4196,9 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + internal const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + private const val TAG = "DashPersistence" /** @@ -4553,6 +4617,79 @@ internal fun contractBoundsJsonToId(blob: ByteArray): ByteArray? { private fun now(): java.util.Date = java.util.Date() private fun nowSeconds(): Long = System.currentTimeMillis() / 1000 +/** [packed] split into its rows of [size] bytes; a partial row is refused. */ +internal fun chunks(packed: ByteArray, size: Int): List { + require(packed.size % size == 0) { "packed rows must be $size bytes each" } + return (0 until packed.size / size).map { packed.copyOfRange(it * size, (it + 1) * size) } +} + +/** Rows of [NativePersistenceBridge.onPersistPrivateContactScanState]'s `positions`. */ +internal fun unpackPrivatePositions( + walletId: ByteArray, + packed: ByteArray, +): List = + chunks(packed, NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE).map { row -> + PrivateContactRequestsPositionEntity( + walletId = walletId.copyOf(), + identityId = row.copyOfRange(0, 32), + position = java.nio.ByteBuffer.wrap(row, 32, 8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).long, + keyFingerprint = row.copyOfRange(40, 72), + privatePaymentsUnresolved = when (row[72].toInt()) { + 0 -> false + 1 -> true + else -> throw IllegalArgumentException("the unresolved private payment marker must be 0 or 1") + }, + ) + } + +/** Rows of [NativePersistenceBridge.onPersistPrivateContactScanState]'s `addresses`. */ +internal fun unpackPrivateAddresses( + walletId: ByteArray, + packed: ByteArray, +): List = + chunks(packed, NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE).map { row -> + PrivateShieldedAddressEntity( + walletId = walletId.copyOf(), + ownerId = row.copyOfRange(0, 32), + contactId = row.copyOfRange(32, 64), + address = row.copyOfRange(64, 107), + ) + } + +/** + * [rows] packed as [NativePersistenceBridge.onLoadPrivateContactScanState] + * hands them back. A stored row whose bytes have the wrong length (Room + * keeps any blob) cannot cross the ABI: it throws, which fails the load + * through [PlatformWalletPersistenceHandler.onLoadPrivateContactScanState]'s + * `loadOrThrow`, rather than handing back the rest without it. + */ +internal fun packPrivatePositions(rows: List): ByteArray { + require(rows.all { it.identityId.size == 32 && it.keyFingerprint.size == 32 }) { + "a stored private contact request read position has a malformed id or fingerprint" + } + val buffer = java.nio.ByteBuffer + .allocate(rows.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (row in rows) { + buffer.put(row.identityId).putLong(row.position).put(row.keyFingerprint) + .put(if (row.privatePaymentsUnresolved) 1.toByte() else 0.toByte()) + } + return buffer.array() +} + +/** See [packPrivatePositions]. */ +internal fun packPrivateAddresses(rows: List): ByteArray { + require(rows.all { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 }) { + "a stored private shielded address has a malformed id or address" + } + val buffer = java.nio.ByteBuffer.allocate(rows.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) + for (row in rows) { + buffer.put(row.ownerId).put(row.contactId).put(row.address) + } + return buffer.array() +} + /** * Split a DIP-0018 bech32m platform address into `(addressType, 20-byte * hash)` — mirror of Swift `platformAddressComponents(fromBech32m:)`. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt new file mode 100644 index 00000000000..3e452f5f3c5 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt @@ -0,0 +1,44 @@ +package org.dashfoundation.dashsdk.persistence.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity + +/** The private contact request sync's scan state, per wallet. */ +@Dao +interface PrivateContactScanStateDao { + @Upsert + suspend fun upsertPosition(row: PrivateContactRequestsPositionEntity) + + @Upsert + suspend fun upsertAddress(row: PrivateShieldedAddressEntity) + + @Query( + "DELETE FROM private_shielded_addresses " + + "WHERE walletId = :walletId AND ownerId = :ownerId AND contactId = :contactId", + ) + suspend fun deleteAddress(walletId: ByteArray, ownerId: ByteArray, contactId: ByteArray) + + @Query( + "DELETE FROM private_contact_requests_positions " + + "WHERE walletId = :walletId AND identityId = :identityId", + ) + suspend fun deletePositionsOfIdentity(walletId: ByteArray, identityId: ByteArray) + + @Query("DELETE FROM private_shielded_addresses WHERE walletId = :walletId AND ownerId = :ownerId") + suspend fun deleteAddressesOfIdentity(walletId: ByteArray, ownerId: ByteArray) + + @Query("SELECT * FROM private_contact_requests_positions WHERE walletId = :walletId") + suspend fun positionsByWallet(walletId: ByteArray): List + + @Query("SELECT * FROM private_shielded_addresses WHERE walletId = :walletId") + suspend fun addressesByWallet(walletId: ByteArray): List + + @Query("DELETE FROM private_contact_requests_positions WHERE walletId = :walletId") + suspend fun deletePositionsByWallet(walletId: ByteArray) + + @Query("DELETE FROM private_shielded_addresses WHERE walletId = :walletId") + suspend fun deleteAddressesByWallet(walletId: ByteArray) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt new file mode 100644 index 00000000000..807559c0f0a --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt @@ -0,0 +1,55 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * Port of `PersistentPrivateContactRequestsPosition.swift`: where one of the + * wallet's identities reads DashPay's private contact request store from + * next, so a relaunch resumes the private contact request sync instead of + * reading the store from its start. Written in the same changeset round as + * the contact requests read before it. + * + * Keyed by the network-scoped wallet id; no foreign key, so the wallet + * deletion purges it explicitly (`deleteWalletDataLocked`), and the round + * that removes the identity clears it (`clearedIdentities`). + */ +@Entity( + tableName = "private_contact_requests_positions", + primaryKeys = ["walletId", "identityId"], +) +data class PrivateContactRequestsPositionEntity( + val walletId: ByteArray, + val identityId: ByteArray, + /** The store position read from next; a `u64` bit pattern. */ + val position: Long, + /** Fingerprint of the scanning keys [position] holds for; opaque here. */ + val keyFingerprint: ByteArray, + /** + * Whether a private contact request the identity paid for may still be + * unresolved; handed back as it came. Loaded set, native holds the + * identity's public contact requests until its private payments are + * restored from the shielded store. + */ + @ColumnInfo(defaultValue = "0") + val privatePaymentsUnresolved: Boolean = false, +) + +/** + * Port of `PersistentPrivateShieldedAddress.swift`: the shielded address a + * private contact's tracked request to one of the wallet's identities + * carries, kept so a relaunch has it without reading the private store again. + */ +@Entity( + tableName = "private_shielded_addresses", + primaryKeys = ["walletId", "ownerId", "contactId"], +) +data class PrivateShieldedAddressEntity( + val walletId: ByteArray, + /** The wallet-owned identity the request is to. */ + val ownerId: ByteArray, + /** The contact who sent the request. */ + val contactId: ByteArray, + /** The raw 43-byte Orchard payment address. */ + val address: ByteArray, +) 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..3af96e3fd4b 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 @@ -276,6 +276,229 @@ class Dashpay internal constructor(private val walletHandle: Long, } } + // ── DashPay private contact requests (protocol version 15) ──────── + // + // ← Swift `PrivateContactRequests.swift` + // (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + // A private request is sealed to one of the recipient's DashPay keys and + // paid for from the shielded pool, so nothing on chain names either + // side; it is signed by the same identity signer as a public request. + // The wallet records it as an ordinary contact request, so the Room rows + // and [contacts] read it. The send and the private-reply accept are + // shielded spends and live on `PlatformWalletManager`. + + /** + * How an identity and one contact exchanged private contact requests. + * ← Swift `PrivateContactInfo`. + */ + data class PrivateContactInfo( + /** The contact's request came privately, so accepting it replies privately. */ + val receivedPrivately: Boolean, + /** This identity's request to the contact went privately. */ + val sentPrivately: Boolean, + /** The 43-byte Orchard address the contact asked to be paid at privately. */ + val shieldedAddress: ByteArray?, + ) { + override fun equals(other: Any?): Boolean = + other is PrivateContactInfo && receivedPrivately == other.receivedPrivately && + sentPrivately == other.sentPrivately && + (shieldedAddress?.contentEquals(other.shieldedAddress) ?: (other.shieldedAddress == null)) + + override fun hashCode(): Int = + (31 * receivedPrivately.hashCode() + sentPrivately.hashCode()) * 31 + + (shieldedAddress?.contentHashCode() ?: 0) + + internal companion object { + /** Decode the JSON object `DashpayNative.managedIdentityPrivateContact` returns. */ + fun fromJson(json: String): PrivateContactInfo { + val obj = org.json.JSONObject(json) + val address = if (obj.isNull("shieldedAddress")) { + null + } else { + obj.getString("shieldedAddress").chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + return PrivateContactInfo( + receivedPrivately = obj.getBoolean("receivedPrivately"), + sentPrivately = obj.getBoolean("sentPrivately"), + shieldedAddress = address, + ) + } + } + } + + /** + * What a contact request from one of the wallet's identities waits for, + * as the wallet decides it, so a host shows "restoring private payments" + * apart from "reading the private store" and from a contact's pending + * request rather than inferring them from the refusals it got. + * ← Swift `PrivateContactReadiness`. + */ + data class PrivateContactReadiness( + /** + * Whether the wallet's private payments from an earlier process are + * restored for the identity. While false, its public sends and + * accepts are refused with + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] + * until the shielded store is configured or the wallet's shielded + * account bound; no private sync lifts it. + */ + val privatePaymentsRestored: Boolean, + /** + * Whether a private sync read DashPay's private contact request store + * to its end for the identity since load. Until then its first send + * or accept reads it first, and is refused with + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactRequestPending] + * while that read cannot finish. + */ + val privateStoreRead: Boolean, + /** + * Whether a private contact request the identity paid for may still + * be unresolved. A contact's own pending request is [privateRepliesPending]. + */ + val unresolvedPrivatePayment: Boolean, + ) + + /** What one pass over DashPay's private contact request store did. */ + data class PrivateContactSyncResult( + /** The requests the pass ingested, received and sent, including ones already held. */ + val requestCount: Long, + /** The store pages read. */ + val pagesRead: Long, + /** Entries that opened for one of the wallet's identities but were refused. */ + val rejectedCount: Long, + /** The store position the pass stopped at. */ + val nextPosition: Long, + /** Whether the pass read to the store's last entry. When false, sync again to read on. */ + val complete: Boolean, + /** Identities whose ingest did not reach disk; the next pass reads their entries again. */ + val unpersistedIdentityCount: Long, + ) + + /** + * Read DashPay's private contact request store for every identity of + * this wallet, at most [maxPages] pages (the default reads to the end), + * and ingest what it holds for them: requests to them, each sender's + * signature checked, and requests they sent, recovered from the store + * alone. The ingested requests reach Room through the persister. + * + * The store is opened with keys derived inside [coreSignerHandle], the + * manager's `MnemonicResolverHandle`. Where the persister attests + * `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (the Room handler does), each + * identity's read position is stored with the requests read before it + * and restored at load, so a pass after launch reads on from there; + * otherwise the first pass after launch reads the store from its start. + * Private requests reach the wallet only through this read. + * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. + */ + suspend fun syncPrivateContactRequests( + coreSignerHandle: Long, + maxPages: Int = Int.MAX_VALUE, + ): PrivateContactSyncResult = gate.op { + val counters = mapNativeErrors { + DashpayNative.syncPrivateContactRequests(walletHandle, coreSignerHandle, maxPages) + } + checkNotNull(counters) { "native syncPrivateContactRequests returned no report" } + check(counters.size == 6) { "expected 6 sync counters, got ${counters.size}" } + PrivateContactSyncResult( + requestCount = counters[0], + pagesRead = counters[1], + rejectedCount = counters[2], + nextPosition = counters[4], + complete = counters[5] != 0L, + unpersistedIdentityCount = counters[3], + ) + } + + /** + * How [ourIdentityId] and [contactId] exchanged private contact + * requests: whether the contact's request came privately, whether ours + * went privately, and the shielded address the contact asked to be paid + * at (only when its request came privately, and only once a private + * sync this launch has read it). Null when neither tracked request + * between them came or went privately, or the identity isn't managed by + * this wallet. Read from a fresh managed-identity snapshot; local read. + * ← Swift `ManagedIdentity.privateContact(contactId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun privateContact( + ourIdentityId: ByteArray, + contactId: ByteArray, + ): PrivateContactInfo? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(ourIdentityId) { handle -> + DashpayNative.managedIdentityPrivateContact(handle, contactId) + }?.let(PrivateContactInfo::fromJson) + } + } + + /** + * What a contact request from [ourIdentityId] waits for, as the wallet + * decides it ([PrivateContactReadiness]). A pure read of live wallet + * state; read it again after the shielded store is configured or the + * wallet binds its shielded account to see the restore. Throws for an + * identity the wallet does not hold. + * ← Swift `ManagedPlatformWallet.privateContactReadiness(identityId:)`. + */ + suspend fun privateContactReadiness(ourIdentityId: ByteArray): PrivateContactReadiness = + withContext(Dispatchers.IO) { + val flags = mapNativeErrors { DashpayNative.privateContactReadiness(walletHandle, ourIdentityId) } + checkNotNull(flags) { "native privateContactReadiness returned no flags" } + check(flags.size == 3) { "expected 3 readiness flags, got ${flags.size}" } + PrivateContactReadiness( + privatePaymentsRestored = flags[0], + privateStoreRead = flags[1], + unresolvedPrivatePayment = flags[2], + ) + } + + /** + * The contacts among [contactIds] that [ourIdentityId] has a private + * contact request to in flight, or paid for and not yet read back from + * the store, as the wallet records it, read from one fresh + * managed-identity snapshot. While one is, Rust refuses another private + * send or reply to it ([DashSdkError.PlatformWallet.PrivateContactRequestPending]), + * so a host shows it as pending and offers neither; a private sync + * settles it. Empty when the identity isn't managed. After a relaunch, a + * reply paid for in an earlier process is pending again once Rust + * restores it from the shielded store, which it does when the store is + * configured after the load or the wallet binds its shielded account. + * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun privateRepliesPending( + ourIdentityId: ByteArray, + contactIds: List, + ): List = withContext(Dispatchers.IO) { + if (contactIds.isEmpty()) return@withContext emptyList() + mapNativeErrors { + withManagedIdentity(ourIdentityId) { handle -> + contactIds.filter { DashpayNative.managedIdentityPrivateReplyPending(handle, it) } + } ?: emptyList() + } + } + + /** + * The incoming contact request from [senderId] to [ourIdentityId] in + * local state, or null when there is none (or the identity isn't + * managed by this wallet). Close the returned ref when done. This is + * the request `PlatformWalletManager.acceptContactRequestReplyingInKind` + * takes: the accept that replies privately to a request that came + * privately, and with a document otherwise. Local read; no network I/O. + * ← Swift `ManagedIdentity.getIncomingContactRequest(senderId:)`. + */ + suspend fun incomingContactRequest( + ourIdentityId: ByteArray, + senderId: ByteArray, + ): ContactRequestRef? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(ourIdentityId) { identityHandle -> + val requestHandle = + TokensNative.getIncomingContactRequest(identityHandle, senderId) + if (requestHandle == 0L) null else ContactRequestRef(requestHandle) + } + } + } + // ── DashPay read surface (upstream #3841 parity) ─────────────────── // // JSON-string reads over the managed-identity snapshot, following 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 5f074a73d5b..c60751e8d7c 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 @@ -41,6 +41,8 @@ import org.dashfoundation.dashsdk.security.IdentityKeyPrivateKeyDeriver import org.dashfoundation.dashsdk.security.KeystoreSigner import org.dashfoundation.dashsdk.security.MnemonicResolverAndPersister import org.dashfoundation.dashsdk.security.WalletStorage +import org.dashfoundation.dashsdk.tokens.ContactRequestRef +import org.dashfoundation.dashsdk.tokens.EstablishedContactRef import java.util.concurrent.atomic.AtomicLong /** Effective native persistence contract exposed for initialization diagnostics. */ @@ -77,6 +79,15 @@ data class PlatformWalletPersistenceCapabilities( * is deliberately absent: Android never attests it). */ const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + /** + * The private contact request sync's scan state (each identity's + * read position in DashPay's private contact request store, and its + * private contacts' shielded addresses) is stored on its round and + * restored at load. Mirrors + * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`. + */ + const val PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE } } @@ -2129,6 +2140,136 @@ class PlatformWalletManager( } } + /** + * Send a DashPay private contact request (protocol version 15) from + * [senderIdentityId] to [recipientIdentityId], paying for it from + * [account]'s shielded notes on [walletId], so nothing on chain names + * either identity. Port of Swift's + * `PlatformWalletManager.sendPrivateContactRequest` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * The request is signed by this manager's identity signer + * ([signerHandle], the handle the public `Dashpay.sendContactRequest` + * is given) and sealed with DashPay keys derived through this manager's + * resolver, which also supplies the Orchard spend authority. + * [shieldedAddress] is the 43-byte raw Orchard address the contact may + * pay us at privately. Blocks for the Halo 2 proof. + * + * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when + * the payment may already be on chain: do not send again. + * + * @return the created request; close it (or `use {}`) when done. + */ + suspend fun sendPrivateContactRequest( + walletId: ByteArray, + senderIdentityId: ByteArray, + recipientIdentityId: ByteArray, + accountLabel: String? = null, + autoAcceptProof: ByteArray? = null, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): ContactRequestRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.sendPrivateContactRequest( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + senderIdentityId, + recipientIdentityId, + accountLabel, + autoAcceptProof, + shieldedAddress, + signerHandle, + ) + } + ContactRequestRef(handle) + } + + /** + * Accept a DashPay contact request that came privately, replying + * privately and paying for the reply from [account]'s shielded notes on + * [walletId]. Port of Swift's + * `PlatformWalletManager.acceptPrivateContactRequest` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * A host accepts with [acceptContactRequestReplyingInKind], which + * picks this reply itself; this explicit accept stays for a host that + * wants a private reply. The reply is signed by this manager's identity + * signer ([signerHandle]). + * [shieldedAddress] is the 43-byte raw Orchard address the contact may + * pay us at privately. Blocks for the Halo 2 proof. + * + * @return the established contact; close it (or `use {}`) when done. + */ + suspend fun acceptPrivateContactRequest( + walletId: ByteArray, + request: ContactRequestRef, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): EstablishedContactRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.acceptPrivateContactRequest( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + request.value, + shieldedAddress, + signerHandle, + ) + } + EstablishedContactRef(handle) + } + + /** + * Accept a DashPay contact request replying the way it came: privately, + * paid from [account]'s shielded notes on [walletId], when it came + * privately and no reply exists yet; otherwise with a `contactRequest` + * document, or by adopting the reply already sent. Which reply a request + * takes is the wallet's decision, so this is the accept a host calls. + * Port of Swift's `PlatformWalletManager.acceptContactRequestReplyingInKind` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * [request] is the incoming request (`Dashpay.incomingContactRequest`). + * The reply is signed by this manager's identity signer ([signerHandle]); + * the Orchard spend authority comes from this manager's resolver and is + * derived only for a private reply. [shieldedAddress] is the 43-byte raw + * Orchard address the contact may pay us at privately; a public reply + * ignores it. + * + * A public or adopted reply needs neither shielded support nor a + * mnemonic read for the payment. Throws + * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a private + * reply may already be on chain: do not accept again; and + * [DashSdkError.PlatformWallet.ShieldedBuildRequired] when the request + * needs a private reply and the native library has no shielded support. + * + * @return the established contact; close it (or `use {}`) when done. + */ + suspend fun acceptContactRequestReplyingInKind( + walletId: ByteArray, + request: ContactRequestRef, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): EstablishedContactRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.acceptContactRequestReplyingInKind( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + request.value, + shieldedAddress, + signerHandle, + ) + } + EstablishedContactRef(handle) + } + /** * Shielded → Core L1 withdrawal (Type 19) — port of Swift's * `PlatformWalletManager.shieldedWithdraw(walletId:account:toCoreAddress:amount:coreFeePerByte:)` diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index fd09ae4051a..85b9c7fa8ee 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -259,6 +259,76 @@ class DashSdkErrorTest { ) } + @Test + fun shouldRouteARequestThatNeedsAPrivateReplyByItsOwnCode() { + // PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply = 68 + // (rs-platform-wallet-ffi/src/error.rs); a host catches this type to + // accept privately, as the Swift app does on its typed error. + val detail = "the contact requests with 9fRYTJZW came or went privately; " + + "reply to or rotate with it privately" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 68, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) + assertEquals(detail, mapped.message) + assertEquals(detail, mapped.userMessage) + assertEquals(native, mapped.cause) + assertFalse("the same public call is refused again", mapped.isRetryable) + } + + @Test + fun shouldTellAPrivateReplyOnABuildWithoutShieldedSupportByItsOwnCode() { + val detail = "this contact request needs a private reply, which is paid " + + "from the shielded pool; this build has no shielded support" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 69, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.ShieldedBuildRequired) + assertEquals(detail, mapped.message) + assertFalse("the same build refuses it again", mapped.isRetryable) + } + + @Test + fun shouldTellARequestTheNetworkDoesNotServeYetByItsOwnCode() { + val detail = "get_private_document_entries needs protocol version 15; " + + "the network is proven at protocol version 14" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 70, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.ProtocolVersionNotActive) + assertEquals(detail, mapped.message) + } + + @Test + fun shouldReportAPendingPrivateRequestByItsOwnCode() { + val detail = "a private contact request to 9fRYTJZW is in flight or not yet read " + + "back from the store; sync private contact requests to settle it" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 71, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRequestPending) + assertEquals(detail, mapped.message) + assertFalse("pending, not a failure to retry", mapped.isRetryable) + } + + /** Unrestored private payments hold the wallet: their own code, apart from 71. */ + @Test + fun shouldReportUnrestoredPrivatePaymentsByTheirOwnCode() { + val detail = "the wallet's private payments are not restored from the shielded store yet" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 72, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored) + assertEquals(detail, mapped.message) + assertFalse("held, not a failure to retry", mapped.isRetryable) + } + + /** A store that cannot keep a private payment's recovery state has its own code. */ + @Test + fun shouldReportUnsupportedPrivateContactRecoveryByItsOwnCode() { + val detail = "the persister does not keep the private contact request scan state atomically" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 73, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported) + assertEquals(detail, mapped.message) + assertFalse("a configuration error, not a retry", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = 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 41326e84f92..ac1ae6f77ae 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,16 +233,17 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion15WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion16WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. // 11 → 12 adds the identity key usage limits columns on top, // 12 → 13 the contract bounds kind, 13 → 14 the token - // once-per-identity distribution block, and 14 → 15 the DashPay - // backfill record columns on `wallets`. - assertEquals(15, db.openHelper.readableDatabase.version) + // once-per-identity distribution block, 14 → 15 the DashPay + // backfill record columns on `wallets`, and 15 → 16 the private + // contact request scan state tables. + assertEquals(16, 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 978b0c43e09..48f32164cbb 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 @@ -220,7 +220,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) + assertEquals(0x4bbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -234,6 +234,9 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) + assertTrue( + diagnostic.contains(PlatformWalletPersistenceCapabilities.PRIVATE_CONTACT_REQUEST_SCAN_STATE), + ) } @Test diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt new file mode 100644 index 00000000000..3d335775a28 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -0,0 +1,244 @@ +package org.dashfoundation.dashsdk.persistence + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The private contact request scan state (read positions and private + * contacts' shielded addresses) round-trips through Room in the packed rows + * the JNI trampolines exchange. Port of + * `PrivateContactScanStatePersistenceTests.swift`. + */ +@RunWith(RobolectricTestRunner::class) +class PrivateContactScanStatePersistenceTest { + + private lateinit var db: DashDatabase + private lateinit var handler: PlatformWalletPersistenceHandler + + private val walletId = ByteArray(32) { 42 } + private val owner = ByteArray(32) { 7 } + private val contact = ByteArray(32) { 5 } + private val otherContact = ByteArray(32) { 6 } + private val fingerprint = ByteArray(32) { 3 } + + @Before + fun setUp() { + db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + assertEquals(0, handler.onPersistWalletMetadata(walletId, 1, ByteArray(32) { 2 }, 0)) + } + + @After + fun tearDown() { + db.close() + } + + private fun position(identityId: ByteArray, position: Long, unresolved: Boolean = false): ByteArray = + java.nio.ByteBuffer.allocate(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .put(identityId).putLong(position).put(fingerprint) + .put(if (unresolved) 1.toByte() else 0.toByte()) + .array() + + private fun address(ownerId: ByteArray, contactId: ByteArray, fill: Int): ByteArray = + ownerId + contactId + ByteArray(43) { fill.toByte() } + + private fun round( + positions: ByteArray = ByteArray(0), + addresses: ByteArray = ByteArray(0), + removed: ByteArray = ByteArray(0), + cleared: ByteArray = ByteArray(0), + success: Boolean = true, + ) { + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals( + 0, + handler.onPersistPrivateContactScanState(walletId, positions, addresses, removed, cleared), + ) + assertEquals(0, handler.onChangesetEnd(walletId, success)) + } + + private fun loaded(): Pair>, List>> { + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + val positionRows = unpackPrivatePositions(walletId, positions) + .map { it.identityId.toHex() to it.position } + val addressRows = unpackPrivateAddresses(walletId, addresses) + .map { it.contactId.toHex() to it.address[0].toInt() } + return positionRows to addressRows + } + + @Test + fun shouldStoreReplaceAndRemoveTheScanStateWithItsRound() { + round( + positions = position(owner, 12), + addresses = address(owner, contact, 9) + address(owner, otherContact, 8), + ) + var (positions, addresses) = loaded() + assertEquals(listOf(owner.toHex() to 12L), positions) + assertEquals(setOf(contact.toHex() to 9, otherContact.toHex() to 8), addresses.toSet()) + + // A rewind writes the lower position; a tombstone removes one address. + round(positions = position(owner, 0), removed = owner + otherContact) + loaded().let { (p, a) -> positions = p; addresses = a } + assertEquals(listOf(owner.toHex() to 0L), positions) + assertEquals(listOf(contact.toHex() to 9), addresses) + + // A rolled-back round leaves the stored state as it was. + round(positions = position(owner, 40), success = false) + assertEquals(listOf(owner.toHex() to 0L), loaded().first) + + // The unresolved private payment marker is stored with the position, + // handed back as it came, and cleared by a later round. + round(positions = position(owner, 0, unresolved = true)) + assertArrayEquals(position(owner, 0, unresolved = true), handler.onLoadPrivateContactScanState(walletId)[0]) + assertTrue(unpackPrivatePositions(walletId, handler.onLoadPrivateContactScanState(walletId)[0]).single().privatePaymentsUnresolved) + round(positions = position(owner, 0)) + assertFalse(unpackPrivatePositions(walletId, handler.onLoadPrivateContactScanState(walletId)[0]).single().privatePaymentsUnresolved) + // A marker byte other than 0 or 1 is not a row. + assertThrows(IllegalArgumentException::class.java) { + unpackPrivatePositions(walletId, position(owner, 0).also { it[72] = 2 }) + } + + // A full-width position survives the signed column. + round(positions = position(owner, -1L)) + assertEquals(listOf(owner.toHex() to -1L), loaded().first) + val (packed, _) = handler.onLoadPrivateContactScanState(walletId) + assertArrayEquals(position(owner, -1L), packed) + } + + @Test + fun shouldClearTheScanStateOfARemovedIdentity() { + val survivor = ByteArray(32) { 1 } + round( + positions = position(owner, 12) + position(survivor, 4), + addresses = address(owner, contact, 9) + address(survivor, contact, 1), + ) + round(cleared = owner) + val (positions, addresses) = loaded() + assertEquals(listOf(survivor.toHex() to 4L), positions) + assertEquals(listOf(contact.toHex() to 1), addresses) + } + + @Test + fun shouldScopeTheScanStateByWalletAndPurgeItWithTheWallet() = runTest { + round(positions = position(owner, 12), addresses = address(owner, contact, 9)) + val (otherPositions, otherAddresses) = + handler.onLoadPrivateContactScanState(ByteArray(32) { 43 }) + assertEquals(0, otherPositions.size) + assertEquals(0, otherAddresses.size) + + handler.deleteWalletData(walletId) + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + assertEquals(0, positions.size) + assertEquals(0, addresses.size) + } + + /** + * A stored row that cannot cross the ABI fails the load instead of + * handing back the rest without it: a valid position beside a 42-byte + * address would otherwise resume past the entry that carried it. + */ + @Test + fun shouldFailTheLoadOnAStoredRowThatCannotCrossTheAbi() = runTest { + round(positions = position(owner, 12)) + db.privateContactScanStateDao().upsertAddress( + org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity( + walletId = walletId, + ownerId = owner, + contactId = contact, + address = ByteArray(42) { 9 }, + ), + ) + assertThrows(IllegalArgumentException::class.java) { + handler.onLoadPrivateContactScanState(walletId) + } + } + + private fun upsertContact(ownerId: ByteArray): Int = handler.onPersistContactUpsert( + walletId, ownerId, contact, false, 0, 0, 0, ByteArray(96), null, null, 0, 0L, + false, null, null, false, null, IntArray(0), false, 0, + ) + + /** + * A contact request whose owner identity is not persisted fails the + * round instead of being skipped: this store declares the private + * contact request scan state, so an acknowledged round without the row + * would let native store a read position past it. The rolled-back round + * leaves nothing, and once the owner is persisted the same upsert (the + * next scan's) lands. + */ + @Test + fun shouldFailTheRoundOnAContactRequestWhoseOwnerIsNotPersisted() = runTest { + val unknownOwner = ByteArray(32) { 0x77 } + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, upsertContact(unknownOwner)) + assertNotEquals("the round fails", 0, handler.onChangesetEnd(walletId, true)) + assertTrue(db.dashpayDao().getContactRequestsByOwner(unknownOwner).isEmpty()) + + // Outside a round the callback itself fails. + assertNotEquals(0, upsertContact(unknownOwner)) + + // An ignore for an unknown owner fails its round too. + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, handler.onPersistContactIgnored(walletId, unknownOwner, contact, true)) + assertNotEquals(0, handler.onChangesetEnd(walletId, true)) + + db.identityDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.IdentityEntity( + identityId = unknownOwner, + networkRaw = 1, + walletId = walletId, + ), + ) + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, upsertContact(unknownOwner)) + assertEquals(0, handler.onChangesetEnd(walletId, true)) + assertEquals(1, db.dashpayDao().getContactRequestsByOwner(unknownOwner).size) + } + + /** A partial row fails the callback instead of storing a misread row. */ + @Test + fun shouldRefuseARoundWithAPartialRow() { + assertEquals(0, handler.onChangesetBegin(walletId)) + assertNotEquals( + 0, + handler.onPersistPrivateContactScanState( + walletId, ByteArray(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE - 1), + ByteArray(0), ByteArray(0), ByteArray(0), + ), + ) + assertEquals(0, handler.onChangesetEnd(walletId, false)) + assertTrue(loaded().first.isEmpty()) + assertThrows(IllegalArgumentException::class.java) { + unpackPrivateAddresses(walletId, ByteArray(NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE + 1)) + } + } + + /** The inherited bridge stores nothing and loads two empty arrays. */ + @Test + fun aBridgeWithoutTheScanStateStoresNothingAndLoadsEmpty() { + val noOp = object : NativePersistenceBridge() {} + assertEquals(0L, noOp.persistenceCapabilitiesBits()) + assertEquals( + 0, + noOp.onPersistPrivateContactScanState( + walletId, position(owner, 1), ByteArray(0), ByteArray(0), ByteArray(0), + ), + ) + val (positions, addresses) = noOp.onLoadPrivateContactScanState(walletId) + assertEquals(0, positions.size + addresses.size) + } +} diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 6308dd3f3b2..39e0de0cbe1 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -185,8 +185,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature. Today only `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build, returns it, for a request that came privately: its private reply is paid from the pool. Nothing was sent; a public or adopted reply works on such a build. Not retryable on the same build. No existing code meant "shielded unsupported" (`ErrorWalletOperation` with a not-configured message is a runtime state of a shielded build), so it takes the next free number. Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) type it | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it | | 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); SwiftExampleApp shows "restoring private payments" apart from a pending request (71) until the readiness reads restored. Kotlin must type it, as for 71 | -| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14) together with `ATOMIC_CHANGESETS` (bit 0, with the changeset begin and end callbacks wired), so the private send, the private accept and the in-kind accept's private reply are refused before anything is sealed or paid. The payment's recovery marker could not survive a restart, and a process reopening the wallet without the `shielded` feature, or before the shielded store is configured, could publish the pair while the payment may still execute. Not retryable as it is: a host that persists the private contact request scan state in changesets it commits whole declares both. Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin) and SwiftExampleApp shows it as a storage configuration error; both Swift and Kotlin hosts declare bit 0 and bit 14 with the begin and end callbacks wired. Kotlin must type it | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored`, `DashSdkErrorTest` pin) and binds the readiness (`Dashpay.privateContactReadiness`); both example apps show "restoring private payments" apart from a pending request (71) until the readiness reads restored | +| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14) together with `ATOMIC_CHANGESETS` (bit 0, with the changeset begin and end callbacks wired), so the private send, the private accept and the in-kind accept's private reply are refused before anything is sealed or paid. The payment's recovery marker could not survive a restart, and a process reopening the wallet without the `shielded` feature, or before the shielded store is configured, could publish the pair while the payment may still execute. Not retryable as it is: a host that persists the private contact request scan state in changesets it commits whole declares both. Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin) and SwiftExampleApp shows it as a storage configuration error; both Swift and Kotlin hosts declare bit 0 and bit 14 with the begin and end callbacks wired. Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported`, `DashSdkErrorTest` pin) and KotlinExampleApp shows it the same way | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b6b36194a90..8cc319d48c2 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1,7 +1,8 @@ //! JNI bridge for the DashPay read surface added by the iOS DashPay //! completion (upstream #3841): payment history, cached contact profiles, //! per-identity sync state, wallet-scoped DPNS search and per-account -//! wallet balances. +//! wallet balances; and DashPay private contact requests (protocol +//! version 15): the store sync, and the shielded send and accept. //! //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DashpayNative`, //! driven by `org.dashfoundation.dashsdk.tokens.Dashpay` and @@ -20,15 +21,22 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; -use crate::support::{guard, take_pwffi_error}; +use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jobject, jstring}; +use jni::sys::{ + jboolean, jbooleanArray, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE, +}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; +use platform_wallet_ffi::error::{platform_wallet_ffi_result_free, PlatformWalletFFIResultCode}; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; use platform_wallet_ffi::invitation::InvitationPreviewFFI; +use platform_wallet_ffi::PrivateContactReadinessFFI; +use platform_wallet_ffi::{ + ContactRequestHandleArray, PrivateContactFFI, PrivateContactSyncReportFFI, +}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; @@ -1210,3 +1218,629 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn } }) } + +// ── DashPay private contact requests (protocol version 15) ──────────── +// +// Mirrors Swift's `PrivateContactRequests.swift`: the store sync is a +// wallet-handle call (`ManagedPlatformWallet.syncPrivateContactRequests`); +// the send and the private-reply accept are manager-handle shielded spends +// (`PlatformWalletManager.sendPrivateContactRequest` / +// `acceptPrivateContactRequest`), gated like the other shielded spends. The +// accept that replies the way a request came +// (`acceptContactRequestReplyingInKind`) is exported in every build. + +/// The bytes of an Orchard payment address the FFI reads from a +/// `shielded_address` pointer: an 11-byte diversifier and a 32-byte +/// transmission key. +const SHIELDED_ADDRESS_LEN: usize = 43; + +/// The longs [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests`] +/// returns, in the order `Dashpay.syncPrivateContactRequests` reads them: +/// the requests the pass ingested, the store pages read, the entries +/// refused, the identities whose ingest did not reach disk, the store +/// position the pass stopped at, and whether it read to the store's last +/// entry (1) or not (0). +fn private_contact_sync_longs(requests: usize, report: &PrivateContactSyncReportFFI) -> [i64; 6] { + [ + i64::try_from(requests).unwrap_or(i64::MAX), + i64::from(report.pages_read), + i64::from(report.rejected_count), + i64::from(report.unpersisted_identity_count), + i64::try_from(report.next_position).unwrap_or(i64::MAX), + i64::from(report.complete), + ] +} + +/// Destroy every `ContactRequest` handle of `array`, free its buffer, and +/// return how many handles it held. Kotlin reads ingested requests from Room +/// (the persister writes them), so the handles are not handed across. +/// +/// # Safety +/// `array` must come from a platform-wallet-ffi enumerator and not yet be +/// freed. +unsafe fn release_contact_request_handles(array: &mut ContactRequestHandleArray) -> usize { + let count = array.count; + if !array.handles.is_null() && count > 0 { + for handle in std::slice::from_raw_parts(array.handles, count) { + let mut result = platform_wallet_ffi::contact_request_destroy(*handle); + platform_wallet_ffi_result_free(&mut result); + } + } + platform_wallet_ffi::platform_wallet_contact_request_handle_array_free(array); + count +} + +/// Read DashPay's private contact request store for every identity of the +/// wallet, at most `maxPages` pages, and ingest what it holds for them +/// (bridges `platform_wallet_sync_private_contact_requests`). The store is +/// opened with keys derived inside `coreSignerHandle`, the manager's +/// `MnemonicResolverHandle`. Ingested requests reach Room through the +/// persister; their handles are released here. Returns the six counters of +/// [`private_contact_sync_longs`]. Blocking (network). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + core_signer_handle: jlong, + max_pages: jint, +) -> jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + if max_pages < 0 { + throw_sdk_exception(env, 1, "maxPages must be non-negative"); + return ptr::null_mut(); + } + let mut array = ContactRequestHandleArray::empty(); + let mut report = PrivateContactSyncReportFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_sync_private_contact_requests( + wallet_handle as Handle, + core_signer_handle as *mut MnemonicResolverHandle, + max_pages as u32, + &mut array, + &mut report, + ) + }; + // The FFI leaves an empty array on every failure, so releasing it + // first is safe on both paths. + let requests = unsafe { release_contact_request_handles(&mut array) }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let longs = private_contact_sync_longs(requests, &report); + let Ok(out) = env.new_long_array(longs.len() as i32) else { + return ptr::null_mut(); + }; + if env.set_long_array_region(&out, 0, &longs).is_err() { + return ptr::null_mut(); + } + out.into_raw() + }) +} + +/// A private contact read as the compact JSON object `managedIdentityPrivateContact` +/// returns: `receivedPrivately`, `sentPrivately`, and `shieldedAddress` (the +/// 43-byte Orchard address as lower hex, or `null`). +fn private_contact_json(contact: &PrivateContactFFI) -> String { + let address = if contact.has_shielded_address { + let hex: String = contact + .shielded_address + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + json_string(&hex) + } else { + "null".to_string() + }; + format!( + "{{\"receivedPrivately\":{},\"sentPrivately\":{},\"shieldedAddress\":{}}}", + contact.received_privately, contact.sent_privately, address + ) +} + +/// What this identity's private contact requests with `contactId` carry off a +/// managed-identity handle (bridges `managed_identity_get_private_contact`): +/// the JSON object of [`private_contact_json`], or null when neither tracked +/// request between them came or went privately (the FFI's NotFound, benign +/// here as for `TokensNative.getIncomingContactRequest`). +/// ← Swift `ManagedIdentity.privateContact(contactId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityPrivateContact( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, + contact_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &contact_id, "contactId") else { + return ptr::null_mut(); + }; + let mut contact = PrivateContactFFI { + received_privately: false, + sent_privately: false, + has_shielded_address: false, + shielded_address: [0; 43], + }; + let mut result = unsafe { + platform_wallet_ffi::managed_identity_get_private_contact( + identity_handle as Handle, + id.as_ptr(), + &mut contact, + ) + }; + if result.code == PlatformWalletFFIResultCode::NotFound { + unsafe { platform_wallet_ffi_result_free(&mut result) }; + return ptr::null_mut(); + } + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + new_jstring(env, private_contact_json(&contact)) + }) +} + +/// Whether a private contact request from this identity to `contactId` is +/// being paid for, or was paid for and is not yet read back from the store, +/// off a managed-identity handle (bridges +/// `managed_identity_get_private_reply_pending`). A pure read; the wallet +/// keeps it in memory only. ← Swift `ManagedIdentity.privateReplyPending(contactId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityPrivateReplyPending( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, + contact_id: JByteArray, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let Some(id) = read_id32(env, &contact_id, "contactId") else { + return JNI_FALSE; + }; + let mut pending = false; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_private_reply_pending( + identity_handle as Handle, + id.as_ptr(), + &mut pending, + ) + }; + if take_pwffi_error(env, result) { + return JNI_FALSE; + } + if pending { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + +/// `DashpayNative.privateContactReadiness`: the wallet's readiness for +/// `identity_id`'s contact requests +/// (`platform_wallet_get_private_contact_readiness`) as three flags, +/// `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment]`. +/// Throws on an unknown wallet or identity, returning null. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privateContactReadiness( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, +) -> jbooleanArray { + guard(&mut env, std::ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return std::ptr::null_mut(); + }; + let mut readiness = PrivateContactReadinessFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_private_contact_readiness( + wallet_handle as Handle, + id.as_ptr(), + &mut readiness, + ) + }; + if take_pwffi_error(env, result) { + return std::ptr::null_mut(); + } + let flags = readiness_flags(&readiness); + let array = match env.new_boolean_array(flags.len() as i32) { + Ok(array) => array, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "allocating the readiness flags failed"); + return std::ptr::null_mut(); + } + }; + if env.set_boolean_array_region(&array, 0, &flags).is_err() { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "writing the readiness flags failed"); + return std::ptr::null_mut(); + } + array.into_raw() + }) +} + +/// The readiness flags in the order Kotlin reads them. +fn readiness_flags(readiness: &PrivateContactReadinessFFI) -> [jboolean; 3] { + let flag = |set: bool| if set { JNI_TRUE } else { JNI_FALSE }; + [ + flag(readiness.private_payments_restored), + flag(readiness.private_store_read), + flag(readiness.unresolved_private_payment), + ] +} + +/// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns +/// `Err` on a JNI error. `field` names the argument in the message. +fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { + if arr.is_null() { + return Ok(None); + } + match env.convert_byte_array(arr) { + Ok(bytes) => Ok(Some(bytes)), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); + Err(()) + } + } +} + +/// Copy an optional Orchard address. The FFI reads exactly +/// [`SHIELDED_ADDRESS_LEN`] bytes from the pointer it is given and has no +/// length to check against, so any other length is refused here before the +/// pointer crosses. +fn read_opt_shielded_address(env: &mut JNIEnv, arr: &JByteArray) -> Result>, ()> { + let address = read_opt_bytes(env, arr, "shieldedAddress")?; + if let Some(address) = &address { + if address.len() != SHIELDED_ADDRESS_LEN { + throw_sdk_exception( + env, + 1, + &format!( + "shieldedAddress must be {SHIELDED_ADDRESS_LEN} raw Orchard bytes, got {}", + address.len() + ), + ); + return Err(()); + } + } + Ok(address) +} + +/// Send a DashPay private contact request from `senderIdentityId` to +/// `recipientIdentityId`, paying for it from `account`'s shielded notes +/// (bridges `platform_wallet_manager_send_private_contact_request`). +/// `resolverHandle` supplies the Orchard spend authority and the DashPay +/// keys; `signerHandle` is the identity signer (`SignerHandle`) the public +/// [`crate::tokens`] `sendContactRequest` takes, and signs the request. +/// `accountLabel`, `autoAcceptProof` and `shieldedAddress` (43 bytes) may be +/// null. Blocks for the Halo 2 proof; returns the created `ContactRequest` +/// handle (destroy via `TokensNative.contactRequestDestroy`). +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendPrivateContactRequest( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + sender_identity_id: JByteArray, + recipient_identity_id: JByteArray, + account_label: JString, + auto_accept_proof: JByteArray, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Some(sender) = read_id32(env, &sender_identity_id, "senderIdentityId") else { + return 0; + }; + let Some(recipient) = read_id32(env, &recipient_identity_id, "recipientIdentityId") else { + return 0; + }; + let Ok(label) = opt_jstring_to_cstring(env, "accountLabel", &account_label) else { + return 0; + }; + let Ok(proof) = read_opt_bytes(env, &auto_accept_proof, "autoAcceptProof") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let (proof_ptr, proof_len) = proof + .as_ref() + .map_or((ptr::null(), 0), |proof| (proof.as_ptr(), proof.len())); + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_send_private_contact_request( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + sender.as_ptr(), + recipient.as_ptr(), + label.as_ref().map_or(ptr::null(), |label| label.as_ptr()), + proof_ptr, + proof_len, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +/// Accept the incoming contact request `requestHandle` with a private reply, +/// paying for the reply from `account`'s shielded notes (bridges +/// `platform_wallet_manager_accept_private_contact_request`). The handles +/// are as for [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendPrivateContactRequest`]; +/// `shieldedAddress` (43 bytes) may be null. Blocks for the Halo 2 proof; +/// returns the `EstablishedContact` handle (destroy via +/// `TokensNative.establishedContactDestroy`). +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + request_handle: jlong, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_accept_private_contact_request( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + request_handle as Handle, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +/// Accept the incoming contact request `requestHandle` replying the way it +/// came (bridges +/// `platform_wallet_manager_accept_contact_request_replying_in_kind`): +/// privately, paid from `account`'s shielded notes, when it came privately +/// and no reply exists yet; otherwise with a `contactRequest` document, or by +/// adopting the reply already sent. The wallet decides; this is the accept a +/// host calls. Arguments and result are as for the private accept above. +/// Exported in every build: without the `shielded` feature a request that +/// needs a private reply is refused with `ErrorShieldedBuildRequired`, and a +/// public or adopted reply works as in a shielded build. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptContactRequestReplyingInKind( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + request_handle: jlong, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_accept_contact_request_replying_in_kind( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + request_handle as Handle, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The natives above need a JVM; the counter layout Kotlin decodes and the + // handle release are pure and are pinned here. + + /// A real `ContactRequest` handle, registered through platform-wallet-ffi's + /// `contact_request_create` as the FFI's own enumerators register theirs. + fn registered_request(sender_byte: u8) -> Handle { + let sender = [sender_byte; 32]; + let recipient = [0xB0; 32]; + let encrypted_public_key = [0x33; 96]; + let mut handle: Handle = 0; + let mut result = unsafe { + platform_wallet_ffi::contact_request_create( + sender.as_ptr(), + recipient.as_ptr(), + 1, + 2, + 7, + encrypted_public_key.as_ptr(), + encrypted_public_key.len(), + 100, + 1_700_000_000_000, + &mut handle, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + assert_ne!(handle, 0); + handle + } + + /// The sender id a getter reads off `handle`, or the code it refuses with. + fn sender_id_of(handle: Handle) -> Result<[u8; 32], PlatformWalletFFIResultCode> { + let mut sender = [0u8; 32]; + let mut result = unsafe { + platform_wallet_ffi::contact_request_get_sender_id(handle, sender.as_mut_ptr()) + }; + let code = result.code; + unsafe { platform_wallet_ffi_result_free(&mut result) }; + if code == PlatformWalletFFIResultCode::Success { + Ok(sender) + } else { + Err(code) + } + } + + #[test] + fn should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads() { + let report = PrivateContactSyncReportFFI { + next_position: 41, + pages_read: 3, + rejected_count: 2, + unpersisted_identity_count: 1, + complete: true, + }; + assert_eq!(private_contact_sync_longs(5, &report), [5, 3, 2, 1, 41, 1]); + let incomplete = PrivateContactSyncReportFFI { + complete: false, + ..report + }; + assert_eq!(private_contact_sync_longs(0, &incomplete)[5], 0); + } + + #[test] + fn should_lay_out_the_readiness_flags_in_the_order_kotlin_reads() { + let readiness = PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: false, + unresolved_private_payment: true, + }; + assert_eq!(readiness_flags(&readiness), [JNI_TRUE, JNI_FALSE, JNI_TRUE]); + assert_eq!( + readiness_flags(&PrivateContactReadinessFFI::default()), + [JNI_FALSE; 3] + ); + } + + #[test] + fn should_saturate_a_store_position_past_the_long_range() { + let report = PrivateContactSyncReportFFI { + next_position: u64::MAX, + ..Default::default() + }; + assert_eq!(private_contact_sync_longs(0, &report)[4], i64::MAX); + } + + #[test] + fn should_render_a_private_contact_as_the_json_kotlin_reads() { + let mut contact = PrivateContactFFI { + received_privately: true, + sent_privately: false, + has_shielded_address: true, + shielded_address: [0xab; 43], + }; + assert_eq!( + private_contact_json(&contact), + format!( + "{{\"receivedPrivately\":true,\"sentPrivately\":false,\"shieldedAddress\":\"{}\"}}", + "ab".repeat(43) + ) + ); + contact.has_shielded_address = false; + contact.sent_privately = true; + assert_eq!( + private_contact_json(&contact), + "{\"receivedPrivately\":true,\"sentPrivately\":true,\"shieldedAddress\":null}" + ); + } + + #[test] + fn should_release_an_empty_handle_array_as_zero_requests() { + let mut array = ContactRequestHandleArray::empty(); + assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 0); + assert!(array.handles.is_null()); + } + + /// The sync bridge hands Kotlin no handles, so it must destroy every + /// handle the FFI registered as well as free the array: both handles read + /// before the release and are refused after it. + #[test] + fn should_destroy_every_handle_of_the_array_and_free_it() { + let handles = [registered_request(0xA1), registered_request(0xA2)]; + assert_eq!(sender_id_of(handles[0]), Ok([0xA1; 32])); + assert_eq!(sender_id_of(handles[1]), Ok([0xA2; 32])); + + // The `ContactRequestHandleArray` contract: a boxed slice of handles, + // as `ContactRequestHandleArray::from_requests` allocates it. + let boxed: Box<[Handle]> = handles.to_vec().into_boxed_slice(); + let count = boxed.len(); + let mut array = ContactRequestHandleArray { + handles: Box::into_raw(boxed) as *mut Handle, + count, + }; + + assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 2); + assert!(array.handles.is_null()); + assert_eq!(array.count, 0); + for handle in handles { + assert_eq!( + sender_id_of(handle), + Err(PlatformWalletFFIResultCode::NotFound), + "a released handle reads nothing" + ); + } + } +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index b3b3b55d40d..51aad244695 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -58,10 +58,10 @@ use platform_wallet_ffi::{ DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, - PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, - TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + PlatformAddressFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI, + ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, + TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, + UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -244,6 +244,22 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence "onWalletChangesetDashPayBackfill", WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, ), + private_contact_scan_state: bridge_overrides( + env, + bridge, + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onPersistContactUpsert", + CONTACT_UPSERT_DESCRIPTOR, + ), }) } @@ -300,6 +316,12 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, + /// `onPersistPrivateContactScanState`, `onLoadPrivateContactScanState` + /// and `onPersistContactUpsert`: an inherited no-op store would + /// acknowledge a read position it never kept, and a read position + /// vouches for the contact requests read before it, which an inherited + /// no-op contact upsert never keeps. + private_contact_scan_state: bool, } /// The extension slots wired for a bridge with `overrides` (see @@ -320,6 +342,15 @@ fn extension_for(overrides: BridgeOverrides) -> PersistenceCallbacksExtension { } else { None }, + on_persist_private_contact_scan_state_fn: overrides + .private_contact_scan_state + .then_some(tramp_persist_private_contact_scan_state), + on_load_private_contact_scan_state_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state), + on_load_private_contact_scan_state_free_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state_free), ..Default::default() } } @@ -1084,6 +1115,223 @@ unsafe extern "C" fn tramp_persist_wallet_dashpay_backfill( }) } +/// Packed `byte[]` row widths of the private contact request scan state, the +/// same flat packing the backfill cover set uses: a read position is +/// `identityId ‖ position (u64 LE) ‖ keyFingerprint ‖ unresolved (0 or 1)`, +/// a shielded address +/// `ownerId ‖ contactId ‖ address`, an address removal `ownerId ‖ +/// contactId`, a cleared identity its id. +pub(crate) const PRIVATE_POSITION_ENTRY_LEN: usize = 32 + 8 + 32 + 1; +pub(crate) const PRIVATE_ADDRESS_ENTRY_LEN: usize = 32 + 32 + 43; +pub(crate) const PRIVATE_ADDRESS_REMOVAL_ENTRY_LEN: usize = 32 + 32; + +/// Descriptor of `NativePersistenceBridge.onPersistPrivateContactScanState`: +/// `(walletId, positions, addresses, removedAddresses, clearedIdentities)`. +const PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B[B[B[B[B)I"; + +/// Descriptor of `NativePersistenceBridge.onLoadPrivateContactScanState`: +/// `(walletId) -> [positions, addresses]`. +const LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B)[[B"; + +fn pack_private_positions(rows: &[PrivateContactRequestsPositionFFI]) -> Vec { + let mut packed = Vec::with_capacity(rows.len() * PRIVATE_POSITION_ENTRY_LEN); + for row in rows { + packed.extend_from_slice(&row.identity_id); + packed.extend_from_slice(&row.position.to_le_bytes()); + packed.extend_from_slice(&row.key_fingerprint); + packed.push(u8::from(row.private_payments_unresolved)); + } + packed +} + +fn pack_private_addresses(rows: &[PrivateShieldedAddressFFI]) -> Vec { + let mut packed = Vec::with_capacity(rows.len() * PRIVATE_ADDRESS_ENTRY_LEN); + for row in rows { + packed.extend_from_slice(&row.owner_id); + packed.extend_from_slice(&row.contact_id); + packed.extend_from_slice(&row.address); + } + packed +} + +/// The rows packed in `packed`, or `None` when its length is not a whole +/// number of rows. +fn unpack_private_positions(packed: &[u8]) -> Option> { + let (rows, rest) = packed.as_chunks::(); + if !rest.is_empty() { + return None; + } + rows.iter() + .map(|row| { + Some(PrivateContactRequestsPositionFFI { + identity_id: row[..32].try_into().ok()?, + position: u64::from_le_bytes(row[32..40].try_into().ok()?), + key_fingerprint: row[40..72].try_into().ok()?, + private_payments_unresolved: match row[72] { + 0 => false, + 1 => true, + _ => return None, + }, + }) + }) + .collect() +} + +/// The rows packed in `packed`, or `None` when its length is not a whole +/// number of rows. +fn unpack_private_addresses(packed: &[u8]) -> Option> { + let (rows, rest) = packed.as_chunks::(); + if !rest.is_empty() { + return None; + } + rows.iter() + .map(|row| { + Some(PrivateShieldedAddressFFI { + owner_id: row[..32].try_into().ok()?, + contact_id: row[32..64].try_into().ok()?, + address: row[64..].try_into().ok()?, + }) + }) + .collect() +} + +/// Deliver a round's private contact request scan state (see +/// `PersistPrivateContactScanStateFn`) as four packed arrays. +#[allow(clippy::too_many_arguments)] +unsafe extern "C" fn tramp_persist_private_contact_scan_state( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let positions = env.byte_array_from_slice(&pack_private_positions(slice_or_empty( + positions, + positions_count, + )))?; + let addresses = env.byte_array_from_slice(&pack_private_addresses(slice_or_empty( + addresses, + addresses_count, + )))?; + let removed_rows = slice_or_empty(removed_addresses, removed_addresses_count); + let mut removed = + Vec::with_capacity(removed_rows.len() * PRIVATE_ADDRESS_REMOVAL_ENTRY_LEN); + for row in removed_rows { + removed.extend_from_slice(&row.owner_id); + removed.extend_from_slice(&row.contact_id); + } + let removed = env.byte_array_from_slice(&removed)?; + let cleared: Vec = slice_or_empty(cleared_identities, cleared_identities_count) + .iter() + .flatten() + .copied() + .collect(); + let cleared = env.byte_array_from_slice(&cleared)?; + env.call_method( + bridge, + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + &[ + (&wid).into(), + (&positions).into(), + (&addresses).into(), + (&removed).into(), + (&cleared).into(), + ], + )? + .i() + }) +} + +/// Hand Rust a wallet's stored scan state (see +/// `LoadPrivateContactScanStateFn`). The rows are staged in owned buffers +/// and minted as raw arrays only once the whole read succeeded; a failed +/// read, an exception or a malformed array returns `ERR_JNI`, which fails +/// the load rather than reading the store from its start. +unsafe extern "C" fn tramp_load_private_contact_scan_state( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, +) -> i32 { + *out_positions = ptr::null(); + *out_positions_count = 0; + *out_addresses = ptr::null(); + *out_addresses_count = 0; + let loaded = with_bridge_load(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let arrays: JObjectArray = env + .call_method( + bridge, + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + &[(&wid).into()], + )? + .l()? + .into(); + if env.get_array_length(&arrays)? != 2 { + return Err(jni::errors::Error::JniCall( + jni::errors::JniError::InvalidArguments, + )); + } + let positions: JByteArray = env.get_object_array_element(&arrays, 0)?.into(); + let addresses: JByteArray = env.get_object_array_element(&arrays, 1)?.into(); + Ok(( + env.convert_byte_array(&positions)?, + env.convert_byte_array(&addresses)?, + )) + }); + let Some((positions, addresses)) = loaded else { + return ERR_JNI; + }; + let (Some(positions), Some(addresses)) = ( + unpack_private_positions(&positions), + unpack_private_addresses(&addresses), + ) else { + return ERR_JNI; + }; + if !positions.is_empty() { + *out_positions_count = positions.len(); + *out_positions = Box::into_raw(positions.into_boxed_slice()) as *const _; + } + if !addresses.is_empty() { + *out_addresses_count = addresses.len(); + *out_addresses = Box::into_raw(addresses.into_boxed_slice()) as *const _; + } + 0 +} + +/// Release the arrays [`tramp_load_private_contact_scan_state`] minted. +unsafe extern "C" fn tramp_load_private_contact_scan_state_free( + _context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, +) { + if !positions.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + positions.cast_mut(), + positions_count, + ))); + } + if !addresses.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + addresses.cast_mut(), + addresses_count, + ))); + } +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -4914,6 +5162,16 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onWalletChangesetDashPayBackfill", WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, ), + // Wired only for a bridge that overrides both (`bridge_overrides`), so a + // drifted descriptor would silently drop the slots and the capability. + ( + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ), + ( + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -5136,6 +5394,7 @@ mod tests { sweeps: false, header, backfill, + private_contact_scan_state: false, }); assert_eq!( extension.on_persist_wallet_dashpay_backfill_fn.is_some(), @@ -5147,11 +5406,98 @@ mod tests { sweeps: true, header: false, backfill: false, + private_contact_scan_state: false, }); assert!(sweeps_only.on_persist_wallet_changeset_sweeps_fn.is_some()); assert!(sweeps_only.on_persist_wallet_dashpay_backfill_fn.is_none()); } + /// The scan-state trio is wired only for a bridge that implements both + /// its store and its load; one that inherits either gets none of it, so + /// `FFIPersister` does not attest the capability and each launch reads + /// the private store from its start. + #[test] + fn the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it() { + for wired in [false, true] { + let extension = extension_for(BridgeOverrides { + sweeps: false, + header: false, + backfill: false, + private_contact_scan_state: wired, + }); + assert_eq!( + extension.on_persist_private_contact_scan_state_fn.is_some(), + wired + ); + assert_eq!( + extension.on_load_private_contact_scan_state_fn.is_some(), + wired + ); + assert_eq!( + extension + .on_load_private_contact_scan_state_free_fn + .is_some(), + wired + ); + } + let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + assert_eq!( + method_descriptor(&names(&["[B", "[B", "[B", "[B", "[B"]), "int"), + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR + ); + assert_eq!( + method_descriptor(&names(&["[B"]), "[[B"), + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR + ); + } + + /// A read position and a shielded address survive the packed arrays the + /// bridge stores and hands back, a full-width position included; an + /// array that is not a whole number of rows is refused. + #[test] + fn the_scan_state_rows_round_trip_through_their_packed_arrays() { + let positions = [ + PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: u64::MAX, + key_fingerprint: [3; 32], + private_payments_unresolved: true, + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 12, + key_fingerprint: [4; 32], + private_payments_unresolved: false, + }, + ]; + let addresses = [PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }]; + let packed = pack_private_positions(&positions); + assert_eq!(packed.len(), 2 * PRIVATE_POSITION_ENTRY_LEN); + assert_eq!( + unpack_private_positions(&packed).as_deref(), + Some(&positions[..]) + ); + let packed = pack_private_addresses(&addresses); + assert_eq!(packed.len(), PRIVATE_ADDRESS_ENTRY_LEN); + assert_eq!( + unpack_private_addresses(&packed).as_deref(), + Some(&addresses[..]) + ); + assert_eq!(unpack_private_positions(&[]).as_deref(), Some(&[][..])); + assert!(unpack_private_positions(&[0; PRIVATE_POSITION_ENTRY_LEN + 1]).is_none()); + let mut bad_marker = pack_private_positions(&positions[..1]); + bad_marker[72] = 2; + assert!( + unpack_private_positions(&bad_marker).is_none(), + "a marker byte other than 0 or 1 is not a row" + ); + assert!(unpack_private_addresses(&[0; PRIVATE_ADDRESS_ENTRY_LEN - 1]).is_none()); + } + /// A bridge inheriting the no-op account writers gets neither account /// slot, so `FFIPersister` cannot derive `ACCOUNT_ADDRESS_POOLS` and does /// not keep outbound-account markers on disk; one implementing both keeps diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 4dfc8cbadef..7fccc2d99e6 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -56,6 +56,7 @@ use platform_wallet_ffi::error::{ }; use platform_wallet_ffi::event_handler::{EventHandlerCallbacks, EventHandlerCallbacksExtension}; use platform_wallet_ffi::handle::Handle; +use platform_wallet_ffi::manager::effective_persistence_capabilities; use platform_wallet_ffi::persistence::{PersistenceCallbacks, PersistenceCapabilitiesFFI}; use platform_wallet_ffi::types::IdentifierArray; use std::ffi::{c_void, CStr}; @@ -211,6 +212,76 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n }) } +/// `WalletManagerNative.nativeEffectivePersistenceCapabilityBits`: the +/// persistence capabilities a manager created with `persistence_bridge` +/// would attest, without creating one. The declaration, the vtable, the +/// account-writer restriction and the extension (with its `bridge_overrides` +/// probes) are built exactly as `nativeCreate` builds them, so an +/// instrumented test can check which bridge overrides keep a capability. +/// Throws and returns 0 when the declaration cannot be read. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_nativeEffectivePersistenceCapabilityBits( + mut env: JNIEnv, + _class: JClass, + persistence_bridge: JObject, +) -> jlong { + guard(&mut env, 0, |env| { + let version = match env + .call_method( + &persistence_bridge, + "persistenceCapabilitiesVersion", + "()I", + &[], + ) + .and_then(|value| value.i()) + { + Ok(value) => value as u32, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "reading persistence capability version failed"); + return 0; + } + }; + let bits = match env + .call_method( + &persistence_bridge, + "persistenceCapabilitiesBits", + "()J", + &[], + ) + .and_then(|value| value.j()) + { + Ok(value) => value as u64, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "reading persistence capability mask failed"); + return 0; + } + }; + let global = match env.new_global_ref(&persistence_bridge) { + Ok(global) => global, + Err(_) => { + throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); + return 0; + } + }; + let context = Box::into_raw(Box::new(KotlinPersistenceCtx::new(global))); + let mut persistence: PersistenceCallbacks = build_vtable(context as *mut c_void); + restrict_account_writers(env, &persistence_bridge, &mut persistence); + let extension = build_extension(env, &persistence_bridge); + let declared = PersistenceCapabilitiesFFI { + version, + reserved: 0, + bits, + }; + // SAFETY: `extension` is a live, fully sized extension; the vtable's + // `release_fn` frees `context` before this returns. + let effective = + unsafe { effective_persistence_capabilities(persistence, &declared, &extension) }; + effective.bits() as jlong + }) +} + /// The raw manager `Handle` for a bundle, as jlong (for the FFI sync / /// wallet-accessor calls). 0 if the bundle pointer is 0. #[no_mangle]