Skip to content
Merged
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
Expand Up @@ -700,4 +700,49 @@ internal object WalletManagerNative {
accountIndex: Int,
coreFeePerByte: Int,
): String?

// ── Ordered wallet bring-up ───────────────────────────────────────

/**
* Ordered wallet bring-up — identity → contacts → contact-account
* drain — run as ONE bounded call so the host can start Core SPV
* knowing the DIP-15 contact addresses exist and will be in the first
* filter set. Wraps `platform_wallet_manager_start_wallet_subsystems`;
* iOS binds the same call (`PlatformWalletManagerStartup.swift`), and
* the ordering / retry / budget policy lives Rust-side in
* `platform_wallet::manager::startup`.
*
* Blocks until the sequence finishes or its budget expires — call from
* a background dispatcher. Throws [DashSDKException] only for a
* malformed request (bad handle, unknown wallet, bad argument); an
* unreachable Platform, a failed sync pass or an unfinished drain are
* all reported through the returned blob's status, because the host
* must be able to start Core SPV regardless.
*
* @param managerHandle the raw manager handle ([nativeManagerHandle]).
* @param walletId the 32-byte wallet id.
* @param mnemonicResolverHandle Keychain mnemonic resolver, or 0 for a
* wallet holding resident keys. Without it the drain is skipped and
* the pending count reported.
* @param identitySignerHandle identity signer for the DIP-15
* auto-accept pass, or 0 to skip it.
* @param budgetSecs ceiling for the whole sequence; 0 = SDK default
* (20s). Never unbounded — this call gates Core SPV.
* @param gapLimit identity-discovery gap limit; 0 = SDK default.
* @return a fixed 57-byte big-endian outcome blob, decoded by
* `WalletStartupOutcome.decode` (the layouts must match the JNI
* side): offset 0 status (1B), 1 hasIdentityId (1B), 2 identityId
* (32B), 34 discoveryAttempts (u32), 38 dashPaySyncRan (1B),
* 39 seedBindingUnverified (1B), 40 identityScanIncomplete (1B),
* 41 contactAccountsDrained (u32), 45 contactAccountsPending (u32),
* 49 elapsedMs (u64).
*/
external fun startWalletSubsystems(
managerHandle: Long,
walletId: ByteArray,
mnemonicResolverHandle: Long,
identitySignerHandle: Long,
budgetSecs: Long,
gapLimit: Int,
): ByteArray
}
Original file line number Diff line number Diff line change
Expand Up @@ -2341,6 +2341,98 @@ class PlatformWalletManager(
}
}

// ── Ordered wallet bring-up ───────────────────────────────────────

