diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt new file mode 100644 index 00000000000..b6f66ea5ca6 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt @@ -0,0 +1,180 @@ +package org.dashfoundation.dashsdk.wallet + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.config.SdkConfig +import org.dashfoundation.dashsdk.persistence.DashDatabase +import org.dashfoundation.dashsdk.persistence.toHex +import org.dashfoundation.dashsdk.security.WalletStorage +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented coverage for the K2 seedless-unlock topology and the + * DashPay sync-service lifecycle (KOTLIN_MIGRATION_SPEC.md §K2), through + * the real native lib: + * + * - The **happy-path unlock is the end-to-end proof of the out-buffer + * mnemonic resolver**: `loadPersistedWallets` auto-runs + * `unlockWalletFromKeystore`, whose `verify_seed_binds_to_wallet` + * derives the BIP44 account-0 xpub through the + * `resolveMnemonicInto(byte[], byte[])` vtable — a broken copy/zero/ + * length in the new no-JVM-String contract fails the verify. + * - The **wrong-seed leg** pins the seed-mismatch contract: a foreign + * (valid) mnemonic stored under the wallet's id must publish + * `seedMismatch = true` on [PlatformWalletManager.dashPayUnlockStatus] + * and never sign. + * - The **sync-service leg** pins the manager-owned lifecycle: + * start → running, idempotent double-start, stop → stopped, and a + * second manager on the same DB can start it again after close + * (the manager-swap disposal path). `dashPaySyncNow` is deliberately + * NOT exercised — it is a live network sweep, `-Ptestnet=true` tier. + */ +@RunWith(AndroidJUnit4::class) +class DashPayUnlockAndSyncTest { + + // BIP39 English test vectors. + private val testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon about" + private val foreignMnemonic = + "legal winner thank year wave sausage worth useful legal winner " + + "thank yellow" + + private lateinit var db: DashDatabase + private lateinit var walletStorage: WalletStorage + private lateinit var sdk: Sdk + + @Before + fun setUp() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = DashDatabase.createInMemory(context) + walletStorage = WalletStorage(context) + sdk = Sdk.create(SdkConfig(network = Network.TESTNET)) + } + + @After + fun tearDown() { + runCatching { db.close() } + runCatching { sdk.close() } + } + + private suspend fun createWallet(manager: PlatformWalletManager): ByteArray = + manager.createWallet( + mnemonic = testMnemonic, + name = "unlock-test", + createDefaultAccounts = true, + ).walletId + + /** Await until [predicate] holds on the unlock status map, or fail. */ + private suspend fun PlatformWalletManager.awaitUnlockStatus( + key: String, + what: String, + predicate: (DashPayUnlockStatus?) -> Boolean, + ) = withTimeout(20_000) { + while (!predicate(dashPayUnlockStatus.value[key])) delay(50) + assertTrue(what, predicate(dashPayUnlockStatus.value[key])) + } + + @Test + fun loadAutoUnlocksVerifiesSeedAndDrains() = runBlocking { + val walletId: ByteArray + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = createWallet(manager) + // Storage discipline: existence check + raw-bytes read both + // work, and the bytes are the exact phrase UTF-8. + assertTrue(walletStorage.hasMnemonic(walletId)) + val utf8 = walletStorage.retrieveMnemonicUtf8(walletId) + assertNotNull(utf8) + assertEquals(testMnemonic, utf8!!.decodeToString()) + utf8.fill(0) + } + + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { reloaded -> + reloaded.loadPersistedWallets() + val key = walletId.toHex() + + // The auto-unlock published a status entry: the seed verified + // (via the out-buffer resolver) and the drain ran (no pending + // entries on a fresh wallet) and cleared its flag. + reloaded.awaitUnlockStatus(key, "unlock status published") { it != null } + assertFalse( + "seed must verify against its own wallet", + reloaded.dashPayUnlockStatus.value[key]!!.seedMismatch, + ) + reloaded.awaitUnlockStatus(key, "drain completes") { it?.draining == false } + assertEquals(0, reloaded.contactCryptoPendingCount(walletId)) + } + } + + @Test + fun wrongSeedPublishesSeedMismatchAndStaysWatchOnly() = runBlocking { + val walletId: ByteArray + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = createWallet(manager) + } + // Mis-map the Keystore slot: a DIFFERENT valid mnemonic under this + // wallet's id. The binding verify derives a different BIP44 + // account-0 xpub and must reject. + walletStorage.storeMnemonic(walletId, foreignMnemonic) + + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { reloaded -> + val restored = reloaded.loadPersistedWallets() + assertTrue("restore itself must not fail", restored.isNotEmpty()) + val key = walletId.toHex() + reloaded.awaitUnlockStatus(key, "seed mismatch published") { + it?.seedMismatch == true + } + assertFalse( + "no drain may run on a mismatched seed", + reloaded.dashPayUnlockStatus.value[key]!!.draining, + ) + } + } + + @Test + fun dashPaySyncLifecycleSurvivesManagerSwap() = runBlocking { + val walletId: ByteArray + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { manager -> + walletId = createWallet(manager) + + assertFalse(manager.isDashPaySyncRunning()) + manager.startDashPaySync() + assertTrue(manager.isDashPaySyncRunning()) + // Idempotent double-start. + manager.startDashPaySync() + assertTrue(manager.isDashPaySyncRunning()) + + manager.setDashPaySyncInterval(300) + + manager.stopDashPaySync() + assertFalse(manager.isDashPaySyncRunning()) + + // Leave it running into close() — the manager-swap disposal + // path must not leak the loop into the next manager. + manager.startDashPaySync() + assertTrue(manager.isDashPaySyncRunning()) + } + + // Fresh manager on the same DB: its own sweep starts cleanly. + PlatformWalletManager(sdk, Network.TESTNET, db, walletStorage).use { second -> + second.loadPersistedWallets() + assertNotNull(second.wallet(forWalletId = walletId)) + assertFalse(second.isDashPaySyncRunning()) + second.startDashPaySync() + assertTrue(second.isDashPaySyncRunning()) + second.stopDashPaySync() + } + } +} 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 2f127d39744..b4f0fe06939 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 @@ -72,4 +72,105 @@ internal object DashpayNative { * `keysTotal`. ← Swift `PlatformWalletManager.accountBalances(for:)`. */ external fun walletManagerAccountBalances(managerHandle: Long, walletId: ByteArray): String? + + // ── Recurring DashPay sync service (manager-scoped) ─────────────── + + /** Start the recurring DashPay sweep (idempotent Rust-side). */ + external fun dashPaySyncStart(managerHandle: Long) + + /** Stop the recurring sweep; leaves it restartable. */ + external fun dashPaySyncStop(managerHandle: Long) + + /** Whether the sweep loop is running. */ + external fun dashPaySyncIsRunning(managerHandle: Long): Boolean + + /** Whether a sweep pass is executing right now (the 1 Hz poll target). */ + external fun dashPaySyncIsSyncing(managerHandle: Long): Boolean + + /** Unix seconds of the last completed sweep; 0 when never. */ + external fun dashPaySyncLastSyncUnixSeconds(managerHandle: Long): Long + + /** Set the sweep interval in seconds (applies from the next tick). */ + external fun dashPaySyncSetInterval(managerHandle: Long, intervalSeconds: Long) + + /** + * Run one sweep pass NOW, blocking until it completes. Returns + * `{"success":…,"errors":…,"syncUnixSeconds":…}` (Swift + * `DashPaySyncSummary`). + */ + external fun dashPaySyncNow(managerHandle: Long): String? + + // ── Seedless unlock ─────────────────────────────────────────────── + + /** + * Verify the Keystore-resolved mnemonic reproduces this wallet's key + * material. A stored-but-foreign seed throws with + * `ErrorInvalidParameter` — callers disambiguate the seed-mismatch + * case ONLY by scoping their catch to this call (the Swift contract). + */ + external fun verifySeedBindsToWallet(walletHandle: Long, coreSignerHandle: Long) + + /** Deferred contact-crypto entries queued on the wallet (in-memory). */ + external fun pendingContactCryptoCount(walletHandle: Long): Int + + /** + * Drain the deferred contact-crypto queue; returns the drained-entry + * count. Blocking and potentially slow (network + ECDH per entry) — + * IO thread only; keep the signer/resolver bridges strongly + * reachable for the whole call. [signerHandle] may be 0 for + * resolver-only drains. + */ + external fun drainPendingContactCrypto( + walletHandle: Long, + signerHandle: Long, + coreSignerHandle: Long, + ): Int + + // ── Profile / contactInfo writes ────────────────────────────────── + + /** + * Create ([doCreate]) or update the DashPay profile, signing with + * [signerHandle]. [avatarBytes] is the raw image — Rust computes the + * SHA-256 hash + perceptual fingerprint. Broadcasts a real document + * state transition (blocking, network). Returns the resulting + * profile JSON. ← Swift `createDashPayProfile` / `updateDashPayProfile`. + */ + @Suppress("LongParameterList") + external fun createOrUpdateProfile( + walletHandle: Long, + identityId: ByteArray, + displayName: String?, + publicMessage: String?, + avatarUrl: String?, + avatarBytes: ByteArray?, + doCreate: Boolean, + signerHandle: Long, + ): String? + + /** + * Set owner-private contactInfo (alias / note / displayHidden) for + * `(identityId, contactId)`. Local state always updates; the + * encrypted on-chain publish is DIP-15-gated. Returns the outcome + * discriminant: 0 published, 1 deferred until two contacts, + * 2 skipped (watch-only). ← Swift `setDashPayContactInfo`. + */ + @Suppress("LongParameterList") + external fun setContactInfo( + walletHandle: Long, + identityId: ByteArray, + contactId: ByteArray, + alias: String?, + note: String?, + displayHidden: Boolean, + signerHandle: Long, + coreSignerHandle: Long, + ): Int + + /** + * Whether the mnemonic resolver can derive-sign identity keys of + * [keyType] — the preflight predicate keeping `canSignWith` + * consistent with the sign path's UNSUPPORTED_KEY_TYPE rejection. + * ← Swift `KeychainSigner.resolverCanDeriveSign`. + */ + external fun resolverSupportsKeyType(keyType: Int): Boolean } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt index ec04a4ff0a2..f0a6f8c19ac 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/MnemonicNative.kt @@ -28,14 +28,40 @@ internal object MnemonicNative { * Rust (possibly on Tokio worker threads) — implementations must be * thread-safe and fast, and MUST NOT throw. * - * Method name and `([B)Ljava/lang/String;` signature are looked up by the - * native trampoline — keep in sync with `mnemonic.rs`. + * Method name and `([B[B)I` signature are looked up by the native + * trampoline — keep in sync with `mnemonic.rs`. */ abstract class NativeMnemonicBridge { + companion object { + /** No mnemonic stored for the wallet (watch-only / external-signable). */ + const val RESOLVE_NOT_FOUND: Int = -1 + + /** The phrase does not fit in the caller-supplied buffer. */ + const val RESOLVE_BUFFER_TOO_SMALL: Int = -2 + + /** + * A mnemonic IS stored but could not be produced — Keystore + * locked, decrypt failure, etc. Distinct from [RESOLVE_NOT_FOUND] + * so a transient failure is never reported as "this wallet has no + * seed" (the iOS `.other` channel; collapsing them points the + * user toward the wrong remediation). + */ + const val RESOLVE_OTHER: Int = -3 + } + /** - * Return the BIP-39 phrase for the 32-byte [walletId], or null when no - * mnemonic is stored (watch-only / external-signable wallets). + * Write the UTF-8 bytes of the BIP-39 phrase for the 32-byte + * [walletId] into the caller-supplied [out] buffer and return the + * byte count, or [RESOLVE_NOT_FOUND] / [RESOLVE_BUFFER_TOO_SMALL]. + * + * Out-buffer discipline (mirrors iOS `MaskedMnemonicUTF8` / + * `retrieveMnemonicUTF8Bytes`): the phrase must never be materialized + * as a JVM `String` — an immutable String cannot be scrubbed and + * lives on the heap until (if ever) collected, recoverable from a + * heap dump. Implementations copy raw bytes into [out] and zero every + * intermediate buffer of their own before returning; the native + * trampoline zeroes [out] after copying it into Rust-owned memory. */ - abstract fun resolveMnemonic(walletId: ByteArray): String? + abstract fun resolveMnemonicInto(walletId: ByteArray, out: ByteArray): Int } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index f9a6a9e4e94..71332a124f6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt @@ -148,6 +148,13 @@ class KeystoreSigner( ) return } + // KNOWN residual seed exposure: this materializes the phrase as an + // un-scrubbable JVM String and passes it across JNI — the exact + // anti-pattern the resolver path eliminated with + // resolveMnemonicInto. Migrating this call to an out-buffer + // signWithMnemonicAndPathInto variant is the tracked follow-up; + // until then every on-demand platform-address signature re-exposes + // the phrase on the JVM heap. val mnemonic = storage.retrieveMnemonic(row.walletId) if (mnemonic == null) { SignerNative.completeSign( @@ -173,13 +180,14 @@ class KeystoreSigner( override fun canSignWith(pubkeyBytes: ByteArray, keyType: Int): Boolean = try { if (keyType == PLATFORM_ADDRESS_HASH_KEY_TYPE) { // Prerequisites for the derive-and-sign path: a row with a - // non-empty derivation path plus a stored wallet mnemonic. Do - // NOT materialize the mnemonic here — existence only. + // non-empty derivation path plus a stored wallet mnemonic. + // Existence-only — hasMnemonic never decrypts, so no plaintext + // is materialized for a mere capability check. runBlocking { val row = platformAddressDao.getByAddressHash(pubkeyBytes) row != null && row.derivationPath.isNotEmpty() && - storage.retrieveMnemonic(row.walletId) != null + storage.hasMnemonic(row.walletId) } } else { runBlocking { storage.hasPrivateKey(storageKeyFor(pubkeyBytes)) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt index 2d02f64fd78..491629454d3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/MnemonicResolverAndPersister.kt @@ -8,9 +8,12 @@ import org.dashfoundation.dashsdk.ffi.NativeMnemonicBridge * Decrypt-on-demand mnemonic resolver — port of * `MnemonicResolverAndPersister.swift`. * - * Rust calls [resolveMnemonic] synchronously whenever a derivation needs - * the seed; per the CLAUDE.md doctrine the phrase crosses the boundary - * exactly there and nowhere else. Persisting new mnemonics goes through + * Rust calls [resolveMnemonicInto] synchronously whenever a derivation + * needs the seed; per the CLAUDE.md doctrine the phrase crosses the + * boundary exactly there and nowhere else, as raw UTF-8 bytes written + * into the caller's buffer — never a JVM `String` (the iOS out-buffer + * discipline; an immutable String can't be scrubbed and would sit in the + * heap recoverable from a dump). Persisting new mnemonics goes through * [WalletStorage.storeMnemonic] (Keystore-wrapped AES-GCM in DataStore). * * [nativeHandle] is the `MnemonicResolverHandle` to pass into FFI entry @@ -31,14 +34,31 @@ class MnemonicResolverAndPersister( /** * Synchronous resolve on the calling (Tokio) thread. `runBlocking` is * required — the FFI contract is synchronous write-into-buffer — and - * safe: this never runs on the main thread. + * safe: this never runs on the main thread. Every intermediate copy + * of the phrase is zeroed before returning; [out] itself is zeroed by + * the native trampoline after the copy into Rust-owned memory. */ - override fun resolveMnemonic(walletId: ByteArray): String? = try { - runBlocking { storage.retrieveMnemonic(walletId) } + override fun resolveMnemonicInto(walletId: ByteArray, out: ByteArray): Int = try { + val plain = runBlocking { storage.retrieveMnemonicUtf8(walletId) } + when { + plain == null -> RESOLVE_NOT_FOUND + plain.size > out.size -> { + plain.fill(0) + RESOLVE_BUFFER_TOO_SMALL + } + else -> { + plain.copyInto(out) + val len = plain.size + plain.fill(0) + len + } + } } catch (_: Exception) { - // Contract: never throw across JNI; null → NOT_FOUND on the Rust - // side, which surfaces as a wallet-operation error. - null + // Contract: never throw across JNI. A decrypt/Keystore failure on + // a wallet that MAY have a stored seed is OTHER, not NOT_FOUND — + // reporting it as "no seed (watch-only)" would misdirect recovery + // (the iOS `.other` discipline). + RESOLVE_OTHER } override fun close() { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index 57a39600162..567999c826f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -37,6 +37,12 @@ class WalletStorage( store.edit { it[mnemonicKey(walletId)] = encode(blob) } } + /** + * Decrypt the mnemonic as a display `String`. For explicit + * user-facing reveal flows ONLY (seed backup, biometric reveal) — + * a String cannot be scrubbed afterwards. Programmatic consumers + * (the FFI resolver, signers) must use [retrieveMnemonicUtf8]. + */ suspend fun retrieveMnemonic(walletId: ByteArray): String? { val encoded = store.data.first()[mnemonicKey(walletId)] ?: return null val plain = keystore.decrypt(decode(encoded)) @@ -45,6 +51,26 @@ class WalletStorage( return phrase } + /** + * Decrypt the mnemonic as raw UTF-8 bytes, never materializing a JVM + * `String` (the iOS `retrieveMnemonicUTF8Bytes` discipline). The + * caller OWNS the returned array and MUST `fill(0)` it as soon as the + * bytes are consumed — unlike a String, a ByteArray can actually be + * scrubbed, so the plaintext exposure window is bounded by the call + * instead of by the garbage collector. + */ + suspend fun retrieveMnemonicUtf8(walletId: ByteArray): ByteArray? { + val encoded = store.data.first()[mnemonicKey(walletId)] ?: return null + return keystore.decrypt(decode(encoded)) + } + + /** + * Whether a mnemonic is stored for [walletId]. Existence-only — never + * decrypts, never materializes plaintext (Swift `hasMnemonic(for:)`). + */ + suspend fun hasMnemonic(walletId: ByteArray): Boolean = + store.data.first().contains(mnemonicKey(walletId)) + suspend fun deleteMnemonic(walletId: ByteArray) { store.edit { it.remove(mnemonicKey(walletId)) } } 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 ae657ace94a..3961eb73f65 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 @@ -319,6 +319,62 @@ class Dashpay internal constructor(private val walletHandle: Long) { mapNativeErrors { DashpayNative.searchDpnsNames(walletHandle, prefix, limit) } } + // ── Profile / contactInfo writes (upstream #3841 parity) ────────── + + /** + * Create ([doCreate] = true) or update the DashPay profile for + * [identityId], signing with [signerHandle]. [avatarBytes] is the raw + * image — Rust computes the SHA-256 hash + perceptual fingerprint. + * Broadcasts a real document state transition (blocking, network). + * Returns the resulting profile JSON (same shape as [getProfile]). + * ← Swift `createDashPayProfile` / `updateDashPayProfile`. + */ + @Suppress("LongParameterList") + suspend fun createOrUpdateProfile( + identityId: ByteArray, + displayName: String?, + publicMessage: String?, + avatarUrl: String?, + avatarBytes: ByteArray? = null, + doCreate: Boolean, + signerHandle: Long, + ): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.createOrUpdateProfile( + walletHandle, identityId, displayName, publicMessage, + avatarUrl, avatarBytes, doCreate, signerHandle, + ) + } + } + + /** + * Set the owner-private contactInfo (alias / note / [displayHidden]) + * for `(identityId, contactId)`. Local state ALWAYS updates; the + * encrypted on-chain publish is DIP-15-gated — the returned + * [ContactInfoPublishOutcome] tells the UI whether the change is + * cross-device yet ([ContactInfoPublishOutcome.DEFERRED_UNTIL_TWO_CONTACTS] + * means local-only until a second contact establishes; surface that, + * matching iOS). ← Swift `setDashPayContactInfo`. + */ + @Suppress("LongParameterList") + suspend fun setContactInfo( + identityId: ByteArray, + contactId: ByteArray, + alias: String?, + note: String?, + displayHidden: Boolean, + signerHandle: Long, + coreSignerHandle: Long, + ): ContactInfoPublishOutcome = withContext(Dispatchers.IO) { + val raw = mapNativeErrors { + DashpayNative.setContactInfo( + walletHandle, identityId, contactId, alias, note, + displayHidden, signerHandle, coreSignerHandle, + ) + } + ContactInfoPublishOutcome.fromRaw(raw) + } + /** * Open the managed-identity handle for [identityId], run [block], * and destroy the handle before returning (the [contacts] / @@ -353,6 +409,36 @@ class Dashpay internal constructor(private val walletHandle: Long) { } } +/** + * Outcome of a contactInfo write — mirror of the Rust + * `CONTACT_INFO_*` discriminants (Swift `ContactInfoPublishOutcome`). + * Local state always updated; this describes the on-chain publish. + */ +enum class ContactInfoPublishOutcome(val raw: Int) { + /** Encrypted contactInfo document broadcast — cross-device. */ + PUBLISHED(0), + + /** + * DIP-15 gate: fewer than two established contacts, so the encrypted + * publish is deferred (local-only until a second contact establishes). + */ + DEFERRED_UNTIL_TWO_CONTACTS(1), + + /** Watch-only wallet — cannot sign the publish; local-only. */ + SKIPPED_WATCH_ONLY(2), + ; + + companion object { + /** + * Unknown discriminants degrade to [DEFERRED_UNTIL_TWO_CONTACTS] + * (the "local-only, not yet cross-device" reading — the safe + * assumption for a newer Rust enum case). + */ + fun fromRaw(raw: Int): ContactInfoPublishOutcome = + entries.firstOrNull { it.raw == raw } ?: DEFERRED_UNTIL_TWO_CONTACTS + } +} + /** Owned native `ContactRequest` handle from [Dashpay.sendContactRequest]. */ class ContactRequestRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) 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 71119c71819..42700074847 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 @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.FundingNative @@ -78,7 +79,7 @@ class PlatformWalletManager( val network: Network, private val database: DashDatabase, private val walletStorage: WalletStorage, - biometricGate: BiometricGate? = null, + private val biometricGate: BiometricGate? = null, ) : AutoCloseable { init { @@ -457,7 +458,6 @@ class PlatformWalletManager( * per restorable id to obtain a [ManagedPlatformWallet] handle. * * Idempotent: with no persisted state, leaves [wallets] untouched. - * Signing fails on watch-only wallets until a future unlock flow. */ suspend fun loadPersistedWallets(): List = withContext(Dispatchers.IO) { mapNativeErrors { WalletManagerNative.loadFromPersistor(managerHandle) } @@ -471,7 +471,6 @@ class PlatformWalletManager( .filter { it.size == 32 } val restored = ArrayList(ids.size) - val additions = LinkedHashMap() for (walletId in ids) { val handle = try { mapNativeErrors { WalletManagerNative.getWallet(managerHandle, walletId) } @@ -481,11 +480,34 @@ class PlatformWalletManager( } val managed = ManagedPlatformWallet(handle = handle, walletId = walletId) restored.add(managed) - additions[walletId.toHex()] = managed - } - if (additions.isNotEmpty()) { - _wallets.update { it + additions } + // Publish into [wallets] BEFORE unlocking (Swift's per-wallet + // ordering): the unlock writes onto [dashPayUnlockStatus], and + // the 1 Hz poll prunes status keys absent from [wallets] — a + // publish-after-unlock ordering would let the prune silently + // drop a just-published seedMismatch. + _wallets.update { it + (walletId.toHex() to managed) } + + // Seedless unlock of the just-restored external-signable wallet: + // verify the Keystore-resolved seed binds to this wallet and + // drain any deferred contact-crypto. Best-effort, per wallet — + // this call is LOAD-BEARING for DashPay recovery: the deferred + // contact-crypto queue is in-memory only (rebuilt by the sweep, + // cleared by the drain), so recovery is self-healing ONLY when + // every launch runs load → unlock → sweep. A banner-triggered + // unlock alone would leave contacts half-established after each + // process restart. Mirrors Swift `loadFromPersistor`. + try { + unlockWalletFromKeystore(managed) + } catch (_: Exception) { + // Outcome is published on [dashPayUnlockStatus] (seedMismatch + // for a binding rejection); one wallet's unlock failure can't + // fail the whole restore — the wallet simply stays + // external-signable. + } } + // Banner state (pendingAccountBuilds) must refresh whenever wallets + // exist, independent of whether the sweep service was started. + if (restored.isNotEmpty()) startDashPayStatusPolling() restored } @@ -1006,6 +1028,252 @@ class PlatformWalletManager( } } + // ── DashPay sync + seedless unlock ──────────────────────────────── + // + // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow + // in `PlatformWalletManager.swift` (563-668). The recurring sweep is + // Rust-owned and manager-scoped; like the SPV/identity/shielded loops + // above, its surface lives on the manager (Swift keeps it in a manager + // extension). Status is REFLECTED via the same 1 Hz change-gated poll + // pattern as [spvProgress] — polling, not events, is deliberate: it is + // exactly how iOS does it, and naive re-assignment burned CPU there. + + private val _dashPaySyncIsSyncing = MutableStateFlow(false) + + /** Whether a DashPay sweep pass is executing right now (1 Hz poll). */ + val dashPaySyncIsSyncing: StateFlow = _dashPaySyncIsSyncing.asStateFlow() + + private val _dashPayUnlockStatus = + MutableStateFlow>(emptyMap()) + + /** + * Per-wallet seedless-unlock status, keyed by wallet-id hex — Swift + * `dashPayUnlockStatus`. `draining` while a deferred-contact-crypto + * drain is in flight; `seedMismatch` when the stored seed failed the + * binding verify (security-relevant: a mis-mapped Keystore slot); + * `pendingAccountBuilds` from the 1 Hz poll (drives the unlock banner). + */ + val dashPayUnlockStatus: StateFlow> = + _dashPayUnlockStatus.asStateFlow() + + private var dashPayPollJob: Job? = null + + /** Start the recurring DashPay sweep + the 1 Hz status poll. */ + suspend fun startDashPaySync() = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncStart(managerHandle) } + startDashPayStatusPolling() + } + + /** + * Stop the recurring sweep (restartable). The 1 Hz status poll keeps + * running — it also serves the unlock banner (`pendingAccountBuilds`), + * which must stay fresh while wallets exist regardless of the sweep; + * it dies with the manager scope in [close]. + */ + suspend fun stopDashPaySync() = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncStop(managerHandle) } + _dashPaySyncIsSyncing.value = false + } + + suspend fun isDashPaySyncRunning(): Boolean = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncIsRunning(managerHandle) } + } + + suspend fun isDashPaySyncing(): Boolean = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncIsSyncing(managerHandle) } + } + + /** Unix seconds of the last completed sweep; 0 when never. */ + suspend fun dashPayLastSyncUnixSeconds(): Long = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncLastSyncUnixSeconds(managerHandle) } + } + + /** Set the sweep interval (seconds); applies from the next tick. */ + suspend fun setDashPaySyncInterval(seconds: Long) = withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.dashPaySyncSetInterval(managerHandle, seconds) } + } + + /** + * Run one sweep pass NOW (pull-to-refresh), blocking until it + * completes. ← Swift `dashPaySyncNow()`. + */ + suspend fun dashPaySyncNow(): DashPaySyncSummary = withContext(Dispatchers.IO) { + val json = mapNativeErrors { DashpayNative.dashPaySyncNow(managerHandle) } + ?: return@withContext DashPaySyncSummary(0, 0, 0) + val obj = org.json.JSONObject(json) + DashPaySyncSummary( + success = obj.optInt("success"), + errors = obj.optInt("errors"), + syncUnixSeconds = obj.optLong("syncUnixSeconds"), + ) + } + + /** Deferred contact-crypto entries queued on [walletId]'s wallet. */ + suspend fun contactCryptoPendingCount(walletId: ByteArray): Int = + withContext(Dispatchers.IO) { + val managed = wallet(forWalletId = walletId) ?: return@withContext 0 + mapNativeErrors { DashpayNative.pendingContactCryptoCount(managed.handle) } + } + + /** + * Seedless unlock of a restored external-signable wallet — port of + * Swift `unlockWalletFromKeychain` (verify → drain; the identity-key + * breadcrumb backfill step is deliberately NOT ported: the Kotlin SDK + * has no pre-breadcrumb installs to heal). + * + * 1. **Verify** the Keystore-resolved seed binds to this wallet + * (derives the BIP44 account-0 xpub through the resolver and + * compares with the persisted one) — a mis-mapped Keystore slot + * fails here, no drain runs, and the wallet stays + * external-signable, so a wrong seed can never produce a + * network-valid signature for it (the enforcement is on-chain key + * ownership — the signer itself is not gated on this verify). + * 2. **Drain** deferred contact-crypto in the background (network + + * ECDH per entry). Guarded against stacking on an in-flight drain. + * + * The seed never crosses into Kotlin: the mnemonic → seed conversion + * happens inside the resolver vtable, and the existence check is + * [WalletStorage.hasMnemonic] (no decrypt). + * + * @return true when the seed verified (drain scheduled); false when + * no mnemonic is stored (a genuine watch-only wallet). + * @throws DashSdkError when the verify fails — a seed mismatch is + * published on [dashPayUnlockStatus] before rethrowing. + */ + suspend fun unlockWalletFromKeystore(managed: ManagedPlatformWallet): Boolean { + val walletId = managed.walletId + require(walletId.size == 32) { "walletId must be 32 bytes, got ${walletId.size}" } + if (!walletStorage.hasMnemonic(walletId)) return false + + val key = walletId.toHex() + // Wrong-seed / wrong-wallet gate. `seedMismatch` is published from + // the verify result itself, scoped to JUST this call: the verify + // FFI maps Rust `SeedMismatch` → ErrorInvalidParameter (de-offset + // native code 2), and scoping the check here keeps any other + // invalid-parameter failure elsewhere from being mistaken for a + // seed mismatch. Rethrown so callers keep their own handling. + try { + withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.verifySeedBindsToWallet( + managed.handle, + mnemonicResolver.nativeHandle, + ) + } + } + updateUnlockStatus(key) { it.copy(seedMismatch = false) } + } catch (e: DashSdkError.PlatformWallet.Generic) { + if (e.nativeCode == PWFFI_INVALID_PARAMETER) { + updateUnlockStatus(key) { it.copy(seedMismatch = true) } + } + throw e + } + + // Don't stack a second drain on an in-flight one: a banner Unlock + // tap while a drain runs would duplicate the network re-fetch + + // ECDH work and race the channel-broken writes. The false→true + // transition happens inside ONE atomic flow update — a separate + // check-then-set would let two concurrent unlocks (auto-unlock + // racing a banner tap) both pass the check and stack drains. + var wonDrainSlot = false + _dashPayUnlockStatus.update { map -> + val prev = map[key] ?: DashPayUnlockStatus() + if (prev.draining) { + wonDrainSlot = false + map + } else { + wonDrainSlot = true + map + (key to prev.copy(draining = true)) + } + } + if (!wonDrainSlot) return true + + // Drain in the background — it re-fetches and decrypts over the + // network, so it must not block the caller. The drain gets its OWN + // resolver + signer, owned by this coroutine (the Swift + // Task.detached + withExtendedLifetime shape): the manager's shared + // children are freed by close() without waiting for an in-flight + // blocking JNI call, so borrowing them would be a use-after-free + // when a manager swap races a slow drain. The raw wallet handle is + // captured, not the wrapper: a wallet destroyed before the drain + // runs just misses Rust-side (NotFound) — wallet handles are + // storage-keyed, unlike the resolver/signer boxes. An auth-gated + // signing failure inside the drain (Android-only: identity keys + // are biometric-gated here, unlike iOS) leaves the entry queued — + // the sweep self-heals — and surfaces like any other drain error. + val walletHandle = managed.handle + scope.launch(Dispatchers.IO) { + val drainResolver = MnemonicResolverAndPersister(walletStorage) + val drainSigner = + KeystoreSigner(walletStorage, network, biometricGate, database.platformAddressDao()) + try { + mapNativeErrors { + DashpayNative.drainPendingContactCrypto( + walletHandle, + drainSigner.nativeHandle, + drainResolver.nativeHandle, + ) + } + } catch (_: Exception) { + // Not fatal: the next signer-present DashPay action (or the + // next unlock) re-attempts; the queue rebuilds via the sweep. + } finally { + updateUnlockStatus(key) { it.copy(draining = false) } + runCatching { drainResolver.close() } + runCatching { drainSigner.close() } + } + } + return true + } + + private inline fun updateUnlockStatus( + key: String, + transform: (DashPayUnlockStatus) -> DashPayUnlockStatus, + ) { + _dashPayUnlockStatus.update { map -> + val next = transform(map[key] ?: DashPayUnlockStatus()) + if (map[key] == next) map else map + (key to next) + } + } + + /** + * Launch the 1 Hz DashPay status poll (idempotent). Change-gated like + * [startSpvProgressPolling]: `isSyncing` plus per-wallet + * `pendingAccountBuilds`, with stale wallet keys pruned — mirror of + * the Swift `startProgressPolling` DashPay block. + */ + private fun startDashPayStatusPolling() { + if (dashPayPollJob?.isActive == true) return + dashPayPollJob = scope.launch { + while (isActive && !isClosed) { + val syncing = runCatching { + DashpayNative.dashPaySyncIsSyncing(managerHandle) + }.getOrDefault(false) + if (syncing != _dashPaySyncIsSyncing.value) { + _dashPaySyncIsSyncing.value = syncing + } + + val current = _wallets.value + _dashPayUnlockStatus.update { map -> + var next = map + // Prune wallets that no longer exist on this manager. + for (staleKey in map.keys - current.keys) next = next - staleKey + for ((hexId, managed) in current) { + val pending = runCatching { + DashpayNative.pendingContactCryptoCount(managed.handle) + }.getOrDefault(0) + val prev = next[hexId] ?: DashPayUnlockStatus() + if (prev.pendingAccountBuilds != pending) { + next = next + (hexId to prev.copy(pendingAccountBuilds = pending)) + } + } + next + } + delay(POLL_INTERVAL_MS) + } + } + } + // ── Lifecycle ───────────────────────────────────────────────────── val isClosed: Boolean get() = bundleRef.get() == 0L @@ -1029,12 +1297,14 @@ class PlatformWalletManager( val bundle = bundleRef.getAndSet(0) if (bundle == 0L) return - // Stop the progress poll loop + event fan-out scope before teardown + // Stop the progress poll loops + event fan-out scope before teardown // so no collector touches a destroyed handle. progressPollJob?.cancel() + dashPayPollJob?.cancel() scope.cancel() // Best-effort stop; ignore failures (destroy shuts everything down). + runCatching { DashpayNative.dashPaySyncStop(managerHandle) } runCatching { WalletManagerNative.platformAddressSyncStop(managerHandle) } runCatching { WalletManagerNative.identitySyncStop(managerHandle) } runCatching { WalletManagerNative.shieldedSyncStop(managerHandle) } @@ -1056,5 +1326,38 @@ class PlatformWalletManager( private companion object { /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ const val POLL_INTERVAL_MS = 1_000L + + /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ + const val PWFFI_INVALID_PARAMETER = 2 } } + +/** + * Per-wallet seedless-unlock status — Swift `DashPayUnlockStatus`. + * Published on [PlatformWalletManager.dashPayUnlockStatus]; drives the + * DashPay tab's unlock banner. + */ +data class DashPayUnlockStatus( + /** A deferred-contact-crypto drain is in flight for this wallet. */ + val draining: Boolean = false, + /** + * The stored seed failed the binding verify — a mis-mapped Keystore + * slot. Security-relevant: no drain runs and the wallet stays + * external-signable, so the wrong seed can never produce a + * network-valid signature for it (its identity keys derive from the + * correct seed; a foreign-seed signature fails on-chain validation). + */ + val seedMismatch: Boolean = false, + /** Deferred contact-crypto entries queued (from the 1 Hz poll). */ + val pendingAccountBuilds: Int = 0, +) + +/** + * One `dashPaySyncNow` result — Swift `DashPaySyncSummary`: + * per-identity success/error counts + the sweep's unix-seconds stamp. + */ +data class DashPaySyncSummary( + val success: Int, + val errors: Int, + val syncUnixSeconds: Long, +) diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index d8f17bcefc6..34ce31b6db4 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -20,20 +20,29 @@ use crate::support::{guard, take_pwffi_error}; use jni::objects::{JByteArray, JClass, JString}; -use jni::sys::{jint, jlong, jstring}; +use jni::sys::{jboolean, jint, jlong, jstring}; use jni::JNIEnv; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; use platform_wallet_ffi::handle::Handle; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; use std::ptr; fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + // throw_sdk_exception hits DashSDKException's (Int, String) ctor — a + // bare throw_new would look for a (String) ctor that does not exist + // and surface as NoSuchMethodError instead of a DashSdkError. + if arr.is_null() { + crate::support::throw_sdk_exception(env, 1, &format!("{field} must not be null")); + return None; + } let len = env.get_array_length(arr).ok()? as usize; if len != 32 { - let _ = env.throw_new( - "org/dashfoundation/dashsdk/ffi/DashSDKException", - format!("{field} must be 32 bytes, got {len}"), + crate::support::throw_sdk_exception( + env, + 1, + &format!("{field} must be 32 bytes, got {len}"), ); return None; } @@ -411,3 +420,390 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_walletM new_jstring(env, format!("[{}]", rows.join(","))) }) } + +// ── Recurring DashPay sync service (manager-scoped) ─────────────────── +// +// Bridges the `platform_wallet_manager_dashpay_sync_*` family — the +// recurring background sweep (contact requests + profiles + reconcile) +// that `DashpaySyncService` owns on the Kotlin side, mirroring +// `PlatformWalletManagerDashPaySync.swift`. + +/// Start the recurring DashPay sweep. Idempotent Rust-side. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncStart( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_start( + manager_handle as Handle, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Stop the recurring DashPay sweep. Leaves it restartable. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncStop( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_stop(manager_handle as Handle) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Whether the recurring sweep loop is running. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncIsRunning( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jboolean { + guard(&mut env, 0, |env| { + let mut running = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_is_running( + manager_handle as Handle, + &mut running as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + running as jboolean + }) +} + +/// Whether a sweep pass is executing right now (the 1 Hz poll target). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncIsSyncing( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jboolean { + guard(&mut env, 0, |env| { + let mut syncing = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_is_syncing( + manager_handle as Handle, + &mut syncing as *mut bool, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + syncing as jboolean + }) +} + +/// Unix seconds of the last completed sweep; 0 when never. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncLastSyncUnixSeconds( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + let mut last: u64 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_last_sync_unix_seconds( + manager_handle as Handle, + &mut last as *mut u64, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + last as jlong + }) +} + +/// Set the sweep interval in seconds (takes effect from the next tick). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncSetInterval( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + interval_seconds: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_set_interval( + manager_handle as Handle, + interval_seconds.max(0) as u64, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Run one sweep pass NOW (pull-to-refresh), blocking until it +/// completes. Returns a JSON object +/// `{"success":…,"errors":…,"syncUnixSeconds":…}` mirroring the Swift +/// `DashPaySyncSummary`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_dashPaySyncNow( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let mut success: usize = 0; + let mut errors: usize = 0; + let mut sync_unix: u64 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_dashpay_sync_sync_now( + manager_handle as Handle, + &mut success as *mut usize, + &mut errors as *mut usize, + &mut sync_unix as *mut u64, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + new_jstring( + env, + format!( + "{{\"success\":{},\"errors\":{},\"syncUnixSeconds\":{}}}", + success, errors, sync_unix + ), + ) + }) +} + +// ── Seedless unlock (verify seed binding + deferred-crypto drain) ───── + +/// Verify that the Keystore-resolved mnemonic reproduces this wallet's +/// key material (bridges `platform_wallet_verify_seed_binds_to_wallet`). +/// A stored-but-foreign seed surfaces as `ErrorInvalidParameter` — the +/// Kotlin caller disambiguates the seed-mismatch case ONLY by scoping +/// its catch to this call (the Swift contract). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_verifySeedBindsToWallet( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + core_signer_handle: jlong, +) { + guard(&mut env, (), |env| { + let result = unsafe { + platform_wallet_ffi::platform_wallet_verify_seed_binds_to_wallet( + wallet_handle as Handle, + core_signer_handle as *mut MnemonicResolverHandle, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Number of deferred contact-crypto entries queued on the wallet +/// (in-memory queue — rebuilt by the sweep, cleared by the drain). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_pendingContactCryptoCount( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, +) -> jint { + guard(&mut env, 0, |env| { + let mut count: u32 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_pending_contact_crypto_count( + wallet_handle as Handle, + &mut count as *mut u32, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + count as jint + }) +} + +/// Drain the deferred contact-crypto queue: per entry, run the seed-side +/// op (receiving-xpub register / external-account build / contactInfo +/// decrypt / auto-accept) through [`core_signer_handle`], signing any +/// reciprocal transitions with [`signer_handle`] (nullable — pass 0 for +/// resolver-only drains). Returns the drained-entry count. Blocking and +/// potentially slow (network + ECDH per entry) — never call on the main +/// thread; the caller keeps both bridge objects strongly reachable for +/// the whole call. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_drainPendingContactCrypto( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jint { + guard(&mut env, 0, |env| { + let mut drained: u32 = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_drain_pending_contact_crypto( + wallet_handle as Handle, + signer_handle as *mut SignerHandle, + core_signer_handle as *mut MnemonicResolverHandle, + &mut drained as *mut u32, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + drained as jint + }) +} + +// ── Profile / contactInfo writes ────────────────────────────────────── + +/// Create (`doCreate == true`) or update the DashPay profile for +/// `identityId`, signing with `signer_handle`. `avatarBytes` is the raw +/// image — Rust computes the SHA-256 hash + perceptual fingerprint. +/// Broadcasts a real document state transition (blocking, network). +/// Returns the resulting profile as JSON (same shape as the readers). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_createOrUpdateProfile( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + display_name: JString, + public_message: JString, + avatar_url: JString, + avatar_bytes: JByteArray, + do_create: jboolean, + signer_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let display = opt_jstring_to_cstring(env, &display_name); + let message = opt_jstring_to_cstring(env, &public_message); + let url = opt_jstring_to_cstring(env, &avatar_url); + let avatar: Option> = if avatar_bytes.is_null() { + None + } else { + env.convert_byte_array(&avatar_bytes).ok() + }; + + let mut profile = DashPayProfileFFI::empty(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_create_or_update_dashpay_profile_with_signer( + wallet_handle as Handle, + id.as_ptr(), + display.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + message.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + url.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + avatar.as_ref().map_or(ptr::null(), |v| v.as_ptr()), + avatar.as_ref().map_or(0, |v| v.len()), + do_create != 0, + signer_handle as *mut SignerHandle, + &mut profile as *mut DashPayProfileFFI, + ) + }; + if take_pwffi_error(env, result) { + // The FFI zero-initializes out_profile before fallible work, + // so nothing was allocated on the error path. + return ptr::null_mut(); + } + let json = crate::tokens::profile_to_json(&profile); + unsafe { + platform_wallet_ffi::dashpay_profile_ffi_free(&mut profile as *mut DashPayProfileFFI) + }; + new_jstring(env, json) + }) +} + +/// Set the owner-private contactInfo (alias / note / displayHidden) for +/// `(identityId, contactId)`. Local state always updates; the encrypted +/// on-chain publish is gated by DIP-15 (needs ≥ 2 established contacts) +/// and by the wallet's signing capability. Returns the +/// `CONTACT_INFO_*` outcome discriminant: 0 published, 1 deferred until +/// two contacts, 2 skipped (watch-only). Blocking when it publishes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_setContactInfo( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + contact_id: JByteArray, + alias: JString, + note: JString, + display_hidden: jboolean, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jint { + guard(&mut env, -1, |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return -1; + }; + let Some(contact) = read_id32(env, &contact_id, "contactId") else { + return -1; + }; + let alias_c = opt_jstring_to_cstring(env, &alias); + let note_c = opt_jstring_to_cstring(env, ¬e); + + let mut outcome: u8 = 0; + let result = unsafe { + platform_wallet_ffi::contact_info::platform_wallet_set_dashpay_contact_info_with_signer( + wallet_handle as Handle, + id.as_ptr(), + contact.as_ptr(), + alias_c.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + note_c.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + display_hidden != 0, + signer_handle as *mut SignerHandle, + core_signer_handle as *mut MnemonicResolverHandle, + &mut outcome as *mut u8, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + outcome as jint + }) +} + +// ── Signer capability preflight ─────────────────────────────────────── + +/// Whether the mnemonic resolver can derive-sign identity keys of +/// `keyType` (bridges `dash_sdk_resolver_supports_key_type`). Preflight +/// only — keeps `canSignWith` consistent with the sign path's +/// `UNSUPPORTED_KEY_TYPE` rejection by reading the one Rust source of +/// truth (Swift `KeychainSigner.resolverCanDeriveSign`). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_resolverSupportsKeyType( + mut env: JNIEnv, + _class: JClass, + key_type: jint, +) -> jboolean { + guard(&mut env, 0, |_| { + let Ok(kt) = u8::try_from(key_type) else { + return 0; + }; + let supported = unsafe { platform_wallet_ffi::dash_sdk_resolver_supports_key_type(kt) }; + supported as jboolean + }) +} + +/// Convert a nullable JString into an owned CString; None on null / +/// conversion failure (treated as "field absent"). NOTE: an interior NUL +/// therefore CLEARS the field on Android (Swift truncates at the NUL +/// instead) — a deliberate divergence for pathological input. +fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option { + if s.is_null() { + return None; + } + let value: String = env.get_string(s).ok()?.into(); + std::ffi::CString::new(value).ok() +} diff --git a/packages/rs-unified-sdk-jni/src/mnemonic.rs b/packages/rs-unified-sdk-jni/src/mnemonic.rs index d689edabefa..2e3778868bb 100644 --- a/packages/rs-unified-sdk-jni/src/mnemonic.rs +++ b/packages/rs-unified-sdk-jni/src/mnemonic.rs @@ -7,7 +7,7 @@ //! which decrypts the Keystore-wrapped mnemonic on demand. use crate::support::{guard, JVM}; -use jni::objects::{GlobalRef, JClass, JObject, JString}; +use jni::objects::{GlobalRef, JClass, JObject}; use jni::sys::jlong; use jni::JNIEnv; use rs_sdk_ffi::{ @@ -23,15 +23,25 @@ const RESULT_NOT_FOUND: i32 = 1; const RESULT_BUFFER_TOO_SMALL: i32 = 2; const RESULT_OTHER: i32 = 3; +/// Sentinel returns from `NativeMnemonicBridge.resolveMnemonicInto` +/// (keep in sync with the Kotlin companion constants). +const RESOLVE_NOT_FOUND: i32 = -1; +const RESOLVE_BUFFER_TOO_SMALL: i32 = -2; +const RESOLVE_OTHER: i32 = -3; + struct KotlinMnemonicCtx { bridge: GlobalRef, } /// The synchronous resolve trampoline. May fire on Tokio worker threads — -/// attaches as daemon, calls -/// `NativeMnemonicBridge.resolveMnemonic(byte[]): String`, copies the -/// UTF-8 bytes into the Rust-owned buffer, and zeroes the intermediate -/// copy. Never unwinds. +/// attaches as daemon and calls +/// `NativeMnemonicBridge.resolveMnemonicInto(byte[], byte[]): int` — the +/// out-buffer contract: Kotlin writes raw UTF-8 phrase bytes into a Java +/// buffer sized to the Rust caller's capacity, this trampoline copies +/// them straight into the Rust-owned buffer and then ZEROES the Java +/// buffer. No `java.lang.String` of the phrase ever exists (an immutable +/// String can't be scrubbed and would sit in the JVM heap, recoverable +/// from a heap dump, until — if ever — collected). Never unwinds. unsafe extern "C" fn resolve_trampoline( ctx: *const c_void, wallet_id_bytes: *const u8, @@ -51,43 +61,49 @@ unsafe extern "C" fn resolve_trampoline( return RESULT_OTHER; }; + // A zero-capacity out buffer cannot even hold the NUL terminator; + // reject up front (the `usable = cap - 1` arithmetic below would + // otherwise let an empty phrase write one byte out of bounds). + if out_capacity == 0 { + return RESULT_BUFFER_TOO_SMALL; + } + match env.with_local_frame(16, |env| -> Result { let wallet_id = std::slice::from_raw_parts(wallet_id_bytes, 32); let jwallet_id = env.byte_array_from_slice(wallet_id)?; - let value = env + // Reserve one byte of the Rust capacity for the NUL terminator. + let usable = out_capacity - 1; + let jout = env.new_byte_array(usable as i32)?; + let written = env .call_method( ctx.bridge.as_obj(), - "resolveMnemonic", - "([B)Ljava/lang/String;", - &[(&jwallet_id).into()], + "resolveMnemonicInto", + "([B[B)I", + &[(&jwallet_id).into(), (&jout).into()], )? - .l()?; - if value.is_null() { - return Ok(RESULT_NOT_FOUND); - } + .i()?; - let jstr = JString::from(value); - let java_str = env.get_string(&jstr)?; - let mut bytes = java_str.to_bytes().to_vec(); - drop(java_str); - - let code = if bytes.len() + 1 > out_capacity { - RESULT_BUFFER_TOO_SMALL - } else { - std::ptr::copy_nonoverlapping( - bytes.as_ptr(), - out_mnemonic_utf8 as *mut u8, - bytes.len(), - ); - *(out_mnemonic_utf8.add(bytes.len())) = 0; - *out_len = bytes.len(); - RESULT_OK + let code = match written { + RESOLVE_NOT_FOUND => RESULT_NOT_FOUND, + RESOLVE_BUFFER_TOO_SMALL => RESULT_BUFFER_TOO_SMALL, + RESOLVE_OTHER => RESULT_OTHER, + len if len < 0 || len as usize > usable => RESULT_OTHER, + len => { + let len = len as usize; + // Copy the phrase bytes straight from the Java buffer + // into the Rust-owned out buffer, then scrub the Java + // buffer — the only JVM-side plaintext copy. + // `.cast()`: c_char is i8 on x86_64 but u8 on + // aarch64-linux-android; jbyte is always i8. + let dst = std::slice::from_raw_parts_mut(out_mnemonic_utf8.cast::(), len); + env.get_byte_array_region(&jout, 0, dst)?; + *(out_mnemonic_utf8.add(len)) = 0; + *out_len = len; + RESULT_OK + } }; - - // Zero the intermediate Rust copy of the phrase. (The JVM String - // itself is garbage-collected — same residual exposure the iOS - // Swift String has.) - bytes.iter_mut().for_each(|b| *b = 0); + let zeros = vec![0i8; usable]; + env.set_byte_array_region(&jout, 0, &zeros)?; Ok(code) }) { Ok(code) => code, diff --git a/packages/rs-unified-sdk-jni/src/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index 44b32e41241..22361a31e3b 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -30,6 +30,14 @@ use std::ffi::{c_void, CString}; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; +/// Zero a [`CString`]'s backing buffer before releasing it. For buffers +/// holding key material (the mnemonic) — a plain drop would return the +/// plaintext bytes to the allocator intact. +fn scrub_cstring(c: CString) { + let mut bytes = c.into_bytes(); + bytes.iter_mut().for_each(|b| *b = 0); +} + struct KotlinSignerCtx { bridge: GlobalRef, } @@ -348,8 +356,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith data: JByteArray, ) -> jbyteArray { guard(&mut env, ptr::null_mut(), |env| { - let (Ok(mnemonic_str), Ok(path_str), Ok(payload)) = ( - env.get_string(&mnemonic).map(String::from), + // Decode the non-secret arguments FIRST, the mnemonic LAST: a + // sibling-conversion failure must never orphan an already-decoded + // plaintext copy of the phrase. + let (Ok(path_str), Ok(payload)) = ( env.get_string(&derivation_path).map(String::from), env.convert_byte_array(&data), ) else { @@ -357,18 +367,38 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith crate::support::throw_sdk_exception(env, 1, "invalid mnemonic-sign arguments"); return ptr::null_mut(); }; - - // Interior NULs would truncate the C string mid-derivation-path; - // reject rather than silently sign under a wrong path. - let (Ok(mnemonic_c), Ok(path_c)) = (CString::new(mnemonic_str), CString::new(path_str)) - else { + let Ok(path_c) = CString::new(path_str) else { + // Interior NULs would truncate the C string mid-derivation-path; + // reject rather than silently sign under a wrong path. crate::support::throw_sdk_exception( env, 1, - "mnemonic or derivation path contained an interior NUL", + "derivation path contained an interior NUL", ); return ptr::null_mut(); }; + let Ok(mnemonic_str) = env.get_string(&mnemonic).map(String::from) else { + let _ = env.exception_clear(); + crate::support::throw_sdk_exception(env, 1, "invalid mnemonic argument"); + return ptr::null_mut(); + }; + + // The mnemonic buffer is scrubbed on every path. `reserve_exact(1)` + // BEFORE `CString::new` matters: the NUL append would otherwise + // reallocate a capacity==len buffer, freeing the original plaintext + // allocation unscrubbed — the scrub would then only touch the copy. + let mut mnemonic_bytes = mnemonic_str.into_bytes(); + mnemonic_bytes.reserve_exact(1); + let mnemonic_c = match CString::new(mnemonic_bytes) { + Ok(c) => c, + Err(e) => { + // NulError hands back the original allocation via into_vec. + let mut leaked = e.into_vec(); + leaked.iter_mut().for_each(|b| *b = 0); + crate::support::throw_sdk_exception(env, 1, "mnemonic contained an interior NUL"); + return ptr::null_mut(); + } + }; let network = match network { 0 => dash_network::ffi::FFINetwork::Mainnet, @@ -401,6 +431,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith ) }; + // The actual secret this function holds is the mnemonic, not the + // signature — scrub it before either return path. (The JVM-side + // String the caller passed remains the pre-existing exposure; + // tracked as the KeystoreSigner follow-up.) + scrub_cstring(mnemonic_c); + if rc != 0 || err_tag != SIGN_WITH_MNEMONIC_OK { crate::support::throw_sdk_exception( env,