Reconciliation
Reconciliation is the mechanism that keeps device-side keyshare state crash-safe across the two operations that rewrite it — keygen and key refresh. It's the reason the example's storage type is called VaultReconcileStoreDao / VaultReconcileRecord and the reason VaultSessionManager.reconcile(keyId) exists at all.
The protocol-level state machine (two slots, stage → commit, rollback on failure) is documented at Keygen reconcile (Kotlin) and Key refresh reconcile (Kotlin) (and the Swift equivalents under /duo/swift/guides/reconcile/). This page documents how the example uses that state machine.
Two slots per keyId
The SDK's ReconcileStoreDao carries two keyshare fields, not one:
keyId → identifier
currentKeyshare: ByteArray? → the share you should use for signing
stagedKeyshare: ByteArray? → an in-progress new share, not yet committed
During a normal "at rest" state, currentKeyshare is the valid on-device share and stagedKeyshare is null. During a key refresh, the SDK writes the newly-produced share to stagedKeyshare before clearing the old one, so a crash mid-refresh leaves both slots populated (or the old one cleared and the new one staged, depending on which half of the flow was interrupted). The two-slot record is what lets the next launch tell the difference between "normal state" and "mid-refresh, commit pending."
The example preserves these two fields across its storage layer with zero interpretation — they pass straight through EncryptedStorageClient.read into VaultReconcileStoreDao (Android) / VaultReconcileRecord (iOS).
What the example's reconcile(keyId) does
VaultSessionManager.reconcile(keyId) is called on app launch for every stored keyId (see next section). If it finds a record with a staged share and no current share, it asks the SDK to commit the staged share via reconcileKeyshare(keyId). Otherwise it does nothing.
suspend fun reconcile(keyId: String) {
val dao = readDao(keyId)
if (dao.stagedKeyshare != null && dao.currentKeyshare == null) {
sessionFor(dao.keyType).reconcileKeyshare(keyId)
Log.i(TAG, "Reconcile completed for keyId: $keyId")
}
}
func reconcile(keyId: String) async throws {
guard let record = ecdsaStorage.readRecord(keyId: keyId) else { return }
if record.stagedKeyshare != nil && record.currentKeyshare == nil {
log.info("Reconcile needed for: \(keyId)")
let keyIdData = Data(keyId.utf8)
_ = try await sessionForKeyType(record.keyType).reconcileKeyshare(keyId: keyIdData).get()
log.info("Reconcile completed: \(keyId)")
}
}
The SDK's reconcileKeyshare does the actual commit: it reads the staged share, promotes it to current, clears the staged slot, and writes the result back via the same EncryptedStorageClient.write callback that staged it in the first place.
The current == null && staged != null precondition is conservative. If both current and staged are populated (e.g. the app crashed before the old share was cleared), the example leaves both in place — currentKeyshare is still a valid share, so signing continues to work, and the staged share can be committed on the next explicit refresh. The reconcile path is only for the case where the user is "stuck" with nothing but a staged share.
When reconcile runs
The demo walks every stored keyId once per launch via reconcileOnStartup(), called right after the first account load:
suspend fun reconcileOnStartup() {
try {
for (account in _accounts.value) {
try {
app.sessionManager.reconcile(account.keyId)
Log.i(TAG, "Reconciled keyId: ${account.keyId}")
} catch (e: Exception) {
Log.e(TAG, "Reconciliation failed for keyId: ${account.keyId}", e)
}
}
} catch (e: Exception) {
Log.e(TAG, "Reconciliation check failed", e)
}
}
func reconcileOnStartup() {
Task { @MainActor in
for account in accounts {
do {
try await sessionManager.reconcile(keyId: account.keyId)
} catch {
log.error("Reconcile failed for \(account.keyId): \(error)")
}
}
}
}
On Android it's invoked from BlockchainViewModel.init; on iOS it's invoked from AppNavigation.onAppear after the AccountManager reload. Either way, by the time the dashboard becomes interactive, any pending reconcile has either completed or errored (with a log entry).
The per-keyId reconcile is wrapped in its own try/catch, so one failing key doesn't block the others — the user still sees the dashboard, they just can't sign with the broken wallet until the issue is resolved.
Why this matters for integrators
If you call session.keyRefresh(keyshare) and the app is killed mid-call (user force-quits, OOM, process crash, power loss), the on-disk state is not corrupted — the SDK's two-slot write order guarantees that one of the two slots always has a usable share. But the device's keyshare is still "stuck in the middle" of the refresh until someone commits the staged share. Without a reconcile() step on next launch, the next sign() call will pick up the old share and the refresh is silently wasted — or worse, it'll pick up null and fail.
Adding a reconcileOnStartup() call to your own app's launch flow is the cheapest way to make keyRefresh crash-safe. The pattern is the one you see in AccountManager / BlockchainManager above: enumerate every stored keyId, call reconcile on each, catch exceptions per-key, log and continue. It's a few lines of code and it closes the class of bugs where a user's wallet gets stuck after a bad app termination.
Keygen reconciliation
The SDK also has a keygen-side reconcile flow for the case where keygen is interrupted — the protocol-level details are at Keygen reconcile. The example does not expose a UI for this separately; the reconcileOnStartup() pass above is sufficient because reconcileKeyshare(keyId) handles both paths server-side. The integrator-facing API is a single SDK method regardless of whether the interrupted operation was keygen or keyRefresh.