/**
* Bring one wallet's DashPay state up in dependency order — identity →
* contacts → contact-account drain — then return so the caller can
* start Core SPV. Port of Swift's `startWalletSubsystems`
* (`PlatformWalletManagerStartup.swift`); the ordering, the retry
* policy and the budget all live Rust-side
* (`platform_wallet::manager::startup`) — this is a thin bridge.
*
* A contact's DIP-15 payment addresses are derived from its contact
* account, and an address the wallet is not watching when the
* compact-filter scan passes its funding height produces no
* transaction at all. Call this once per wallet load, immediately
* before [startSpv], so the first filter set already covers them —
* a restored wallet then needs no receival-payment rescan at all.
*
* Budget expiry is reported in the outcome, never thrown: Core sync is
* the wallet's primary function and must not be held hostage to
* Platform being slow. Start SPV regardless of the returned status;
* inspect [WalletStartupOutcome.contactAccountsPending] for
* diagnostics.
*
* Key material follows the drain's per-call contract: the mnemonic
* resolver and identity signer are built for this call and closed when
* it returns — Rust borrows and never retains them. An auth-gated
* signing failure (identity keys are biometric-gated on Android)
* leaves the affected entries queued; the recurring sweep self-heals.
*
* Throws only for a malformed request (bad wallet id, negative
* arguments, unknown wallet, torn-down manager).
*
* @param walletId the 32-byte wallet id.
* @param budgetSecs ceiling for the whole sequence in seconds; 0 = SDK
* default (20s). Never unbounded — this call gates Core SPV.
* @param gapLimit identity-discovery gap limit; 0 = SDK default.
*/
suspend fun startWalletSubsystems(
walletId: ByteArray,
budgetSecs: Long = 0,
gapLimit: Int = 0,
): WalletStartupOutcome = teardownGate.op {
require(walletId.size == 32) { "walletId must be 32 bytes, got ${walletId.size}" }
require(budgetSecs >= 0) { "budgetSecs must be non-negative, got $budgetSecs" }
require(gapLimit >= 0) { "gapLimit must be non-negative, got $gapLimit" }
withContext(Dispatchers.IO) {
// Each handle is guarded from the moment it exists: the signer's
// constructor can throw (Keystore unlock, DAO access), and a single
// try covering both would leak the resolver's native handle when it
// does. Closed in reverse construction order.
val startupResolver = MnemonicResolverAndPersister(walletStorage)
try {
val startupSigner =
KeystoreSigner(
walletStorage,
network,
biometricGate,
database.platformAddressDao(),
)
try {
val blob = mapNativeErrors {
WalletManagerNative.startWalletSubsystems(
managerHandle,
walletId,
startupResolver.nativeHandle,
startupSigner.nativeHandle,
budgetSecs,
Comment on lines +2385 to +2410

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Omit the mnemonic resolver for genuinely watch-only wallets

MnemonicResolverAndPersister is constructed and passed unconditionally, even when walletStorage.hasMnemonic(walletId) confirms that the wallet has no stored seed. In that case, discovery invokes resolve_seed_from_resolver_classified, whose NOT_FOUND path is intentionally classified as temporary unavailability because the host is expected to filter seedless wallets before calling. Startup therefore reports PARTIAL_NO_IDENTITY with retryable discovery on every invocation, although retrying cannot provide the absent seed. The existing storage API exposes the required existence check, and the Swift integration passes a null resolver for confirmed seed absence. Check mnemonic presence before constructing the resolver and pass a null native handle when it is genuinely absent; retain the resolver when storage exists or its availability is uncertain, with coverage for both paths.

source: gpt-6-astra (phase2-reviewer: general)

gapLimit,
)
}
val rawStatus = blob.firstOrNull()?.toInt()?.and(0xFF)
if (rawStatus != null && !WalletStartupStatus.isKnownRaw(rawStatus)) {
// Append-only ABI: a newer native library reported a status
// this build predates. decode() maps it to PARTIAL_NO_IDENTITY
// (Swift parity); say so once so the mismatch is visible.
android.util.Log.w(
"PlatformWalletManager",
"startWalletSubsystems: unknown WalletStartupStatus discriminant " +
"$rawStatus from the native library; treating as PARTIAL_NO_IDENTITY " +
"(update the Kotlin SDK to match the native build)",
)
}
WalletStartupOutcome.decode(blob)
} finally {
runCatching { startupSigner.close() }
}
} finally {
runCatching { startupResolver.close() }
}
}
}

// ── Lifecycle ─────────────────────────────────────────────────────

val isClosed: Boolean get() = bundleRef.get() == 0L
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package org.dashfoundation.dashsdk.wallet

import java.nio.ByteBuffer
import java.nio.ByteOrder

