Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)) }
Expand Down
Loading
Loading