diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 26101839912..ddc1d05d764 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -32,6 +32,7 @@ "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_spv_rescan_filters": "packages/rs-platform-wallet-ffi/src/spv.rs", + "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_token_purchase": "packages/rs-platform-wallet-ffi/src/tokens/purchase.rs", @@ -811,6 +812,63 @@ } ] }, + { + "id": "persistence.sync_fault_latch", + "title": "Watermark-freeze sync fault latch is host-visible", + "area": "recovery", + "shared_apis": [ + "platform_wallet_manager_sync_fault_detected" + ], + "required_persistence_capabilities": [], + "hosts": { + "swift": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + }, + "kotlin": { + "sdk": "supported", + "example_app": "not-applicable", + "restart": "not_applicable", + "reason": null + } + }, + "verification": [ + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SyncFaultDetectedTests.swift", + "id": "testSyncFaultDetectedRequiresConfiguredManager", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftDashSDKTests/SyncFaultDetectedTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt", + "id": "syncFaultDetectedSymbolBindsAndRejectsInvalidHandle", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/changeset/core_bridge.rs", + "id": "lagged_broadcast_freezes_and_strips_subsequent_watermark", + "command": "cargo test -p platform-wallet lagged_broadcast_freezes_and_strips_subsequent_watermark", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/changeset/core_bridge.rs", + "id": "rejected_store_freezes_wallet_and_strips_watermark", + "command": "cargo test -p platform-wallet rejected_store_freezes_wallet_and_strips_watermark", + "covers_restart": false + } + ] + }, { "id": "network.masternode_discovery", "title": "Canonical masternode endpoint discovery", diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 2ac15049f5c..11057ae22a1 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: **22** +Capabilities tracked: **23** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 12 | 8 | 1 | 1 | -| Swift | Example app | 4 | 12 | 1 | 5 | -| Kotlin | SDK | 10 | 12 | 0 | 0 | -| Kotlin | Example app | 5 | 12 | 0 | 5 | +| Swift | SDK | 13 | 8 | 1 | 1 | +| Swift | Example app | 4 | 12 | 1 | 6 | +| Kotlin | SDK | 11 | 12 | 0 | 0 | +| Kotlin | Example app | 5 | 12 | 0 | 6 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 0 | 7 | 15 | -| Kotlin | 4 | 6 | 12 | +| Swift | 0 | 7 | 16 | +| Kotlin | 4 | 6 | 13 | ## Capability status @@ -34,6 +34,7 @@ Capabilities tracked: **22** | `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 | +| `persistence.sync_fault_latch` | supported / not-applicable / not_applicable | supported / not-applicable / not_applicable | | `network.masternode_discovery` | partial / not-applicable / not_applicable | partial / not-applicable / not_applicable | | `identity.platform_address_auto_funding` | partial / partial / not_applicable | partial / partial / not_applicable | | `tokens.authorization_decisions` | partial / partial / not_applicable | partial / partial / not_applicable | 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 1e561ee35d6..d9bfe5ee81b 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 @@ -3,6 +3,7 @@ package org.dashfoundation.dashsdk 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.NativeLoader import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.ffi.SignerNative @@ -13,6 +14,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -84,6 +86,22 @@ class FfiSmokeTest { assertEquals("compact recoverable ECDSA signature", 65, signature!!.size) } + /** + * The #4069 watermark-freeze fault latch surfaced by PR #4314: the + * `syncFaultDetected` JNI symbol binds, and an invalid manager handle + * surfaces as the mapped [DashSDKException] rather than a crash. The + * latch behavior itself (freeze on dropped events / rejected store) is + * covered by the shared Rust tests in + * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. + */ + @Test + fun syncFaultDetectedSymbolBindsAndRejectsInvalidHandle() { + NativeLoader.ensureLoaded() + assertThrows(DashSDKException::class.java) { + WalletManagerNative.syncFaultDetected(0L) + } + } + /** * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve * each bridge method by (name, descriptor) only when its slot first diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift index 90550d26e2d..c03f37f2aae 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift @@ -184,6 +184,28 @@ extension PlatformWalletManager { return running } + /// Whether the native manager has frozen its durable sync watermark this + /// session (dashpay/platform#4069). `true` means the wallet-event adapter + /// dropped record-bearing events, or a persistence `store()` was rejected, + /// so the persisted `syncedHeight` is deliberately held behind the chain + /// tip and a rescan is pending on the next launch. Poll this to surface a + /// hard "verification failed / rescan pending" state instead of leaving + /// the fault visible only in the error logs. + /// + /// The flag latches for this native manager's lifetime: once `true` it stays + /// `true` until the manager is destroyed. + public func syncFaultDetected() throws -> Bool { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + + var detected = false + try platform_wallet_manager_sync_fault_detected(handle, &detected).check() + return detected + } + public func isPlatformAddressSyncing() throws -> Bool { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SyncFaultDetectedTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SyncFaultDetectedTests.swift new file mode 100644 index 00000000000..784e3d91ea9 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SyncFaultDetectedTests.swift @@ -0,0 +1,45 @@ +// SyncFaultDetectedTests.swift +// SwiftDashSDKTests +// +// Unit tests for the `PlatformWalletManager.syncFaultDetected()` FFI bridge. + +import XCTest +@testable import SwiftDashSDK + +/// Coverage for the Swift side of the watermark-freeze fault latch +/// (dashpay/platform#4069), exported over the C ABI by PR #4314 as +/// `platform_wallet_manager_sync_fault_detected`. +/// +/// `syncFaultDetected()` is a pure bridge: guard the handle, call the export, +/// return the out-param. The latch's actual semantics — the persisted +/// `syncedHeight` being deliberately held behind the chain tip after the +/// wallet-event adapter drops record-bearing events or a persistence `store()` +/// is rejected, and the flag latching `true` for the process lifetime — live in +/// Rust and are covered by the shared tests in +/// `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. What is Swift's +/// to get right is the unconfigured-handle guard, which must throw rather than +/// hand `NULL_HANDLE` across the FFI boundary. +/// +/// `PlatformWalletManager` is main-actor isolated, so the suite runs on the +/// main actor. +@MainActor +final class SyncFaultDetectedTests: XCTestCase { + + /// A bare `PlatformWalletManager()` has never been configured: `handle` is + /// `NULL_HANDLE` and `isConfigured` is false, so the wrapper must throw + /// `PlatformWalletError.invalidHandle` instead of calling the export with + /// a null handle. + func testSyncFaultDetectedRequiresConfiguredManager() { + let manager = PlatformWalletManager() + XCTAssertFalse(manager.isConfigured, "a bare manager is unconfigured") + + XCTAssertThrowsError(try manager.syncFaultDetected()) { error in + XCTAssertTrue(error is PlatformWalletError) + if case PlatformWalletError.invalidHandle = error { + // Expected error + } else { + XCTFail("Expected invalidHandle error, got \(error)") + } + } + } +}