/**
* Why a wallet bring-up stopped where it did — Kotlin mirror of the Rust
* `WalletStartupStatus` and of Swift's `WalletStartupStatus`
* (`SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift`). Raw
* values are the `WalletStartupStatusFFI` ABI discriminants: append, never
* renumber.
*
* Every case is a normal result. A host starts Core SPV on all of them —
* the partial cases just mean the DIP-15 rescan has work left to do.
*/
enum class WalletStartupStatus(val raw: Int) {
/**
* Identity resolved, contacts synced, no contact-account builds left
* queued. Everything a contact payment needs is in place.
*/
READY(0),

/**
* Platform answered that this seed owns no identity. Terminal, and not
* a failure — there is nothing to sync and nothing to drain.
*/
NO_IDENTITY(1),

/**
* The identity scan never reached Platform inside the budget. The
* wallet may well own one; we do not know yet, and asking again may
* answer it.
*/
PARTIAL_NO_IDENTITY(2),

/**
* Identity resolved and synced, but contact-account builds are still
* queued — the budget ran out, the drain failed on some entries, or no
* contact-crypto provider was available.
*/
PARTIAL_ACCOUNTS_PENDING(3),

/**
* Discovery failed locally — a wallet or persistence fault, not a
* reachability problem. Another scan will not answer it: the same
* fault is still there.
*/
DISCOVERY_FAILED(4),

/**
* The contact-crypto provider does not resolve the seed that owns this
* wallet, so the contact-account drain was skipped without deriving
* anything. Not about Platform being slow: the signer handed to the
* call belongs to a different wallet, and deriving anyway would write
* contact receiving addresses from the wrong seed that no later
* correct-seed pass would ever revisit. The queued work is intact; a
* rerun with the right signer completes it.
*/
SEED_BINDING_UNVERIFIED(5),

/**
* An identity is known and every later step ran, but the gap-limit
* identity scan is on record as having left indices unanswered. The
* identity reported is real; it may not be the only one. The verdict
* stays on record, so the next launch re-scans instead of taking the
* warm shortcut.
*/
IDENTITY_SCAN_INCOMPLETE(6),
;

/**
* Whether another discovery scan could change the answer: true for
* [PARTIAL_NO_IDENTITY] (Platform was never reached) and
* [IDENTITY_SCAN_INCOMPLETE] (reached, but not for every index). The
* others are terminal for this launch.
*/
val discoveryWorthRetrying: Boolean
get() = this == PARTIAL_NO_IDENTITY || this == IDENTITY_SCAN_INCOMPLETE

/**
* Whether the identity question has an answer. Not the inverse of
* [discoveryWorthRetrying]: [DISCOVERY_FAILED] leaves the question
* open AND is not worth retrying, while [IDENTITY_SCAN_INCOMPLETE]
* has an answer that is merely known to be partial. Use this to
* decide what to show, and [discoveryWorthRetrying] to decide whether
* to scan again.
*/
val identityIsSettled: Boolean
get() = this != PARTIAL_NO_IDENTITY && this != DISCOVERY_FAILED

companion object {
/**
* The raw values are an append-only ABI, so a newer native library can
* report a status this Kotlin build predates. Treat it as
* [PARTIAL_NO_IDENTITY] rather than fail the bring-up: that is the
* conservative case (host starts Core SPV, DIP-15 rescan still owed),
* and it is what Swift does (`WalletStartupStatus(rawValue:) ??
* .partialNoIdentity` in `PlatformWalletManagerStartup.swift`). Callers
* that want to surface the mismatch check [isKnownRaw] first.
*/
fun fromRaw(raw: Int): WalletStartupStatus =
entries.firstOrNull { it.raw == raw } ?: PARTIAL_NO_IDENTITY

/** True when [raw] is a discriminant this build knows. */
fun isKnownRaw(raw: Int): Boolean = entries.any { it.raw == raw }
}
}

