diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index aa26292d4b4..addcb451cca 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -8,6 +8,7 @@ "atomic_changesets", "invitations", "pending_contact_crypto", + "private_contact_request_scan_state", "provider_transactions", "shielded_viewing_keys", "tracked_asset_locks", @@ -30,18 +31,31 @@ "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_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_private_contact_scan_state_wallets_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_load_private_contact_scan_state_wallets_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_dashpay_payments_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_persist_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "platform_address_wallet_addresses_with_balances": "packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs", "platform_wallet_claim_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_create_document_with_signer": "packages/rs-platform-wallet-ffi/src/document.rs", "platform_wallet_create_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", + "platform_wallet_get_private_contact_readiness": "packages/rs-platform-wallet-ffi/src/contact_request.rs", + "platform_wallet_get_private_pair_held": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_get_private_payments_restore_failure": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_get_public_contact_request_unconfirmed": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_manager_accept_contact_request_replying_in_kind": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_manager_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", + "platform_wallet_manager_send_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_shielded_fund_from_wallet_drain": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_shielded_wallet_drain_preview": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_shielded_wallet_drain_status": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", @@ -49,6 +63,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 +785,275 @@ } ] }, + { + "id": "dashpay.private_contact_requests", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact, pair-hold and public-unconfirmed reads, codes 68 to 76, readiness and restore failure", + "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", + "platform_wallet_get_private_pair_held", + "platform_wallet_get_public_contact_request_unconfirmed", + "platform_wallet_get_private_contact_readiness", + "platform_wallet_get_private_payments_restore_failure" + ], + "required_persistence_capabilities": [ + "atomic_changesets", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 76 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; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), 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 and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a timer for as long as the view is shown while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, 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. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered. A public send or accept that returned 20 is held by the wallet until a DashPay sync settles it, and every later one to the pair is refused with code 76 (typed as publicContactRequestUnconfirmed): the example app reads that hold per pair (publicContactRequestUnconfirmed) beside the private one, shows the pair as being checked with no request or accept offered until the read says settled, shows 20 and 76 the same way at once, and runs the DashPay sync on the request list's refresh and Add Contact's Check again, reading again when any sync pass ends; no gate records it against a network." + }, + "kotlin": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, 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 76 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 a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, 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 and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a timer for as long as the screen is shown while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, 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 (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered. A public send or accept that returned 20 is held by the wallet until a DashPay sync settles it, and every later one to the pair is refused with code 76 (typed as PublicContactRequestUnconfirmed): Dashpay.publicContactRequestsUnconfirmed reads that hold per pair, and the example app reads it beside the private one, shows the pair as being checked with no request or accept offered until the read says settled, shows 20 and 76 the same way at once, and runs the DashPay sync on the Requests refresh and Add Contact's Check again, reading again when any sync pass ends; no gate records it against a network." + } + }, + "verification": [ + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs", + "id": "should_send_read_and_accept_a_private_contact_request", + "command": "cargo test -p platform-wallet --lib should_send_read_and_accept_a_private_contact_request", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs", + "id": "should_accept_a_public_request_replying_in_kind", + "command": "cargo test -p platform-wallet --lib should_accept_a_public_request_replying_in_kind", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "id": "should_accept_publicly_without_shielded_support_or_a_mnemonic_read", + "command": "cargo test -p platform-wallet-ffi --lib should_accept_publicly_without_shielded_support_or_a_mnemonic_read", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "id": "should_read_a_pair_s_hold_and_the_restore_failure_from_the_wallet", + "command": "cargo test -p platform-wallet-ffi --lib should_read_a_pair_s_hold_and_the_restore_failure_from_the_wallet", + "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": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/error.rs", + "id": "should_report_an_unconfirmed_public_request_by_its_own_code", + "command": "cargo test -p platform-wallet-ffi --lib should_report_an_unconfirmed_public_request_by_its_own_code", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift", + "id": "testShouldRouteAPrivatelyReceivedRequestByItsOwnCode", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftDashSDKTests/ErrorHandlingTests", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldKeepAnUnconfirmedPrivateSubmissionPending", + "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt", + "id": "shouldRouteARequestThatNeedsAPrivateReplyByItsOwnCode", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*DashSdkErrorTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAnUnconfirmedPrivateSubmissionPending", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAPendingPrivateRequestPending", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/dashpay.rs", + "id": "should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", + "command": "cargo test -p rs-unified-sdk-jni --lib should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/dashpay.rs", + "id": "should_destroy_every_handle_of_the_array_and_free_it", + "command": "cargo test -p rs-unified-sdk-jni --lib should_destroy_every_handle_of_the_array_and_free_it", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt", + "id": "privateContactRequestSymbolsBindAndRejectInvalidInput", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh", + "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": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldHoldAPublicAcceptThatMayHaveGoneThroughThroughAReloadAndARetryUntilTheWalletSettlesIt", + "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh", + "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": "shouldKeepAPublicAcceptHeldAfterCode20ThroughAnEmptyPrivateReadAndAnUnsettledSync", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + } + ] + }, + { + "id": "dashpay.private_contact_request_scan_state", + "title": "DashPay private contact request sync resumes after restart from its persisted read position; a marked record outlives its wallet", + "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", + "on_load_private_contact_scan_state_wallets_fn", + "on_load_private_contact_scan_state_wallets_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, and purges it with the wallet except a position record marked unresolved, which it keeps until a record stored cleared for the same wallet and identity replaces it and lists through the wallet list shim, so the load hands it back as retained and a wallet registered again holds the identity it names; it hands the state 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 and notes a kept record. 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 except a position record marked unresolved, which it keeps until a cleared record for the same wallet and identity replaces it and lists through onLoadPrivateContactScanStateWallets, and declares the capability; JNI wires the five scan state slots only for a bridge that overrides its store, its load, its wallet list and the contact upsert (the capability also needs contact persistence on the Rust side), rows cross as packed byte arrays, and a stored row that cannot cross fails the load. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." + } + }, + "verification": [ + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_restore_the_private_contact_request_scan_state_onto_its_identity", + "command": "cargo test -p platform-wallet-ffi --lib should_restore_the_private_contact_request_scan_state_onto_its_identity", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_leave_hosts_without_the_scan_state_capability_unchanged", + "command": "cargo test -p platform-wallet-ffi --lib should_leave_hosts_without_the_scan_state_capability_unchanged", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_hand_back_the_marked_records_no_restored_identity_takes", + "command": "cargo test -p platform-wallet-ffi --lib should_hand_back_the_marked_records_no_restored_identity_takes", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again", + "command": "cargo test -p platform-wallet-ffi --lib should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift", + "id": "testShouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt", + "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": "shouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*PrivateContactScanStatePersistenceTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/persistence.rs", + "id": "the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it", + "command": "cargo test -p rs-unified-sdk-jni --lib the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it", + "covers_restart": false + } + ] + }, { "id": "identity.asset_lock_resume", "title": "Identity registration and top-up resume from tracked locks", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index b2d0b1fed42..35b30f96085 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 @@ -153,6 +153,16 @@ class AppContainer(private val context: Context) { network = sdk.network, loadWallets = { val activatedManager = walletManagerStore.activate(sdk.network, sdk) + // Open the shielded store before any wallet is loaded or + // created: this app's persister keeps the private contact + // request scan state, so Rust holds every wallet published + // while no store is configured (code 72) until its private + // payment journal is read, and a later bind may never come (no + // mnemonic, declined biometric). With the store open first, + // each wallet's journal is read as it is published, and the + // binds' own calls on the same path are no-ops. A failure is + // recorded and retried; the wallets stay held meanwhile. + configureShieldedStore(activatedManager) activatedManager.loadPersistedWallets() org.dashfoundation.example.ui.dashpay.DashPayWalletLoad( value = activatedManager, @@ -200,6 +210,49 @@ class AppContainer(private val context: Context) { } } + private val _shieldedStoreFailure = MutableStateFlow(null) + + /** + * Why opening the active network's shielded store failed, until a later + * attempt succeeds. The DashPay screens show it while the wallet's private + * payments wait for that store, and retry it ([configureShieldedStoreAgain]). + * ← Swift `WalletManagerStore.shieldedStoreFailures`. + */ + val shieldedStoreFailure: StateFlow = _shieldedStoreFailure.asStateFlow() + + /** + * Open [manager]'s network's shielded store, recording why it failed, or + * clearing that once it opens. A repeat on the same path does not wait + * on the store. + */ + private suspend fun configureShieldedStore(manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager) { + if (!shieldedService.isAvailable) return + try { + manager.configureShielded(shieldedDbPath(manager.network)) + _shieldedStoreFailure.value = null + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _shieldedStoreFailure.value = e.message ?: e.toString() + android.util.Log.w(TAG, "Failed to configure the shielded store", e) + } + } + + /** + * Open the active network's shielded store again and return why it + * failed, or null once it is open. On a store already open this asks the + * wallet to retry restoring every wallet whose private payments are still + * held, a restoration that failed after the store opened included; it + * never waits on a busy store (a busy store defers the restoration to a + * task). + * ← Swift `WalletManagerStore.configureShieldedAgain(_:)`. + */ + suspend fun configureShieldedStoreAgain(): String? { + val manager = walletManagerStore.activeManager.value ?: return _shieldedStoreFailure.value + configureShieldedStore(manager) + return _shieldedStoreFailure.value + } + /** * Wallet-scoped rebind — drive the manager-wide sync loops from the * deterministic first wallet (← iOS `rebindWalletScopedServices` in @@ -207,6 +260,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 +300,7 @@ class AppContainer(private val context: Context) { // and doesn't throw. dbPath mirrors iOS's // `ShieldedService.dbPath(for:)` naming // (`shielded_tree_.sqlite`), rooted in filesDir. - val shieldedDbPath = java.io.File( - context.filesDir, - "shielded_tree_${manager.network.networkName}.sqlite", - ).absolutePath + val shieldedDbPath = shieldedDbPath(manager.network) shieldedService.bind( manager = manager, walletId = wallet.walletId, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 7a92748c67e..a7bfcfccb1a 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 @@ -39,6 +39,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -98,6 +100,30 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { var accountLabel by remember { mutableStateOf("") } var isSending by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + // Set when an accept's private reply may have gone through (its result + // could not be confirmed): the screen only offers to close, never to + // submit again, as the send screen does for an unconfirmed spend. + var unconfirmedNotice by remember { mutableStateOf(null) } + // Whether the wallet holds the pair with the resolved recipient for a + // private request, as a send decides it (in flight, paid and not yet + // read back, or kept by the shielded store's journal until the payment is + // proven never to land), or for a public request awaiting its outcome + // (`contactRequestHolds`). While it does, Send and the collision accept + // are off and the screen offers the sync that settles it: the private + // one, which reads a landed request back, or the DashPay one, which + // settles a public request. 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()) } + // Why the shielded store could not be opened, as last read with the + // pending state; null when it is open. + var shieldedStoreFailure by remember { mutableStateOf(null) } + var isSyncingPrivate by remember { mutableStateOf(false) } + // A DashPay sync the screen started is running. + var isCheckingPublic by remember { mutableStateOf(false) } + var pendingReadVersion by remember { mutableStateOf(0) } + // The automatic refreshes made since the wallet's private payments were + // last read restored (`PrivatePaymentsRecovery.recheckDelayMs`). + var readinessRechecks by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -107,7 +133,15 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { AddMode.IDENTITY_ID -> parsedIdentityId } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true - val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending + val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && + unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex()) + val recipientBlocked = resolvedRecipient != null && + ( + recipientPending.isRestoringPrivatePayments || + recipientPending.isAwaitingOutcome(resolvedRecipient.toHex()) || + recipientPending.isPending(resolvedRecipient.toHex()) || + recipientPending.isUnavailable + ) // Scan-to-send: consume the QR string handed back by the scanner screen // (the house pattern — observe the saved-state flow, then null it out). @@ -168,6 +202,50 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // Cache-only profile for the preview card (local read; most unknown // identities won't have one). val recipientHex = resolvedRecipient?.toHex() + LaunchedEffect(recipientHex, wallet, pendingReadVersion) { + val contactIds = listOfNotNull(resolvedRecipient) + // Recovery first: the readiness shown is read after the wallet was + // asked to retry a restoration its payments still wait for. + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = recipientPending.readiness, + storeFailure = container.shieldedStoreFailure.value, + readReadiness = { privateContactReadiness(wallet, idBytes) }, + configureAgain = { container.configureShieldedStoreAgain() }, + ) + shieldedStoreFailure = refreshed.storeFailure + val pendingRead = contactRequestHolds(wallet, idBytes, contactIds) + recipientPending = recipientPending + .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + .recorded(pendingRead, contactIds.map { it.toHex() }) + // While the payments stay unrestored, refresh again after a pause, for + // as long as the screen is shown: a restoration the wallet deferred + // behind a busy store completes later and changes no key of this + // effect (neither a bind nor a DashPay sync ends for it). + if (PrivatePaymentsRecovery.waitForRecheck(recipientPending.readiness, readinessRechecks)) { + readinessRechecks++ + pendingReadVersion++ + } else if (recipientPending.readiness?.privatePaymentsRestored == true) { + readinessRechecks = 0 + } + } + // Binding the shielded account restores the wallet's private payments: + // read the readiness again then. + LaunchedEffect(container.shieldedService) { + container.shieldedService.isBound.collect { pendingReadVersion++ } + } + // A deferred restoration runs once the shielded scan holding the store + // ends: refresh then too, ahead of the timer. + LaunchedEffect(container.shieldedService) { + container.shieldedService.state + .map { it.isSyncing } + .distinctUntilChanged() + .collect { syncing -> if (!syncing) pendingReadVersion++ } + } + // A DashPay sync settles a public request awaiting its outcome: read the + // recipient's holds again when one ends, the background pass included. + LaunchedEffect(manager) { + manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } + } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient val w = wallet @@ -204,9 +282,71 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { kickDashPaySync(scope, m) navController.popBackStack() } catch (e: Exception) { - errorMessage = e.message ?: "Send failed" + val outcome = ContactRequestSubmissionOutcome.of(e) + recipientPending = recipientPending.withOutcome(outcome, recipient.toHex()) + when (outcome) { + // Nothing was sent: the wallet holds the pair. The screen + // shows it as pending, with the private sync, until the + // per-contact read says it is free. + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + // Nothing was sent: the screen shows the wallet setting up + // its private payments until its readiness says restored. + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> Unit + // Nothing was signed: an earlier public request to the + // recipient awaits its outcome. The screen shows it being + // checked, with the DashPay sync, never a retry. + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed -> kickDashPaySync(scope, m) + // Nothing was sent and nothing is pending: Send stays on, + // and sending again continues the read. + ContactRequestSubmissionOutcome.PrivateStoreNotRead -> + errorMessage = ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE + 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++ + } + } + } + + /** + * Run a DashPay sync, which settles a public request awaiting its + * outcome first (attaching to one already running), then re-read the + * recipient's holds: Send comes back once the wallet frees the pair, and + * a request found is recorded as sent. + */ + fun checkPublicRequest() { + val m = manager ?: return + isCheckingPublic = true + scope.launch { + try { + attachOrStartSync(m) + } finally { + isCheckingPublic = false + pendingReadVersion++ } } } @@ -214,15 +354,17 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return + // A private reply to this contact may be pending in the wallet. + if (!recipientPending.allowsSubmission(recipient.toHex())) return isSending = true errorMessage = null scope.launch { try { - val ok = w.dashpay.acceptIncomingRequest( + val ok = acceptIncomingContactRequest( + wallet = w, + manager = m, ourIdentityId = idBytes, senderId = recipient, - signerHandle = m.signerHandle, - coreSignerHandle = m.mnemonicResolverHandle, ) if (ok) { kickDashPaySync(scope, m) @@ -232,9 +374,28 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { "accept it from Requests." } } catch (e: Exception) { - errorMessage = "Accept failed: ${e.message ?: "unknown error"}" + val outcome = ContactRequestSubmissionOutcome.of(e) + recipientPending = recipientPending.withOutcome(outcome, recipient.toHex()) + when (outcome) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + // Pending, not failed: the next sync settles it. + kickDashPaySync(scope, m) + unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } + // Nothing was signed: the screen shows the pair being + // checked, with the DashPay sync, never a retry. + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed -> kickDashPaySync(scope, m) + // Nothing was sent: the screen offers the private sync. + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> Unit + ContactRequestSubmissionOutcome.PrivateStoreNotRead -> + errorMessage = ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE + is ContactRequestSubmissionOutcome.Failed -> + errorMessage = "Accept failed: ${outcome.message}" + } } finally { isSending = false + pendingReadVersion++ } } } @@ -334,6 +495,38 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { enabled = canSend, modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), ) { attemptSend() } + if (recipientBlocked) { + Text( + ContactRequestSubmissionOutcome.rowMessage( + pending = recipientPending.isPending(recipient.toHex()), + awaitingOutcome = recipientPending.isAwaitingOutcome(recipient.toHex()), + restoring = recipientPending.isRestoringPrivatePayments, + restoreFailure = recipientPending.restoreFailure, + shieldedStoreFailure = shieldedStoreFailure, + unavailable = recipientPending.isUnavailable, + error = null, + ).orEmpty(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("dashpay.addContact.pending"), + ) + // A DashPay sync settles a public request awaiting its + // outcome, and a private sync a pending private request; + // neither restores private payments, so neither is offered + // for that. + when { + recipientPending.isRestoringPrivatePayments -> Unit + recipientPending.isAwaitingOutcome(recipient.toHex()) -> TextButton( + onClick = { checkPublicRequest() }, + enabled = !isCheckingPublic, + modifier = Modifier.testTag("dashpay.addContact.checkPublic"), + ) { Text(if (isCheckingPublic) "Checking…" else "Check again") } + else -> TextButton( + onClick = { syncPrivateRequests() }, + enabled = !isSyncingPrivate, + modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), + ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } + } + } } if (errorMessage != null) { @@ -366,6 +559,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/ClaimInvitationSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt index bcf176f8985..9857acaa653 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt @@ -208,7 +208,20 @@ fun ClaimInvitationSheet( ).close() onClose() } catch (t: Throwable) { - errorMessage = t.message ?: "Sending the contact request failed." + // One that may have gone through (18, 20), or that the wallet + // refused while an earlier one awaits its outcome (76), is not + // a failure: the DashPay sync settles it. + // ← Swift `ClaimInvitationSheet.sendContact`. + errorMessage = when (ContactRequestSubmissionOutcome.of(t)) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, + -> { + kickDashPaySync(container.applicationScope, mgr) + "Identity claimed. " + + ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE + } + else -> t.message ?: "Sending the contact request failed." + } } } } 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..6fb1e9b5261 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -0,0 +1,478 @@ +package org.dashfoundation.example.ui.dashpay + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.tokens.Dashpay + +/** + * How a contact request send or accept that threw ended, for the DashPay + * screens. Follows the send screen (and iOS `SendViewModel.swift`): a shielded + * spend whose result could not be confirmed (code 18) may already be on chain + * (Rust keeps its notes reserved), and so may a state transition the SDK could + * not confirm after it may have been accepted (code 20), so the screen keeps + * either pending and never offers a fresh submission; the next sync settles + * it. For a public request the wallet holds the pair itself after a 20, the + * signed transition kept, until a public contact request sync settles it, + * and refuses every later request or accept to it with code 76: the screen + * shows "may have gone through; it is being checked" until the wallet's + * per-contact read says settled. A pair Rust reports as + * held for a private request (code 71), which a public send or accept can + * also meet, sent nothing new: the screen shows it as pending until the + * wallet's per-contact read says the pair is free. Unrestored private + * payments (code 72) hold the wallet, not one contact: the screen shows + * "setting up" until the wallet's readiness reads restored, or why the last + * restoration failed; no private sync lifts it. A private store read that did + * not finish (code 74) sent nothing and is not pending: the user may try + * again. Every other error is an ordinary failure the user may retry; a + * storage backend that cannot protect private payments (73) and an SDK + * pinned below the protocol version (75) are shown as configuration problems. + * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). + */ +sealed interface ContactRequestSubmissionOutcome { + /** The private reply may have gone through: do not submit again. */ + data object MayHaveGoneThrough : ContactRequestSubmissionOutcome + + /** The wallet holds the pair for a private request; nothing was sent. */ + data object PrivateRequestPending : ContactRequestSubmissionOutcome + + /** + * The wallet holds the pair for a public request whose outcome is not + * proved yet (code 76); nothing was signed. A public contact request + * sync settles it. + */ + data object PublicRequestUnconfirmed : ContactRequestSubmissionOutcome + + /** The wallet's private payments are not restored yet; nothing was sent. */ + data object PrivatePaymentsNotRestored : ContactRequestSubmissionOutcome + + /** + * The private store read every contact request runs first did not + * finish; nothing was sent, and trying again continues it. + */ + data object PrivateStoreNotRead : 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." + + /** + * A public request or accept that may have gone through, as the + * wallet holds it until a sync settles its outcome. + */ + const val PUBLIC_REQUEST_UNCONFIRMED_MESSAGE = "This request may have gone through; " + + "it is being checked. The next sync settles it." + + const val PRIVATE_REQUEST_PENDING_MESSAGE = "A private request to this contact is " + + "pending. It clears once the request is read back from the private store, " + + "or once its payment can no longer land, which can take a while. Sync " + + "private requests to check." + + const val SETTING_UP_PRIVATE_PAYMENTS_MESSAGE = "Setting up this wallet's private " + + "payments. Contact requests wait until it finishes." + + const val PRIVATE_STORE_NOT_READ_MESSAGE = "Still reading private contact requests. " + + "Nothing was sent; try again in a moment." + + /** + * 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." + + /** + * Code 75 is the developer's setting: only raising or removing the + * SDK's protocol version pin lifts it. + */ + const val SDK_PINNED_MESSAGE = "This build's SDK is pinned below the protocol " + + "version private contact requests need. Raise or remove the SDK's protocol " + + "version pin." + + /** The wallet's last restoration of its private payments failed. */ + fun restoreFailedMessage(reason: String): String = + "Restoring this wallet's private payments failed: $reason. Contact requests " + + "wait until a restore succeeds." + + /** The app could not open the shielded store, which restores the wallet's private payments. */ + fun shieldedStoreFailedMessage(reason: String): String = + "The shielded store could not be opened: $reason. Contact requests wait " + + "until it opens." + + /** + * The notice shown while the wallet's private payments are not + * restored: why opening the shielded store failed, why the last + * restoration failed, or that they are being set up. + */ + fun restoringMessage(restoreFailure: String?, shieldedStoreFailure: String?): String = when { + shieldedStoreFailure != null -> shieldedStoreFailedMessage(shieldedStoreFailure) + restoreFailure != null -> restoreFailedMessage(restoreFailure) + else -> SETTING_UP_PRIVATE_PAYMENTS_MESSAGE + } + + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed, + is DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed, + is DashSdkError.StateTransitionResultUnconfirmed, + -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending + is DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed -> PublicRequestUnconfirmed + is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored + is DashSdkError.PlatformWallet.PrivateContactStoreNotRead -> PrivateStoreNotRead + is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported -> + Failed(RECOVERY_UNSUPPORTED_MESSAGE) + is DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion -> Failed(SDK_PINNED_MESSAGE) + else -> Failed(error.message ?: "unknown error") + } + + /** + * The message a request row shows: the restoring notice while the + * wallet's private payments are not restored, the being-checked + * notice while a request to the contact awaits its outcome, the + * pending notice while the wallet holds the pair for a private + * request, the unavailable notice while the wallet could not be + * read, and whatever error an earlier attempt left otherwise. + */ + fun rowMessage( + pending: Boolean, + awaitingOutcome: Boolean = false, + restoring: Boolean = false, + restoreFailure: String? = null, + shieldedStoreFailure: String? = null, + unavailable: Boolean = false, + error: String?, + ): String? = when { + restoring -> restoringMessage(restoreFailure, shieldedStoreFailure) + awaitingOutcome -> PUBLIC_REQUEST_UNCONFIRMED_MESSAGE + pending -> PRIVATE_REQUEST_PENDING_MESSAGE + unavailable -> PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE + else -> error + } + } +} + +/** + * Which contacts (lower hex ids) the wallet holds a pair with for a private + * request, and which for a public request awaiting its outcome, as the + * screens last read it ([contactRequestHolds]: per contact, the private pair + * read, which answers as a send decides it, and the public one). While either + * holds a pair, Rust refuses another send or reply to that contact, so the + * screens turn off + * what would submit one (Accept, Ignore, Send). A failed read is not "nothing + * pending": the last successful snapshot stays, and those controls stay off + * for every contact until a read covering it succeeds. + * + * A send or accept Rust refused as pending (code 71) marks the contact + * pending at once; the next successful read covering it decides, as it reads + * what the refusal read. Controls for a pair re-enable only when that read + * says it is free: a payment that will never land frees it only once the + * wallet proves so, which the read shows. So does a send or accept that may + * have gone through (18 or 20), or was refused as awaiting a public request's + * outcome (76): the contact shows "being checked" at once, and a read decides + * it the same way, so a reload whose sync did not settle the wallet's hold + * keeps it so. A read always covers both holds: a read of the private hold + * alone would offer a public request awaiting its outcome again. + * + * 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 "setting up" (or why the last restoration failed) for every contact, + * and only a readiness read that says restored lifts it. A private sync + * changes neither on its own. + * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). + */ +data class PrivateRepliesPendingSnapshot( + /** The contacts the last successful read found held for a private request. */ + val pending: Set = emptySet(), + /** The contacts the last successful read covered. */ + val covered: Set = emptySet(), + /** False after a failed read, until a read succeeds. */ + val available: Boolean = true, + /** Contacts a send or accept was refused for as pending (code 71) since the last successful read covering them. */ + val refused: Set = emptySet(), + /** The contacts the last successful read found held for a public request awaiting its outcome. */ + val publicUnconfirmed: Set = emptySet(), + /** + * Contacts a send or accept may have gone through to (18, 20), or was + * refused for as awaiting a public request's outcome (76), since the last + * successful read covering them. + */ + val awaitingOutcome: Set = emptySet(), + /** The wallet's readiness for the identity, as last read; null until a read succeeds. */ + val readiness: Dashpay.PrivateContactReadiness? = null, + /** Why the last restoration failed, as last read with the readiness; null when none failed or it could not be read. */ + val restoreFailure: String? = null, + /** Set by a code 72 refusal; cleared only by a readiness read that says restored. */ + val restoringRefused: Boolean = false, +) { + /** Whether the last read failed. */ + val isUnavailable: Boolean get() = !available + + /** + * This snapshot after a read of both holds for [contactIds] + * ([contactRequestHolds]), or after a failed one ([read] null). A + * successful read decides each contact it covered, a refusal or a + * submission that may have gone through included. + */ + fun recorded(read: ContactRequestHolds?, contactIds: Collection): PrivateRepliesPendingSnapshot = + if (read == null) { + copy(available = false) + } else { + val decided = contactIds.toSet() + copy( + pending = read.privatelyHeld, + publicUnconfirmed = read.publicUnconfirmed, + covered = decided, + refused = refused - decided, + awaitingOutcome = awaitingOutcome - decided, + available = true, + ) + } + + /** + * This snapshot after a send or accept to [contactId] threw with + * [outcome]: a 71 refusal marks it pending and a 72 refusal holds every + * contact, as [withRefusal] and [withRestoringRefusal] do; one that may + * have gone through, or a 76 refusal, marks it awaiting its outcome. + * Nothing else changes a hold. + */ + fun withOutcome(outcome: ContactRequestSubmissionOutcome, contactId: String): PrivateRepliesPendingSnapshot = + when (outcome) { + ContactRequestSubmissionOutcome.PrivateRequestPending -> withRefusal(contactId) + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, + -> copy(awaitingOutcome = awaitingOutcome + contactId) + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> withRestoringRefusal() + ContactRequestSubmissionOutcome.PrivateStoreNotRead, + is ContactRequestSubmissionOutcome.Failed, + -> this + } + + /** This snapshot after a send or accept to [contactId] was refused as pending (code 71). */ + fun withRefusal(contactId: String): PrivateRepliesPendingSnapshot = copy(refused = refused + contactId) + + /** This snapshot after a send or accept refused because the private payments are not restored (code 72). */ + fun withRestoringRefusal(): PrivateRepliesPendingSnapshot = copy(restoringRefused = true) + + /** + * This snapshot after a read of the wallet's readiness and, when it flags + * a failed restoration, the reason read with it; a null readiness (the + * read failed) keeps what the last read said. A readiness that says + * restored lifts a code 72 refusal and its failure. + */ + fun withReadiness( + read: Dashpay.PrivateContactReadiness?, + restoreFailure: String? = null, + ): PrivateRepliesPendingSnapshot = + if (read == null) { + this + } else { + copy( + readiness = read, + restoreFailure = restoreFailure.takeIf { read.privatePaymentsRestoreFailed }, + restoringRefused = restoringRefused && !read.privatePaymentsRestored, + ) + } + + /** + * 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 the + * pair held, or a send or accept to it was refused as pending since. + */ + fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused + + /** + * Whether a request to [contactId] awaits its outcome: the last + * successful read found the pair held for a public request awaiting it, + * or a send or accept to the contact may have gone through, or was + * refused so (76), since. + */ + fun isAwaitingOutcome(contactId: String): Boolean = + contactId in publicUnconfirmed || contactId in awaitingOutcome + + /** + * Whether a send or reply to [contactId] may be offered: a successful + * read covered it and found the pair free of both holds. + */ + fun allowsSubmission(contactId: String): Boolean = + available && contactId in covered && !isPending(contactId) && + !isAwaitingOutcome(contactId) && !isRestoringPrivatePayments + + companion object { + const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + + "contact is pending. Sync private requests to check again." + } +} + +/** + * One read of the wallet's two per-contact holds, as lower hex ids. + * ← Swift `ContactRequestHolds` (`ContactRequestOutcomes.swift`). + */ +data class ContactRequestHolds( + /** The contacts whose pair the wallet holds for a private request. */ + val privatelyHeld: Set = emptySet(), + /** The contacts whose pair the wallet holds for a public request awaiting its outcome. */ + val publicUnconfirmed: Set = emptySet(), +) + +/** + * Read the wallet's two holds: the pairs held for a private request + * ([readPrivatelyHeld]) and those held for a public request awaiting its + * outcome ([readPublicUnconfirmed]). Null when either read failed, which the + * screens keep apart from "nothing held" ([PrivateRepliesPendingSnapshot]); + * an identity the wallet does not manage reads as nothing held in both. + * ← Swift `contactRequestHolds(contactIds:privatePairHeld:publicUnconfirmed:)`. + */ +suspend fun contactRequestHolds( + readPrivatelyHeld: suspend () -> Set, + readPublicUnconfirmed: suspend () -> Set, +): ContactRequestHolds? = try { + ContactRequestHolds( + privatelyHeld = readPrivatelyHeld(), + publicUnconfirmed = readPublicUnconfirmed(), + ) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + null +} + +/** + * The DashPay screens' readiness refresh, one flow for Requests and Add + * Contact. The wallet owns the recovery: on a store already open, a repeat + * `configureShielded` retries restoring every wallet whose private payments + * are still held, a restoration that failed after the store opened included, + * and defers it to a task while a scan holds the store. So a refresh asks for + * it whenever the payments are not restored, or the store did not open, and + * only then reads the readiness it shows: the readiness is read after the + * attempt, never cached from before it. While the payments stay unrestored (a + * deferred restoration completes later, changing nothing a screen observes but + * the end of the shielded scan that held the store), the screen keeps + * refreshing on a timer for as long as it is shown, and again when a shielded + * sync pass ends, so the gate lifts without a bind, a restart or a submitted + * request. The screens change no hold themselves. + * ← Swift `PrivatePaymentsRecovery` (`ContactRequestOutcomes.swift`). + */ +object PrivatePaymentsRecovery { + /* + * The cadence of the automatic refreshes while the payments stay + * unrestored: every FAST_RECHECK_INTERVAL_MS for the first FAST_RECHECKS + * of them (a minute), then every SLOW_RECHECK_INTERVAL_MS, for as long as + * the screen is shown. It never stops on a count: a shielded scan can hold + * the store for minutes, and the wallet runs a restoration it deferred + * only once the scan frees it. Each refresh is local and cheap, reads of + * in-memory wallet state and a repeat `configureShielded` that checks + * which wallets are still held and queues at most one deferred + * restoration per wallet, with no network call, so an open-ended poll at + * this cadence costs little. It stops when the readiness reads restored + * or the screen leaves the composition; a screen for another identity + * starts its own. + */ + + /** The automatic refreshes made at the fast cadence. */ + const val FAST_RECHECKS = 20 + + /** The pause between automatic refreshes for the first minute. */ + const val FAST_RECHECK_INTERVAL_MS = 3_000L + + /** The pause between automatic refreshes after the first minute. */ + const val SLOW_RECHECK_INTERVAL_MS = 15_000L + + /** + * Whether a refresh asks the wallet to retry before it reads the + * readiness it shows: the store did not open last time, or the last + * readiness read says the payments are not restored. + */ + fun retries(readiness: Dashpay.PrivateContactReadiness?, storeFailure: String?): Boolean = + storeFailure != null || readiness?.privatePaymentsRestored == false + + /** + * The pause before the next automatic refresh after [attempt] of them, or + * null when there is none: the readiness reads restored, or no read + * succeeded yet (left to the refresh the screen makes anyway). + */ + fun recheckDelayMs(after: Dashpay.PrivateContactReadiness?, attempt: Int): Long? = when { + after?.privatePaymentsRestored != false -> null + attempt < FAST_RECHECKS -> FAST_RECHECK_INTERVAL_MS + else -> SLOW_RECHECK_INTERVAL_MS + } + + /** + * Wait for the next automatic refresh and say whether to make it: false at + * once when none is due ([recheckDelayMs]), true after the pause. The + * screens pass `delay`; a screen leaving the composition cancels the wait, + * and the cancellation ends its effect. + */ + suspend fun waitForRecheck( + after: Dashpay.PrivateContactReadiness?, + attempt: Int, + sleep: suspend (Long) -> Unit = { delay(it) }, + ): Boolean { + val pause = recheckDelayMs(after, attempt) ?: return false + sleep(pause) + return true + } + + /** A refresh's readiness read, null when it failed, and why the store did not open, or null. */ + data class Refreshed(val read: PrivateContactReadinessRead?, val storeFailure: String?) + + /** + * One refresh. [lastReadiness] is the readiness the screen shows now; + * [readReadiness] reads the wallet's (with the reason a restoration + * failed); [configureAgain] asks the wallet to retry and returns why the + * store did not open, or null. When [retries] says so, the wallet is asked + * first and the readiness is read after it; otherwise it is read once. + */ + suspend fun refresh( + lastReadiness: Dashpay.PrivateContactReadiness?, + storeFailure: String?, + readReadiness: suspend () -> PrivateContactReadinessRead?, + configureAgain: suspend () -> String?, + ): Refreshed { + if (retries(lastReadiness, storeFailure)) { + val failure = configureAgain() + return Refreshed(readReadiness(), failure) + } + val read = readReadiness() + if (retries(read?.readiness, storeFailure)) { + val failure = configureAgain() + return Refreshed(readReadiness(), failure) + } + return Refreshed(read, storeFailure) + } +} + +/** + * 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? = when (error) { + is DashSdkError.PlatformWallet.ProtocolVersionNotActive -> null + is DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion -> + ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE + else -> "Private requests could not be read: ${error.message ?: "unknown error"}" + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index b6057c802ed..0e5ab5af494 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 @@ -38,6 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -92,6 +94,24 @@ 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 pair the wallet holds for a private request, as a send + // decides it, or for a public request awaiting its outcome + // (`contactRequestHolds`): the row stays pending or "being checked", + // without its buttons, until the wallet's read says the pair is free. + // Read from the wallet, not kept by this screen, so leaving and reopening it keeps the + // state. Re-read on composition and row changes, after an accept, on + // refresh and after each sync pass. A failed read keeps the last snapshot + // and leaves every row without its buttons until a read succeeds. + var walletPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } + // Why the shielded store could not be opened, as last read with the + // pending state; null when it is open. + var shieldedStoreFailure by remember { mutableStateOf(null) } + var pendingReadVersion by remember { mutableStateOf(0) } + // The automatic refreshes made since the wallet's private payments were + // last read restored (`PrivatePaymentsRecovery.recheckDelayMs`). + var readinessRechecks by remember { mutableStateOf(0) } + // What the last private-store refresh has to tell the user, if anything. + var privateRefreshNotice by remember { mutableStateOf(null) } // Prune the optimistic-removal overlay against the backing query: keep a // hex only while its pair is STILL incoming-only, so an entry is dropped @@ -147,6 +167,53 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle .sortedByDescending { it.createdAtMillis } } + LaunchedEffect(wallet, rows, pendingReadVersion) { + val contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId } + // Recovery first: the readiness shown is read after the wallet was + // asked to retry a restoration its payments still wait for. + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = walletPending.readiness, + storeFailure = container.shieldedStoreFailure.value, + readReadiness = { privateContactReadiness(wallet, idBytes) }, + configureAgain = { container.configureShieldedStoreAgain() }, + ) + shieldedStoreFailure = refreshed.storeFailure + val read = contactRequestHolds( + wallet = wallet, + ourIdentityId = idBytes, + contactIds = contactIds, + ) + walletPending = walletPending + .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + .recorded(read, contactIds.map { it.toHex() }) + // While the payments stay unrestored, refresh again after a pause, for + // as long as the screen is shown: a restoration the wallet deferred + // behind a busy store completes later and changes no key of this + // effect. + if (PrivatePaymentsRecovery.waitForRecheck(walletPending.readiness, readinessRechecks)) { + readinessRechecks++ + pendingReadVersion++ + } else if (walletPending.readiness?.privatePaymentsRestored == true) { + readinessRechecks = 0 + } + } + // Binding the shielded account restores the wallet's private payments: + // read the readiness again then. + LaunchedEffect(container.shieldedService) { + container.shieldedService.isBound.collect { pendingReadVersion++ } + } + // A deferred restoration runs once the shielded scan holding the store + // ends: refresh then too, ahead of the timer. + LaunchedEffect(container.shieldedService) { + container.shieldedService.state + .map { it.isSyncing } + .distinctUntilChanged() + .collect { syncing -> if (!syncing) pendingReadVersion++ } + } + LaunchedEffect(manager) { + manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } + } + fun accept(contactId: ByteArray) { val w = wallet ?: return val m = manager ?: return @@ -155,11 +222,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle inFlightIds = inFlightIds + hex scope.launch { try { - val ok = w.dashpay.acceptIncomingRequest( + val ok = acceptIncomingContactRequest( + wallet = w, + manager = m, ourIdentityId = idBytes, senderId = contactId, - signerHandle = m.signerHandle, - coreSignerHandle = m.mnemonicResolverHandle, ) if (ok) { removedOverlayIds = removedOverlayIds + hex @@ -168,9 +235,41 @@ 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"}") + val outcome = ContactRequestSubmissionOutcome.of(e) + walletPending = walletPending.withOutcome(outcome, hex) + when (outcome) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, + -> { + // As the send screen treats an unconfirmed spend: not + // a failure to retry. The wallet holds the pair (a + // private reply pending, or a public one awaiting its + // outcome, 76 when an earlier one still does), and the + // row reads that; the sync settles it. + kickDashPaySync(scope, m) + } + ContactRequestSubmissionOutcome.PrivateRequestPending -> { + // Nothing was sent: the wallet holds the pair. The + // row stays pending until its per-contact read says + // free. + kickDashPaySync(scope, m) + } + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> { + // The wallet is held until its private payments are + // restored; every row waits for the readiness to say so. + } + ContactRequestSubmissionOutcome.PrivateStoreNotRead -> { + // Nothing was sent and nothing is pending: the user may + // accept again, which continues the read. + rowErrors = rowErrors + + (hex to ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE) + } + is ContactRequestSubmissionOutcome.Failed -> + rowErrors = rowErrors + (hex to "Accept failed: ${outcome.message}") + } } finally { inFlightIds = inFlightIds - hex + pendingReadVersion++ } } } @@ -209,7 +308,13 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle onRefresh = { scope.launch { isRefreshing = true - manager?.let { attachOrStartSync(it) } + manager?.let { m -> + // The DashPay sync settles a public request awaiting + // its outcome first, so the reload reads what it decided. + attachOrStartSync(m) + wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } + pendingReadVersion++ + } isRefreshing = false } }, @@ -220,6 +325,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + privateRefreshNotice?.let { notice -> + item { + Text( + notice, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("dashpay.requests.privateRefreshNotice"), + ) + } + } if (incomingPending.isEmpty() && outgoingPending.isEmpty()) { item { DashPayEmptyRow( @@ -235,8 +350,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex), - errorMessage = rowErrors[hex], + isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), + errorMessage = ContactRequestSubmissionOutcome.rowMessage( + pending = walletPending.isPending(hex), + awaitingOutcome = walletPending.isAwaitingOutcome(hex), + restoring = walletPending.isRestoringPrivatePayments, + restoreFailure = walletPending.restoreFailure, + shieldedStoreFailure = shieldedStoreFailure, + unavailable = walletPending.isUnavailable, + error = rowErrors[hex], + ), onAccept = { accept(row.contactId) }, onIgnore = { ignore(row.contactId) }, ) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 0080bbdb3a7..7ff68d6aec1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -1,9 +1,13 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.tokens.Dashpay +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.util.toHex /** * Shared DashPay sync helpers — port of the free functions in @@ -32,3 +36,112 @@ suspend fun attachOrStartSync(manager: PlatformWalletManager) { fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { scope.launch { runCatching { manager.dashPaySyncNow() } } } + +/** + * The wallet's two holds on [ourIdentityId]'s pairs with [contactIds], as + * hex ids, one read of each per contact: held for a private request, as a + * send decides it (`Dashpay.privateRepliesPending`: a private request in + * flight, paid and not yet read back, or kept by the shielded store's + * journal), and held for a public request whose broadcast may have been + * delivered (`Dashpay.publicContactRequestsUnconfirmed`), which a DashPay + * sync settles. While either holds a pair, Rust refuses another send or + * reply to it, so the screens offer neither. 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 held" ([PrivateRepliesPendingSnapshot]). + * ← Swift `contactRequestHolds(wallet:identityId:contactIds:)` (`ContactsView.swift`). + */ +suspend fun contactRequestHolds( + wallet: ManagedPlatformWallet?, + ourIdentityId: ByteArray, + contactIds: List, +): ContactRequestHolds? { + if (contactIds.isEmpty()) return ContactRequestHolds() + if (wallet == null) return null + return contactRequestHolds( + readPrivatelyHeld = { + wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() + }, + readPublicUnconfirmed = { + wallet.dashpay.publicContactRequestsUnconfirmed(ourIdentityId, contactIds) + .map { it.toHex() }.toSet() + }, + ) +} + +/** The wallet's readiness for an identity's contact requests and, when it flags a failed restoration, why. */ +data class PrivateContactReadinessRead( + val readiness: Dashpay.PrivateContactReadiness, + val restoreFailure: String?, +) + +/** + * The wallet's readiness for [ourIdentityId]'s contact requests and, when it + * flags a failed restoration, why it failed; null when the readiness could + * not be read ([PrivateRepliesPendingSnapshot.withReadiness] keeps the last + * one then). + * ← Swift `privateContactReadiness(wallet:identityId:)` (`ContactsView.swift`). + */ +suspend fun privateContactReadiness( + wallet: ManagedPlatformWallet?, + ourIdentityId: ByteArray, +): PrivateContactReadinessRead? { + if (wallet == null) return null + val readiness = try { + wallet.dashpay.privateContactReadiness(ourIdentityId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + return null + } + if (!readiness.privatePaymentsRestoreFailed) return PrivateContactReadinessRead(readiness, null) + val reason = try { + wallet.dashpay.privatePaymentsRestoreFailure(ourIdentityId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + return PrivateContactReadinessRead(readiness, reason) +} + +/** + * Read DashPay's private contact request store (protocol version 15) for + * [wallet], returning what the refresh should tell the user, or null. + * Private requests reach the wallet only through this read, so it runs with + * every pull-to-refresh of the request list. A network before protocol + * version 15 does not serve the store yet, which is expected and quiet; any + * other failure, or requests read but not saved, is reported. + * ← Swift `syncPrivateContactRequests` (`ContactsView.swift`). + */ +suspend fun syncPrivateContactRequests( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, +): String? = try { + val result = wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) + PrivateContactRefreshNotice.notice(result.unpersistedIdentityCount) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + PrivateContactRefreshNotice.notice(e) +} + +/** + * Accept the incoming request from [senderId] to [ourIdentityId] with the + * wallet's one accept, which replies the way the request came: privately + * (paid from the wallet's shielded balance) or with a document, signed by the + * manager's identity signer either way. The wallet decides; the app makes one + * call. Returns false when the request is not in local state. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`, as + * `ContactRequestsView.swift` and `AddContactView.swift` call it. + */ +suspend fun acceptIncomingContactRequest( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, + ourIdentityId: ByteArray, + senderId: ByteArray, +): Boolean { + val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) ?: return false + request.use { manager.acceptContactRequestReplyingInKind(wallet.walletId, it).close() } + return true +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 783128fef29..e1c133c12f2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -729,16 +729,20 @@ fun SendTransactionScreen( successMessage = "Withdrawal submitted" } } - } catch (e: DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { - // It may have reached a node and its result is not - // proved (a node's refusal or verdict included), so it - // may already be on chain and a retry signs a second - // transition: this must NOT read as a retryable - // failure (← SendViewModel.swift:790). - successMessage = "Transaction may have gone through — waiting for " + - "the next shielded sync to confirm. Do not retry." } catch (t: Throwable) { - error = t.message ?: "Failed to send the transaction." + if (shieldedOperationMayHaveGoneThrough(t)) { + // It may have reached a node and its result is not + // proved (a node's refusal or verdict included, + // code 18), or the SDK could not confirm a + // transition it may have accepted (code 20), so it + // may already be on chain and a retry signs a + // second transition: this must NOT read as a + // retryable failure (← SendViewModel.swift). + successMessage = "Transaction may have gone through — waiting for " + + "the next shielded sync to confirm. Do not retry." + } else { + error = t.message ?: "Failed to send the transaction." + } } finally { isSending = false } @@ -796,3 +800,14 @@ fun SendTransactionScreen( ErrorAlertDialog(message = error, onDismiss = { error = null }) } + +/** + * Whether a shielded operation's error means it may already be on chain, so + * no retry is offered: a spend whose result is not proved (18) or a state + * transition the SDK could not confirm after it may have been accepted (20). + * ← Swift `SendViewModel.mayHaveGoneThrough`. + */ +internal fun shieldedOperationMayHaveGoneThrough(error: Throwable): Boolean = + error is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed || + error is DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed || + error is DashSdkError.StateTransitionResultUnconfirmed 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..93a4d04ab20 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -0,0 +1,709 @@ +package org.dashfoundation.example.ui.dashpay + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.tokens.Dashpay +import org.dashfoundation.example.ui.wallet.shieldedOperationMayHaveGoneThrough +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Port of `ContactRequestOutcomesTests.swift`, code by code: an unconfirmed + * private send or reply (18) stays pending with no fresh submission offered, + * a held pair (71) is pending until the per-contact read says free, + * unrestored payments (72) show "setting up" or why the restoration failed, + * an unsupported store (73) and a pinned SDK (75) are configuration messages, + * an unfinished private store read (74) is a retry with no pending row, and a + * network that does not serve the private store yet is quiet while real + * failures are shown. A public request or accept that may have gone through + * (20), or one the wallet refuses while an earlier one awaits its outcome + * (76), shows "being checked" with no submission offered until the wallet's + * read of the pair says settled. + */ +class ContactRequestOutcomesTest { + + @Test + fun shouldKeepAnUnconfirmedPrivateSubmissionPending() { + val error = DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed("result could not be confirmed") + assertEquals( + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.of(error), + ) + } + + /** + * Code 20: a state transition the SDK could not confirm after it may have + * been accepted, which a private send or accept can now return through + * the spend contract, and a public one through the blanket conversion. It + * may have executed: nothing is offered again, on a contact request or a + * shielded send alike. + */ + @Test + fun shouldKeepAnUnconfirmedStateTransitionPending() { + val wallet = DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed("result could not be confirmed") + val sdk = DashSdkError.StateTransitionResultUnconfirmed("result could not be confirmed") + assertEquals(ContactRequestSubmissionOutcome.MayHaveGoneThrough, ContactRequestSubmissionOutcome.of(wallet)) + assertEquals(ContactRequestSubmissionOutcome.MayHaveGoneThrough, ContactRequestSubmissionOutcome.of(sdk)) + assertTrue(shieldedOperationMayHaveGoneThrough(wallet)) + assertTrue( + shieldedOperationMayHaveGoneThrough( + DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed("not proved"), + ), + ) + assertFalse( + shieldedOperationMayHaveGoneThrough( + DashSdkError.PlatformWallet.ShieldedBroadcastFailed("never sent"), + ), + ) + } + + /** + * A pending private request (code 71), which a public send or accept can + * meet too, sent nothing: it is shown as pending, with a private sync to + * settle it, rather than as a submission that may have gone through. + */ + @Test + fun shouldKeepAPendingPrivateRequestPending() { + val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") + assertEquals( + ContactRequestSubmissionOutcome.PrivateRequestPending, + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldShowAWalletPendingReplyOverAnEarlierError() { + assertEquals( + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, error = "Accept failed: boom"), + ) + assertEquals( + "Accept failed: boom", + ContactRequestSubmissionOutcome.rowMessage(pending = false, error = "Accept failed: boom"), + ) + assertEquals( + PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + unavailable = true, + error = "Accept failed: boom", + ), + ) + assertNull(ContactRequestSubmissionOutcome.rowMessage(pending = false, error = null)) + } + + /** + * A read that found a private request pending, followed by a failed + * read: the contact stays pending and nothing is offered for any + * contact, until a read succeeds again. + */ + @Test + fun shouldKeepThePendingSnapshotAndOfferNothingAfterAFailedRead() { + val contacts = listOf("01", "02") + var snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld("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(privatelyHeld(), contacts) + assertFalse(snapshot.isUnavailable) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** + * A send refused as pending (code 71) marks the contact pending at once; + * the next read of the pair, which reads what the refusal read, decides: + * held keeps it pending, free re-enables it. The readiness alone never + * re-enables it. + */ + @Test + fun shouldReEnableARefusedPairOnlyWhenThePerContactReadSaysFree() { + var snapshot = PrivateRepliesPendingSnapshot() + .recorded(privatelyHeld(), listOf("01")) + .withRefusal("01") + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = snapshot.isPending("01"), error = null), + ) + + // A readiness that says the store was read and nothing is held for + // the wallet does not free one pair. + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = true)) + assertFalse(snapshot.allowsSubmission("01")) + + // The pair's read says held: still pending. + snapshot = snapshot.recorded(privatelyHeld("01"), listOf("01")) + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + + // A payment that will never land frees the pair once the wallet + // proves so; the next read says free and the controls come back. + snapshot = snapshot.recorded(privatelyHeld(), listOf("01")) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** A read that does not cover a refused contact leaves its refusal. */ + @Test + fun shouldKeepARefusalAReadDidNotCover() { + val snapshot = PrivateRepliesPendingSnapshot() + .withRefusal("01") + .recorded(privatelyHeld(), listOf("02")) + assertTrue(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("02")) + } + + /** + * Code 74: the private store read every contact request runs first did + * not finish. Nothing was sent and nothing is pending: its own outcome, + * with a retry message, and no pending row. + */ + @Test + fun shouldOfferARetryForAnUnfinishedPrivateStoreRead() { + assertEquals( + ContactRequestSubmissionOutcome.PrivateStoreNotRead, + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactStoreNotRead("not read to its end"), + ), + ) + val snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) + assertFalse("the screen records no refusal for 74", snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertTrue( + ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE != + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ) + } + + /** Code 75 is the developer's setting, shown as such, on a send and on a refresh alike. */ + @Test + fun shouldShowAPinnedSdkAsADeveloperMessage() { + val error = DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion("pinned at 14") + assertEquals( + ContactRequestSubmissionOutcome.Failed(ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE), + ContactRequestSubmissionOutcome.of(error), + ) + assertEquals(ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE, PrivateContactRefreshNotice.notice(error)) + } + + /** + * The store opened, but the wallet's journal restoration failed for a + * transient reason. A DashPay refresh asks the wallet to retry even + * though no store-open failure is recorded, reads the readiness after the + * attempt, and once the retry restores the payments the gate lifts: no + * bind, no restart and no submitted request. + */ + @Test + fun shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh() = runTest { + var restorationFails = true + var configures = 0 + val read: suspend () -> PrivateContactReadinessRead? = { + if (restorationFails) { + PrivateContactReadinessRead( + readiness(restored = false, storeRead = true, restoreFailed = true), + "the journal could not be read", + ) + } else { + PrivateContactReadinessRead(readiness(restored = true, storeRead = true), null) + } + } + var snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) + + // The first refresh: the store opened, the restoration failed, and the + // retry it asks for fails too. + var refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { + configures++ + null + } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertEquals("not restored asks the wallet to retry with no store failure recorded", 1, configures) + assertNull(refreshed.storeFailure) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals("the journal could not be read", snapshot.restoreFailure) + assertNotNull(PrivatePaymentsRecovery.recheckDelayMs(snapshot.readiness, 0)) + + // The failure was transient: the next refresh asks first, the retry + // restores, and the readiness shown is the one read after it. + refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { + configures++ + restorationFails = false + null + } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertEquals(2, configures) + assertFalse(snapshot.isRestoringPrivatePayments) + assertNull(snapshot.restoreFailure) + assertTrue("the gate lifts on the refresh alone", snapshot.allowsSubmission("01")) + assertNull(PrivatePaymentsRecovery.recheckDelayMs(snapshot.readiness, 1)) + } + + /** + * A refresh asks for the retry before it reads the readiness it shows: + * a screen that last showed "not restored" asks first and shows only the + * read made after it, so recovery without an isBound change re-enables + * the existing screen. + */ + @Test + fun shouldShowTheReadinessReadAfterTheRetry() = runTest { + val events = mutableListOf() + var restored = false + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = readiness(restored = false, storeRead = true), + storeFailure = null, + readReadiness = { + events += "read" + PrivateContactReadinessRead(readiness(restored = restored, storeRead = true), null) + }, + configureAgain = { + events += "configure" + restored = true + null + }, + ) + assertEquals(listOf("configure", "read"), events) + assertEquals(true, refreshed.read?.readiness?.privatePaymentsRestored) + val shown = PrivateRepliesPendingSnapshot() + .withReadiness(readiness(restored = false, storeRead = true)) + .recorded(privatelyHeld(), listOf("01")) + .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertTrue(shown.allowsSubmission("01")) + } + + /** Restored payments and an open store need no retry: one read. */ + @Test + fun shouldNotAskTheWalletToRetryWhenThePaymentsAreRestored() = runTest { + var configures = 0 + var reads = 0 + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = readiness(restored = true, storeRead = true), + storeFailure = null, + readReadiness = { + reads++ + PrivateContactReadinessRead(readiness(restored = true, storeRead = true), null) + }, + configureAgain = { + configures++ + null + }, + ) + assertEquals(0, configures) + assertEquals(1, reads) + assertEquals(true, refreshed.read?.readiness?.privatePaymentsRestored) + } + + /** + * Opening the store succeeds on the refresh after a failed open: the + * failure clears, whatever the readiness says. + */ + @Test + fun shouldOpenTheStoreAgainWhileAStoreFailureIsRecorded() = runTest { + var configures = 0 + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = null, + storeFailure = "disk full", + readReadiness = { null }, + configureAgain = { + configures++ + null + }, + ) + assertEquals(1, configures) + assertNull(refreshed.storeFailure) + } + + /** + * A restoration the wallet deferred behind a scan that holds the store for + * minutes: the screen keeps refreshing past the first minute (every 3 s + * for 20 refreshes, then every 15 s, with no last one), and the refresh + * after the deferred restoration completes re-enables submission and + * schedules no more. + */ + @Test + fun shouldKeepRefreshingWhileARestorationStaysDeferred() = runTest { + var deferredDone = false + val read: suspend () -> PrivateContactReadinessRead? = { + PrivateContactReadinessRead(readiness(restored = deferredDone, storeRead = true), null) + } + var snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) + val pauses = mutableListOf() + var refreshes = 0 + // Well past the first minute: 200 automatic refreshes, about 46 minutes. + for (attempt in 0 until 200) { + val refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { null } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + refreshes++ + if (attempt == 150) deferredDone = true + if (!PrivatePaymentsRecovery.waitForRecheck(snapshot.readiness, attempt) { pauses += it }) break + } + assertEquals("it kept refreshing until the restoration completed", 152, refreshes) + assertTrue(snapshot.allowsSubmission("01")) + assertEquals("no pause once the payments read restored", 151, pauses.size) + assertEquals(List(20) { PrivatePaymentsRecovery.FAST_RECHECK_INTERVAL_MS }, pauses.take(20)) + assertEquals(setOf(PrivatePaymentsRecovery.SLOW_RECHECK_INTERVAL_MS), pauses.drop(20).toSet()) + } + + /** + * The timer stops when the payments read restored, before any read + * succeeded, and when the screen leaves the composition (its wait is + * cancelled), however many refreshes came before. + */ + @Test + fun shouldStopRefreshingWhenRestoredOrWhenTheScreenGoesAway() = runTest { + var slept = false + assertFalse(PrivatePaymentsRecovery.waitForRecheck(readiness(restored = true, storeRead = true), 0) { slept = true }) + assertFalse("nothing is due once restored", slept) + assertFalse(PrivatePaymentsRecovery.waitForRecheck(null, 0) { slept = true }) + assertFalse(slept) + var refreshedAfter = false + try { + if (PrivatePaymentsRecovery.waitForRecheck(readiness(restored = false, storeRead = true), 500) { + throw CancellationException("the screen left the composition") + } + ) { + refreshedAfter = true + } + } catch (e: CancellationException) { + // The screen's effect ends with its cancelled wait. + } + assertFalse("a screen that went away refreshes no more", refreshedAfter) + } + + private fun readiness( + restored: Boolean, + storeRead: Boolean, + unresolved: Boolean = false, + restoreFailed: Boolean = false, + ) = Dashpay.PrivateContactReadiness( + privatePaymentsRestored = restored, + privateStoreRead = storeRead, + unresolvedPrivatePayment = unresolved, + privatePaymentsRestoreFailed = restoreFailed, + ) + + /** A store that cannot protect private payments (code 73) is a configuration error, shown as such. */ + @Test + fun shouldShowUnsupportedPrivateRecoveryAsAStorageError() { + assertEquals( + ContactRequestSubmissionOutcome.Failed(ContactRequestSubmissionOutcome.RECOVERY_UNSUPPORTED_MESSAGE), + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported("no atomic scan state"), + ), + ) + } + + /** Unrestored private payments (code 72) are their own outcome, apart from a pending request (71). */ + @Test + fun shouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { + assertEquals( + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored, + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored("not restored"), + ), + ) + assertEquals( + ContactRequestSubmissionOutcome.SETTING_UP_PRIVATE_PAYMENTS_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, restoring = true, error = null), + ) + } + + /** + * Code 72 after a failed restoration shows why it failed, read with the + * readiness, until a readiness says restored; a shielded store that did + * not open is shown over both. + */ + @Test + fun shouldShowWhyARestorationFailedUntilTheReadinessSaysRestored() { + var snapshot = PrivateRepliesPendingSnapshot() + .recorded(privatelyHeld(), listOf("01")) + .withReadiness( + readiness(restored = false, storeRead = false, restoreFailed = true), + "the journal does not read", + ) + assertTrue(snapshot.isRestoringPrivatePayments) + assertEquals("the journal does not read", snapshot.restoreFailure) + assertEquals( + ContactRequestSubmissionOutcome.restoreFailedMessage("the journal does not read"), + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + restoring = snapshot.isRestoringPrivatePayments, + restoreFailure = snapshot.restoreFailure, + error = null, + ), + ) + assertEquals( + ContactRequestSubmissionOutcome.shieldedStoreFailedMessage("disk full"), + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + restoring = true, + restoreFailure = snapshot.restoreFailure, + shieldedStoreFailure = "disk full", + error = null, + ), + ) + + // A readiness that no longer flags a failure drops the reason. + snapshot = snapshot.withReadiness(readiness(restored = false, storeRead = false), "stale") + assertNull(snapshot.restoreFailure) + + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = false)) + assertFalse(snapshot.isRestoringPrivatePayments) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** + * 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(privatelyHeld(), 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(privatelyHeld(), listOf("01")) + .withReadiness(readiness(restored = false, storeRead = false)) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + } + + /** + * A successful read that found nothing pending offers every contact it + * covered, and nothing it did not. + */ + @Test + fun shouldOfferEveryContactASuccessfulEmptyReadCovered() { + val snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) + assertFalse(snapshot.isUnavailable) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertFalse( + "a contact the read did not cover is not offered yet", + snapshot.allowsSubmission("02"), + ) + } + + @Test + fun shouldOfferARetryForAnOrdinaryFailure() { + val error = DashSdkError.PlatformWallet.ShieldedBroadcastFailed("rejected by the relay") + assertEquals( + ContactRequestSubmissionOutcome.Failed("rejected by the relay"), + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldStayQuietWhenTheNetworkDoesNotServeThePrivateStoreYet() { + assertNull( + PrivateContactRefreshNotice.notice( + DashSdkError.PlatformWallet.ProtocolVersionNotActive("needs protocol version 15"), + ), + ) + } + + @Test + fun shouldShowAPrivateRefreshThatFailed() { + assertEquals( + "Private requests could not be read: no node answered", + PrivateContactRefreshNotice.notice(DashSdkError.PlatformWallet.WalletOperation("no node answered")), + ) + } + + @Test + fun shouldShowRequestsThatWereReadButNotSaved() { + assertNull(PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 0)) + assertEquals( + "Private requests for 2 identities were read but not saved. Pull to refresh again.", + PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 2), + ) + } + + /** + * Code 76: an earlier public request to the pair awaits its outcome, so + * the wallet signed nothing. Not a failure to retry: the contact shows + * "being checked" and no submission is offered. + */ + @Test + fun shouldShowARefusedUnconfirmedPublicRequestAsBeingChecked() { + val outcome = ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed("an earlier request awaits its outcome"), + ) + assertEquals(ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, outcome) + val snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) + .withOutcome(outcome, "01") + assertTrue(snapshot.isAwaitingOutcome("01")) + assertFalse("not a private request pending", snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, + rowMessage(snapshot, "01"), + ) + } + + /** + * Code 20 on a public accept, followed by an empty private-pending read + * and a public sync that did not settle it: an unsuccessful one (the read + * after it fails) or an incomplete one (the wallet's public hold still + * stands). The row stays held through both; a second accept is not + * offered, and one made anyway is refused (76) with no error to retry; + * once the sync settles the hold the read frees the pair and the accept + * comes back. + */ + @Test + fun shouldKeepAPublicAcceptHeldAfterCode20ThroughAnEmptyPrivateReadAndAnUnsettledSync() = runTest { + val wallet = FakeContactRequestHolds() + var snapshot = PrivateRepliesPendingSnapshot().recorded(wallet.read(), listOf("01")) + assertTrue("the accept is offered", snapshot.allowsSubmission("01")) + + // The accept broadcasts and its result cannot be confirmed: the + // wallet holds the pair, the signed transition kept. + wallet.publicUnconfirmed = setOf("01") + val accepted = ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed("result could not be confirmed"), + ) + assertEquals(ContactRequestSubmissionOutcome.MayHaveGoneThrough, accepted) + snapshot = snapshot.withOutcome(accepted, "01") + assertFalse(snapshot.allowsSubmission("01")) + assertEquals(ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, rowMessage(snapshot, "01")) + + // An unsuccessful sync: the read after it fails, and the row stays + // held as the last read left it. + wallet.failure = DashSdkError.PlatformWallet.WalletOperation("no node answered") + snapshot = snapshot.recorded(wallet.read(), listOf("01")) + assertTrue(snapshot.isUnavailable) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals(ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, rowMessage(snapshot, "01")) + + // An incomplete sync: the private-pending read is empty, the public + // hold stands, and the row stays held. + wallet.failure = null + val reload = wallet.read() + assertEquals("the private-pending read is empty", emptySet(), reload?.privatelyHeld) + snapshot = snapshot.recorded(reload, listOf("01")) + assertTrue(snapshot.isAwaitingOutcome("01")) + assertFalse("a second accept is not offered", snapshot.allowsSubmission("01")) + assertEquals(ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, rowMessage(snapshot, "01")) + + // A second accept made anyway is refused before anything is signed. + val retried = ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed("an earlier request awaits its outcome"), + ) + snapshot = snapshot.withOutcome(retried, "01").recorded(wallet.read(), listOf("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + "a refusal while held is not an error to retry", + ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, + rowMessage(snapshot, "01"), + ) + + // The sync settles the hold (a proved-used nonce frees the pair; a + // request found is recorded as sent and the row leaves the list): the + // next read says free and the accept comes back. + wallet.publicUnconfirmed = emptySet() + snapshot = snapshot.recorded(wallet.read(), listOf("01")) + assertFalse(snapshot.isAwaitingOutcome("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertNull(rowMessage(snapshot, "01")) + } + + /** An unconfirmed submission marks only its own contact, and a read that does not cover it leaves the mark. */ + @Test + fun shouldKeepAnUnconfirmedSubmissionAReadDidNotCover() { + val snapshot = PrivateRepliesPendingSnapshot() + .withOutcome(ContactRequestSubmissionOutcome.MayHaveGoneThrough, "01") + .recorded(privatelyHeld(), listOf("02")) + assertTrue(snapshot.isAwaitingOutcome("01")) + assertTrue(snapshot.allowsSubmission("02")) + } + + /** + * The screens' read covers both holds: the private one and the public + * one. A failed read is null, never "nothing held"; a cancelled one is + * not swallowed. + */ + @Test + fun shouldReadBothHoldsAndTellAFailedReadFromNothingHeld() = runTest { + val wallet = FakeContactRequestHolds() + wallet.privatelyHeld = setOf("01") + wallet.publicUnconfirmed = setOf("02") + assertEquals(ContactRequestHolds(privatelyHeld = setOf("01"), publicUnconfirmed = setOf("02")), wallet.read()) + + wallet.failure = DashSdkError.PlatformWallet.WalletOperation("read failed") + assertNull(wallet.read()) + + wallet.failure = CancellationException("left the screen") + var cancelled = false + try { + wallet.read() + } catch (e: CancellationException) { + cancelled = true + } + assertTrue(cancelled) + } +} + +/** A read of both holds that found [contacts] held for a private request and none awaiting a public request's outcome. */ +private fun privatelyHeld(vararg contacts: String): ContactRequestHolds = + ContactRequestHolds(privatelyHeld = contacts.toSet()) + +/** The message the screens show for [contact], from [snapshot]. */ +private fun rowMessage(snapshot: PrivateRepliesPendingSnapshot, contact: String): String? = + ContactRequestSubmissionOutcome.rowMessage( + pending = snapshot.isPending(contact), + awaitingOutcome = snapshot.isAwaitingOutcome(contact), + restoring = snapshot.isRestoringPrivatePayments, + restoreFailure = snapshot.restoreFailure, + unavailable = snapshot.isUnavailable, + error = null, + ) + +/** The wallet's two per-contact holds, read through the screens' helper ([contactRequestHolds]) as the wallet answers them. */ +private class FakeContactRequestHolds { + var privatelyHeld: Set = emptySet() + var publicUnconfirmed: Set = emptySet() + + /** Thrown by every read when set. */ + var failure: Exception? = null + + suspend fun read(): ContactRequestHolds? = contactRequestHolds( + readPrivatelyHeld = { + failure?.let { throw it } + privatelyHeld + }, + readPublicUnconfirmed = { + failure?.let { throw it } + publicUnconfirmed + }, + ) +} diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index ff6bef9ca6e..7bc14e553d1 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **27** +Capabilities tracked: **29** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 16 | 9 | 1 | 1 | -| Swift | Example app | 4 | 12 | 2 | 9 | -| Kotlin | SDK | 13 | 12 | 1 | 1 | -| Kotlin | Example app | 5 | 12 | 1 | 9 | +| Swift | SDK | 16 | 11 | 1 | 1 | +| Swift | Example app | 4 | 14 | 2 | 9 | +| Kotlin | SDK | 13 | 14 | 1 | 1 | +| Kotlin | Example app | 5 | 13 | 1 | 10 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 1 | 8 | 18 | -| Kotlin | 4 | 6 | 17 | +| Swift | 1 | 10 | 18 | +| Kotlin | 4 | 8 | 17 | ## Capability status @@ -33,6 +33,8 @@ Capabilities tracked: **27** | `dashpay.invitations` | supported / partial / required | supported / partial / required | | `core.provider_restore` | supported / partial / required | partial / partial / required | | `dashpay.deferred_contact_crypto` | partial / partial / required | partial / partial / required | +| `dashpay.private_contact_requests` | partial / partial / required | partial / partial / required | +| `dashpay.private_contact_request_scan_state` | partial / partial / required | partial / not-applicable / required | | `identity.asset_lock_resume` | supported / partial / required | partial / partial / required | | `core.compact_filter_rescan` | supported / partial / not_applicable | partial / partial / not_applicable | | `dpns.contested_names_by_identity` | supported / partial / not_applicable | partial / partial / required | diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json new file mode 100644 index 00000000000..fcb3c307fa0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json @@ -0,0 +1,4284 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "df09e705e8b63a04af564a1257b4a2cd", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `dashPayBackfillFloor` INTEGER, `dashPayBackfillRewoundFrom` INTEGER, `dashPayBackfillCovered` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillFloor", + "columnName": "dashPayBackfillFloor", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillRewoundFrom", + "columnName": "dashPayBackfillRewoundFrom", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillCovered", + "columnName": "dashPayBackfillCovered", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `externalAccountReference` INTEGER, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "externalAccountReference", + "columnName": "externalAccountReference", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + }, + { + "tableName": "private_contact_requests_positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, `position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, `privatePaymentsUnresolved` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`walletId`, `identityId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyFingerprint", + "columnName": "keyFingerprint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "privatePaymentsUnresolved", + "columnName": "privatePaymentsUnresolved", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "identityId" + ] + } + }, + { + "tableName": "private_shielded_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `ownerId` BLOB NOT NULL, `contactId` BLOB NOT NULL, `address` BLOB NOT NULL, PRIMARY KEY(`walletId`, `ownerId`, `contactId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "ownerId", + "contactId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df09e705e8b63a04af564a1257b4a2cd')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index d9bfe5ee81b..419de77c636 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -4,6 +4,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.Dispatchers import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.DashpayNative +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.NativeLoader import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.ffi.SignerNative @@ -11,6 +13,7 @@ import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.persistence.DashDatabase import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -86,6 +89,62 @@ class FfiSmokeTest { assertEquals("compact recoverable ECDSA signature", 65, signature!!.size) } + /** + * DashPay private contact requests (protocol version 15): the + * `DashpayNative` symbols bind with the descriptors Kotlin declares, and + * bad input surfaces as a mapped [DashSDKException] rather than a crash. + * A null resolver is refused before any wallet lookup, and a shielded + * address that is not 43 bytes never reaches the FFI (which reads + * exactly 43). The explicit private send and private-reply accept are + * exported only by a shielded build, so they are probed only there; the + * sync, the reads and the in-kind accept are exported by every build. + * The flows themselves need a protocol version 15 network with shielded + * funds; their Rust side is covered by the platform-wallet and + * platform-wallet-ffi tests. + */ + @Test + fun privateContactRequestSymbolsBindAndRejectInvalidInput() { + NativeLoader.ensureLoaded() + assertThrows(DashSDKException::class.java) { + DashpayNative.syncPrivateContactRequests(0L, 0L, 1) + } + if (Sdk.hasShielded()) { + assertThrows(DashSDKException::class.java) { + DashpayNative.sendPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, ByteArray(32), ByteArray(32), + null, null, null, 0L, + ) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } + } + // An unknown wallet handle is NotFound. + assertThrows(DashSDKException::class.java) { + DashpayNative.privateContactReadiness(0L, ByteArray(32)) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptContactRequestReplyingInKind( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } + // The private contact read is benign on a miss (null, as Swift's + // nil); the pair-hold, public-unconfirmed and restore-failure reads + // report an unknown wallet handle. + assertNull(DashpayNative.managedIdentityPrivateContact(0L, ByteArray(32))) + assertThrows(DashSDKException::class.java) { + DashpayNative.privatePairHeld(0L, ByteArray(32), ByteArray(32)) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.publicContactRequestUnconfirmed(0L, ByteArray(32), ByteArray(32)) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.privatePaymentsRestoreFailure(0L, ByteArray(32)) + } + } + /** * The #4069 watermark-freeze fault latch surfaced by PR #4314: the * `syncFaultDetected` JNI symbol binds, and an invalid manager handle @@ -102,6 +161,63 @@ class FfiSmokeTest { } } + /** + * The private contact request scan state needs every override it + * depends on, and atomic changesets: JNI wires its slots only for a + * bridge that overrides the contact upsert native calls, the + * scan-state store, the scan-state load and the scan-state wallet list + * (a read position vouches for the requests an inherited no-op contact + * writer never stored, and a record kept marked after its wallet's + * removal reaches the wallet only through that list), 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 four and + * declaring both, and the Room handler, keep both. + */ + @Test + fun privateContactScanStateCapabilityNeedsEveryOverride() { + NativeLoader.ensureLoaded() + val bits = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or + ATOMIC_CHANGESETS + fun effective(bridge: NativePersistenceBridge): Boolean = + WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bits == bits + + assertTrue("every override", effective(ScanStateBridge.Full())) + assertFalse("no contact upsert", effective(ScanStateBridge.WithoutContactUpsert())) + assertFalse("no scan-state store", effective(ScanStateBridge.WithoutScanStateStore())) + assertFalse("no scan-state load", effective(ScanStateBridge.WithoutScanStateLoad())) + assertFalse( + "no scan-state wallet list", + effective(ScanStateBridge.WithoutScanStateWalletList()), + ) + assertFalse( + "every override, not declared", + effective(object : ScanStateBridge.Full() { + override fun persistenceCapabilitiesBits(): Long = 0L + }), + ) + assertFalse( + "every override, no atomic changesets", + effective(object : ScanStateBridge.Full() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + }), + ) + + val db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + try { + assertTrue( + "the Room handler", + effective(PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined)), + ) + } finally { + db.close() + } + } + /** * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve * each bridge method by (name, descriptor) only when its slot first @@ -127,3 +243,177 @@ class FfiSmokeTest { } } } + +/** + * Bridges declaring the private contact request scan state with some of the + * overrides it depends on, for + * [FfiSmokeTest.privateContactScanStateCapabilityNeedsEveryOverride]. Each + * concrete class declares exactly the overrides its name says. + */ +private abstract class ScanStateBridge : NativePersistenceBridge() { + override fun persistenceCapabilitiesVersion(): Int = 1 + + override fun persistenceCapabilitiesBits(): Long = + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or ATOMIC_CHANGESETS + + open class Full : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = 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)) + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = 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)) + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = 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 + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) + } + + class WithoutScanStateWalletList : 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)) + } +} + +/** `PersistenceCapabilities::ATOMIC_CHANGESETS` (bit 0). */ +private const val ATOMIC_CHANGESETS: Long = 1L diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 9a98201510b..19797f2678a 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -690,13 +690,63 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v15 -> v16 creates the two private contact request scan state tables + * empty (the first sync reads the store from its start) and leaves the + * existing rows alone; rows written afterwards keep every column, + * a full-width position included. + */ + @Test + fun migrate15To16AddsPrivateContactScanStateTables() { + helper.createDatabase(dbName, 15).apply { + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 2309809, 0, 0, 0, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 16, true, DashDatabase.MIGRATION_15_16) + + db.query("SELECT COUNT(*) FROM private_contact_requests_positions").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + db.query("SELECT syncedHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(2_309_809, c.getInt(0)) + } + db.execSQL( + "INSERT INTO private_contact_requests_positions (walletId, identityId, position, " + + "keyFingerprint) VALUES (x'01', x'0A', -1, x'03')", + ) + db.execSQL( + "INSERT INTO private_shielded_addresses (walletId, ownerId, contactId, address) " + + "VALUES (x'01', x'0A', x'0B', x'09')", + ) + db.query( + "SELECT position, keyFingerprint, privatePaymentsUnresolved FROM private_contact_requests_positions", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals("the u64 bit pattern survives", -1L, c.getLong(0)) + assertTrue(byteArrayOf(3).contentEquals(c.getBlob(1))) + assertEquals("the unresolved marker defaults to clear", 0, c.getInt(2)) + } + db.query("SELECT address FROM private_shielded_addresses WHERE contactId = x'0B'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(byteArrayOf(9).contentEquals(c.getBlob(0))) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -709,16 +759,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } - /** The full chain from v1 must also land on a valid v15 schema. */ + /** The full chain from v1 must also land on a valid v16 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -734,6 +785,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 81b2ea92985..91547fb3e1d 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,139 @@ sealed class DashSdkError( override val userMessage: String get() = SPV_PROCESS_RESTART_REQUIRED_USER_MESSAGE } + /** + * `ErrorPrivateContactRequestNeedsPrivateReply` (native code 68). The + * contact requests with that identity came or went privately + * (protocol version 15), so a public request to it, in reply or as a + * rotation, would publish the relationship. The public accept and + * send refuse it and nothing was sent. Accept it with + * `PlatformWalletManager.acceptContactRequestReplyingInKind` (which + * picks the reply itself) or `acceptPrivateContactRequest`, or send + * with `PlatformWalletManager.sendPrivateContactRequest`. Not + * retryable as-is: the same public call is refused again. + * ← Swift `PlatformWalletError.privateContactRequestNeedsPrivateReply` + * (`PlatformWalletResult.swift`). + */ + class PrivateContactRequestNeedsPrivateReply(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorShieldedBuildRequired` (native code 69). The operation needs + * the shielded pool and the native library was built without the + * `shielded` feature (`./build_android.sh --no-shielded`): today, + * accepting a contact request that came privately with + * `PlatformWalletManager.acceptContactRequestReplyingInKind`. Nothing + * was sent; a public or adopted reply works on such a build. Not + * retryable on the same build. + * ← Swift `PlatformWalletError.shieldedBuildRequired`. + */ + class ShieldedBuildRequired(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorProtocolVersionNotActive` (native code 70). The SDK did not + * send the request: the network is proven at a protocol version below + * the one that serves it, for example DashPay's private contact + * request store before protocol version 15. Expected on such a + * network, so a host may stay quiet about it; it lifts when the + * network upgrades. A refusal that may lift sooner (the version could + * not be confirmed) arrives as [Generic] instead, and an SDK pinned + * below the version as [SdkPinnedBelowProtocolVersion]. + * ← Swift `PlatformWalletError.protocolVersionNotActive`. + */ + class ProtocolVersionNotActive(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactRequestPending` (native code 71). A private + * contact request to that identity is pending: in flight (another + * private send or reply reserved it), paid for and not yet read back + * from the store, or a private payment the shielded store's journal + * keeps unresolved holds the pair. Nothing new was paid. Pending, not + * failed: do not submit it again. A `Dashpay.syncPrivateContactRequests` + * that reads the entry back frees it, as does the payment proven never + * to execute; an endpoint's refusal of the payment does not. + * `Dashpay.privateRepliesPending` reads whether the pair is still held. + * An unfinished private store read is [PrivateContactStoreNotRead]. + * ← Swift `PlatformWalletError.privateContactRequestPending`. + */ + class PrivateContactRequestPending(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactPaymentsNotRestored` (native code 72). The + * wallet, not one contact, is held: its private payments are not + * restored from the shielded store's journal, so no contact request, + * public or private, is sent or accepted, nothing signed or + * broadcast. The wallet was published with no shielded store + * configured (configure it when the manager is activated, before + * loading or creating wallets, and again after a failure), it bound a + * shielded account in an earlier process, its last restoration failed + * or waits for a busy store, or the identity was loaded marking a + * private payment that may be unresolved. + * `PlatformWalletManager.configureShielded` or binding the wallet's + * shielded account restores them; a private sync does not. + * `Dashpay.privateContactReadiness` reads whether they are and + * whether the last restoration failed, and + * `Dashpay.privatePaymentsRestoreFailure` why. + * ← Swift `PlatformWalletError.privateContactPaymentsNotRestored`. + */ + class PrivateContactPaymentsNotRestored(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactRecoveryUnsupported` (native code 73). The + * wallet's storage cannot keep a private payment's recovery state + * across a restart (it does not declare the private contact request + * scan state together with atomic changesets), so a private send or + * reply is refused before anything is sealed or paid. A + * configuration error of the store, not something a retry fixes. + * ← Swift `PlatformWalletError.privateContactRecoveryUnsupported`. + */ + class PrivateContactRecoveryUnsupported(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPrivateContactStoreNotRead` (native code 74). DashPay's + * private contact request store could not be read to its end for the + * identity before a contact request or acceptance, public or private, + * was decided: a node behind the store already seen, or keys that + * kept changing, stop the read. Nothing was reserved, sent or paid; + * calling again continues the read. Not a pending request. + * ← Swift `PlatformWalletError.privateContactStoreNotRead`. + */ + class PrivateContactStoreNotRead(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + + /** + * `ErrorSdkPinnedBelowProtocolVersion` (native code 75). The SDK is + * pinned below the protocol version the request needs, so it did not + * send it, whatever the network runs. A network upgrade does not lift + * it; only raising or removing the SDK's protocol version pin does: a + * developer's setting. + * ← Swift `PlatformWalletError.sdkPinnedBelowProtocolVersion`. + */ + class SdkPinnedBelowProtocolVersion(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorPublicContactRequestUnconfirmed` (native code 76). A public + * contact request to that identity was broadcast and may have been + * delivered, and no proof settled its outcome yet, so the wallet + * holds the pair and signed nothing new to it, public or private. + * The send or accept that broadcast it threw + * [TransactionBroadcastUnconfirmed] (20). Not retryable as it is: a + * DashPay sync settles it (the request found: recorded as sent; its + * nonce proved used: the pair freed), and + * `Dashpay.publicContactRequestsUnconfirmed` reads whether it still + * stands. Not a private request pending ([PrivateContactRequestPending]). + * ← Swift `PlatformWalletError.publicContactRequestUnconfirmed`. + */ + class PublicContactRequestUnconfirmed(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -860,6 +993,17 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired + // ErrorPrivateContactRequestNeedsPrivateReply: the explicitly + // public accept or send refused a contact that went private. + 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) + 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired + 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive + 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending + 72 -> PlatformWallet.PrivateContactPaymentsNotRestored(message, cause) // ErrorPrivateContactPaymentsNotRestored + 73 -> PlatformWallet.PrivateContactRecoveryUnsupported(message, cause) // ErrorPrivateContactRecoveryUnsupported + 74 -> PlatformWallet.PrivateContactStoreNotRead(message, cause) // ErrorPrivateContactStoreNotRead + 75 -> PlatformWallet.SdkPinnedBelowProtocolVersion(message, cause) // ErrorSdkPinnedBelowProtocolVersion + 76 -> PlatformWallet.PublicContactRequestUnconfirmed(message, cause) // ErrorPublicContactRequestUnconfirmed 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..60f99620a5e 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,145 @@ internal object DashpayNative { signerHandle: Long, nowUnix: Long, ): IdentityRegistrationNativeResult + + // ── DashPay private contact requests (protocol version 15) ──────── + // + // ← Swift `PrivateContactRequests.swift` + // (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + + /** + * Read DashPay's private contact request store for every identity of + * the wallet, at most [maxPages] pages, and ingest what it holds for + * them. The store is opened with keys derived inside + * [coreSignerHandle] (the manager's `MnemonicResolverHandle`). Ingested + * requests reach Room through the persister; no handles cross. Returns + * six longs: requests ingested, pages read, entries refused, identities + * whose ingest did not reach disk, the store position the pass stopped + * at, and 1 when it read to the store's last entry (else 0). Blocking. + * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. + */ + external fun syncPrivateContactRequests( + walletHandle: Long, + coreSignerHandle: Long, + maxPages: Int, + ): LongArray? + + /** + * Send a DashPay private contact request, paid from [account]'s + * shielded notes on [walletId]. [resolverHandle] supplies the Orchard + * spend authority and the DashPay keys; [signerHandle] is the identity + * signer [TokensNative.sendContactRequest] takes. [shieldedAddress] + * must be 43 raw Orchard bytes when set. Needs the `shielded` native + * feature. Returns the created `ContactRequest` handle (destroy via + * [TokensNative.contactRequestDestroy]). + * ← Swift `PlatformWalletManager.sendPrivateContactRequest`. + */ + @Suppress("LongParameterList") + external fun sendPrivateContactRequest( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + senderIdentityId: ByteArray, + recipientIdentityId: ByteArray, + accountLabel: String?, + autoAcceptProof: ByteArray?, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long + + /** + * Accept the incoming contact request [requestHandle] with a private + * reply, paid from [account]'s shielded notes. Handles as for + * [sendPrivateContactRequest]. Returns the `EstablishedContact` handle + * (destroy via [TokensNative.establishedContactDestroy]). + * ← Swift `PlatformWalletManager.acceptPrivateContactRequest`. + */ + @Suppress("LongParameterList") + external fun acceptPrivateContactRequest( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + requestHandle: Long, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long + + /** + * What a managed identity's private contact requests with [contactId] + * carry: a JSON object (`receivedPrivately`, `sentPrivately`, + * `shieldedAddress` lower hex or null), or null when neither tracked + * request between them came or went privately. + * ← Swift `ManagedIdentity.privateContact(contactId:)`. + */ + external fun managedIdentityPrivateContact(identityHandle: Long, contactId: ByteArray): String? + + /** + * Whether a contact request from [senderId], one of the wallet's + * identities, to [recipientId], public or private, is held for a private + * one, as a send decides it (`platform_wallet_get_private_pair_held`): a + * private request in flight, paid and not yet read back, or kept by the + * shielded store's journal. A journal record discharged since is applied + * first. An unknown wallet handle throws (NotFound), as does a sender the + * wallet does not hold. + * ← Swift `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)`. + */ + external fun privatePairHeld(walletHandle: Long, senderId: ByteArray, recipientId: ByteArray): Boolean + + /** + * Whether a public contact request from [senderId], one of the wallet's + * identities, to [recipientId] was broadcast and may have been + * delivered, with no proof settling its outcome yet + * (`platform_wallet_get_public_contact_request_unconfirmed`). While it + * stands, the wallet refuses every contact request or acceptance to the + * recipient (code 76); a DashPay sync settles it. An unknown wallet + * handle throws (NotFound), as does a sender the wallet does not hold. + * ← Swift `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)`. + */ + external fun publicContactRequestUnconfirmed( + walletHandle: Long, + senderId: ByteArray, + recipientId: ByteArray, + ): Boolean + + /** + * What a contact request from [identityId], one of the wallet's + * identities, waits for, as the wallet decides it: four flags, + * `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment, + * privatePaymentsRestoreFailed]` + * (`platform_wallet_get_private_contact_readiness`). A journal record + * discharged since is applied first. 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? + + /** + * Why the last restoration of the wallet's private payments failed, + * while they are not restored for [identityId] + * (`platform_wallet_get_private_payments_restore_failure`), or null when + * none failed. Throws as [privateContactReadiness] does. + * ← Swift `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)`. + */ + external fun privatePaymentsRestoreFailure(walletHandle: Long, identityId: ByteArray): String? + + /** + * Accept the incoming contact request [requestHandle] replying the way + * it came: privately, paid from [account]'s shielded notes, when it came + * privately and no reply exists yet; otherwise with a document, or by + * adopting the reply already sent. The wallet decides. Arguments and + * result as for [acceptPrivateContactRequest]. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. + */ + @Suppress("LongParameterList") + external fun acceptContactRequestReplyingInKind( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + requestHandle: Long, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 7d6ba3275c5..ae4d7946395 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,33 @@ 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 15, `0x8000`; bit 14 is `ASSET_LOCK_SHIELDED_RECIPIENTS`): the + * private contact request sync's scan state is stored on its round + * and restored at load. A store declaring it keeps a position record + * marked unresolved through its wallet's removal, until a record + * stored cleared for the same wallet and identity replaces it, and + * lists that wallet ([onLoadPrivateContactScanStateWallets]). The + * one Kotlin home of this bit, aliased by the public diagnostic + * mirror. + */ + const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = 1L shl 15 + + /** + * Packed row widths of [onPersistPrivateContactScanState] and + * [onLoadPrivateContactScanState]: a read position is the identity + * id (32), the position (u64, little-endian), the key fingerprint + * (32) and the unresolved private payment marker (1: 0 or 1); a + * shielded address the owner id (32), the contact id (32) and the + * address (43); an address removal the owner and contact ids; a + * cleared identity its id. + */ + const val PRIVATE_POSITION_ENTRY_SIZE: Int = 32 + 8 + 32 + 1 + const val PRIVATE_ADDRESS_ENTRY_SIZE: Int = 32 + 32 + 43 + const val PRIVATE_ADDRESS_REMOVAL_ENTRY_SIZE: Int = 32 + 32 + const val PRIVATE_CLEARED_IDENTITY_ENTRY_SIZE: Int = 32 } /** @@ -471,6 +498,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 +958,27 @@ 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_load_private_contact_scan_state_wallets_fn`: the ids of every + * wallet the store keeps scan state for, 32 bytes each, packed back to + * back, a removed wallet whose position records were kept marked + * included. Rust reads each one its wallet-list load does not return + * through [onLoadPrivateContactScanState] and hands its marked records to + * the wallet as retained. Descriptor `()[B`. Throw on a store that cannot + * be read. + */ + open fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) + /** * `on_get_core_tx_record_fn`. Returns the record for `txid` or `null` * if no row exists. Descriptor diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 58351c59fbb..1b887c5599b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -63,6 +63,16 @@ internal object WalletManagerNative { bridge: NativePersistenceBridge, ): String? + /** + * The persistence capability bits a manager created with [bridge] would + * attest, without creating one: its declaration intersected with the + * callbacks JNI wires for it, built exactly as [nativeCreate] builds + * them (which methods the bridge overrides decides some of those). The + * instrumented suite checks with it that a capability needs every + * override it depends on. + */ + external fun nativeEffectivePersistenceCapabilityBits(bridge: NativePersistenceBridge): Long + // ── Wallet creation / restore ───────────────────────────────────── /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 0283ac2904f..0b3445a5397 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -18,6 +18,7 @@ import org.dashfoundation.dashsdk.persistence.dao.DpnsNameDao import org.dashfoundation.dashsdk.persistence.dao.IdentityDao import org.dashfoundation.dashsdk.persistence.dao.InvitationDao import org.dashfoundation.dashsdk.persistence.dao.PlatformAddressDao +import org.dashfoundation.dashsdk.persistence.dao.PrivateContactScanStateDao import org.dashfoundation.dashsdk.persistence.dao.PublicKeyDao import org.dashfoundation.dashsdk.persistence.dao.ShieldedDao import org.dashfoundation.dashsdk.persistence.dao.StorageCountsDao @@ -45,6 +46,8 @@ import org.dashfoundation.dashsdk.persistence.entities.KeywordEntity import org.dashfoundation.dashsdk.persistence.entities.PendingInputEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressesSyncStateEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PropertyEntity import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedActivityEntity @@ -179,9 +182,17 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * treated the account as rotated, tore it down and rebuilt it — and that * rebuild was the per-launch cursor rewind. NULL for pre-migration rows: * native rebuilds once, stamps it, and never again. + * + * Version 16 (private contact request scan state): adds the + * `private_contact_requests_positions` and `private_shielded_addresses` + * tables, the scan state native writes through + * `onPersistPrivateContactScanState` and reads back through + * `onLoadPrivateContactScanState`, so a relaunch resumes the private contact + * request sync where it stopped. Empty after the migration: the first sync + * reads the store from its start, as every sync did before. */ @Database( - version = 15, + version = 16, exportSchema = true, entities = [ WalletEntity::class, @@ -218,6 +229,8 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti ShieldedSyncStateEntity::class, ShieldedViewingKeyEntity::class, WalletManagerMetadataEntity::class, + PrivateContactRequestsPositionEntity::class, + PrivateShieldedAddressEntity::class, ], ) @TypeConverters(Converters::class) @@ -241,6 +254,7 @@ abstract class DashDatabase : RoomDatabase() { abstract fun shieldedDao(): ShieldedDao abstract fun walletManagerMetadataDao(): WalletManagerMetadataDao abstract fun storageCountsDao(): StorageCountsDao + abstract fun privateContactScanStateDao(): PrivateContactScanStateDao companion object { const val DATABASE_NAME: String = "dash-sdk.db" @@ -716,6 +730,28 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v15 -> v16: the two private contact request scan state tables, see + * the version-16 class doc above. Created empty. + */ + val MIGRATION_15_16: Migration = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `private_contact_requests_positions` (" + + "`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, " + + "`position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, " + + "`privatePaymentsUnresolved` INTEGER NOT NULL DEFAULT 0, " + + "PRIMARY KEY(`walletId`, `identityId`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `private_shielded_addresses` (" + + "`walletId` BLOB NOT NULL, `ownerId` BLOB NOT NULL, " + + "`contactId` BLOB NOT NULL, `address` BLOB NOT NULL, " + + "PRIMARY KEY(`walletId`, `ownerId`, `contactId`))", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -746,6 +782,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 4a5d469053d..b33f99a467d 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,62 @@ 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 onLoadPrivateContactScanStateWallets(): ByteArray = + loadOrThrow { + runBlockingResult { + database.privateContactScanStateDao().walletIdsWithPositions() + .fold(ByteArray(0)) { packed, id -> packed + id } + } + } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { val round = openRound(walletId) round?.finalityAdvanced = true @@ -2180,9 +2239,16 @@ class PlatformWalletPersistenceHandler( externalAccountReference: Int, ): Int = guarded { stage(walletId) { db -> - // Owner identity must exist; skip silently otherwise (replayed - // next round), reading networkRaw off the owner. - val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + // The owner identity must exist (networkRaw is read off it). + // Within a round identities apply before contacts, and native + // stores contact requests only of identities it manages, so it + // is there; when it is not, the round fails rather than being + // acknowledged without the row: this store declares the private + // contact request scan state, so an acknowledged round would let + // native store a read position past a request that was never + // saved, and no later scan would read it again. + val owner = db.identityDao().getByIdentityId(ownerId) + ?: throw IllegalStateException("contact request owner identity is not persisted") db.dashpayDao().upsertContactRequest( DashpayContactRequestEntity( networkRaw = owner.networkRaw, @@ -2231,9 +2297,10 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> if (isIgnored) { db.dashpayDao().deleteContactRequest(ownerId, senderId, isOutgoing = false) - // Owner identity must exist (networkRaw is read off it); - // skip silently otherwise — replayed next round. - val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + // The owner identity must exist (networkRaw is read off it); + // the round fails otherwise, as for a contact upsert. + val owner = db.identityDao().getByIdentityId(ownerId) + ?: throw IllegalStateException("ignored sender owner identity is not persisted") db.dashpayDao().upsertIgnoredSender( DashpayIgnoredSenderEntity( networkRaw = owner.networkRaw, @@ -3650,6 +3717,12 @@ class PlatformWalletPersistenceHandler( database.shieldedDao().deleteActivityByWallet(walletId) database.shieldedDao().deleteSyncStatesByWallet(walletId) database.shieldedDao().deleteViewingKeysByWallet(walletId) + // A position record marked unresolved outlives the wallet: its + // private payment may still execute, and the next load hands it + // back as retained, so a wallet registered again holds the + // identity it names. + database.privateContactScanStateDao().deleteUnmarkedPositionsByWallet(walletId) + database.privateContactScanStateDao().deleteAddressesByWallet(walletId) // The wallet row itself — cascades accounts → core / platform // addresses. @@ -4135,6 +4208,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 +4629,79 @@ internal fun contractBoundsJsonToId(blob: ByteArray): ByteArray? { private fun now(): java.util.Date = java.util.Date() private fun nowSeconds(): Long = System.currentTimeMillis() / 1000 +/** [packed] split into its rows of [size] bytes; a partial row is refused. */ +internal fun chunks(packed: ByteArray, size: Int): List { + require(packed.size % size == 0) { "packed rows must be $size bytes each" } + return (0 until packed.size / size).map { packed.copyOfRange(it * size, (it + 1) * size) } +} + +/** Rows of [NativePersistenceBridge.onPersistPrivateContactScanState]'s `positions`. */ +internal fun unpackPrivatePositions( + walletId: ByteArray, + packed: ByteArray, +): List = + chunks(packed, NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE).map { row -> + PrivateContactRequestsPositionEntity( + walletId = walletId.copyOf(), + identityId = row.copyOfRange(0, 32), + position = java.nio.ByteBuffer.wrap(row, 32, 8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).long, + keyFingerprint = row.copyOfRange(40, 72), + privatePaymentsUnresolved = when (row[72].toInt()) { + 0 -> false + 1 -> true + else -> throw IllegalArgumentException("the unresolved private payment marker must be 0 or 1") + }, + ) + } + +/** Rows of [NativePersistenceBridge.onPersistPrivateContactScanState]'s `addresses`. */ +internal fun unpackPrivateAddresses( + walletId: ByteArray, + packed: ByteArray, +): List = + chunks(packed, NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE).map { row -> + PrivateShieldedAddressEntity( + walletId = walletId.copyOf(), + ownerId = row.copyOfRange(0, 32), + contactId = row.copyOfRange(32, 64), + address = row.copyOfRange(64, 107), + ) + } + +/** + * [rows] packed as [NativePersistenceBridge.onLoadPrivateContactScanState] + * hands them back. A stored row whose bytes have the wrong length (Room + * keeps any blob) cannot cross the ABI: it throws, which fails the load + * through [PlatformWalletPersistenceHandler.onLoadPrivateContactScanState]'s + * `loadOrThrow`, rather than handing back the rest without it. + */ +internal fun packPrivatePositions(rows: List): ByteArray { + require(rows.all { it.identityId.size == 32 && it.keyFingerprint.size == 32 }) { + "a stored private contact request read position has a malformed id or fingerprint" + } + val buffer = java.nio.ByteBuffer + .allocate(rows.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (row in rows) { + buffer.put(row.identityId).putLong(row.position).put(row.keyFingerprint) + .put(if (row.privatePaymentsUnresolved) 1.toByte() else 0.toByte()) + } + return buffer.array() +} + +/** See [packPrivatePositions]. */ +internal fun packPrivateAddresses(rows: List): ByteArray { + require(rows.all { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 }) { + "a stored private shielded address has a malformed id or address" + } + val buffer = java.nio.ByteBuffer.allocate(rows.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) + for (row in rows) { + buffer.put(row.ownerId).put(row.contactId).put(row.address) + } + return buffer.array() +} + /** * Split a DIP-0018 bech32m platform address into `(addressType, 20-byte * hash)` — mirror of Swift `platformAddressComponents(fromBech32m:)`. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt new file mode 100644 index 00000000000..b3ad12bed63 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt @@ -0,0 +1,57 @@ +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 + + /** + * The wallet's position records, except those marked unresolved: a + * marked record stands for a private payment that may still execute, so + * it outlives its wallet until a record stored cleared for the same + * wallet and identity replaces it. + */ + @Query( + "DELETE FROM private_contact_requests_positions " + + "WHERE walletId = :walletId AND privatePaymentsUnresolved = 0", + ) + suspend fun deleteUnmarkedPositionsByWallet(walletId: ByteArray) + + /** The ids of every wallet with a stored position record, a removed wallet's kept ones included. */ + @Query("SELECT DISTINCT walletId FROM private_contact_requests_positions") + suspend fun walletIdsWithPositions(): List + + @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..214a9055e65 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * Port of `PersistentPrivateContactRequestsPosition.swift`: where one of the + * wallet's identities reads DashPay's private contact request store from + * next, so a relaunch resumes the private contact request sync instead of + * reading the store from its start. Written in the same changeset round as + * the contact requests read before it. + * + * Keyed by the network-scoped wallet id; no foreign key, so the wallet + * deletion purges it explicitly (`deleteWalletDataLocked`), except a record + * whose [privatePaymentsUnresolved] is set, which outlives its wallet until a + * record stored cleared for the same wallet and identity replaces it; and + * the round that removes the identity clears it (`clearedIdentities`). + */ +@Entity( + tableName = "private_contact_requests_positions", + primaryKeys = ["walletId", "identityId"], +) +data class PrivateContactRequestsPositionEntity( + val walletId: ByteArray, + val identityId: ByteArray, + /** The store position read from next; a `u64` bit pattern. */ + val position: Long, + /** Fingerprint of the scanning keys [position] holds for; opaque here. */ + val keyFingerprint: ByteArray, + /** + * Whether a private contact request the identity paid for may still be + * unresolved; handed back as it came. Loaded set, native holds the + * identity's public contact requests until its private payments are + * restored from the shielded store. + */ + @ColumnInfo(defaultValue = "0") + val privatePaymentsUnresolved: Boolean = false, +) + +/** + * Port of `PersistentPrivateShieldedAddress.swift`: the shielded address a + * private contact's tracked request to one of the wallet's identities + * carries, kept so a relaunch has it without reading the private store again. + */ +@Entity( + tableName = "private_shielded_addresses", + primaryKeys = ["walletId", "ownerId", "contactId"], +) +data class PrivateShieldedAddressEntity( + val walletId: ByteArray, + /** The wallet-owned identity the request is to. */ + val ownerId: ByteArray, + /** The contact who sent the request. */ + val contactId: ByteArray, + /** The raw 43-byte Orchard payment address. */ + val address: ByteArray, +) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 62eb7d33457..4060881efa7 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,298 @@ class Dashpay internal constructor(private val walletHandle: Long, } } + // ── DashPay private contact requests (protocol version 15) ──────── + // + // ← Swift `PrivateContactRequests.swift` + // (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + // A private request is sealed to one of the recipient's DashPay keys and + // paid for from the shielded pool, so nothing on chain names either + // side; it is signed by the same identity signer as a public request. + // The wallet records it as an ordinary contact request, so the Room rows + // and [contacts] read it. The send and the private-reply accept are + // shielded spends and live on `PlatformWalletManager`. + + /** + * How an identity and one contact exchanged private contact requests. + * ← Swift `PrivateContactInfo`. + */ + data class PrivateContactInfo( + /** The contact's request came privately, so accepting it replies privately. */ + val receivedPrivately: Boolean, + /** This identity's request to the contact went privately. */ + val sentPrivately: Boolean, + /** The 43-byte Orchard address the contact asked to be paid at privately. */ + val shieldedAddress: ByteArray?, + ) { + override fun equals(other: Any?): Boolean = + other is PrivateContactInfo && receivedPrivately == other.receivedPrivately && + sentPrivately == other.sentPrivately && + (shieldedAddress?.contentEquals(other.shieldedAddress) ?: (other.shieldedAddress == null)) + + override fun hashCode(): Int = + (31 * receivedPrivately.hashCode() + sentPrivately.hashCode()) * 31 + + (shieldedAddress?.contentHashCode() ?: 0) + + internal companion object { + /** Decode the JSON object `DashpayNative.managedIdentityPrivateContact` returns. */ + fun fromJson(json: String): PrivateContactInfo { + val obj = org.json.JSONObject(json) + val address = if (obj.isNull("shieldedAddress")) { + null + } else { + obj.getString("shieldedAddress").chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + return PrivateContactInfo( + receivedPrivately = obj.getBoolean("receivedPrivately"), + sentPrivately = obj.getBoolean("sentPrivately"), + shieldedAddress = address, + ) + } + } + } + + /** + * What a contact request from one of the wallet's identities waits for, + * as the wallet decides it, so a host shows "restoring private payments" + * apart from "reading the private store" and from a contact's pending + * request rather than inferring them from the refusals it got. + * ← Swift `PrivateContactReadiness`. + */ + data class PrivateContactReadiness( + /** + * Whether the wallet's private payments are restored for the + * identity. While false, its contact requests and acceptances, public + * or private, are refused with + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] + * until the shielded store is configured or the wallet's shielded + * account bound, or a contact request's own retry of a failed + * restoration succeeds; 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 with its current keys. Every contact + * request and acceptance reads it again from the cursor before it is + * decided, and is refused with + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactStoreNotRead] + * while that read cannot finish. + */ + val privateStoreRead: Boolean, + /** + * Whether a private contact request the identity paid for may still + * be unresolved: the marker its private scan state stores, or a + * payment the shielded store's journal keeps unresolved. One pair's + * hold is [privateRepliesPending]. + */ + val unresolvedPrivatePayment: Boolean, + /** + * Whether the last restoration of the wallet's private payments + * failed while [privatePaymentsRestored] is false: the hold then + * lasts until one succeeds. [privatePaymentsRestoreFailure] reads why. + */ + val privatePaymentsRestoreFailed: Boolean = false, + ) + + /** What one pass over DashPay's private contact request store did. */ + data class PrivateContactSyncResult( + /** The requests the pass ingested, received and sent, including ones already held. */ + val requestCount: Long, + /** The store pages read. */ + val pagesRead: Long, + /** Entries that opened for one of the wallet's identities but were refused. */ + val rejectedCount: Long, + /** The store position the pass stopped at. */ + val nextPosition: Long, + /** Whether the pass read to the store's last entry. When false, sync again to read on. */ + val complete: Boolean, + /** Identities whose ingest did not reach disk; the next pass reads their entries again. */ + val unpersistedIdentityCount: Long, + ) + + /** + * Read DashPay's private contact request store for every identity of + * this wallet, at most [maxPages] pages (the default reads to the end), + * and ingest what it holds for them: requests to them, each sender's + * signature checked, and requests they sent, recovered from the store + * alone. The ingested requests reach Room through the persister. + * + * The store is opened with keys derived inside [coreSignerHandle], the + * manager's `MnemonicResolverHandle`. Where the persister attests + * `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (the Room handler does), each + * identity's read position is stored with the requests read before it + * and restored at load, so a pass after launch reads on from there; + * otherwise the first pass after launch reads the store from its start. + * Private requests reach the wallet only through this read. + * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. + */ + suspend fun syncPrivateContactRequests( + coreSignerHandle: Long, + maxPages: Int = Int.MAX_VALUE, + ): PrivateContactSyncResult = gate.op { + val counters = mapNativeErrors { + DashpayNative.syncPrivateContactRequests(walletHandle, coreSignerHandle, maxPages) + } + checkNotNull(counters) { "native syncPrivateContactRequests returned no report" } + check(counters.size == 6) { "expected 6 sync counters, got ${counters.size}" } + PrivateContactSyncResult( + requestCount = counters[0], + pagesRead = counters[1], + rejectedCount = counters[2], + nextPosition = counters[4], + complete = counters[5] != 0L, + unpersistedIdentityCount = counters[3], + ) + } + + /** + * How [ourIdentityId] and [contactId] exchanged private contact + * requests: whether the contact's request came privately, whether ours + * went privately, and the shielded address the contact asked to be paid + * at (only when its request came privately, and only once a private + * sync this launch has read it). Null when neither tracked request + * between them came or went privately, or the identity isn't managed by + * this wallet. Read from a fresh managed-identity snapshot; local read. + * ← Swift `ManagedIdentity.privateContact(contactId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun privateContact( + ourIdentityId: ByteArray, + contactId: ByteArray, + ): PrivateContactInfo? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(ourIdentityId) { handle -> + DashpayNative.managedIdentityPrivateContact(handle, contactId) + }?.let(PrivateContactInfo::fromJson) + } + } + + /** + * What a contact request from [ourIdentityId] waits for, as the wallet + * decides it ([PrivateContactReadiness]). Not a pure read: a journal + * record discharged since (a payment proven never to execute) is applied + * first, briefly taking the wallet's write lock, as a send would see it; + * then it reads 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 == 4) { "expected 4 readiness flags, got ${flags.size}" } + PrivateContactReadiness( + privatePaymentsRestored = flags[0], + privateStoreRead = flags[1], + unresolvedPrivatePayment = flags[2], + privatePaymentsRestoreFailed = flags[3], + ) + } + + /** + * Why the last restoration of the wallet's private payments failed, + * while they are not restored for [ourIdentityId] and none succeeded + * since, or null when none failed (restored, or only not restored yet). + * What [PrivateContactReadiness.privatePaymentsRestoreFailed] flags. + * Throws for an identity the wallet does not hold. + * ← Swift `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)`. + */ + suspend fun privatePaymentsRestoreFailure(ourIdentityId: ByteArray): String? = + withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.privatePaymentsRestoreFailure(walletHandle, ourIdentityId) } + } + + /** + * The contacts among [contactIds] whose pair with [ourIdentityId] the + * wallet holds for a private contact request, as a send decides it + * (`DashPayView::private_pair_held`, one read per contact): a private + * request in flight, paid and not yet read back from the store, or kept + * by the shielded store's journal, also after the identity was removed + * and added again. While a pair is held, Rust refuses another private + * send or reply to it + * ([DashSdkError.PlatformWallet.PrivateContactRequestPending]) and holds + * a public one, so a host shows it as pending and offers neither. A + * private sync that reads the entry back frees it, as does the payment + * proven never to execute (its anchor pruned, which the shielded sync + * judges); an endpoint's refusal of the payment does not. A journal + * record discharged since is applied first. Empty when the identity + * isn't managed. + * ← Swift `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)` + * (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 { + // An identity the wallet does not manage holds nothing. + val managed = withManagedIdentity(ourIdentityId) { true } ?: false + if (!managed) { + emptyList() + } else { + contactIds.filter { DashpayNative.privatePairHeld(walletHandle, ourIdentityId, it) } + } + } + } + + /** + * The contacts among [contactIds] to which a public contact request from + * [ourIdentityId] was broadcast and may have been delivered, with no + * proof settling its outcome yet (`DashPayView::public_contact_request_unconfirmed`, + * one read per contact). The send or accept that broadcast it threw + * [DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed] (20); + * while it stands, the wallet refuses every contact request or acceptance + * to the contact, public or private + * ([DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed], 76), so + * a host shows the pair as unconfirmed and offers neither. A DashPay sync + * settles it: a request found is recorded as sent, a nonce proved used + * frees the pair, and otherwise the same signed bytes are broadcast + * again. Read it beside [privateRepliesPending]. Empty when the identity + * isn't managed. + * ← Swift `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun publicContactRequestsUnconfirmed( + ourIdentityId: ByteArray, + contactIds: List, + ): List = withContext(Dispatchers.IO) { + if (contactIds.isEmpty()) return@withContext emptyList() + mapNativeErrors { + // An identity the wallet does not manage holds nothing. + val managed = withManagedIdentity(ourIdentityId) { true } ?: false + if (!managed) { + emptyList() + } else { + contactIds.filter { + DashpayNative.publicContactRequestUnconfirmed(walletHandle, ourIdentityId, it) + } + } + } + } + + /** + * The incoming contact request from [senderId] to [ourIdentityId] in + * local state, or null when there is none (or the identity isn't + * managed by this wallet). Close the returned ref when done. This is + * the request `PlatformWalletManager.acceptContactRequestReplyingInKind` + * takes: the accept that replies privately to a request that came + * privately, and with a document otherwise. Local read; no network I/O. + * ← Swift `ManagedIdentity.getIncomingContactRequest(senderId:)`. + */ + suspend fun incomingContactRequest( + ourIdentityId: ByteArray, + senderId: ByteArray, + ): ContactRequestRef? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(ourIdentityId) { identityHandle -> + val requestHandle = + TokensNative.getIncomingContactRequest(identityHandle, senderId) + if (requestHandle == 0L) null else ContactRequestRef(requestHandle) + } + } + } + // ── DashPay read surface (upstream #3841 parity) ─────────────────── // // JSON-string reads over the managed-identity snapshot, following the diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 325504110da..ab4207b1213 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -41,6 +41,8 @@ import org.dashfoundation.dashsdk.security.IdentityKeyPrivateKeyDeriver import org.dashfoundation.dashsdk.security.KeystoreSigner import org.dashfoundation.dashsdk.security.MnemonicResolverAndPersister import org.dashfoundation.dashsdk.security.WalletStorage +import org.dashfoundation.dashsdk.tokens.ContactRequestRef +import org.dashfoundation.dashsdk.tokens.EstablishedContactRef import java.util.concurrent.atomic.AtomicLong /** Effective native persistence contract exposed for initialization diagnostics. */ @@ -77,6 +79,15 @@ data class PlatformWalletPersistenceCapabilities( * is deliberately absent: Android never attests it). */ const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + /** + * The private contact request sync's scan state (each identity's + * read position in DashPay's private contact request store, and its + * private contacts' shielded addresses) is stored on its round and + * restored at load. Mirrors + * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`. + */ + const val PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE } } @@ -2130,6 +2141,153 @@ 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. + * + * Before anything is paid the wallet reads DashPay's private store for + * the sender to its end. Throws + * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when the payment + * may already be on chain, + * [DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed] when the + * SDK could not confirm a transition it may have accepted, and + * [DashSdkError.PlatformWallet.PrivateContactRequestPending] when the + * pair is held for a private request: in each case do not send again. + * [DashSdkError.PlatformWallet.PrivateContactStoreNotRead] sent nothing; + * calling again continues the read. + * [DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] and + * [DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported] refuse + * before the read. + * + * @return the created request; close it (or `use {}`) when done. + */ + suspend fun sendPrivateContactRequest( + walletId: ByteArray, + senderIdentityId: ByteArray, + recipientIdentityId: ByteArray, + accountLabel: String? = null, + autoAcceptProof: ByteArray? = null, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): ContactRequestRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.sendPrivateContactRequest( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + senderIdentityId, + recipientIdentityId, + accountLabel, + autoAcceptProof, + shieldedAddress, + signerHandle, + ) + } + ContactRequestRef(handle) + } + + /** + * Accept a DashPay contact request that came privately, replying + * privately and paying for the reply from [account]'s shielded notes on + * [walletId]. Port of Swift's + * `PlatformWalletManager.acceptPrivateContactRequest` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * A host accepts with [acceptContactRequestReplyingInKind], which + * picks this reply itself; this explicit accept stays for a host that + * wants a private reply. The reply is signed by this manager's identity + * signer ([signerHandle]). + * [shieldedAddress] is the 43-byte raw Orchard address the contact may + * pay us at privately. Blocks for the Halo 2 proof. + * + * @return the established contact; close it (or `use {}`) when done. + */ + suspend fun acceptPrivateContactRequest( + walletId: ByteArray, + request: ContactRequestRef, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): EstablishedContactRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.acceptPrivateContactRequest( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + request.value, + shieldedAddress, + signerHandle, + ) + } + EstablishedContactRef(handle) + } + + /** + * Accept a DashPay contact request replying the way it came: privately, + * paid from [account]'s shielded notes on [walletId], when it came + * privately and no reply exists yet; otherwise with a `contactRequest` + * document, or by adopting the reply already sent. Which reply a request + * takes is the wallet's decision, so this is the accept a host calls. + * Port of Swift's `PlatformWalletManager.acceptContactRequestReplyingInKind` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * [request] is the incoming request (`Dashpay.incomingContactRequest`). + * The reply is signed by this manager's identity signer ([signerHandle]); + * the Orchard spend authority comes from this manager's resolver and is + * derived only for a private reply. [shieldedAddress] is the 43-byte raw + * Orchard address the contact may pay us at privately; a public reply + * ignores it. + * + * A public or adopted reply needs neither shielded support nor a + * mnemonic read for the payment. Throws + * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] (or + * [DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed]) when a + * private reply may already be on chain, or + * [DashSdkError.PlatformWallet.PrivateContactRequestPending] when the pair + * is held for one: do not accept again; + * [DashSdkError.PlatformWallet.PrivateContactStoreNotRead] when the + * private store read every accept runs first did not finish (nothing + * sent; calling again continues it); and + * [DashSdkError.PlatformWallet.ShieldedBuildRequired] when the request + * needs a private reply and the native library has no shielded support. + * + * @return the established contact; close it (or `use {}`) when done. + */ + suspend fun acceptContactRequestReplyingInKind( + walletId: ByteArray, + request: ContactRequestRef, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): EstablishedContactRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.acceptContactRequestReplyingInKind( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + request.value, + shieldedAddress, + signerHandle, + ) + } + EstablishedContactRef(handle) + } + /** * Shielded → Core L1 withdrawal (Type 19) — port of Swift's * `PlatformWalletManager.shieldedWithdraw(walletId:account:toCoreAddress:amount:coreFeePerByte:)` diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index ff568acb553..f56f0736e5e 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 @@ -284,6 +284,122 @@ class DashSdkErrorTest { ) } + @Test + fun shouldRouteARequestThatNeedsAPrivateReplyByItsOwnCode() { + // PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply = 68 + // (rs-platform-wallet-ffi/src/error.rs); a host catches this type to + // accept privately, as the Swift app does on its typed error. + val detail = "the contact requests with 9fRYTJZW came or went privately; " + + "reply to or rotate with it privately" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 68, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) + assertEquals(detail, mapped.message) + assertEquals(detail, mapped.userMessage) + assertEquals(native, mapped.cause) + assertFalse("the same public call is refused again", mapped.isRetryable) + } + + @Test + fun shouldTellAPrivateReplyOnABuildWithoutShieldedSupportByItsOwnCode() { + val detail = "this contact request needs a private reply, which is paid " + + "from the shielded pool; this build has no shielded support" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 69, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.ShieldedBuildRequired) + assertEquals(detail, mapped.message) + assertFalse("the same build refuses it again", mapped.isRetryable) + } + + @Test + fun shouldTellARequestTheNetworkDoesNotServeYetByItsOwnCode() { + val detail = "get_private_document_entries needs protocol version 15; " + + "the network is proven at protocol version 14" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 70, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.ProtocolVersionNotActive) + assertEquals(detail, mapped.message) + } + + @Test + fun shouldReportAPendingPrivateRequestByItsOwnCode() { + val detail = "a private contact request to 9fRYTJZW is in flight or not yet read " + + "back from the store; sync private contact requests to settle it" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 71, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRequestPending) + assertEquals(detail, mapped.message) + assertFalse("pending, not a failure to retry", mapped.isRetryable) + } + + /** Unrestored private payments hold the wallet: their own code, apart from 71. */ + @Test + fun shouldReportUnrestoredPrivatePaymentsByTheirOwnCode() { + val detail = "the wallet's private payments are not restored from the shielded store yet" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 72, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored) + assertEquals(detail, mapped.message) + assertFalse("held, not a failure to retry", mapped.isRetryable) + } + + /** A store that cannot keep a private payment's recovery state has its own code. */ + @Test + fun shouldReportUnsupportedPrivateContactRecoveryByItsOwnCode() { + val detail = "the persister does not keep the private contact request scan state atomically" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 73, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported) + assertEquals(detail, mapped.message) + assertFalse("a configuration error, not a retry", mapped.isRetryable) + } + + /** + * An unfinished private store read has its own code, apart from a + * pending request (71): nothing was reserved, and calling again + * continues the read. + */ + @Test + fun shouldReportAnUnfinishedPrivateStoreReadByItsOwnCode() { + val detail = "the private contact request store was not read to its end for 9fRYTJZW" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 74, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactStoreNotRead) + assertEquals(detail, mapped.message) + assertTrue("calling again continues the read", mapped.isRetryable) + } + + /** + * An SDK pinned below the protocol version has its own code, apart from a + * network below it (70), which lifts by itself. + */ + @Test + fun shouldReportAnSdkPinnedBelowTheProtocolVersionByItsOwnCode() { + val detail = "get_private_document_entries needs protocol version 15; " + + "the SDK is pinned at protocol version 14" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 75, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion) + assertEquals(detail, mapped.message) + assertFalse("only raising the pin lifts it", mapped.isRetryable) + } + + /** + * A public request awaiting its outcome has its own code, apart from a + * private request pending (71): a DashPay sync, not a private one, + * settles it. + */ + @Test + fun shouldReportAnUnconfirmedPublicRequestByItsOwnCode() { + val detail = "a public contact request to this identity awaits its outcome" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 76, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed) + assertFalse(mapped is DashSdkError.PlatformWallet.PrivateContactRequestPending) + assertEquals(detail, mapped.message) + assertFalse("a sync settles it, not a retry", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 41326e84f92..ac1ae6f77ae 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,16 +233,17 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion15WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion16WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. // 11 → 12 adds the identity key usage limits columns on top, // 12 → 13 the contract bounds kind, 13 → 14 the token - // once-per-identity distribution block, and 14 → 15 the DashPay - // backfill record columns on `wallets`. - assertEquals(15, db.openHelper.readableDatabase.version) + // once-per-identity distribution block, 14 → 15 the DashPay + // backfill record columns on `wallets`, and 15 → 16 the private + // contact request scan state tables. + assertEquals(16, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 978b0c43e09..6a40c939b72 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,10 +220,13 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) + // The private contact request scan state is bit 15 (0x8000). + assertEquals(0x8bbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) + // Nor does it keep the shielded recipient of an asset lock (bit 14). + assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x4000L) val diagnostic = PlatformWalletPersistenceCapabilities( handler.persistenceCapabilitiesVersion(), @@ -234,6 +237,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..d23ea1ca9f1 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -0,0 +1,293 @@ +package org.dashfoundation.dashsdk.persistence + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The private contact request scan state (read positions and private + * contacts' shielded addresses) round-trips through Room in the packed rows + * the JNI trampolines exchange. Port of + * `PrivateContactScanStatePersistenceTests.swift`. + */ +@RunWith(RobolectricTestRunner::class) +class PrivateContactScanStatePersistenceTest { + + private lateinit var db: DashDatabase + private lateinit var handler: PlatformWalletPersistenceHandler + + private val walletId = ByteArray(32) { 42 } + private val owner = ByteArray(32) { 7 } + private val contact = ByteArray(32) { 5 } + private val otherContact = ByteArray(32) { 6 } + private val fingerprint = ByteArray(32) { 3 } + + @Before + fun setUp() { + db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + assertEquals(0, handler.onPersistWalletMetadata(walletId, 1, ByteArray(32) { 2 }, 0)) + } + + @After + fun tearDown() { + db.close() + } + + private fun position(identityId: ByteArray, position: Long, unresolved: Boolean = false): ByteArray = + java.nio.ByteBuffer.allocate(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .put(identityId).putLong(position).put(fingerprint) + .put(if (unresolved) 1.toByte() else 0.toByte()) + .array() + + private fun address(ownerId: ByteArray, contactId: ByteArray, fill: Int): ByteArray = + ownerId + contactId + ByteArray(43) { fill.toByte() } + + private fun round( + positions: ByteArray = ByteArray(0), + addresses: ByteArray = ByteArray(0), + removed: ByteArray = ByteArray(0), + cleared: ByteArray = ByteArray(0), + success: Boolean = true, + ) { + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals( + 0, + handler.onPersistPrivateContactScanState(walletId, positions, addresses, removed, cleared), + ) + assertEquals(0, handler.onChangesetEnd(walletId, success)) + } + + private fun loaded(): Pair>, List>> { + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + val positionRows = unpackPrivatePositions(walletId, positions) + .map { it.identityId.toHex() to it.position } + val addressRows = unpackPrivateAddresses(walletId, addresses) + .map { it.contactId.toHex() to it.address[0].toInt() } + return positionRows to addressRows + } + + @Test + fun shouldStoreReplaceAndRemoveTheScanStateWithItsRound() { + round( + positions = position(owner, 12), + addresses = address(owner, contact, 9) + address(owner, otherContact, 8), + ) + var (positions, addresses) = loaded() + assertEquals(listOf(owner.toHex() to 12L), positions) + assertEquals(setOf(contact.toHex() to 9, otherContact.toHex() to 8), addresses.toSet()) + + // A rewind writes the lower position; a tombstone removes one address. + round(positions = position(owner, 0), removed = owner + otherContact) + loaded().let { (p, a) -> positions = p; addresses = a } + assertEquals(listOf(owner.toHex() to 0L), positions) + assertEquals(listOf(contact.toHex() to 9), addresses) + + // A rolled-back round leaves the stored state as it was. + round(positions = position(owner, 40), success = false) + assertEquals(listOf(owner.toHex() to 0L), loaded().first) + + // The unresolved private payment marker is stored with the position, + // handed back as it came, and cleared by a later round. + round(positions = position(owner, 0, unresolved = true)) + assertArrayEquals(position(owner, 0, unresolved = true), handler.onLoadPrivateContactScanState(walletId)[0]) + assertTrue(unpackPrivatePositions(walletId, handler.onLoadPrivateContactScanState(walletId)[0]).single().privatePaymentsUnresolved) + round(positions = position(owner, 0)) + assertFalse(unpackPrivatePositions(walletId, handler.onLoadPrivateContactScanState(walletId)[0]).single().privatePaymentsUnresolved) + // A marker byte other than 0 or 1 is not a row. + assertThrows(IllegalArgumentException::class.java) { + unpackPrivatePositions(walletId, position(owner, 0).also { it[72] = 2 }) + } + + // A full-width position survives the signed column. + round(positions = position(owner, -1L)) + assertEquals(listOf(owner.toHex() to -1L), loaded().first) + val (packed, _) = handler.onLoadPrivateContactScanState(walletId) + assertArrayEquals(position(owner, -1L), packed) + } + + @Test + fun shouldClearTheScanStateOfARemovedIdentity() { + val survivor = ByteArray(32) { 1 } + round( + positions = position(owner, 12) + position(survivor, 4), + addresses = address(owner, contact, 9) + address(survivor, contact, 1), + ) + round(cleared = owner) + val (positions, addresses) = loaded() + assertEquals(listOf(survivor.toHex() to 4L), positions) + assertEquals(listOf(contact.toHex() to 1), addresses) + } + + @Test + fun shouldScopeTheScanStateByWalletAndPurgeItWithTheWallet() = runTest { + round(positions = position(owner, 12), addresses = address(owner, contact, 9)) + val (otherPositions, otherAddresses) = + handler.onLoadPrivateContactScanState(ByteArray(32) { 43 }) + assertEquals(0, otherPositions.size) + assertEquals(0, otherAddresses.size) + + handler.deleteWalletData(walletId) + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + assertEquals(0, positions.size) + assertEquals(0, addresses.size) + } + + /** + * A position record marked unresolved survives its wallet's purge and is + * listed for the load to hand back as retained; an unmarked record and + * every address go with the wallet; a cleared record stored later for the + * same wallet and identity replaces it, so the next purge removes it. + */ + @Test + fun shouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt() = runTest { + val unmarked = ByteArray(32) { 1 } + round( + positions = position(owner, 12, unresolved = true) + position(unmarked, 4), + addresses = address(owner, contact, 9), + ) + assertArrayEquals(walletId, handler.onLoadPrivateContactScanStateWallets()) + + handler.deleteWalletData(walletId) + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + assertArrayEquals( + "only the marked record outlives the wallet", + position(owner, 12, unresolved = true), + positions, + ) + assertEquals(0, addresses.size) + assertArrayEquals( + "the removed wallet is still listed for its kept record", + walletId, + handler.onLoadPrivateContactScanStateWallets(), + ) + + // The wallet registered again stores the record cleared. + assertEquals(0, handler.onPersistWalletMetadata(walletId, 1, ByteArray(32) { 2 }, 0)) + round(positions = position(owner, 0)) + assertFalse( + unpackPrivatePositions(walletId, handler.onLoadPrivateContactScanState(walletId)[0]) + .single().privatePaymentsUnresolved, + ) + handler.deleteWalletData(walletId) + assertEquals(0, handler.onLoadPrivateContactScanState(walletId)[0].size) + assertEquals(0, handler.onLoadPrivateContactScanStateWallets().size) + } + + /** The wallet list packs one 32-byte id per wallet with stored records. */ + @Test + fun shouldListEveryWalletWithStoredScanStateOnce() { + assertEquals(0, handler.onLoadPrivateContactScanStateWallets().size) + round(positions = position(owner, 12) + position(ByteArray(32) { 1 }, 4)) + assertArrayEquals(walletId, handler.onLoadPrivateContactScanStateWallets()) + } + + /** + * A stored row that cannot cross the ABI fails the load instead of + * handing back the rest without it: a valid position beside a 42-byte + * address would otherwise resume past the entry that carried it. + */ + @Test + fun shouldFailTheLoadOnAStoredRowThatCannotCrossTheAbi() = runTest { + round(positions = position(owner, 12)) + db.privateContactScanStateDao().upsertAddress( + org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity( + walletId = walletId, + ownerId = owner, + contactId = contact, + address = ByteArray(42) { 9 }, + ), + ) + assertThrows(IllegalArgumentException::class.java) { + handler.onLoadPrivateContactScanState(walletId) + } + } + + private fun upsertContact(ownerId: ByteArray): Int = handler.onPersistContactUpsert( + walletId, ownerId, contact, false, 0, 0, 0, ByteArray(96), null, null, 0, 0L, + false, null, null, false, null, IntArray(0), false, 0, + ) + + /** + * A contact request whose owner identity is not persisted fails the + * round instead of being skipped: this store declares the private + * contact request scan state, so an acknowledged round without the row + * would let native store a read position past it. The rolled-back round + * leaves nothing, and once the owner is persisted the same upsert (the + * next scan's) lands. + */ + @Test + fun shouldFailTheRoundOnAContactRequestWhoseOwnerIsNotPersisted() = runTest { + val unknownOwner = ByteArray(32) { 0x77 } + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, upsertContact(unknownOwner)) + assertNotEquals("the round fails", 0, handler.onChangesetEnd(walletId, true)) + assertTrue(db.dashpayDao().getContactRequestsByOwner(unknownOwner).isEmpty()) + + // Outside a round the callback itself fails. + assertNotEquals(0, upsertContact(unknownOwner)) + + // An ignore for an unknown owner fails its round too. + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, handler.onPersistContactIgnored(walletId, unknownOwner, contact, true)) + assertNotEquals(0, handler.onChangesetEnd(walletId, true)) + + db.identityDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.IdentityEntity( + identityId = unknownOwner, + networkRaw = 1, + walletId = walletId, + ), + ) + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, upsertContact(unknownOwner)) + assertEquals(0, handler.onChangesetEnd(walletId, true)) + assertEquals(1, db.dashpayDao().getContactRequestsByOwner(unknownOwner).size) + } + + /** A partial row fails the callback instead of storing a misread row. */ + @Test + fun shouldRefuseARoundWithAPartialRow() { + assertEquals(0, handler.onChangesetBegin(walletId)) + assertNotEquals( + 0, + handler.onPersistPrivateContactScanState( + walletId, ByteArray(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE - 1), + ByteArray(0), ByteArray(0), ByteArray(0), + ), + ) + assertEquals(0, handler.onChangesetEnd(walletId, false)) + assertTrue(loaded().first.isEmpty()) + assertThrows(IllegalArgumentException::class.java) { + unpackPrivateAddresses(walletId, ByteArray(NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE + 1)) + } + } + + /** The inherited bridge stores nothing and loads two empty arrays. */ + @Test + fun aBridgeWithoutTheScanStateStoresNothingAndLoadsEmpty() { + val noOp = object : NativePersistenceBridge() {} + assertEquals(0L, noOp.persistenceCapabilitiesBits()) + assertEquals( + 0, + noOp.onPersistPrivateContactScanState( + walletId, position(owner, 1), ByteArray(0), ByteArray(0), ByteArray(0), + ), + ) + val (positions, addresses) = noOp.onLoadPrivateContactScanState(walletId) + assertEquals(0, positions.size + addresses.size) + } +} diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 4e949c6e9fa..a5655430abd 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -183,15 +183,15 @@ Fork-era numbers remain in the collision history, which is immutable record. | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy, and in Platform consensus for an instant proof): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | | 61 | `ErrorAssetLockRecipientMismatch` | dashpay/platform#5603 (tracked-lock shielded recipient) | Proposed — a shielded fund-from-asset-lock resume (the generic outpoint resume or a repeated-UUID wallet drain) names a different Orchard recipient than the one the tracked lock recorded when it was built. Nothing was submitted or paid; resume with the recorded recipient, which the row and `TrackedAssetLockFFI` expose. Rows tracked before recipients were recorded carry none and accept the caller's. Typed in Rust, Swift and Kotlin | -| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. Returned by the public send and accept; the host sends with `platform_wallet_manager_send_private_contact_request`, or accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`. Swift types it (`PlatformWalletError.privateContactRequestNeedsPrivateReply`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | -| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | -| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin typing is added by the Kotlin PR (#5813) | -| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin typing is added by the Kotlin PR (#5813) | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored, it configures the store again (on a store already open, the wallet's own retry of every wallet still held, a restoration that failed after the open included) and reads the readiness after that, refreshing again on a timer (every 3 s for a minute, then every 15 s) for as long as the view is shown and the payments stay unrestored, and when a shielded sync pass ends; it shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | -| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin typing is added by the Kotlin PR (#5813) | -| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin typing is added by the Kotlin PR (#5813) | -| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin typing is added by the Kotlin PR (#5813) | -| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. `platform_wallet_get_public_contact_request_unconfirmed` reads the hold per pair (`DashPayView::public_contact_request_unconfirmed`; 7 for a sender the wallet does not hold). Swift types it (`PlatformWalletError.publicContactRequestUnconfirmed`, `ErrorHandlingTests` pin) and wraps the read (`ManagedPlatformWallet.publicContactRequestUnconfirmed`); SwiftExampleApp shows the pair as being checked, with no request or accept offered, until the read says settled, and its refreshes run the DashPay sync that settles it; Kotlin typing is added by the Kotlin PR (#5813) | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. Returned by the public send and accept; the host sends with `platform_wallet_manager_send_private_contact_request`, or accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`. Swift types it (`PlatformWalletError.privateContactRequestNeedsPrivateReply`, `ErrorHandlingTests` pin); Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply`, `DashSdkErrorTest` pin) | +| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin types it (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) | +| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin types it (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`, `DashSdkErrorTest` pin) and KotlinExampleApp keeps a refresh quiet on it | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin; `Dashpay.privateRepliesPending` reads the pair's hold through JNI `DashpayNative.privatePairHeld`), and KotlinExampleApp keeps the pair pending until that read says free | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored, it configures the store again (on a store already open, the wallet's own retry of every wallet still held, a restoration that failed after the open included) and reads the readiness after that, refreshing again on a timer (every 3 s for a minute, then every 15 s) for as long as the view is shown and the payments stay unrestored, and when a shielded sync pass ends; it shows "setting up" or the failure's reason until the readiness reads restored. Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored`, `DashSdkErrorTest` pin) and binds both reads (`Dashpay.privateContactReadiness`, `privatePaymentsRestoreFailure`); KotlinExampleApp configures the shielded store before loading wallets and on each DashPay refresh retries as SwiftExampleApp does, showing "setting up" or the failure's reason | +| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported`, `DashSdkErrorTest` pin), KotlinExampleApp shows it as a storage configuration error, and the Room handler declares bit 0 and bit 15 with the begin and end callbacks wired | +| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactStoreNotRead`, `DashSdkErrorTest` pin) and KotlinExampleApp offers a retry with no pending row | +| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin types it (`DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion`, `DashSdkErrorTest` pin) and KotlinExampleApp shows a developer-facing message | +| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. `platform_wallet_get_public_contact_request_unconfirmed` reads the hold per pair (`DashPayView::public_contact_request_unconfirmed`; 7 for a sender the wallet does not hold). Swift types it (`PlatformWalletError.publicContactRequestUnconfirmed`, `ErrorHandlingTests` pin) and wraps the read (`ManagedPlatformWallet.publicContactRequestUnconfirmed`); SwiftExampleApp shows the pair as being checked, with no request or accept offered, until the read says settled, and its refreshes run the DashPay sync that settles it; Kotlin types it (`DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed`, `DashSdkErrorTest` pin), bridges the read (`DashpayNative.publicContactRequestUnconfirmed`, `Dashpay.publicContactRequestsUnconfirmed`), and KotlinExampleApp shows the pair as SwiftExampleApp does | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b6b36194a90..b240a4f261a 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1,7 +1,8 @@ //! JNI bridge for the DashPay read surface added by the iOS DashPay //! completion (upstream #3841): payment history, cached contact profiles, //! per-identity sync state, wallet-scoped DPNS search and per-account -//! wallet balances. +//! wallet balances; and DashPay private contact requests (protocol +//! version 15): the store sync, and the shielded send and accept. //! //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DashpayNative`, //! driven by `org.dashfoundation.dashsdk.tokens.Dashpay` and @@ -20,15 +21,22 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; -use crate::support::{guard, take_pwffi_error}; +use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jobject, jstring}; +use jni::sys::{ + jboolean, jbooleanArray, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE, +}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; +use platform_wallet_ffi::error::{platform_wallet_ffi_result_free, PlatformWalletFFIResultCode}; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; use platform_wallet_ffi::invitation::InvitationPreviewFFI; +use platform_wallet_ffi::PrivateContactReadinessFFI; +use platform_wallet_ffi::{ + ContactRequestHandleArray, PrivateContactFFI, PrivateContactSyncReportFFI, +}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; @@ -1210,3 +1218,734 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn } }) } + +// ── DashPay private contact requests (protocol version 15) ──────────── +// +// Mirrors Swift's `PrivateContactRequests.swift`: the store sync is a +// wallet-handle call (`ManagedPlatformWallet.syncPrivateContactRequests`); +// the send and the private-reply accept are manager-handle shielded spends +// (`PlatformWalletManager.sendPrivateContactRequest` / +// `acceptPrivateContactRequest`), gated like the other shielded spends. The +// accept that replies the way a request came +// (`acceptContactRequestReplyingInKind`) is exported in every build. + +/// The bytes of an Orchard payment address the FFI reads from a +/// `shielded_address` pointer: an 11-byte diversifier and a 32-byte +/// transmission key. +const SHIELDED_ADDRESS_LEN: usize = 43; + +/// The longs [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests`] +/// returns, in the order `Dashpay.syncPrivateContactRequests` reads them: +/// the requests the pass ingested, the store pages read, the entries +/// refused, the identities whose ingest did not reach disk, the store +/// position the pass stopped at, and whether it read to the store's last +/// entry (1) or not (0). +fn private_contact_sync_longs(requests: usize, report: &PrivateContactSyncReportFFI) -> [i64; 6] { + [ + i64::try_from(requests).unwrap_or(i64::MAX), + i64::from(report.pages_read), + i64::from(report.rejected_count), + i64::from(report.unpersisted_identity_count), + i64::try_from(report.next_position).unwrap_or(i64::MAX), + i64::from(report.complete), + ] +} + +/// Destroy every `ContactRequest` handle of `array`, free its buffer, and +/// return how many handles it held. Kotlin reads ingested requests from Room +/// (the persister writes them), so the handles are not handed across. +/// +/// # Safety +/// `array` must come from a platform-wallet-ffi enumerator and not yet be +/// freed. +unsafe fn release_contact_request_handles(array: &mut ContactRequestHandleArray) -> usize { + let count = array.count; + if !array.handles.is_null() && count > 0 { + for handle in std::slice::from_raw_parts(array.handles, count) { + let mut result = platform_wallet_ffi::contact_request_destroy(*handle); + platform_wallet_ffi_result_free(&mut result); + } + } + platform_wallet_ffi::platform_wallet_contact_request_handle_array_free(array); + count +} + +/// Read DashPay's private contact request store for every identity of the +/// wallet, at most `maxPages` pages, and ingest what it holds for them +/// (bridges `platform_wallet_sync_private_contact_requests`). The store is +/// opened with keys derived inside `coreSignerHandle`, the manager's +/// `MnemonicResolverHandle`. Ingested requests reach Room through the +/// persister; their handles are released here. Returns the six counters of +/// [`private_contact_sync_longs`]. Blocking (network). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + core_signer_handle: jlong, + max_pages: jint, +) -> jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + if max_pages < 0 { + throw_sdk_exception(env, 1, "maxPages must be non-negative"); + return ptr::null_mut(); + } + let mut array = ContactRequestHandleArray::empty(); + let mut report = PrivateContactSyncReportFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_sync_private_contact_requests( + wallet_handle as Handle, + core_signer_handle as *mut MnemonicResolverHandle, + max_pages as u32, + &mut array, + &mut report, + ) + }; + // The FFI leaves an empty array on every failure, so releasing it + // first is safe on both paths. + let requests = unsafe { release_contact_request_handles(&mut array) }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let longs = private_contact_sync_longs(requests, &report); + let Ok(out) = env.new_long_array(longs.len() as i32) else { + return ptr::null_mut(); + }; + if env.set_long_array_region(&out, 0, &longs).is_err() { + return ptr::null_mut(); + } + out.into_raw() + }) +} + +/// A private contact read as the compact JSON object `managedIdentityPrivateContact` +/// returns: `receivedPrivately`, `sentPrivately`, and `shieldedAddress` (the +/// 43-byte Orchard address as lower hex, or `null`). +fn private_contact_json(contact: &PrivateContactFFI) -> String { + let address = if contact.has_shielded_address { + let hex: String = contact + .shielded_address + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + json_string(&hex) + } else { + "null".to_string() + }; + format!( + "{{\"receivedPrivately\":{},\"sentPrivately\":{},\"shieldedAddress\":{}}}", + contact.received_privately, contact.sent_privately, address + ) +} + +/// What this identity's private contact requests with `contactId` carry off a +/// managed-identity handle (bridges `managed_identity_get_private_contact`): +/// the JSON object of [`private_contact_json`], or null when neither tracked +/// request between them came or went privately (the FFI's NotFound, benign +/// here as for `TokensNative.getIncomingContactRequest`). +/// ← Swift `ManagedIdentity.privateContact(contactId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityPrivateContact( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, + contact_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &contact_id, "contactId") else { + return ptr::null_mut(); + }; + let mut contact = PrivateContactFFI { + received_privately: false, + sent_privately: false, + has_shielded_address: false, + shielded_address: [0; 43], + }; + let mut result = unsafe { + platform_wallet_ffi::managed_identity_get_private_contact( + identity_handle as Handle, + id.as_ptr(), + &mut contact, + ) + }; + if result.code == PlatformWalletFFIResultCode::NotFound { + unsafe { platform_wallet_ffi_result_free(&mut result) }; + return ptr::null_mut(); + } + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + new_jstring(env, private_contact_json(&contact)) + }) +} + +/// Whether a contact request from `senderId`, one of the wallet's identities, +/// to `recipientId`, public or private, is held for a private one, as a send +/// decides it (bridges `platform_wallet_get_private_pair_held`): a private +/// request in flight, paid and not yet read back, or kept by the shielded +/// store's journal. A journal record discharged since is applied first. +/// Throws on an unknown wallet or sender, returning false. +/// ← Swift `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privatePairHeld( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + sender_id: JByteArray, + recipient_id: JByteArray, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let Some(sender) = read_id32(env, &sender_id, "senderId") else { + return JNI_FALSE; + }; + let Some(recipient) = read_id32(env, &recipient_id, "recipientId") else { + return JNI_FALSE; + }; + let mut held = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_private_pair_held( + wallet_handle as Handle, + sender.as_ptr(), + recipient.as_ptr(), + &mut held, + ) + }; + if take_pwffi_error(env, result) { + return JNI_FALSE; + } + if held { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + +/// Whether a public contact request from `senderId`, one of the wallet's +/// identities, to `recipientId` was broadcast and may have been delivered, +/// with no proof settling its outcome yet (bridges +/// `platform_wallet_get_public_contact_request_unconfirmed`). While it +/// stands, the wallet refuses every contact request or acceptance to the +/// recipient with code 76; a DashPay sync settles it. Throws on an unknown +/// wallet or sender, returning false. +/// ← Swift `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_publicContactRequestUnconfirmed( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + sender_id: JByteArray, + recipient_id: JByteArray, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let Some(sender) = read_id32(env, &sender_id, "senderId") else { + return JNI_FALSE; + }; + let Some(recipient) = read_id32(env, &recipient_id, "recipientId") else { + return JNI_FALSE; + }; + let mut unconfirmed = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_public_contact_request_unconfirmed( + wallet_handle as Handle, + sender.as_ptr(), + recipient.as_ptr(), + &mut unconfirmed, + ) + }; + if take_pwffi_error(env, result) { + return JNI_FALSE; + } + if unconfirmed { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + +/// `DashpayNative.privatePaymentsRestoreFailure`: why the last restoration +/// of the wallet's private payments failed, while they are not restored for +/// `identity_id` (bridges `platform_wallet_get_private_payments_restore_failure`), +/// or null when none failed. Throws on an unknown wallet or identity, +/// returning null. +/// ← Swift `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privatePaymentsRestoreFailure( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let mut reason: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_private_payments_restore_failure( + wallet_handle as Handle, + id.as_ptr(), + &mut reason, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if reason.is_null() { + return ptr::null_mut(); + } + // SAFETY: a non-null `reason` is the NUL-terminated string the call + // allocated; it is read once, then released to its allocator. + let text = unsafe { CStr::from_ptr(reason) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(reason) }; + new_jstring(env, text) + }) +} + +/// `DashpayNative.privateContactReadiness`: the wallet's readiness for +/// `identity_id`'s contact requests +/// (`platform_wallet_get_private_contact_readiness`) as four flags, +/// `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment, +/// privatePaymentsRestoreFailed]`. +/// 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; 4] { + 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), + flag(readiness.private_payments_restore_failed), + ] +} + +/// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns +/// `Err` on a JNI error. `field` names the argument in the message. +fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { + if arr.is_null() { + return Ok(None); + } + match env.convert_byte_array(arr) { + Ok(bytes) => Ok(Some(bytes)), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); + Err(()) + } + } +} + +/// Copy an optional Orchard address. The FFI reads exactly +/// [`SHIELDED_ADDRESS_LEN`] bytes from the pointer it is given and has no +/// length to check against, so any other length is refused here before the +/// pointer crosses. +fn read_opt_shielded_address(env: &mut JNIEnv, arr: &JByteArray) -> Result>, ()> { + let address = read_opt_bytes(env, arr, "shieldedAddress")?; + if let Some(address) = &address { + if address.len() != SHIELDED_ADDRESS_LEN { + throw_sdk_exception( + env, + 1, + &format!( + "shieldedAddress must be {SHIELDED_ADDRESS_LEN} raw Orchard bytes, got {}", + address.len() + ), + ); + return Err(()); + } + } + Ok(address) +} + +/// Send a DashPay private contact request from `senderIdentityId` to +/// `recipientIdentityId`, paying for it from `account`'s shielded notes +/// (bridges `platform_wallet_manager_send_private_contact_request`). +/// `resolverHandle` supplies the Orchard spend authority and the DashPay +/// keys; `signerHandle` is the identity signer (`SignerHandle`) the public +/// [`crate::tokens`] `sendContactRequest` takes, and signs the request. +/// `accountLabel`, `autoAcceptProof` and `shieldedAddress` (43 bytes) may be +/// null. Blocks for the Halo 2 proof; returns the created `ContactRequest` +/// handle (destroy via `TokensNative.contactRequestDestroy`). +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendPrivateContactRequest( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + sender_identity_id: JByteArray, + recipient_identity_id: JByteArray, + account_label: JString, + auto_accept_proof: JByteArray, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Some(sender) = read_id32(env, &sender_identity_id, "senderIdentityId") else { + return 0; + }; + let Some(recipient) = read_id32(env, &recipient_identity_id, "recipientIdentityId") else { + return 0; + }; + let Ok(label) = opt_jstring_to_cstring(env, "accountLabel", &account_label) else { + return 0; + }; + let Ok(proof) = read_opt_bytes(env, &auto_accept_proof, "autoAcceptProof") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let (proof_ptr, proof_len) = proof + .as_ref() + .map_or((ptr::null(), 0), |proof| (proof.as_ptr(), proof.len())); + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_send_private_contact_request( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + sender.as_ptr(), + recipient.as_ptr(), + label.as_ref().map_or(ptr::null(), |label| label.as_ptr()), + proof_ptr, + proof_len, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +/// Accept the incoming contact request `requestHandle` with a private reply, +/// paying for the reply from `account`'s shielded notes (bridges +/// `platform_wallet_manager_accept_private_contact_request`). The handles +/// are as for [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendPrivateContactRequest`]; +/// `shieldedAddress` (43 bytes) may be null. Blocks for the Halo 2 proof; +/// returns the `EstablishedContact` handle (destroy via +/// `TokensNative.establishedContactDestroy`). +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + request_handle: jlong, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_accept_private_contact_request( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + request_handle as Handle, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +/// Accept the incoming contact request `requestHandle` replying the way it +/// came (bridges +/// `platform_wallet_manager_accept_contact_request_replying_in_kind`): +/// privately, paid from `account`'s shielded notes, when it came privately +/// and no reply exists yet; otherwise with a `contactRequest` document, or by +/// adopting the reply already sent. The wallet decides; this is the accept a +/// host calls. Arguments and result are as for the private accept above. +/// Exported in every build: without the `shielded` feature a request that +/// needs a private reply is refused with `ErrorShieldedBuildRequired`, and a +/// public or adopted reply works as in a shielded build. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptContactRequestReplyingInKind( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + request_handle: jlong, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_accept_contact_request_replying_in_kind( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + request_handle as Handle, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The natives above need a JVM; the counter layout Kotlin decodes and the + // handle release are pure and are pinned here. + + /// A real `ContactRequest` handle, registered through platform-wallet-ffi's + /// `contact_request_create` as the FFI's own enumerators register theirs. + fn registered_request(sender_byte: u8) -> Handle { + let sender = [sender_byte; 32]; + let recipient = [0xB0; 32]; + let encrypted_public_key = [0x33; 96]; + let mut handle: Handle = 0; + let mut result = unsafe { + platform_wallet_ffi::contact_request_create( + sender.as_ptr(), + recipient.as_ptr(), + 1, + 2, + 7, + encrypted_public_key.as_ptr(), + encrypted_public_key.len(), + 100, + 1_700_000_000_000, + &mut handle, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + assert_ne!(handle, 0); + handle + } + + /// The sender id a getter reads off `handle`, or the code it refuses with. + fn sender_id_of(handle: Handle) -> Result<[u8; 32], PlatformWalletFFIResultCode> { + let mut sender = [0u8; 32]; + let mut result = unsafe { + platform_wallet_ffi::contact_request_get_sender_id(handle, sender.as_mut_ptr()) + }; + let code = result.code; + unsafe { platform_wallet_ffi_result_free(&mut result) }; + if code == PlatformWalletFFIResultCode::Success { + Ok(sender) + } else { + Err(code) + } + } + + #[test] + fn should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads() { + let report = PrivateContactSyncReportFFI { + next_position: 41, + pages_read: 3, + rejected_count: 2, + unpersisted_identity_count: 1, + complete: true, + }; + assert_eq!(private_contact_sync_longs(5, &report), [5, 3, 2, 1, 41, 1]); + let incomplete = PrivateContactSyncReportFFI { + complete: false, + ..report + }; + assert_eq!(private_contact_sync_longs(0, &incomplete)[5], 0); + } + + #[test] + fn should_lay_out_the_readiness_flags_in_the_order_kotlin_reads() { + let readiness = PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: false, + unresolved_private_payment: true, + private_payments_restore_failed: false, + }; + assert_eq!( + readiness_flags(&readiness), + [JNI_TRUE, JNI_FALSE, JNI_TRUE, JNI_FALSE] + ); + let failed = PrivateContactReadinessFFI { + private_payments_restore_failed: true, + ..PrivateContactReadinessFFI::default() + }; + assert_eq!( + readiness_flags(&failed), + [JNI_FALSE, JNI_FALSE, JNI_FALSE, JNI_TRUE] + ); + assert_eq!( + readiness_flags(&PrivateContactReadinessFFI::default()), + [JNI_FALSE; 4] + ); + } + + #[test] + fn should_saturate_a_store_position_past_the_long_range() { + let report = PrivateContactSyncReportFFI { + next_position: u64::MAX, + ..Default::default() + }; + assert_eq!(private_contact_sync_longs(0, &report)[4], i64::MAX); + } + + #[test] + fn should_render_a_private_contact_as_the_json_kotlin_reads() { + let mut contact = PrivateContactFFI { + received_privately: true, + sent_privately: false, + has_shielded_address: true, + shielded_address: [0xab; 43], + }; + assert_eq!( + private_contact_json(&contact), + format!( + "{{\"receivedPrivately\":true,\"sentPrivately\":false,\"shieldedAddress\":\"{}\"}}", + "ab".repeat(43) + ) + ); + contact.has_shielded_address = false; + contact.sent_privately = true; + assert_eq!( + private_contact_json(&contact), + "{\"receivedPrivately\":true,\"sentPrivately\":true,\"shieldedAddress\":null}" + ); + } + + #[test] + fn should_release_an_empty_handle_array_as_zero_requests() { + let mut array = ContactRequestHandleArray::empty(); + assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 0); + assert!(array.handles.is_null()); + } + + /// The sync bridge hands Kotlin no handles, so it must destroy every + /// handle the FFI registered as well as free the array: both handles read + /// before the release and are refused after it. + #[test] + fn should_destroy_every_handle_of_the_array_and_free_it() { + let handles = [registered_request(0xA1), registered_request(0xA2)]; + assert_eq!(sender_id_of(handles[0]), Ok([0xA1; 32])); + assert_eq!(sender_id_of(handles[1]), Ok([0xA2; 32])); + + // The `ContactRequestHandleArray` contract: a boxed slice of handles, + // as `ContactRequestHandleArray::from_requests` allocates it. + let boxed: Box<[Handle]> = handles.to_vec().into_boxed_slice(); + let count = boxed.len(); + let mut array = ContactRequestHandleArray { + handles: Box::into_raw(boxed) as *mut Handle, + count, + }; + + assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 2); + assert!(array.handles.is_null()); + assert_eq!(array.count, 0); + for handle in handles { + assert_eq!( + sender_id_of(handle), + Err(PlatformWalletFFIResultCode::NotFound), + "a released handle reads nothing" + ); + } + } +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index b3b3b55d40d..86a38c3329e 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,27 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence "onWalletChangesetDashPayBackfill", WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, ), + private_contact_scan_state: bridge_overrides( + env, + bridge, + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onLoadPrivateContactScanStateWallets", + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onPersistContactUpsert", + CONTACT_UPSERT_DESCRIPTOR, + ), }) } @@ -300,6 +321,13 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, + /// `onPersistPrivateContactScanState`, `onLoadPrivateContactScanState`, + /// `onLoadPrivateContactScanStateWallets` and `onPersistContactUpsert`: + /// an inherited no-op store would + /// acknowledge a read position it never kept, and a read position + /// vouches for the contact requests read before it, which an inherited + /// no-op contact upsert never keeps. + private_contact_scan_state: bool, } /// The extension slots wired for a bridge with `overrides` (see @@ -320,6 +348,21 @@ 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), + on_load_private_contact_scan_state_wallets_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state_wallets), + on_load_private_contact_scan_state_wallets_free_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state_wallets_free), ..Default::default() } } @@ -1084,6 +1127,285 @@ unsafe extern "C" fn tramp_persist_wallet_dashpay_backfill( }) } +/// Packed `byte[]` row widths of the private contact request scan state, the +/// same flat packing the backfill cover set uses: a read position is +/// `identityId ‖ position (u64 LE) ‖ keyFingerprint ‖ unresolved (0 or 1)`, +/// a shielded address +/// `ownerId ‖ contactId ‖ address`, an address removal `ownerId ‖ +/// contactId`, a cleared identity its id. +pub(crate) const PRIVATE_POSITION_ENTRY_LEN: usize = 32 + 8 + 32 + 1; +pub(crate) const PRIVATE_ADDRESS_ENTRY_LEN: usize = 32 + 32 + 43; +pub(crate) const PRIVATE_ADDRESS_REMOVAL_ENTRY_LEN: usize = 32 + 32; + +/// Descriptor of `NativePersistenceBridge.onPersistPrivateContactScanState`: +/// `(walletId, positions, addresses, removedAddresses, clearedIdentities)`. +const PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B[B[B[B[B)I"; + +/// Descriptor of `NativePersistenceBridge.onLoadPrivateContactScanState`: +/// `(walletId) -> [positions, addresses]`. +const LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B)[[B"; + +/// Descriptor of `NativePersistenceBridge.onLoadPrivateContactScanStateWallets`: +/// `() -> walletIds`, the 32-byte ids packed back to back. +const LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR: &str = "()[B"; + +fn pack_private_positions(rows: &[PrivateContactRequestsPositionFFI]) -> Vec { + let mut packed = Vec::with_capacity(rows.len() * PRIVATE_POSITION_ENTRY_LEN); + for row in rows { + packed.extend_from_slice(&row.identity_id); + packed.extend_from_slice(&row.position.to_le_bytes()); + packed.extend_from_slice(&row.key_fingerprint); + packed.push(u8::from(row.private_payments_unresolved)); + } + packed +} + +fn pack_private_addresses(rows: &[PrivateShieldedAddressFFI]) -> Vec { + let mut packed = Vec::with_capacity(rows.len() * PRIVATE_ADDRESS_ENTRY_LEN); + for row in rows { + packed.extend_from_slice(&row.owner_id); + packed.extend_from_slice(&row.contact_id); + packed.extend_from_slice(&row.address); + } + packed +} + +/// The rows packed in `packed`, or `None` when its length is not a whole +/// number of rows. +fn unpack_private_positions(packed: &[u8]) -> Option> { + let (rows, rest) = packed.as_chunks::(); + if !rest.is_empty() { + return None; + } + rows.iter() + .map(|row| { + Some(PrivateContactRequestsPositionFFI { + identity_id: row[..32].try_into().ok()?, + position: u64::from_le_bytes(row[32..40].try_into().ok()?), + key_fingerprint: row[40..72].try_into().ok()?, + private_payments_unresolved: match row[72] { + 0 => false, + 1 => true, + _ => return None, + }, + }) + }) + .collect() +} + +/// The rows packed in `packed`, or `None` when its length is not a whole +/// number of rows. +fn unpack_private_addresses(packed: &[u8]) -> Option> { + let (rows, rest) = packed.as_chunks::(); + if !rest.is_empty() { + return None; + } + rows.iter() + .map(|row| { + Some(PrivateShieldedAddressFFI { + owner_id: row[..32].try_into().ok()?, + contact_id: row[32..64].try_into().ok()?, + address: row[64..].try_into().ok()?, + }) + }) + .collect() +} + +/// Deliver a round's private contact request scan state (see +/// `PersistPrivateContactScanStateFn`) as four packed arrays. +#[allow(clippy::too_many_arguments)] +unsafe extern "C" fn tramp_persist_private_contact_scan_state( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let positions = env.byte_array_from_slice(&pack_private_positions(slice_or_empty( + positions, + positions_count, + )))?; + let addresses = env.byte_array_from_slice(&pack_private_addresses(slice_or_empty( + addresses, + addresses_count, + )))?; + let removed_rows = slice_or_empty(removed_addresses, removed_addresses_count); + let mut removed = + Vec::with_capacity(removed_rows.len() * PRIVATE_ADDRESS_REMOVAL_ENTRY_LEN); + for row in removed_rows { + removed.extend_from_slice(&row.owner_id); + removed.extend_from_slice(&row.contact_id); + } + let removed = env.byte_array_from_slice(&removed)?; + let cleared: Vec = slice_or_empty(cleared_identities, cleared_identities_count) + .iter() + .flatten() + .copied() + .collect(); + let cleared = env.byte_array_from_slice(&cleared)?; + env.call_method( + bridge, + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + &[ + (&wid).into(), + (&positions).into(), + (&addresses).into(), + (&removed).into(), + (&cleared).into(), + ], + )? + .i() + }) +} + +/// Hand Rust a wallet's stored scan state (see +/// `LoadPrivateContactScanStateFn`). The rows are staged in owned buffers +/// and minted as raw arrays only once the whole read succeeded; a failed +/// read, an exception or a malformed array returns `ERR_JNI`, which fails +/// the load rather than reading the store from its start. +unsafe extern "C" fn tramp_load_private_contact_scan_state( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, +) -> i32 { + *out_positions = ptr::null(); + *out_positions_count = 0; + *out_addresses = ptr::null(); + *out_addresses_count = 0; + let loaded = with_bridge_load(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let arrays: JObjectArray = env + .call_method( + bridge, + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + &[(&wid).into()], + )? + .l()? + .into(); + if env.get_array_length(&arrays)? != 2 { + return Err(jni::errors::Error::JniCall( + jni::errors::JniError::InvalidArguments, + )); + } + let positions: JByteArray = env.get_object_array_element(&arrays, 0)?.into(); + let addresses: JByteArray = env.get_object_array_element(&arrays, 1)?.into(); + Ok(( + env.convert_byte_array(&positions)?, + env.convert_byte_array(&addresses)?, + )) + }); + let Some((positions, addresses)) = loaded else { + return ERR_JNI; + }; + let (Some(positions), Some(addresses)) = ( + unpack_private_positions(&positions), + unpack_private_addresses(&addresses), + ) else { + return ERR_JNI; + }; + if !positions.is_empty() { + *out_positions_count = positions.len(); + *out_positions = Box::into_raw(positions.into_boxed_slice()) as *const _; + } + if !addresses.is_empty() { + *out_addresses_count = addresses.len(); + *out_addresses = Box::into_raw(addresses.into_boxed_slice()) as *const _; + } + 0 +} + +/// Hand Rust the ids of every wallet the store keeps scan state for (see +/// `LoadPrivateContactScanStateWalletsFn`), a removed wallet whose marked +/// records outlived it included, from one packed array of 32-byte ids. A +/// failed read, an exception or an array that is not a whole number of ids +/// returns `ERR_JNI`, which fails the load. +unsafe extern "C" fn tramp_load_private_contact_scan_state_wallets( + context: *mut c_void, + out_wallet_ids: *mut *const [u8; 32], + out_wallet_ids_count: *mut usize, +) -> i32 { + *out_wallet_ids = ptr::null(); + *out_wallet_ids_count = 0; + let loaded = with_bridge_load(context, |env, bridge| { + let packed: JByteArray = env + .call_method( + bridge, + "onLoadPrivateContactScanStateWallets", + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR, + &[], + )? + .l()? + .into(); + env.convert_byte_array(&packed) + }); + let Some(packed) = loaded else { + return ERR_JNI; + }; + let Some(ids) = unpack_wallet_ids(&packed) else { + return ERR_JNI; + }; + if !ids.is_empty() { + *out_wallet_ids_count = ids.len(); + *out_wallet_ids = Box::into_raw(ids.into_boxed_slice()) as *const _; + } + 0 +} + +/// The 32-byte ids packed back to back in `packed`, or `None` when it is not +/// a whole number of them. +fn unpack_wallet_ids(packed: &[u8]) -> Option> { + let (ids, rest) = packed.as_chunks::<32>(); + rest.is_empty().then(|| ids.to_vec()) +} + +/// Release the array [`tramp_load_private_contact_scan_state_wallets`] minted. +unsafe extern "C" fn tramp_load_private_contact_scan_state_wallets_free( + _context: *mut c_void, + wallet_ids: *const [u8; 32], + wallet_ids_count: usize, +) { + if !wallet_ids.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + wallet_ids.cast_mut(), + wallet_ids_count, + ))); + } +} + +/// Release the arrays [`tramp_load_private_contact_scan_state`] minted. +unsafe extern "C" fn tramp_load_private_contact_scan_state_free( + _context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, +) { + if !positions.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + positions.cast_mut(), + positions_count, + ))); + } + if !addresses.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + addresses.cast_mut(), + addresses_count, + ))); + } +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -4914,6 +5236,20 @@ 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, + ), + ( + "onLoadPrivateContactScanStateWallets", + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR, + ), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -5136,6 +5472,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 +5484,124 @@ 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 + ); + assert_eq!( + extension + .on_load_private_contact_scan_state_wallets_fn + .is_some(), + wired + ); + assert_eq!( + extension + .on_load_private_contact_scan_state_wallets_free_fn + .is_some(), + wired + ); + } + assert_eq!( + unpack_wallet_ids(&[[1u8; 32], [2u8; 32]].concat()), + Some(vec![[1u8; 32], [2u8; 32]]) + ); + assert_eq!(unpack_wallet_ids(&[]), Some(vec![])); + assert_eq!( + unpack_wallet_ids(&[0u8; 33]), + None, + "not a whole number of ids" + ); + 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"), + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR + ); + assert_eq!( + method_descriptor(&names(&["[B"]), "[[B"), + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR + ); + } + + /// A read position and a shielded address survive the packed arrays the + /// bridge stores and hands back, a full-width position included; an + /// array that is not a whole number of rows is refused. + #[test] + fn the_scan_state_rows_round_trip_through_their_packed_arrays() { + let positions = [ + PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: u64::MAX, + key_fingerprint: [3; 32], + private_payments_unresolved: true, + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 12, + key_fingerprint: [4; 32], + private_payments_unresolved: false, + }, + ]; + let addresses = [PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }]; + let packed = pack_private_positions(&positions); + assert_eq!(packed.len(), 2 * PRIVATE_POSITION_ENTRY_LEN); + assert_eq!( + unpack_private_positions(&packed).as_deref(), + Some(&positions[..]) + ); + let packed = pack_private_addresses(&addresses); + assert_eq!(packed.len(), PRIVATE_ADDRESS_ENTRY_LEN); + assert_eq!( + unpack_private_addresses(&packed).as_deref(), + Some(&addresses[..]) + ); + assert_eq!(unpack_private_positions(&[]).as_deref(), Some(&[][..])); + assert!(unpack_private_positions(&[0; PRIVATE_POSITION_ENTRY_LEN + 1]).is_none()); + let mut bad_marker = pack_private_positions(&positions[..1]); + bad_marker[72] = 2; + assert!( + unpack_private_positions(&bad_marker).is_none(), + "a marker byte other than 0 or 1 is not a row" + ); + assert!(unpack_private_addresses(&[0; PRIVATE_ADDRESS_ENTRY_LEN - 1]).is_none()); + } + /// A bridge inheriting the no-op account writers gets neither account /// slot, so `FFIPersister` cannot derive `ACCOUNT_ADDRESS_POOLS` and does /// not keep outbound-account markers on disk; one implementing both keeps diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 4dfc8cbadef..b077777228c 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -56,7 +56,10 @@ use platform_wallet_ffi::error::{ }; use platform_wallet_ffi::event_handler::{EventHandlerCallbacks, EventHandlerCallbacksExtension}; use platform_wallet_ffi::handle::Handle; -use platform_wallet_ffi::persistence::{PersistenceCallbacks, PersistenceCapabilitiesFFI}; +use platform_wallet_ffi::manager::effective_persistence_capabilities; +use platform_wallet_ffi::persistence::{ + PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, +}; use platform_wallet_ffi::types::IdentifierArray; use std::ffi::{c_void, CStr}; use std::os::raw::c_char; @@ -109,60 +112,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n return 0; } - // Read the backend's explicit semantic attestation before installing - // any trampolines. NativePersistenceBridge defaults both methods to - // zero, so a no-op subclass remains fail-closed even though JNI wires a - // structurally complete callback table. - let declared_capabilities_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 declared_capabilities_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; - } - }; - - // Box the persistence context (GlobalRef → boxed KotlinPersistenceCtx). - let persistence_global = match env.new_global_ref(&persistence_bridge) { - Ok(g) => g, - Err(_) => { - throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); - return 0; - } - }; - let persistence_ctx = - Box::into_raw(Box::new(KotlinPersistenceCtx::new(persistence_global))); - let mut persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); - restrict_account_writers(env, &persistence_bridge, &mut persistence); - let persistence_extension = build_extension(env, &persistence_bridge); - let persistence_capabilities = PersistenceCapabilitiesFFI { - version: declared_capabilities_version, - reserved: 0, - bits: declared_capabilities_bits, + let Some(BridgePersistence { + context: persistence_ctx, + callbacks: persistence, + extension: persistence_extension, + declared: persistence_capabilities, + }) = bridge_persistence(env, &persistence_bridge) + else { + return 0; }; // Box the event context. @@ -211,6 +168,116 @@ 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 Some(BridgePersistence { + callbacks, + extension, + declared, + .. + }) = bridge_persistence(env, &persistence_bridge) + else { + return 0; + }; + // SAFETY: `extension` is a live, fully sized extension; the vtable's + // `release_fn` frees the context before this returns. + let effective = + unsafe { effective_persistence_capabilities(callbacks, &declared, &extension) }; + effective.bits() as jlong + }) +} + +/// The persistence a manager is created with from a Kotlin +/// `NativePersistenceBridge`: the bridge's declared capabilities, a vtable +/// over a boxed context holding a global reference to it (with the account +/// writers it does not override removed), and the extension its overrides +/// wire. `nativeCreate` creates its manager from it and +/// `nativeEffectivePersistenceCapabilityBits` asks what that manager would +/// attest, so the two cannot drift apart. +struct BridgePersistence { + /// The boxed context the vtable points at. Its `release_fn` frees it + /// once a manager (or `effective_persistence_capabilities`) takes the + /// vtable; a caller that hands it to neither frees it. + context: *mut KotlinPersistenceCtx, + callbacks: PersistenceCallbacks, + extension: PersistenceCallbacksExtension, + declared: PersistenceCapabilitiesFFI, +} + +/// Build [`BridgePersistence`] for `persistence_bridge`. The backend's +/// explicit semantic attestation is read before any trampoline is installed: +/// `NativePersistenceBridge` defaults both methods to zero, so a no-op +/// subclass stays fail-closed even though JNI wires a structurally complete +/// callback table. Throws and returns `None` when the declaration cannot be +/// read or the global reference made. +fn bridge_persistence(env: &mut JNIEnv, persistence_bridge: &JObject) -> Option { + 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 None; + } + }; + 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 None; + } + }; + let global = match env.new_global_ref(persistence_bridge) { + Ok(global) => global, + Err(_) => { + throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); + return None; + } + }; + let context = Box::into_raw(Box::new(KotlinPersistenceCtx::new(global))); + let mut callbacks: PersistenceCallbacks = build_vtable(context as *mut c_void); + restrict_account_writers(env, persistence_bridge, &mut callbacks); + let extension = build_extension(env, persistence_bridge); + Some(BridgePersistence { + context, + callbacks, + extension, + declared: PersistenceCapabilitiesFFI { + version, + reserved: 0, + bits, + }, + }) +} + /// 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]