From 4ad0b1d8ea5720dc02a4cfc54991f8373f356a00 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:17:32 +0700 Subject: [PATCH 01/35] feat(kotlin-sdk): bridge DashPay private contact requests and type code 68 Ports what Swift's `PrivateContactRequests.swift` exposes, over the same platform-wallet-ffi functions: - `Dashpay.syncPrivateContactRequests(coreSignerHandle, maxPages)` reads the private store for every identity of the wallet and returns a `PrivateContactSyncResult` (requests ingested, pages read, entries refused, store position, complete, identities not persisted). The requests reach Room through the persister, so the JNI bridge releases their handles instead of leaking them. - `PlatformWalletManager.sendPrivateContactRequest(...)` and `acceptPrivateContactRequest(walletId, request, ...)` are shielded spends, so they sit with the other manager-handle spends and use the manager's resolver and identity signer, the signer the public send and accept are given. The JNI passes the signer as `*mut SignerHandle`, as the public `sendContactRequest` does. Built with the `shielded` feature only. - `Dashpay.incomingContactRequest(ourIdentityId, senderId)` returns the request the private accept takes (Swift `ManagedIdentity.getIncomingContactRequest(senderId:)`). - Code 68 maps to `DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply`, so a host routes a refused public accept to the private accept. The JNI refuses a shielded address that is not 43 bytes before the pointer crosses, since the FFI reads exactly 43. `managed_identity_get_private_contact` is not bridged: the Swift example app has no caller for it. Tests: `DashSdkErrorTest` pins code 68; the JNI crate pins the counter layout Kotlin decodes; `FfiSmokeTest` checks the three symbols bind and reject bad input. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 30 ++ .../dashsdk/errors/DashSdkError.kt | 18 + .../dashsdk/ffi/DashpayNative.kt | 64 ++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 82 +++++ .../dashsdk/wallet/PlatformWalletManager.kt | 86 +++++ .../dashsdk/errors/DashSdkErrorTest.kt | 16 + packages/rs-unified-sdk-jni/src/dashpay.rs | 320 +++++++++++++++++- 7 files changed, 613 insertions(+), 3 deletions(-) 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..a1767679e48 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,7 @@ 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.NativeLoader import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.ffi.SignerNative @@ -94,6 +95,35 @@ class FfiSmokeTest { * covered by the shared Rust tests in * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. */ + /** + * DashPay private contact requests (protocol version 15): the three + * `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 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) + } + 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, + ) + } + } + @Test fun syncFaultDetectedSymbolBindsAndRejectsInvalidHandle() { NativeLoader.ensureLoaded() 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..e5bf9a1d4ef 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,21 @@ 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.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) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -842,6 +857,9 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired + // ErrorPrivateContactRequestNeedsPrivateReply: the host routes the + // refused public accept (or send) to the private one. + 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) 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..3fb6fcc752f 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,68 @@ 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 } 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..6787b34bd56 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,88 @@ 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`. + + /** 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`. The store cursors live in memory + * only, so 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], + ) + } + + /** + * 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.acceptPrivateContactRequest` + * takes when the public accept refuses a contact whose requests came + * privately ([DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]). + * 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..e1aae239b36 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. */ @@ -2129,6 +2131,90 @@ 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`). + * + * The public `Dashpay.acceptContactRequest` refuses such a request with + * [DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]; + * read it with `Dashpay.incomingContactRequest` and accept it here. 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) + } + /** * 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..f244bea164e 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,22 @@ 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 platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b6b36194a90..15ac9a42322 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,17 @@ 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, jint, jlong, jlongArray, jobject, jstring}; 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; 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::{ContactRequestHandleArray, PrivateContactSyncReportFFI}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; @@ -1210,3 +1213,314 @@ 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 bytes of an Orchard payment address the FFI reads from a +/// `shielded_address` pointer: an 11-byte diversifier and a 32-byte +/// transmission key. +#[cfg(feature = "shielded")] +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() + }) +} + +/// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns +/// `Err` on a JNI error. `field` names the argument in the message. +#[cfg(feature = "shielded")] +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. +#[cfg(feature = "shielded")] +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 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The natives above need a JVM; the counter layout Kotlin decodes is + // pure and is pinned here. + + #[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_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_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()); + } +} From d3b4f89d1d5df71166546a01bac8aaf5b5026d28 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:17:32 +0700 Subject: [PATCH 02/35] feat(kotlin-example-app): accept privately when the public accept refuses As the Swift app does (`ContactsView.swift`): - Accepting a request in Requests, or from Add Contact's collision path, goes through `acceptIncomingContactRequest`, which falls back to the private accept (paid from the wallet's shielded balance, signed by the same identity signer) when Rust refuses the public one with `PrivateContactRequestNeedsPrivateReply`. Rust decides which; the app only reacts to the refusal. - Pull-to-refresh on Requests also reads the private store, the only way private requests reach the wallet. Add Contact has no send-privately toggle yet. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 6 +-- .../ui/dashpay/ContactRequestsScreen.kt | 11 +++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) 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..e787616ba7d 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 @@ -218,11 +218,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { 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) 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..a51d282cf09 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 @@ -155,11 +155,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 @@ -209,7 +209,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle onRefresh = { scope.launch { isRefreshing = true - manager?.let { attachOrStartSync(it) } + manager?.let { m -> + attachOrStartSync(m) + wallet?.let { syncPrivateContactRequests(it, m) } + } isRefreshing = false } }, 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..6294b4fc2d1 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 @@ -3,6 +3,8 @@ package org.dashfoundation.example.ui.dashpay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager /** @@ -32,3 +34,44 @@ suspend fun attachOrStartSync(manager: PlatformWalletManager) { fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { scope.launch { runCatching { manager.dashPaySyncNow() } } } + +/** + * Read DashPay's private contact request store (protocol version 15) for + * [wallet]. Best effort: a network before protocol version 15 has no such + * store. Private requests reach the wallet only through this read, so it + * runs with every pull-to-refresh of the request list. + * ← Swift `syncPrivateContactRequests` (`ContactsView.swift`). + */ +suspend fun syncPrivateContactRequests( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, +) { + runCatching { wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) } +} + +/** + * Accept the incoming request from [senderId] to [ourIdentityId]. Rust refuses + * a public reply to a contact whose requests came privately, as it would + * publish the relationship; that one is accepted with a private reply + * instead, paid from the wallet's shielded balance and signed by the same + * identity signer. Rust decides which; the app only reacts to the refusal. + * Returns false when the request is not in local state. + * ← Swift `acceptIncomingContactRequest` (`ContactsView.swift`). + */ +suspend fun acceptIncomingContactRequest( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, + ourIdentityId: ByteArray, + senderId: ByteArray, +): Boolean = try { + wallet.dashpay.acceptIncomingRequest( + ourIdentityId = ourIdentityId, + senderId = senderId, + signerHandle = manager.signerHandle, + coreSignerHandle = manager.mnemonicResolverHandle, + ) +} catch (refused: DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) { + val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) + request?.use { manager.acceptPrivateContactRequest(wallet.walletId, it).close() } + request != null +} From 95c0dd2a85b359712000b512d1f7d13062ef0aab Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:17:32 +0700 Subject: [PATCH 03/35] docs(kotlin-sdk): track DashPay private contact request parity Adds `dashpay.private_contact_requests` (store sync, shielded send, private-reply accept, private contact read, code 68 routing) with its four FFI symbols. Both hosts are partial with restart required: the sync, send and accept need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, Kotlin does not bridge the private contact read, and the Kotlin app has no send-privately toggle. `PARITY_SUMMARY.md` is regenerated by the checker. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 83 +++++++++++++++++++++++++++ packages/kotlin-sdk/PARITY_SUMMARY.md | 15 ++--- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index aa26292d4b4..109342d77a4 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -30,6 +30,7 @@ "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", "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", @@ -40,8 +41,10 @@ "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_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 +52,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 +774,85 @@ } ] }, + { + "id": "dashpay.private_contact_requests", + "title": "DashPay private contact requests: store sync, shielded send, private-reply accept, private contact read, code 68 routing", + "area": "correctness", + "shared_apis": [ + "platform_wallet_sync_private_contact_requests", + "platform_wallet_manager_send_private_contact_request", + "platform_wallet_manager_accept_private_contact_request", + "managed_identity_get_private_contact" + ], + "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 and private-reply accept (signed with the identity signer the public calls take) and PrivateContactInfo; code 68 is typed and pinned in ErrorHandlingTests. The sync, send and accept have no automated or recorded gate: they need 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. The example app has the send-privately toggle, the private-accept fallback on code 68 and the private sync on pull-to-refresh, with no recorded gate." + }, + "kotlin": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. 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, and no test covers a restart. The example app falls back to the private accept on code 68 and syncs the private store on pull-to-refresh; it has no send-privately toggle." + } + }, + "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-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": "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/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": "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": "identity.asset_lock_resume", "title": "Identity registration and top-up resume from tracked locks", diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index ff6bef9ca6e..5b6a2af0178 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: **28** ## 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 | 10 | 1 | 1 | +| Swift | Example app | 4 | 13 | 2 | 9 | +| Kotlin | SDK | 13 | 13 | 1 | 1 | +| Kotlin | Example app | 5 | 13 | 1 | 9 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 1 | 8 | 18 | -| Kotlin | 4 | 6 | 17 | +| Swift | 1 | 9 | 18 | +| Kotlin | 4 | 7 | 17 | ## Capability status @@ -33,6 +33,7 @@ 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 | | `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 | From 399161e8b065ef539ce0f6758fab682006382480 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 04/35] feat(kotlin-sdk): accept a contact request replying in kind `PlatformWalletManager.acceptContactRequestReplyingInKind(walletId, request, shieldedAddress, account)` bridges `platform_wallet_manager_accept_contact_request_replying_in_kind` through the new JNI export `DashpayNative.acceptContactRequestReplyingInKind` (`shielded` feature): the wallet replies privately to a request that came privately with no reply yet, and with a document (or by adopting the reply already sent) otherwise. It is the accept a host calls, so hosts no longer catch code 68 and call the private accept themselves. Port of Swift's `PlatformWalletManager.acceptContactRequestReplyingInKind`. Like the other manager-level shielded spends it signs with the manager's identity signer and takes the spend authority from the manager's resolver. The explicit public and private accepts stay; their KDoc, and code 68's, point at the new accept. `FfiSmokeTest` binds the new symbol. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 7 ++- .../dashsdk/errors/DashSdkError.kt | 11 ++-- .../dashsdk/ffi/DashpayNative.kt | 19 ++++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 7 +-- .../dashsdk/wallet/PlatformWalletManager.kt | 50 +++++++++++++-- packages/rs-unified-sdk-jni/src/dashpay.rs | 61 ++++++++++++++++++- 6 files changed, 139 insertions(+), 16 deletions(-) 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 a1767679e48..8a58af68e37 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 @@ -96,7 +96,7 @@ class FfiSmokeTest { * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. */ /** - * DashPay private contact requests (protocol version 15): the three + * DashPay private contact requests (protocol version 15): the four * `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 @@ -122,6 +122,11 @@ class FfiSmokeTest { 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, ) } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptContactRequestReplyingInKind( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } } @Test 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 e5bf9a1d4ef..fa4583fa59e 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 @@ -652,9 +652,10 @@ sealed class DashSdkError( * (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.acceptPrivateContactRequest`, or send with - * `PlatformWalletManager.sendPrivateContactRequest`. Not retryable - * as-is: the same public call is refused again. + * `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`). */ @@ -857,8 +858,8 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired - // ErrorPrivateContactRequestNeedsPrivateReply: the host routes the - // refused public accept (or send) to the private one. + // ErrorPrivateContactRequestNeedsPrivateReply: the explicitly + // public accept or send refused a contact that went private. 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) else -> // @Deprecated fallback — see the code-6 arm; code 31 is the 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 3fb6fcc752f..4096e78c2e4 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 @@ -327,4 +327,23 @@ internal object DashpayNative { shieldedAddress: ByteArray?, signerHandle: Long, ): Long + + /** + * 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/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 6787b34bd56..6d777e671db 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 @@ -339,10 +339,9 @@ class Dashpay internal constructor(private val walletHandle: Long, * 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.acceptPrivateContactRequest` - * takes when the public accept refuses a contact whose requests came - * privately ([DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]). - * Local read; no network I/O. + * 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( 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 e1aae239b36..b7f68b00aa6 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 @@ -2185,10 +2185,10 @@ class PlatformWalletManager( * `PlatformWalletManager.acceptPrivateContactRequest` * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). * - * The public `Dashpay.acceptContactRequest` refuses such a request with - * [DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]; - * read it with `Dashpay.incomingContactRequest` and accept it here. The - * reply is signed by this manager's identity signer ([signerHandle]). + * 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. * @@ -2215,6 +2215,48 @@ class PlatformWalletManager( 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. + * + * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a + * private reply may already be on chain: do not accept again. + * + * @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/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 15ac9a42322..1b3803f2ec1 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1218,9 +1218,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn // // 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 +// the send, the private-reply accept and the accept that replies the way a +// request came are manager-handle shielded spends // (`PlatformWalletManager.sendPrivateContactRequest` / -// `acceptPrivateContactRequest`), gated like the other shielded spends. +// `acceptPrivateContactRequest` / `acceptContactRequestReplyingInKind`), +// gated like the other shielded spends. /// The bytes of an Orchard payment address the FFI reads from a /// `shielded_address` pointer: an 11-byte diversifier and a 32-byte @@ -1484,6 +1486,61 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptP }) } +/// 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 +/// [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest`]. +#[cfg(feature = "shielded")] +#[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::*; From 3b136217a4045456ff46f75d7c648679cf94933d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 05/35] test(kotlin-sdk): release real contact request handles in the JNI sync bridge `release_contact_request_handles` destroys the handles the private store sync registers, since Kotlin reads those requests from Room. The new JVM-free test registers two real requests through platform-wallet-ffi's `contact_request_create`, puts their handles in a boxed slice as `ContactRequestHandleArray::from_requests` allocates one, releases the array, and checks the count, the null pointer and zero count left behind, and that `contact_request_get_sender_id` refuses both handles afterward. Co-Authored-By: Claude Opus 5.5 --- packages/rs-unified-sdk-jni/src/dashpay.rs | 77 +++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 1b3803f2ec1..b7a1c9bdddf 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1544,9 +1544,52 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptC #[cfg(test)] mod tests { use super::*; + use platform_wallet_ffi::error::PlatformWalletFFIResultCode; - // The natives above need a JVM; the counter layout Kotlin decodes is - // pure and is pinned here. + // 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() { @@ -1580,4 +1623,34 @@ mod tests { 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" + ); + } + } } From 58c180a5bfdadf1039b1e1801a8ae1d6494ead64 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 06/35] refactor(kotlin-example-app): let the wallet choose how an accepted request is replied to `acceptIncomingContactRequest` looks the request up and makes the one `acceptContactRequestReplyingInKind` call, instead of trying the public accept and falling back to the private one on code 68. Requests and Add Contact's collision accept keep calling it. Mirrors the Swift app. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/DashPaySyncHelpers.kt | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) 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 6294b4fc2d1..3d1f0b9bc95 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 @@ -3,7 +3,6 @@ package org.dashfoundation.example.ui.dashpay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager @@ -50,28 +49,21 @@ suspend fun syncPrivateContactRequests( } /** - * Accept the incoming request from [senderId] to [ourIdentityId]. Rust refuses - * a public reply to a contact whose requests came privately, as it would - * publish the relationship; that one is accepted with a private reply - * instead, paid from the wallet's shielded balance and signed by the same - * identity signer. Rust decides which; the app only reacts to the refusal. - * Returns false when the request is not in local state. - * ← Swift `acceptIncomingContactRequest` (`ContactsView.swift`). + * 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 = try { - wallet.dashpay.acceptIncomingRequest( - ourIdentityId = ourIdentityId, - senderId = senderId, - signerHandle = manager.signerHandle, - coreSignerHandle = manager.mnemonicResolverHandle, - ) -} catch (refused: DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) { - val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) - request?.use { manager.acceptPrivateContactRequest(wallet.walletId, it).close() } - request != null +): Boolean { + val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) ?: return false + request.use { manager.acceptContactRequestReplyingInKind(wallet.walletId, it).close() } + return true } From 13f4784734dad20528003308c73ae5cbfe4c1fed Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 07/35] docs(kotlin-sdk): track the accept replying in kind in the private contact parity entry `dashpay.private_contact_requests` lists `platform_wallet_manager_accept_contact_request_replying_in_kind`, both hosts' reasons say the example apps accept with it, and the entry cites the wallet's in-kind test and the JNI handle-release test. Statuses are unchanged, so `PARITY_SUMMARY.md` regenerates identical. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 109342d77a4..f8e76467ca8 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -41,6 +41,7 @@ "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_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", @@ -776,11 +777,12 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, private-reply accept, private contact read, code 68 routing", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact read, code 68 routing", "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" ], @@ -793,13 +795,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send and private-reply accept (signed with the identity signer the public calls take) and PrivateContactInfo; code 68 is typed and pinned in ErrorHandlingTests. The sync, send and accept have no automated or recorded gate: they need 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. The example app has the send-privately toggle, the private-accept fallback on code 68 and the private sync on pull-to-refresh, with no recorded gate." + "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), the explicit private-reply accept and PrivateContactInfo, all signed with the identity signer the public calls take; code 68 is 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. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh, with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. 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, and no test covers a restart. The example app falls back to the private accept on code 68 and syncs the private store on pull-to-refresh; it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. 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, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh; it has no send-privately toggle." } }, "verification": [ @@ -811,6 +813,14 @@ "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", @@ -843,6 +853,14 @@ "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", From bb29bd677d2155a2a734fd1cfe3426e0af7d985e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:21:24 +0700 Subject: [PATCH 08/35] fix(kotlin-sdk): export the in-kind accept in every build and type codes 69 and 70 - `DashpayNative.acceptContactRequestReplyingInKind` and the helpers it uses are no longer behind `shielded`, matching the FFI export, which now exists in every build: on `./build_android.sh --no-shielded` a public or adopted reply works, and only a request that needs a private reply is refused, typed. The private send and accept bridges stay shielded-only. - `DashSdkError.PlatformWallet.ShieldedBuildRequired` (69) and `ProtocolVersionNotActive` (70), pinned in `DashSdkErrorTest`. - The in-kind accept's KDoc names both and says a public or adopted reply needs neither shielded support nor a payment mnemonic read. The manager's private send and accepts already run under its teardown gate, so nothing changes there. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 28 +++++++++++++++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 8 ++++-- .../dashsdk/errors/DashSdkErrorTest.kt | 21 ++++++++++++++ packages/rs-unified-sdk-jni/src/dashpay.rs | 18 ++++++------ 4 files changed, 63 insertions(+), 12 deletions(-) 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 fa4583fa59e..c2643aa4924 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 @@ -662,6 +662,32 @@ sealed class DashSdkError( 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) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -861,6 +887,8 @@ sealed class DashSdkError( // 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 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/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index b7f68b00aa6..35c333012e0 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 @@ -2231,8 +2231,12 @@ class PlatformWalletManager( * Orchard address the contact may pay us at privately; a public reply * ignores it. * - * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a - * private reply may already be on chain: do not accept again. + * 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. */ 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 f244bea164e..44501c20ea8 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 @@ -275,6 +275,27 @@ class DashSdkErrorTest { 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 platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b7a1c9bdddf..025a892171d 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1218,16 +1218,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn // // Mirrors Swift's `PrivateContactRequests.swift`: the store sync is a // wallet-handle call (`ManagedPlatformWallet.syncPrivateContactRequests`); -// the send, the private-reply accept and the accept that replies the way a -// request came are manager-handle shielded spends +// the send and the private-reply accept are manager-handle shielded spends // (`PlatformWalletManager.sendPrivateContactRequest` / -// `acceptPrivateContactRequest` / `acceptContactRequestReplyingInKind`), -// gated like the other shielded spends. +// `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. -#[cfg(feature = "shielded")] const SHIELDED_ADDRESS_LEN: usize = 43; /// The longs [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests`] @@ -1316,7 +1315,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPri /// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns /// `Err` on a JNI error. `field` names the argument in the message. -#[cfg(feature = "shielded")] fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { if arr.is_null() { return Ok(None); @@ -1335,7 +1333,6 @@ fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result Result>, ()> { let address = read_opt_bytes(env, arr, "shieldedAddress")?; if let Some(address) = &address { @@ -1492,9 +1489,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptP /// 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 -/// [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest`]. -#[cfg(feature = "shielded")] +/// 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( From a27385734fd8fe15f83d281e42df6c16d841033c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:21:24 +0700 Subject: [PATCH 09/35] fix(kotlin-example-app): keep an unconfirmed private reply pending and report private refresh failures As the Swift app does (`ContactRequestOutcomes.swift`): - An accept whose private reply could not be confirmed (`ShieldedSpendUnconfirmed`) stays pending, as the send screen treats an unconfirmed spend: the request row keeps its pending state without its buttons, and Add Contact shows a Done-only dialog and no longer enables Send. - The request list's private refresh stays quiet when the network does not serve the private store yet (code 70), and shows any other failure, and requests read but not saved, above the list. - `ContactRequestSubmissionOutcome` and `PrivateContactRefreshNotice` hold those readings, pinned by `ContactRequestOutcomesTest`. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 28 ++++++++- .../ui/dashpay/ContactRequestOutcomes.kt | 57 ++++++++++++++++++ .../ui/dashpay/ContactRequestsScreen.kt | 37 ++++++++++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 18 ++++-- .../ui/dashpay/ContactRequestOutcomesTest.kt | 58 +++++++++++++++++++ 5 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt 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 e787616ba7d..1a06c468a46 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,10 @@ 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) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -107,7 +111,8 @@ 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 // 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). @@ -232,7 +237,15 @@ 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 + } + is ContactRequestSubmissionOutcome.Failed -> + errorMessage = "Accept failed: ${outcome.message}" + } } finally { isSending = false } @@ -366,6 +379,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..97fd72c29e8 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError + +/** + * How a contact request 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. 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 + + /** 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." + + fun of(error: Throwable): ContactRequestSubmissionOutcome = + if (error is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { + MayHaveGoneThrough + } else { + Failed(error.message ?: "unknown error") + } + } +} + +/** + * 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 a51d282cf09..5a102240207 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,12 @@ 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 whose accept may have gone through (a private reply whose result + // could not be confirmed): the row stays pending, without its buttons, + // until the next sync settles it. Never offered again here. + var unconfirmedIds by remember { mutableStateOf(emptySet()) } + // 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 @@ -168,7 +174,16 @@ 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 next sync settles it. + unconfirmedIds = unconfirmedIds + hex + kickDashPaySync(scope, m) + } + is ContactRequestSubmissionOutcome.Failed -> + rowErrors = rowErrors + (hex to "Accept failed: ${outcome.message}") + } } finally { inFlightIds = inFlightIds - hex } @@ -211,7 +226,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isRefreshing = true manager?.let { m -> attachOrStartSync(m) - wallet?.let { syncPrivateContactRequests(it, m) } + wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } } isRefreshing = false } @@ -223,6 +238,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( @@ -238,8 +263,12 @@ 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) || unconfirmedIds.contains(hex), + errorMessage = if (unconfirmedIds.contains(hex)) { + ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } else { + 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 3d1f0b9bc95..1e48f973385 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,5 +1,6 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -36,16 +37,23 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { /** * Read DashPay's private contact request store (protocol version 15) for - * [wallet]. Best effort: a network before protocol version 15 has no such - * store. Private requests reach the wallet only through this read, so it - * runs with every pull-to-refresh of the request list. + * [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, -) { - runCatching { wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) } +): String? = try { + val result = wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) + PrivateContactRefreshNotice.notice(result.unpersistedIdentityCount) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + PrivateContactRefreshNotice.notice(e) } /** 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..e398f8ee37f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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), + ) + } + + @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), + ) + } +} From 89d2330796a78010002ec49a68943fec3726c27e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:21:24 +0700 Subject: [PATCH 10/35] docs(kotlin-sdk): record the pending, refresh and build-without-shielded behaviour in the private contact parity entry Both hosts' reasons now say the in-kind accept works without shielded support for a public or adopted reply, codes 68 to 70 are typed, and the example apps keep an unconfirmed private submission pending and report private refresh failures. The entry cites the FFI test of an accept on a manager without shielded support and both apps' outcome tests. Statuses are unchanged, so `PARITY_SUMMARY.md` regenerates identical. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index f8e76467ca8..e8126c7a963 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -795,13 +795,13 @@ "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), the explicit private-reply accept and PrivateContactInfo, all signed with the identity signer the public calls take; code 68 is 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. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh, with no recorded gate." + "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 and PrivateContactInfo, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 70 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. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed private send or reply pending, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. 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, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh; it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests 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 70 map to typed PlatformWallet errors (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. 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, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed reply pending, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." } }, "verification": [ @@ -821,6 +821,14 @@ "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", @@ -837,6 +845,14 @@ "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", @@ -845,6 +861,14 @@ "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", From 34cc1e78ccfd7be3a1415e6c52d3ad9b84e8c535 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:26:07 +0700 Subject: [PATCH 11/35] feat(kotlin-sdk): read a contact's private requests and pending reply, and type code 71 - JNI and `DashpayNative` bridge `managed_identity_get_private_contact` (a compact JSON object, null when nothing went privately, as Swift's nil) and `managed_identity_get_private_reply_pending`. - `Dashpay.privateContact(ourIdentityId, contactId)` and `Dashpay.privateRepliesPending(ourIdentityId, contactIds)` read them from a fresh managed-identity snapshot. The second lets a host disable a second send or accept from wallet-owned state. Ports of Swift's `ManagedIdentity.privateContact(contactId:)` and `privateReplyPending(contactId:)`, which the Swift app now calls. - `DashSdkError.PlatformWallet.PrivateContactRequestPending` (71), pinned in `DashSdkErrorTest`. - `FfiSmokeTest` binds both new symbols. The JNI crate pins the JSON layout Kotlin decodes. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 8 +- .../dashsdk/errors/DashSdkError.kt | 13 ++ .../dashsdk/ffi/DashpayNative.kt | 17 +++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 85 ++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 11 ++ packages/rs-unified-sdk-jni/src/dashpay.rs | 128 +++++++++++++++++- 6 files changed, 257 insertions(+), 5 deletions(-) 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 8a58af68e37..1285795cc2f 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 @@ -96,7 +96,7 @@ class FfiSmokeTest { * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. */ /** - * DashPay private contact requests (protocol version 15): the four + * DashPay private contact requests (protocol version 15): the six * `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 @@ -127,6 +127,12 @@ class FfiSmokeTest { 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)) + } } @Test 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 c2643aa4924..e8dca35cecb 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 @@ -688,6 +688,18 @@ sealed class DashSdkError( 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) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -889,6 +901,7 @@ sealed class DashSdkError( 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive + 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending 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 4096e78c2e4..3d1a97ce53f 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 @@ -336,6 +336,23 @@ internal object DashpayNative { * result as for [acceptPrivateContactRequest]. * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. */ + /** + * 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 + @Suppress("LongParameterList") external fun acceptContactRequestReplyingInKind( managerHandle: Long, 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 6d777e671db..bc36e55427a 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 @@ -287,6 +287,45 @@ class Dashpay internal constructor(private val walletHandle: Long, // 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 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. */ @@ -335,6 +374,52 @@ class Dashpay internal constructor(private val walletHandle: Long, ) } + /** + * 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) + } + } + + /** + * 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. Rust keeps it in + * memory only, so it is empty after a relaunch. + * ← 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 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 44501c20ea8..a4a39f7f994 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 @@ -296,6 +296,17 @@ class DashSdkErrorTest { 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) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 025a892171d..e2d51979e07 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -23,15 +23,17 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jlongArray, jobject, jstring}; +use jni::sys::{jboolean, 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; +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::{ContactRequestHandleArray, PrivateContactSyncReportFFI}; +use platform_wallet_ffi::{ + ContactRequestHandleArray, PrivateContactFFI, PrivateContactSyncReportFFI, +}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; @@ -1313,6 +1315,102 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPri }) } +/// 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 + } + }) +} + /// 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>, ()> { @@ -1542,7 +1640,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptC #[cfg(test)] mod tests { use super::*; - use platform_wallet_ffi::error::PlatformWalletFFIResultCode; // The natives above need a JVM; the counter layout Kotlin decodes and the // handle release are pure and are pinned here. @@ -1615,6 +1712,29 @@ mod tests { 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(); From f5a8e76c07e59dd31431717c55ea23d997c3f645 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:26:07 +0700 Subject: [PATCH 12/35] fix(kotlin-example-app): read a pending private reply from the wallet As the Swift app does: the Requests rows and Add Contact's Send and collision accept read whether the wallet has a private request to the contact in flight or not yet read back (`privateRepliesPending`), instead of keeping an unconfirmed accept in a `remember` set. While it is set, the row shows pending without its buttons, and Send and the collision accept are off. It clears when a private sync settles it and holds when the screen is left and reopened. Requests re-reads it on composition and row changes, after an accept, on refresh and after each sync pass; Add Contact re-reads it for the resolved recipient and after an accept. `PrivateContactRequestPending` (71) is the same outcome as an unconfirmed spend: pending, no fresh submission. `ContactRequestOutcomesTest` covers it and the row message. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 24 +++++++++- .../ui/dashpay/ContactRequestOutcomes.kt | 26 +++++++---- .../ui/dashpay/ContactRequestsScreen.kt | 45 ++++++++++++++----- .../example/ui/dashpay/DashPaySyncHelpers.kt | 22 +++++++++ .../ui/dashpay/ContactRequestOutcomesTest.kt | 22 +++++++++ 5 files changed, 118 insertions(+), 21 deletions(-) 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 1a06c468a46..9c47c5149c6 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 @@ -102,6 +102,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // 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; a private sync settles + // it. Read from the wallet, so reopening this screen keeps it. + var recipientPending by remember { mutableStateOf(false) } + var pendingReadVersion by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -112,7 +118,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && - unconfirmedNotice == null + unconfirmedNotice == null && !recipientPending // 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). @@ -173,6 +179,12 @@ 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 recipient = resolvedRecipient + val w = wallet + recipientPending = recipient != null && w != null && + recipient.toHex() in privateRepliesPending(w, idBytes, listOf(recipient)) + } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient val w = wallet @@ -219,6 +231,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return + // A private reply to this contact is already pending in the wallet. + if (recipientPending) return isSending = true errorMessage = null scope.launch { @@ -248,6 +262,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } } finally { isSending = false + pendingReadVersion++ } } } @@ -347,6 +362,13 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { enabled = canSend, modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), ) { attemptSend() } + if (recipientPending) { + Text( + ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("dashpay.addContact.pending"), + ) + } } if (errorMessage != null) { 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 index 97fd72c29e8..eeb67a10f60 100644 --- 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 @@ -7,8 +7,10 @@ import org.dashfoundation.dashsdk.errors.DashSdkError * 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. Every other error is an ordinary - * failure the user may retry. + * 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) + * is the same outcome: nothing new was paid, and the next private sync + * settles it. Every other error is an ordinary failure the user may retry. * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). */ sealed interface ContactRequestSubmissionOutcome { @@ -22,12 +24,20 @@ sealed interface ContactRequestSubmissionOutcome { 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." - fun of(error: Throwable): ContactRequestSubmissionOutcome = - if (error is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { - MayHaveGoneThrough - } else { - Failed(error.message ?: "unknown error") - } + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed, + is DashSdkError.PlatformWallet.PrivateContactRequestPending, + -> MayHaveGoneThrough + else -> Failed(error.message ?: "unknown error") + } + + /** + * The message a request row shows: the pending notice while the + * wallet has a private reply to the contact pending, whatever error an + * earlier attempt left, and that error otherwise. + */ + fun rowMessage(pending: Boolean, error: String?): String? = + if (pending) MAY_HAVE_GONE_THROUGH_MESSAGE else 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 5a102240207..817e30e67d6 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,10 +92,14 @@ 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 whose accept may have gone through (a private reply whose result - // could not be confirmed): the row stays pending, without its buttons, - // until the next sync settles it. Never offered again here. - var unconfirmedIds by remember { mutableStateOf(emptySet()) } + // 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. + var walletPendingIds by remember { mutableStateOf(emptySet()) } + 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) } @@ -153,6 +157,22 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle .sortedByDescending { it.createdAtMillis } } + LaunchedEffect(wallet, rows, pendingReadVersion) { + val w = wallet + walletPendingIds = if (w == null) { + emptySet() + } else { + privateRepliesPending( + wallet = w, + ourIdentityId = idBytes, + contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId }, + ) + } + } + LaunchedEffect(manager) { + manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } + } + fun accept(contactId: ByteArray) { val w = wallet ?: return val m = manager ?: return @@ -177,8 +197,8 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle when (val outcome = ContactRequestSubmissionOutcome.of(e)) { ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { // As the send screen treats an unconfirmed spend: not - // a failure to retry. The next sync settles it. - unconfirmedIds = unconfirmedIds + hex + // a failure to retry. The wallet keeps the reply + // pending, and the row reads that. kickDashPaySync(scope, m) } is ContactRequestSubmissionOutcome.Failed -> @@ -186,6 +206,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } } finally { inFlightIds = inFlightIds - hex + pendingReadVersion++ } } } @@ -227,6 +248,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle manager?.let { m -> attachOrStartSync(m) wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } + pendingReadVersion++ } isRefreshing = false } @@ -263,12 +285,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex) || unconfirmedIds.contains(hex), - errorMessage = if (unconfirmedIds.contains(hex)) { - ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE - } else { - rowErrors[hex] - }, + isInFlight = inFlightIds.contains(hex) || walletPendingIds.contains(hex), + errorMessage = ContactRequestSubmissionOutcome.rowMessage( + pending = walletPendingIds.contains(hex), + 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 1e48f973385..658fe499add 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 @@ -6,6 +6,7 @@ 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 @@ -35,6 +36,27 @@ 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 private send + * or reply to it, so the screens show it as pending and offer neither; a + * private sync settles it. Empty when the read fails or the identity is not + * managed. + * ← Swift `privateRepliesPending` (`ContactsView.swift`). + */ +suspend fun privateRepliesPending( + wallet: ManagedPlatformWallet, + ourIdentityId: ByteArray, + contactIds: List, +): Set = try { + wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + emptySet() +} + /** * Read DashPay's private contact request store (protocol version 15) for * [wallet], returning what the refresh should tell the user, or null. 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 index e398f8ee37f..1a1457161d0 100644 --- 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 @@ -21,6 +21,28 @@ class ContactRequestOutcomesTest { ) } + @Test + fun shouldKeepAPendingPrivateRequestPending() { + val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") + assertEquals( + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldShowAWalletPendingReplyOverAnEarlierError() { + assertEquals( + ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, error = "Accept failed: boom"), + ) + assertEquals( + "Accept failed: boom", + ContactRequestSubmissionOutcome.rowMessage(pending = false, error = "Accept failed: boom"), + ) + assertNull(ContactRequestSubmissionOutcome.rowMessage(pending = false, error = null)) + } + @Test fun shouldOfferARetryForAnOrdinaryFailure() { val error = DashSdkError.PlatformWallet.ShieldedBroadcastFailed("rejected by the relay") From 2890ed8580079c4b7cf58b754c2d83c730f91446 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:26:08 +0700 Subject: [PATCH 13/35] docs(kotlin-sdk): track the private contact and pending reads in the private contact parity entry `dashpay.private_contact_requests` lists `managed_identity_get_private_reply_pending`. Both hosts' reasons now say the private contact and pending reads are wrapped (Kotlin now bridges them), codes 68 to 71 are typed, and the example apps read the wallet's pending flag. The entry cites the FFI getter test and the Kotlin pending outcome test. Statuses are unchanged, so `PARITY_SUMMARY.md` regenerates identical. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index e8126c7a963..a7a6cd1510f 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -31,6 +31,7 @@ "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_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", @@ -777,14 +778,15 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact read, code 68 routing", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 71", "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_contact", + "managed_identity_get_private_reply_pending" ], "required_persistence_capabilities": [ "atomic_changesets", @@ -795,13 +797,13 @@ "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 and PrivateContactInfo, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 70 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. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed private send or reply pending, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." + "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 71 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, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests 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 70 map to typed PlatformWallet errors (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. 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, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed reply pending, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." + "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 71 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, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." } }, "verification": [ @@ -829,6 +831,14 @@ "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", @@ -869,6 +879,14 @@ "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", From aa341f9c3b8cd0456a046ee9019a78e6f4a4b874 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:07 +0700 Subject: [PATCH 14/35] feat(kotlin-sdk,unified-sdk-jni): persist the private contact request scan state The Android half of `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14): each identity's read position in DashPay's private contact request store, and its private contacts' shielded addresses, survive a relaunch, so the private sync resumes where it stopped. JNI: the three scan-state extension slots, wired only for a bridge that overrides both `onPersistPrivateContactScanState` and `onLoadPrivateContactScanState`. Rows cross as flat packed `byte[]`s, as the backfill cover set does; a load whose arrays are not whole rows, or that throws, fails the load. Kotlin: Room v16 adds `private_contact_requests_positions` and `private_shielded_addresses` (migration 15 to 16). The handler stages the writes into the round, clears the state of identities the round removed, purges it with the wallet, and declares the capability. Co-Authored-By: Claude Opus 5.5 --- .../persistence/DashDatabaseMigrationTest.kt | 55 ++- .../dashsdk/ffi/NativePersistenceBridge.kt | 55 +++ .../dashsdk/persistence/DashDatabase.kt | 38 ++- .../PlatformWalletPersistenceHandler.kt | 119 ++++++- .../dao/PrivateContactScanStateDao.kt | 44 +++ .../PrivateContactScanStateEntities.kt | 46 +++ .../dashsdk/wallet/PlatformWalletManager.kt | 9 + .../PlatformWalletPersistenceHandlerTest.kt | 5 +- .../PrivateContactScanStatePersistenceTest.kt | 167 +++++++++ .../rs-unified-sdk-jni/src/persistence.rs | 322 +++++++++++++++++- 10 files changed, 850 insertions(+), 10 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt 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..7fb787bbf26 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,60 @@ 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 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))) + } + 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 +756,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 +782,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/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 7d6ba3275c5..f55707de2b9 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,27 @@ 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) and the key + * fingerprint (32); 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 + 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 +492,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 +952,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/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 0283ac2904f..5ef6dfb6a7c 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,27 @@ 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, " + + "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 +781,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..912fa65111d 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 @@ -3650,6 +3701,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 +4188,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 +4609,67 @@ 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), + ) + } + +/** 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 row whose stored bytes have the wrong length is + * skipped rather than handed back misaligned. + */ +internal fun packPrivatePositions(rows: List): ByteArray { + val kept = rows.filter { it.identityId.size == 32 && it.keyFingerprint.size == 32 } + val buffer = java.nio.ByteBuffer + .allocate(kept.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (row in kept) { + buffer.put(row.identityId).putLong(row.position).put(row.keyFingerprint) + } + return buffer.array() +} + +/** See [packPrivatePositions]. */ +internal fun packPrivateAddresses(rows: List): ByteArray { + val kept = rows.filter { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 } + val buffer = java.nio.ByteBuffer.allocate(kept.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) + for (row in kept) { + 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..c2cabddcd23 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt @@ -0,0 +1,46 @@ +package org.dashfoundation.dashsdk.persistence.entities + +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, +) + +/** + * 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/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 35c333012e0..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 @@ -79,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 } } 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..013d1971d9b --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -0,0 +1,167 @@ +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.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): ByteArray = + java.nio.ByteBuffer.allocate(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .put(identityId).putLong(position).put(fingerprint) + .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) + + // 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 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-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index b3b3b55d40d..cc469d508aa 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,17 @@ 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, + ), }) } @@ -300,6 +311,10 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, + /// Both `onPersistPrivateContactScanState` and + /// `onLoadPrivateContactScanState`: an inherited no-op store would + /// acknowledge a read position it never kept. + private_contact_scan_state: bool, } /// The extension slots wired for a bridge with `overrides` (see @@ -320,6 +335,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 +1108,216 @@ 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`, 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; +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 +} + +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..].try_into().ok()?, + }) + }) + .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, @@ -5136,6 +5370,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 +5382,90 @@ 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], + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 12, + key_fingerprint: [4; 32], + }, + ]; + 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()); + 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 From bb6b8eca070739a9bed24f581ce4e6fb530b80b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:08 +0700 Subject: [PATCH 15/35] docs(kotlin-sdk): attach the in-kind accept KDoc to its declaration The block documenting `acceptContactRequestReplyingInKind` sat above `managedIdentityPrivateContact`; it now sits directly above the accept's `@Suppress`. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/ffi/DashpayNative.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 3d1a97ce53f..90583e0f03f 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 @@ -328,14 +328,6 @@ internal object DashpayNative { signerHandle: Long, ): Long - /** - * 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`. - */ /** * What a managed identity's private contact requests with [contactId] * carry: a JSON object (`receivedPrivately`, `sentPrivately`, @@ -353,6 +345,14 @@ internal object DashpayNative { */ external fun managedIdentityPrivateReplyPending(identityHandle: Long, contactId: ByteArray): Boolean + /** + * 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, From 8dd88f80d0d987a0ef36bdedf4f3693b1d984545 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:08 +0700 Subject: [PATCH 16/35] fix(kotlin-example-app): keep a failed pending read apart from nothing pending `privateRepliesPending` caught a failed wallet read and returned an empty set, so Requests brought back Accept and Ignore, and Add Contact enabled Send, with nothing showing the pending request had cleared. The read now returns null on a failure, and `PrivateRepliesPendingSnapshot` keeps the last successful snapshot and leaves those controls off for every contact until a read covering it succeeds. An unmanaged identity still reads as nothing pending. A pending private request (code 71), which the first public send or accept since load can now meet, gets its own outcome: nothing was sent, so Add Contact shows it as pending and offers "Sync private requests", after which Send comes back for the user to try again. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 71 ++++++++++++--- .../ui/dashpay/ContactRequestOutcomes.kt | 89 +++++++++++++++---- .../ui/dashpay/ContactRequestsScreen.kt | 35 ++++---- .../example/ui/dashpay/DashPaySyncHelpers.kt | 28 +++--- .../ui/dashpay/ContactRequestOutcomesTest.kt | 59 +++++++++++- 5 files changed, 225 insertions(+), 57 deletions(-) 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 9c47c5149c6..883b985fa84 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 @@ -104,9 +104,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { 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; a private sync settles - // it. Read from the wallet, so reopening this screen keeps it. - var recipientPending by remember { mutableStateOf(false) } + // 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) } @@ -118,7 +120,9 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && - unconfirmedNotice == null && !recipientPending + unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex()) + val recipientBlocked = resolvedRecipient != null && + (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). @@ -180,10 +184,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // identities won't have one). val recipientHex = resolvedRecipient?.toHex() LaunchedEffect(recipientHex, wallet, pendingReadVersion) { - val recipient = resolvedRecipient - val w = wallet - recipientPending = recipient != null && w != null && - recipient.toHex() in privateRepliesPending(w, idBytes, listOf(recipient)) + val contactIds = listOfNotNull(resolvedRecipient) + recipientPending = recipientPending.recorded( + privateRepliesPending(wallet, idBytes, contactIds), + contactIds.map { it.toHex() }, + ) } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient @@ -221,9 +226,38 @@ 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: the screen shows the pending request + // and offers the private sync that settles it. + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + 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++ } } } @@ -231,8 +265,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return - // A private reply to this contact is already pending in the wallet. - if (recipientPending) 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 { @@ -257,6 +291,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE } + // Nothing was sent: the screen offers the private sync. + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit is ContactRequestSubmissionOutcome.Failed -> errorMessage = "Accept failed: ${outcome.message}" } @@ -362,12 +398,21 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { enabled = canSend, modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), ) { attemptSend() } - if (recipientPending) { + if (recipientBlocked) { Text( - ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage( + pending = recipientPending.isPending(recipient.toHex()), + unavailable = recipientPending.isUnavailable, + error = null, + ).orEmpty(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("dashpay.addContact.pending"), ) + TextButton( + onClick = { syncPrivateRequests() }, + enabled = !isSyncingPrivate, + modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), + ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } } } 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 index eeb67a10f60..e0c2cafe1a7 100644 --- 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 @@ -3,20 +3,25 @@ package org.dashfoundation.example.ui.dashpay import org.dashfoundation.dashsdk.errors.DashSdkError /** - * How a contact request 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) - * is the same outcome: nothing new was paid, and the next private sync - * settles it. Every other error is an ordinary failure the user may retry. + * 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. 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 + /** It failed; the message is the error's. */ data class Failed(val message: String) : ContactRequestSubmissionOutcome @@ -24,20 +29,74 @@ sealed interface ContactRequestSubmissionOutcome { 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." + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { - is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed, - is DashSdkError.PlatformWallet.PrivateContactRequestPending, - -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending else -> Failed(error.message ?: "unknown error") } /** * The message a request row shows: the pending notice while the - * wallet has a private reply to the contact pending, whatever error an - * earlier attempt left, and that error otherwise. + * wallet has a private request to the contact pending, the + * unavailable notice while the wallet could not be read, whatever + * error an earlier attempt left, and that error otherwise. */ - fun rowMessage(pending: Boolean, error: String?): String? = - if (pending) MAY_HAVE_GONE_THROUGH_MESSAGE else error + fun rowMessage(pending: Boolean, unavailable: Boolean = false, error: String?): String? = when { + 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. + * ← 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, +) { + /** 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 { + PrivateRepliesPendingSnapshot(read, contactIds.toSet(), available = true) + } + + /** Whether the last successful read found a private request to [contactId] pending. */ + fun isPending(contactId: String): Boolean = contactId in pending + + /** + * 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 && contactId !in pending + + companion object { + const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + + "contact is pending. Sync private requests to check again." } } 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 817e30e67d6..976b7f91435 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 @@ -97,8 +97,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle // 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. - var walletPendingIds by remember { mutableStateOf(emptySet()) } + // 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) } @@ -158,16 +160,13 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } LaunchedEffect(wallet, rows, pendingReadVersion) { - val w = wallet - walletPendingIds = if (w == null) { - emptySet() - } else { - privateRepliesPending( - wallet = w, - ourIdentityId = idBytes, - contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId }, - ) - } + 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() }) } LaunchedEffect(manager) { manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } @@ -195,10 +194,13 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PrivateRequestPending, + -> { // As the send screen treats an unconfirmed spend: not // a failure to retry. The wallet keeps the reply - // pending, and the row reads that. + // pending, and the row reads that; a sync (pull to + // refresh reads the private store) settles it. kickDashPaySync(scope, m) } is ContactRequestSubmissionOutcome.Failed -> @@ -285,9 +287,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex) || walletPendingIds.contains(hex), + isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), errorMessage = ContactRequestSubmissionOutcome.rowMessage( - pending = walletPendingIds.contains(hex), + pending = walletPending.isPending(hex), + unavailable = walletPending.isUnavailable, error = rowErrors[hex], ), onAccept = { accept(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 658fe499add..2b83428721e 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 @@ -39,22 +39,28 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { /** * 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 private send - * or reply to it, so the screens show it as pending and offer neither; a - * private sync settles it. Empty when the read fails or the identity is not - * managed. + * 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, + wallet: ManagedPlatformWallet?, ourIdentityId: ByteArray, contactIds: List, -): Set = try { - wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() -} catch (e: CancellationException) { - throw e -} catch (e: Exception) { - emptySet() +): 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 + } } /** 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 index 1a1457161d0..1d1cffae97f 100644 --- 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 @@ -2,7 +2,9 @@ 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 /** @@ -21,11 +23,16 @@ class ContactRequestOutcomesTest { ) } + /** + * 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.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PrivateRequestPending, ContactRequestSubmissionOutcome.of(error), ) } @@ -33,16 +40,64 @@ class ContactRequestOutcomesTest { @Test fun shouldShowAWalletPendingReplyOverAnEarlierError() { assertEquals( - ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + 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 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") From 0efc8875a45d935e19eb9b2b61d6283b7a858155 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:01:04 +0700 Subject: [PATCH 17/35] chore(kotlin-sdk): export Room schema 16 The schema the Room compiler exports for version 16, with the two private contact request scan state tables; `DashDatabaseMigrationTest` validates the 15 to 16 migration against it. Co-Authored-By: Claude Opus 5.5 --- .../16.json | 4277 +++++++++++++++++ 1 file changed, 4277 insertions(+) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json 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..8fc2d4e5725 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json @@ -0,0 +1,4277 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "cfad080e6fde9c3bc142ef0829b3af4b", + "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, 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 + } + ], + "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, 'cfad080e6fde9c3bc142ef0829b3af4b')" + ] + } +} \ No newline at end of file From 808bb9b938b6b33ce669cfefdb1a14d0b2e9668f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:01:04 +0700 Subject: [PATCH 18/35] docs(sdk): record the private contact request scan state in the parity manifest Declares the `private_contact_request_scan_state` persistence capability, adds `dashpay.private_contact_request_scan_state` with the three extension slots and the tests on each layer, and notes the unavailable pending state and the code 71 sync offer in the private contact request entry. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 79 ++++++++++++++++++++++++++- packages/kotlin-sdk/PARITY_SUMMARY.md | 15 ++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index a7a6cd1510f..5c0277b3646 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", @@ -32,10 +33,13 @@ "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", @@ -797,13 +801,13 @@ "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 71 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, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." + "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 71 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." }, "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 71 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, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." + "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 71 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." } }, "verification": [ @@ -913,6 +917,77 @@ } ] }, + { + "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 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 rows cross as packed byte arrays. 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/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 5b6a2af0178..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: **28** +Capabilities tracked: **29** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 16 | 10 | 1 | 1 | -| Swift | Example app | 4 | 13 | 2 | 9 | -| Kotlin | SDK | 13 | 13 | 1 | 1 | -| Kotlin | Example app | 5 | 13 | 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 | 9 | 18 | -| Kotlin | 4 | 7 | 17 | +| Swift | 1 | 10 | 18 | +| Kotlin | 4 | 8 | 17 | ## Capability status @@ -34,6 +34,7 @@ Capabilities tracked: **28** | `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 | From 31d393e4ab55e5eb7597064c33eed4dec08d76c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:08:05 +0700 Subject: [PATCH 19/35] test(kotlin-sdk): expect Room schema 16 in the schema version test The scan state migration (15 -> 16) moved the schema to version 16; the schema version unit test still asserted 15. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/persistence/DashDatabaseTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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") From 4f2b553d66508bf18e2e62f378f9ddc0e03c2bfa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 20/35] fix(kotlin-sdk): fail a scan-state load on a stored row that cannot cross the ABI Room keeps a blob of any length, and the load packer filtered out rows of the wrong length while handing back the rest: a 42-byte address vanished while its identity's read position was restored, so the sync resumed past the entry that carried it. A malformed row now throws, which fails the load through `loadOrThrow`; what to do about such a store is the wallet's call, not the adapter's. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 22 ++++++++++++------- .../PrivateContactScanStatePersistenceTest.kt | 21 ++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) 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 912fa65111d..5703d67f6d4 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 @@ -4646,15 +4646,19 @@ internal fun unpackPrivateAddresses( /** * [rows] packed as [NativePersistenceBridge.onLoadPrivateContactScanState] - * hands them back. A row whose stored bytes have the wrong length is - * skipped rather than handed back misaligned. + * 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 { - val kept = rows.filter { it.identityId.size == 32 && it.keyFingerprint.size == 32 } + 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(kept.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .allocate(rows.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) .order(java.nio.ByteOrder.LITTLE_ENDIAN) - for (row in kept) { + for (row in rows) { buffer.put(row.identityId).putLong(row.position).put(row.keyFingerprint) } return buffer.array() @@ -4662,9 +4666,11 @@ internal fun packPrivatePositions(rows: List): ByteArray { - val kept = rows.filter { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 } - val buffer = java.nio.ByteBuffer.allocate(kept.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) - for (row in kept) { + 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() 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 index 013d1971d9b..730ce0820ef 100644 --- 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 @@ -132,6 +132,27 @@ class PrivateContactScanStatePersistenceTest { 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) + } + } + /** A partial row fails the callback instead of storing a misread row. */ @Test fun shouldRefuseARoundWithAPartialRow() { From 622677caa09ded344b61beef8e3648eadad67d30 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 21/35] fix(unified-sdk-jni): tie the scan-state slots to contact persistence and check their descriptors The scan-state slots are now wired only for a bridge that also overrides `onPersistContactUpsert`: a read position vouches for the contact requests read before it, which an inherited no-op upsert never keeps. Both scan-state callbacks join `BRIDGE_METHOD_TABLE`, so the descriptor smoke check resolves them against the compiled Kotlin methods; a drift would otherwise drop the slots and the capability without a failure. Co-Authored-By: Claude Opus 5.5 --- .../rs-unified-sdk-jni/src/persistence.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index cc469d508aa..e0e243ea233 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -254,6 +254,11 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence bridge, "onLoadPrivateContactScanState", LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onPersistContactUpsert", + CONTACT_UPSERT_DESCRIPTOR, ), }) } @@ -311,9 +316,11 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, - /// Both `onPersistPrivateContactScanState` and - /// `onLoadPrivateContactScanState`: an inherited no-op store would - /// acknowledge a read position it never kept. + /// `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, } @@ -5148,6 +5155,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;\ From 69c83102b6b682342c73e0097ffa1c29e604db8f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 22/35] docs(kotlin-sdk): describe the restored private scan state and pending replies `syncPrivateContactRequests` said the store cursors live in memory only; they are restored where the persister attests `PRIVATE_CONTACT_REQUEST_SCAN_STATE`, with the full read as the fallback. `privateRepliesPending` is restored too once the shielded store is configured after the load or the wallet binds. Co-Authored-By: Claude Opus 5.5 --- .../org/dashfoundation/dashsdk/tokens/Dashpay.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 bc36e55427a..fcc43fb9c49 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 @@ -350,8 +350,11 @@ class Dashpay internal constructor(private val walletHandle: Long, * alone. The ingested requests reach Room through the persister. * * The store is opened with keys derived inside [coreSignerHandle], the - * manager's `MnemonicResolverHandle`. The store cursors live in memory - * only, so the first pass after launch reads the store from its start. + * 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:)`. */ @@ -403,8 +406,10 @@ class Dashpay internal constructor(private val walletHandle: Long, * 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. Rust keeps it in - * memory only, so it is empty after a relaunch. + * 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). */ From f0d7c95a13e72592eff44e641884b73e31c6fc6b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 23/35] fix(kotlin-example-app): configure the shielded store right after the load A wallet that bound a shielded account in an earlier process has its public contact requests refused (code 71) until Rust restores its unconfirmed private payments, which configuring the shielded store does with no keys. The app configured it only when binding, which needs the mnemonic; it now configures the same per-network path right after `loadPersistedWallets`. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/example/di/AppContainer.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) 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, From 6b3c4d4748de72148dc9fb42c38ed54d525f2404 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:47 +0700 Subject: [PATCH 24/35] fix(kotlin-example-app): keep a code 71 refusal visible until a private sync Code 71 does not mean the wallet holds a reservation for the contact: it is also returned, with nothing reserved, when the private store could not be read to its end before the first send or accept since load. Add Contact's public Send, its collision accept and the Requests rows dropped the refusal and read the pending state, which then found nothing and brought the controls back with no notice. `PrivateRepliesPendingSnapshot` now keeps the refused contact pending, with the private sync offered, until a private sync completes. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 18 +++++++++---- .../ui/dashpay/ContactRequestOutcomes.kt | 26 ++++++++++++++++--- .../ui/dashpay/ContactRequestsScreen.kt | 20 +++++++++----- .../ui/dashpay/ContactRequestOutcomesTest.kt | 24 +++++++++++++++++ 4 files changed, 73 insertions(+), 15 deletions(-) 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 883b985fa84..e051ae7bc7a 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 @@ -227,9 +227,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { navController.popBackStack() } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - // Nothing was sent: the screen shows the pending request - // and offers the private sync that settles it. - ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + // 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()) ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE @@ -254,7 +256,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { isSyncingPrivate = true scope.launch { try { - syncPrivateContactRequests(w, m)?.let { errorMessage = it } + val notice = syncPrivateContactRequests(w, m) + if (notice == null) { + recipientPending = recipientPending.afterPrivateSync() + } else { + errorMessage = notice + } } finally { isSyncingPrivate = false pendingReadVersion++ @@ -292,7 +299,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE } // Nothing was sent: the screen offers the private sync. - ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + ContactRequestSubmissionOutcome.PrivateRequestPending -> + recipientPending = recipientPending.withRefusal(recipient.toHex()) is ContactRequestSubmissionOutcome.Failed -> errorMessage = "Accept failed: ${outcome.message}" } 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 index e0c2cafe1a7..b89bb838a35 100644 --- 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 @@ -60,6 +60,12 @@ sealed interface ContactRequestSubmissionOutcome { * 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 a private sync completes. * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). */ data class PrivateRepliesPendingSnapshot( @@ -69,6 +75,8 @@ data class PrivateRepliesPendingSnapshot( 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 since the last completed private sync. */ + val refused: Set = emptySet(), ) { /** Whether the last read failed. */ val isUnavailable: Boolean get() = !available @@ -81,18 +89,28 @@ data class PrivateRepliesPendingSnapshot( if (read == null) { copy(available = false) } else { - PrivateRepliesPendingSnapshot(read, contactIds.toSet(), available = true) + copy(pending = read, covered = contactIds.toSet(), available = true) } - /** Whether the last successful read found a private request to [contactId] pending. */ - fun isPending(contactId: String): Boolean = contactId in pending + /** 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 completed private sync, which settles every refusal. */ + fun afterPrivateSync(): PrivateRepliesPendingSnapshot = copy(refused = emptySet()) + + /** + * 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 since the last completed private sync. + */ + 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 && contactId !in pending + available && contactId in covered && !isPending(contactId) companion object { const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + 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 976b7f91435..ce8ec14b110 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 @@ -194,13 +194,18 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - ContactRequestSubmissionOutcome.MayHaveGoneThrough, - ContactRequestSubmissionOutcome.PrivateRequestPending, - -> { + 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; a sync (pull to - // refresh reads the private store) settles it. + // 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 a private sync (pull to + // refresh) settles it. + walletPending = walletPending.withRefusal(hex) kickDashPaySync(scope, m) } is ContactRequestSubmissionOutcome.Failed -> @@ -249,7 +254,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isRefreshing = true manager?.let { m -> attachOrStartSync(m) - wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } + wallet?.let { + privateRefreshNotice = syncPrivateContactRequests(it, m) + if (privateRefreshNotice == null) walletPending = walletPending.afterPrivateSync() + } pendingReadVersion++ } isRefreshing = false 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 index 1d1cffae97f..f017c665153 100644 --- 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 @@ -82,6 +82,30 @@ class ContactRequestOutcomesTest { 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 a private sync completes. + */ + @Test + fun shouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + 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), + ) + + snapshot = snapshot.afterPrivateSync().recorded(emptySet(), listOf("01")) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + } + /** * A successful read that found nothing pending offers every contact it * covered, and nothing it did not. From 4763d418550841783f732b6545b0e738594310ac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:47 +0700 Subject: [PATCH 25/35] docs(sdk): note the scan-state load and contact dependency in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 5c0277b3646..775e27c0987 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -936,13 +936,13 @@ "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 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." + "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 rows cross as packed byte arrays. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." + "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": [ From c69b54985fa3e657ed622e12305f4e56507f0d3c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 26/35] feat(kotlin-sdk,unified-sdk-jni): store the unresolved private payment marker with the read position `private_contact_requests_positions` (Room 16, unreleased, so edited in place with its migration and exported schema) gains `privatePaymentsUnresolved`, 0 by default. A packed position row grows by one byte (0 or 1; any other value is not a row), JNI carries it to and from `PrivateContactRequestsPositionFFI`, and the handler stores it with the position and hands it back at load. Co-Authored-By: Claude Opus 5.5 --- .../16.json | 13 +++++++++--- .../persistence/DashDatabaseMigrationTest.kt | 5 ++++- .../dashsdk/ffi/NativePersistenceBridge.kt | 11 +++++----- .../dashsdk/persistence/DashDatabase.kt | 1 + .../PlatformWalletPersistenceHandler.kt | 6 ++++++ .../PrivateContactScanStateEntities.kt | 9 ++++++++ .../PrivateContactScanStatePersistenceTest.kt | 16 +++++++++++++- .../rs-unified-sdk-jni/src/persistence.rs | 21 ++++++++++++++++--- 8 files changed, 69 insertions(+), 13 deletions(-) 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 index 8fc2d4e5725..fcb3c307fa0 100644 --- 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 @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 16, - "identityHash": "cfad080e6fde9c3bc142ef0829b3af4b", + "identityHash": "df09e705e8b63a04af564a1257b4a2cd", "entities": [ { "tableName": "wallets", @@ -4195,7 +4195,7 @@ }, { "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, PRIMARY KEY(`walletId`, `identityId`))", + "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", @@ -4220,6 +4220,13 @@ "columnName": "keyFingerprint", "affinity": "BLOB", "notNull": true + }, + { + "fieldPath": "privatePaymentsUnresolved", + "columnName": "privatePaymentsUnresolved", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -4271,7 +4278,7 @@ ], "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, 'cfad080e6fde9c3bc142ef0829b3af4b')" + "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/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 7fb787bbf26..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 @@ -725,10 +725,13 @@ class DashDatabaseMigrationTest { "INSERT INTO private_shielded_addresses (walletId, ownerId, contactId, address) " + "VALUES (x'01', x'0A', x'0B', x'09')", ) - db.query("SELECT position, keyFingerprint FROM private_contact_requests_positions").use { c -> + 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()) 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 f55707de2b9..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 @@ -111,12 +111,13 @@ abstract class NativePersistenceBridge { /** * Packed row widths of [onPersistPrivateContactScanState] and * [onLoadPrivateContactScanState]: a read position is the identity - * id (32), the position (u64, little-endian) and the key - * fingerprint (32); 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. + * 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 + 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 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 5ef6dfb6a7c..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 @@ -740,6 +740,7 @@ abstract class DashDatabase : RoomDatabase() { "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( 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 5703d67f6d4..9a83af98e4d 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 @@ -4627,6 +4627,11 @@ internal fun unpackPrivatePositions( 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") + }, ) } @@ -4660,6 +4665,7 @@ internal fun packPrivatePositions(rows: List Vec 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 } @@ -1164,7 +1166,12 @@ fn unpack_private_positions(packed: &[u8]) -> Option false, + 1 => true, + _ => return None, + }, }) }) .collect() @@ -5454,11 +5461,13 @@ mod tests { 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 { @@ -5480,6 +5489,12 @@ mod tests { ); 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()); } From ab7751098c01572e064042d1b7eb6f8cb7b193a2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 27/35] feat(kotlin-sdk,unified-sdk-jni): type code 72 and read the wallet's private contact readiness `DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored` binds `ErrorPrivateContactPaymentsNotRestored` (72), apart from a contact's pending request (71). `Dashpay.privateContactReadiness` binds `platform_wallet_get_private_contact_readiness` through a new JNI export returning its three flags. Port of the Swift bindings on #5812. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 14 ++++ .../dashsdk/ffi/DashpayNative.kt | 11 +++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 52 +++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 11 +++ .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-unified-sdk-jni/src/dashpay.rs | 74 ++++++++++++++++++- 6 files changed, 162 insertions(+), 2 deletions(-) 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 e8dca35cecb..dfd4d170ca8 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 @@ -700,6 +700,19 @@ sealed class DashSdkError( 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) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -902,6 +915,7 @@ sealed class DashSdkError( 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending + 72 -> PlatformWallet.PrivateContactPaymentsNotRestored(message, cause) // ErrorPrivateContactPaymentsNotRestored 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 90583e0f03f..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 @@ -345,6 +345,17 @@ internal object DashpayNative { */ 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 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 fcc43fb9c49..4686b9f5458 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 @@ -326,6 +326,38 @@ class Dashpay internal constructor(private val walletHandle: Long, } } + /** + * 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. */ @@ -413,6 +445,26 @@ class Dashpay internal constructor(private val walletHandle: Long, * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). */ + /** + * 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], + ) + } + suspend fun privateRepliesPending( ourIdentityId: ByteArray, contactIds: List, 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 a4a39f7f994..66e08d7dcd7 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 @@ -307,6 +307,17 @@ class DashSdkErrorTest { 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) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 397aac4e358..dc292ed1afb 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -185,7 +185,7 @@ 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 | +| 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 | **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 e2d51979e07..8cc319d48c2 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -23,7 +23,9 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE}; +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; @@ -31,6 +33,7 @@ use platform_wallet_ffi::error::{platform_wallet_ffi_result_free, PlatformWallet 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, }; @@ -1411,6 +1414,61 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managed }) } +/// `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>, ()> { @@ -1703,6 +1761,20 @@ mod tests { 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 { From c67ac29a993e9a798833a1f74516c4dca3a7eca2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 28/35] fix(kotlin-example-app): show restoring private payments from the wallet's readiness The screens cleared every code 71 refusal once a private sync finished, though a sync releases neither a contact's reservation nor the wallet's unrestored payments. They now render the wallet's own state: - code 72 shows "restoring private payments" for every contact, apart from a pending request (71), and only a readiness read that says restored lifts it; a private sync is not offered for it; - a code 71 refusal stays pending until the readiness says the private store was read to its end, after which the pending read decides; - the readiness is read with the pending state and again when the shielded account binds. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 41 +++++++--- .../ui/dashpay/ContactRequestOutcomes.kt | 80 ++++++++++++++++--- .../ui/dashpay/ContactRequestsScreen.kt | 21 +++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 20 +++++ .../ui/dashpay/ContactRequestOutcomesTest.kt | 67 +++++++++++++++- 5 files changed, 194 insertions(+), 35 deletions(-) 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 e051ae7bc7a..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 @@ -122,7 +122,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex()) val recipientBlocked = resolvedRecipient != null && - (recipientPending.isPending(resolvedRecipient.toHex()) || recipientPending.isUnavailable) + ( + 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). @@ -188,7 +192,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { 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 @@ -232,6 +241,10 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // 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 @@ -256,12 +269,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { isSyncingPrivate = true scope.launch { try { - val notice = syncPrivateContactRequests(w, m) - if (notice == null) { - recipientPending = recipientPending.afterPrivateSync() - } else { - errorMessage = notice - } + syncPrivateContactRequests(w, m)?.let { errorMessage = it } } finally { isSyncingPrivate = false pendingReadVersion++ @@ -301,6 +309,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // 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}" } @@ -410,17 +420,22 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { 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"), ) - TextButton( - onClick = { syncPrivateRequests() }, - enabled = !isSyncingPrivate, - modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), - ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } + // 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") } + } } } 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 index b89bb838a35..b6e634b8617 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -11,8 +12,10 @@ import org.dashfoundation.dashsdk.errors.DashSdkError * 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. Every other error is an ordinary - * failure the user may retry. + * 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 { @@ -22,6 +25,9 @@ sealed interface 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 @@ -33,19 +39,30 @@ sealed interface ContactRequestSubmissionOutcome { "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." + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending + is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored else -> Failed(error.message ?: "unknown error") } /** - * The message a request row shows: the pending notice while the - * wallet has a private request to the contact pending, the - * unavailable notice while the wallet could not be read, whatever - * error an earlier attempt left, and that error otherwise. + * 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, unavailable: Boolean = false, error: String?): String? = when { + 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 @@ -65,7 +82,15 @@ sealed interface ContactRequestSubmissionOutcome { * 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 a private sync completes. + * 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( @@ -75,8 +100,12 @@ data class PrivateRepliesPendingSnapshot( 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 since the last completed private sync. */ + /** 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 @@ -95,13 +124,38 @@ data class PrivateRepliesPendingSnapshot( /** 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 completed private sync, which settles every refusal. */ - fun afterPrivateSync(): PrivateRepliesPendingSnapshot = copy(refused = emptySet()) + /** 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 since the last completed private sync. + * pending and the store has not been read to its end since. */ fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused @@ -110,7 +164,7 @@ data class PrivateRepliesPendingSnapshot( * read covered it and found nothing pending. */ fun allowsSubmission(contactId: String): Boolean = - available && contactId in covered && !isPending(contactId) + available && contactId in covered && !isPending(contactId) && !isRestoringPrivatePayments companion object { const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + 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 ce8ec14b110..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 @@ -167,6 +167,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle 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++ } @@ -203,11 +209,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle 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 a private sync (pull to - // refresh) settles it. + // 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}") } @@ -254,10 +265,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isRefreshing = true manager?.let { m -> attachOrStartSync(m) - wallet?.let { - privateRefreshNotice = syncPrivateContactRequests(it, m) - if (privateRefreshNotice == null) walletPending = walletPending.afterPrivateSync() - } + wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } pendingReadVersion++ } isRefreshing = false @@ -298,6 +306,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), errorMessage = ContactRequestSubmissionOutcome.rowMessage( pending = walletPending.isPending(hex), + restoring = walletPending.isRestoringPrivatePayments, unavailable = walletPending.isUnavailable, error = rowErrors[hex], ), 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 2b83428721e..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 @@ -63,6 +63,26 @@ suspend fun privateRepliesPending( } } +/** + * 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. 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 index f017c665153..a442884a53c 100644 --- 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 @@ -86,10 +86,10 @@ class ContactRequestOutcomesTest { * 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 a private sync completes. + * until the wallet's readiness says the store was read. */ @Test - fun shouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + fun shouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { var snapshot = PrivateRepliesPendingSnapshot() .withRefusal("01") .recorded(emptySet(), listOf("01")) @@ -101,11 +101,72 @@ class ContactRequestOutcomesTest { ContactRequestSubmissionOutcome.rowMessage(pending = snapshot.isPending("01"), error = null), ) - snapshot = snapshot.afterPrivateSync().recorded(emptySet(), listOf("01")) + // 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, + ) + + /** 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. From c5ccf424e366aaa84c982ee5ada879bc8a401ef0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 29/35] test(kotlin-sdk): exercise the scan-state overrides and gate shielded-only probes The scan-state slot test supplied the override decision to `extension_for` itself. A new JNI hook, `nativeEffectivePersistenceCapabilityBits`, builds a bridge's vtable and extension as manager creation does and returns the effective capability bits, and an instrumented test declares the scan-state capability with each required override (contact upsert, scan-state store, scan-state load) left out in turn: each loses it, while a bridge with all three and the Room handler keep it. The private contact symbol probe now calls the explicit private send and private-reply accept only in a shielded build (`Sdk.hasShielded()`), as `build_android.sh --no-shielded` does not export them; the sync, the reads, the new readiness read and the in-kind accept are probed in every build. The syncFaultDetected KDoc moves back onto its own test. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 215 ++++++++++++++++-- .../dashsdk/ffi/WalletManagerNative.kt | 10 + .../rs-unified-sdk-jni/src/wallet_manager.rs | 71 ++++++ 3 files changed, 276 insertions(+), 20 deletions(-) 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 1285795cc2f..bc4c9c40391 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 @@ -5,6 +5,7 @@ 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 @@ -12,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 @@ -88,22 +90,17 @@ class FfiSmokeTest { } /** - * The #4069 watermark-freeze fault latch surfaced by PR #4314: the - * `syncFaultDetected` JNI symbol binds, and an invalid manager handle - * surfaces as the mapped [DashSDKException] rather than a crash. The - * latch behavior itself (freeze on dropped events / rejected store) is - * covered by the shared Rust tests in - * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. - */ - /** - * DashPay private contact requests (protocol version 15): the six + * 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 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. + * 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() { @@ -111,16 +108,22 @@ class FfiSmokeTest { assertThrows(DashSDKException::class.java) { DashpayNative.syncPrivateContactRequests(0L, 0L, 1) } - assertThrows(DashSDKException::class.java) { - DashpayNative.sendPrivateContactRequest( - 0L, ByteArray(32), 0L, 0, ByteArray(32), ByteArray(32), - null, null, null, 0L, - ) + 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.acceptPrivateContactRequest( - 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, - ) + DashpayNative.privateContactReadiness(0L, ByteArray(32)) } assertThrows(DashSDKException::class.java) { DashpayNative.acceptContactRequestReplyingInKind( @@ -135,6 +138,14 @@ class FfiSmokeTest { } } + /** + * The #4069 watermark-freeze fault latch surfaced by PR #4314: the + * `syncFaultDetected` JNI symbol binds, and an invalid manager handle + * surfaces as the mapped [DashSDKException] rather than a crash. The + * latch behavior itself (freeze on dropped events / rejected store) is + * covered by the shared Rust tests in + * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. + */ @Test fun syncFaultDetectedSymbolBindsAndRejectsInvalidHandle() { NativeLoader.ensureLoaded() @@ -150,6 +161,44 @@ class FfiSmokeTest { * live, funded `create_invitation`. Resolving the whole table up front * turns a descriptor typo into a CI failure instead. */ + /** + * The private contact request scan state needs every override it + * depends on: 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). A bridge declaring the capability but + * leaving any one of them inherited is not attested; one implementing + * all three, and the Room handler, keep it. + */ + @Test + fun privateContactScanStateCapabilityNeedsEveryOverride() { + NativeLoader.ensureLoaded() + val bit = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + fun effective(bridge: NativePersistenceBridge): Boolean = + WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bit != 0L + + 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 + }), + ) + + val db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + try { + assertTrue( + "the Room handler", + effective(PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined)), + ) + } finally { + db.close() + } + } + @Test fun persistenceBridgeDescriptorsAllResolve() { NativeLoader.ensureLoaded() @@ -168,3 +217,129 @@ 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 + + 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 + } +} 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/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] From 7bc3a030da3e65f25ffd150dd03559a61503b446 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 30/35] docs(sdk): record code 72 and the readiness getter in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 775e27c0987..37d0a2b510d 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -46,6 +46,7 @@ "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", @@ -782,7 +783,7 @@ }, { "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 71", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 72, readiness", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", @@ -790,7 +791,8 @@ "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" + "managed_identity_get_private_reply_pending", + "platform_wallet_get_private_contact_readiness" ], "required_persistence_capabilities": [ "atomic_changesets", @@ -801,13 +803,13 @@ "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 71 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." + "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 72 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." }, "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 71 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." + "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 72 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." } }, "verification": [ From 46bd798dc23edbc7308144eb68a151d33ff4f5a2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:44 +0700 Subject: [PATCH 31/35] fix(kotlin-sdk): fail the round when a contact request cannot be stored The Room handler skipped a contact request (or an ignored sender) whose owner identity was not persisted and acknowledged the round. This store declares the private contact request scan state, so native then stored a read position past a request that was never saved, and no later scan would read it again. The staged write now throws, which fails the round at its end (or the callback outside a round): the read position stays where it was, and the next scan stores the request. Port of the Swift fix on #5812. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 20 ++++++--- .../PrivateContactScanStatePersistenceTest.kt | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) 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 9a83af98e4d..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 @@ -2231,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, @@ -2282,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, 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 index a195667339a..3d335775a28 100644 --- 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 @@ -167,6 +167,48 @@ class PrivateContactScanStatePersistenceTest { } } + 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() { From 3d82b5f74ea071a81d8429a144f3e87682f41ed6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 32/35] feat(kotlin-sdk): type code 73 and show it as a storage configuration error `DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported` binds `ErrorPrivateContactRecoveryUnsupported` (73). The Room handler declares atomic changesets and the scan state, and JNI wires the begin and end callbacks, so it should not occur; KotlinExampleApp shows it as "this storage backend can't protect private payments". Port of the Swift binding on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/ContactRequestOutcomes.kt | 9 +++++++++ .../ui/dashpay/ContactRequestOutcomesTest.kt | 11 +++++++++++ .../dashfoundation/dashsdk/errors/DashSdkError.kt | 13 +++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 11 +++++++++++ .../rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md | 2 +- 5 files changed, 45 insertions(+), 1 deletion(-) 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 index b6e634b8617..5afbfc297a6 100644 --- 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 @@ -42,10 +42,19 @@ sealed interface ContactRequestSubmissionOutcome { 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") } 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 index a442884a53c..0bac7ae22c4 100644 --- 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 @@ -117,6 +117,17 @@ class ContactRequestOutcomesTest { 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() { 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 dfd4d170ca8..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 @@ -713,6 +713,18 @@ sealed class DashSdkError( 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 @@ -916,6 +928,7 @@ sealed class DashSdkError( 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/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 66e08d7dcd7..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 @@ -318,6 +318,17 @@ class DashSdkErrorTest { 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/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 064bb550bb7..39e0de0cbe1 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -186,7 +186,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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`); 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 must type it | +| 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 From f0c3ada2f50b211fc742bb001efba31ee8ea495f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 33/35] test(kotlin-sdk): require atomic changesets with the scan state in the override test The JVM-backed capability test now checks the wallet's rule for keeping the private contact request scan state, the scan state with atomic changesets: a bridge declaring the scan state without atomic rounds has neither, beside the cases that leave an override inherited. JNI always wires the changeset begin and end callbacks, so the atomic half is the declaration. The descriptor-lockstep KDoc moves back onto `persistenceBridgeDescriptorsAllResolve`. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) 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 bc4c9c40391..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 @@ -154,28 +154,27 @@ class FfiSmokeTest { } } - /** - * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve - * each bridge method by (name, descriptor) only when its slot first - * fires — for the invitation upsert that would be mid-way through a - * live, funded `create_invitation`. Resolving the whole table up front - * turns a descriptor typo into a CI failure instead. - */ /** * The private contact request scan state needs every override it - * depends on: 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). A bridge declaring the capability but - * leaving any one of them inherited is not attested; one implementing - * all three, and the Room handler, keep 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 bit = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + val bits = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or + ATOMIC_CHANGESETS fun effective(bridge: NativePersistenceBridge): Boolean = - WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bit != 0L + WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bits == bits assertTrue("every override", effective(ScanStateBridge.Full())) assertFalse("no contact upsert", effective(ScanStateBridge.WithoutContactUpsert())) @@ -187,6 +186,13 @@ class FfiSmokeTest { 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 { @@ -199,6 +205,13 @@ class FfiSmokeTest { } } + /** + * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve + * each bridge method by (name, descriptor) only when its slot first + * fires — for the invitation upsert that would be mid-way through a + * live, funded `create_invitation`. Resolving the whole table up front + * turns a descriptor typo into a CI failure instead. + */ @Test fun persistenceBridgeDescriptorsAllResolve() { NativeLoader.ensureLoaded() @@ -228,7 +241,7 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { override fun persistenceCapabilitiesVersion(): Int = 1 override fun persistenceCapabilitiesBits(): Long = - CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or ATOMIC_CHANGESETS open class Full : ScanStateBridge() { @Suppress("LongParameterList") @@ -343,3 +356,6 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { ): Int = 0 } } + +/** `PersistenceCapabilities::ATOMIC_CHANGESETS` (bit 0). */ +private const val ATOMIC_CHANGESETS: Long = 1L From 47c02cb0746787c8c06afd08e0a0e5197660f49d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 34/35] docs(kotlin-sdk): attach the pending-replies KDoc to privateRepliesPending The block documenting `privateRepliesPending` sat above `privateContactReadiness`; it now sits directly above its own declaration. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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 4686b9f5458..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 @@ -431,20 +431,6 @@ class Dashpay internal constructor(private val walletHandle: Long, } } - /** - * 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). - */ /** * What a contact request from [ourIdentityId] waits for, as the wallet * decides it ([PrivateContactReadiness]). A pure read of live wallet @@ -465,6 +451,20 @@ class Dashpay internal constructor(private val walletHandle: Long, ) } + /** + * 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, From 2abc9b594effdce7b4d547f635d1e099aca4296c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 35/35] docs(sdk): record code 73 and the atomic requirement in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 37d0a2b510d..17e5a54f81c 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -783,7 +783,7 @@ }, { "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 72, readiness", + "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", @@ -803,13 +803,13 @@ "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 72 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." + "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 72 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." + "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": [