/**
* What a wallet bring-up did — Kotlin mirror of the Rust
* `WalletStartupOutcome` and of Swift's `WalletStartupOutcome`
* (`SwiftDashSDK/PlatformWallet/PlatformWalletManagerStartup.swift`).
*/
data class WalletStartupOutcome(
val status: WalletStartupStatus,
/** The wallet's identity, when one is known by the time this returned. */
val identityId: ByteArray?,
/**
* Discovery scans performed; 0 when a local identity was already known
* and no network scan was needed.
*/
val discoveryAttempts: Int,
/**
* Whether the inline contact-request pass ran TO COMPLETION. False when
* it was skipped, failed, ran out of budget, or came back degraded — a
* pass that could not read some identities' contact documents left
* their account builds unenqueued, so an empty pending count does not
* mean their addresses are ready.
*/
val dashPaySyncRan: Boolean,
/**
* The drain was skipped because the contact-crypto provider does not
* resolve this wallet's seed. Nothing was derived and nothing written;
* the queued work is intact.
*/
val seedBindingUnverified: Boolean,
/**
* The wallet's identity scan is on record as having left indices
* unanswered and this launch did not close the gap. Carried separately
* from [status] because a pending contact queue outranks it there.
*/
val identityScanIncomplete: Boolean,
/** Contact-crypto entries the drain completed. */
val contactAccountsDrained: Int,
/**
* Contact-account builds still queued on return. Non-zero means the
* DIP-15 rescan will have to backfill those contacts' payments.
*/
val contactAccountsPending: Int,
/** Wall-clock duration of the whole sequence, in milliseconds. */
val elapsedMs: Long,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WalletStartupOutcome) return false
return status == other.status &&
(identityId?.contentEquals(other.identityId) ?: (other.identityId == null)) &&
discoveryAttempts == other.discoveryAttempts &&
dashPaySyncRan == other.dashPaySyncRan &&
seedBindingUnverified == other.seedBindingUnverified &&
identityScanIncomplete == other.identityScanIncomplete &&
contactAccountsDrained == other.contactAccountsDrained &&
contactAccountsPending == other.contactAccountsPending &&
elapsedMs == other.elapsedMs
}

override fun hashCode(): Int {
var result = status.hashCode()
result = 31 * result + (identityId?.contentHashCode() ?: 0)
result = 31 * result + discoveryAttempts
result = 31 * result + dashPaySyncRan.hashCode()
result = 31 * result + seedBindingUnverified.hashCode()
result = 31 * result + identityScanIncomplete.hashCode()
result = 31 * result + contactAccountsDrained
result = 31 * result + contactAccountsPending
result = 31 * result + elapsedMs.hashCode()
return result
}

companion object {
/** Size of the JNI outcome blob; must match the Rust serializer. */
const val BLOB_SIZE: Int = 57

/**
* Decode the fixed-layout big-endian blob produced by
* `WalletManagerNative.startWalletSubsystems` — the layout table
* lives on both the JNI wrapper and the external declaration, and
* the three must stay in lockstep.
*/
fun decode(blob: ByteArray): WalletStartupOutcome {
require(blob.size == BLOB_SIZE) {
"startup outcome blob must be $BLOB_SIZE bytes, got ${blob.size}"
}
val buf = ByteBuffer.wrap(blob).order(ByteOrder.BIG_ENDIAN)
val status = WalletStartupStatus.fromRaw(buf.get().toInt() and 0xFF)
val hasIdentity = buf.get().toInt() != 0
val identity = ByteArray(32).also { buf.get(it) }
val discoveryAttempts = buf.int
val dashPaySyncRan = buf.get().toInt() != 0
val seedBindingUnverified = buf.get().toInt() != 0
val identityScanIncomplete = buf.get().toInt() != 0
val drained = buf.int
val pending = buf.int
val elapsedMs = buf.long
return WalletStartupOutcome(
status = status,
identityId = if (hasIdentity) identity else null,
discoveryAttempts = discoveryAttempts,
dashPaySyncRan = dashPaySyncRan,
seedBindingUnverified = seedBindingUnverified,
identityScanIncomplete = identityScanIncomplete,
contactAccountsDrained = drained,
contactAccountsPending = pending,
elapsedMs = elapsedMs,
)
}
}
}
Loading
Loading