Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4ad0b1d
feat(kotlin-sdk): bridge DashPay private contact requests and type co…
QuantumExplorer Nov 8, 2026
d3b4f89
feat(kotlin-example-app): accept privately when the public accept ref…
QuantumExplorer Nov 8, 2026
95c0dd2
docs(kotlin-sdk): track DashPay private contact request parity
QuantumExplorer Nov 8, 2026
655d1e3
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
f9db591
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
399161e
feat(kotlin-sdk): accept a contact request replying in kind
QuantumExplorer Nov 8, 2026
3b13621
test(kotlin-sdk): release real contact request handles in the JNI syn…
QuantumExplorer Nov 8, 2026
58c180a
refactor(kotlin-example-app): let the wallet choose how an accepted r…
QuantumExplorer Nov 8, 2026
13f4784
docs(kotlin-sdk): track the accept replying in kind in the private co…
QuantumExplorer Nov 8, 2026
a423a68
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
bb29bd6
fix(kotlin-sdk): export the in-kind accept in every build and type co…
QuantumExplorer Nov 8, 2026
a273857
fix(kotlin-example-app): keep an unconfirmed private reply pending an…
QuantumExplorer Nov 8, 2026
89d2330
docs(kotlin-sdk): record the pending, refresh and build-without-shiel…
QuantumExplorer Nov 8, 2026
a7f6ccf
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
34cc1e7
feat(kotlin-sdk): read a contact's private requests and pending reply…
QuantumExplorer Nov 8, 2026
f5a8e76
fix(kotlin-example-app): read a pending private reply from the wallet
QuantumExplorer Nov 8, 2026
2890ed8
docs(kotlin-sdk): track the private contact and pending reads in the …
QuantumExplorer Nov 8, 2026
446fab5
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
8118927
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
3125670
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
e050ab0
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 8, 2026
c490a27
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 9, 2026
aa341f9
feat(kotlin-sdk,unified-sdk-jni): persist the private contact request…
QuantumExplorer Nov 9, 2026
bb6b8ec
docs(kotlin-sdk): attach the in-kind accept KDoc to its declaration
QuantumExplorer Nov 9, 2026
8dd88f8
fix(kotlin-example-app): keep a failed pending read apart from nothin…
QuantumExplorer Nov 9, 2026
d5519cd
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 9, 2026
0efc887
chore(kotlin-sdk): export Room schema 16
QuantumExplorer Nov 9, 2026
808bb9b
docs(sdk): record the private contact request scan state in the parit…
QuantumExplorer Nov 9, 2026
31d393e
test(kotlin-sdk): expect Room schema 16 in the schema version test
QuantumExplorer Nov 9, 2026
911ef5c
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 9, 2026
4f2b553
fix(kotlin-sdk): fail a scan-state load on a stored row that cannot c…
QuantumExplorer Nov 9, 2026
622677c
fix(unified-sdk-jni): tie the scan-state slots to contact persistence…
QuantumExplorer Nov 9, 2026
69c8310
docs(kotlin-sdk): describe the restored private scan state and pendin…
QuantumExplorer Nov 9, 2026
f0d7c95
fix(kotlin-example-app): configure the shielded store right after the…
QuantumExplorer Nov 9, 2026
6b3c4d4
fix(kotlin-example-app): keep a code 71 refusal visible until a priva…
QuantumExplorer Nov 9, 2026
4763d41
docs(sdk): note the scan-state load and contact dependency in the par…
QuantumExplorer Nov 9, 2026
30b515d
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 9, 2026
c69b549
feat(kotlin-sdk,unified-sdk-jni): store the unresolved private paymen…
QuantumExplorer Nov 9, 2026
ab77510
feat(kotlin-sdk,unified-sdk-jni): type code 72 and read the wallet's …
QuantumExplorer Nov 9, 2026
c67ac29
fix(kotlin-example-app): show restoring private payments from the wal…
QuantumExplorer Nov 9, 2026
c5ccf42
test(kotlin-sdk): exercise the scan-state overrides and gate shielded…
QuantumExplorer Nov 9, 2026
7bc3a03
docs(sdk): record code 72 and the readiness getter in the parity mani…
QuantumExplorer Nov 9, 2026
b9fa87e
Merge branch 'feat/ffi-swift-private-contact-requests' into feat/kotl…
QuantumExplorer Nov 9, 2026
46bd798
fix(kotlin-sdk): fail the round when a contact request cannot be stored
QuantumExplorer Nov 9, 2026
3d82b5f
feat(kotlin-sdk): type code 73 and show it as a storage configuration…
QuantumExplorer Nov 9, 2026
f0c3ada
test(kotlin-sdk): require atomic changesets with the scan state in th…
QuantumExplorer Nov 9, 2026
47c02cb
docs(kotlin-sdk): attach the pending-replies KDoc to privateRepliesPe…
QuantumExplorer Nov 9, 2026
2abc9b5
docs(sdk): record code 73 and the atomic requirement in the parity ma…
QuantumExplorer Nov 9, 2026
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
220 changes: 220 additions & 0 deletions docs/sdk/sdk-parity-manifest.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ class AppContainer(private val context: Context) {
loadWallets = {
val activatedManager = walletManagerStore.activate(sdk.network, sdk)
activatedManager.loadPersistedWallets()
// Open the shielded store right after the load: Rust then
// restores, with no keys, the unconfirmed private contact
// request payments of every wallet that bound a shielded
// account in an earlier process. Until it does, their public
// contact requests are refused (code 71), and a later bind
// may never come (no mnemonic, declined biometric). The same
// path the bind opens, so the bind's call is a no-op.
if (shieldedService.isAvailable) {
try {
activatedManager.configureShielded(shieldedDbPath(activatedManager.network))
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to configure the shielded store after the load", e)
}
}
org.dashfoundation.example.ui.dashpay.DashPayWalletLoad(
value = activatedManager,
loadedWalletIdsHex = activatedManager.wallets.value.keys,
Expand Down Expand Up @@ -207,6 +223,10 @@ class AppContainer(private val context: Context) {
* `bindShielded` and the start-if-not-running guards make repeated calls
* (on every wallet-set change) safe.
*/
/** The per-network commitment-tree store, as iOS `ShieldedService.dbPath(for:)` names it. */
private fun shieldedDbPath(network: org.dashfoundation.dashsdk.Network): String =
java.io.File(context.filesDir, "shielded_tree_${network.networkName}.sqlite").absolutePath

private suspend fun rebindWalletScopedServices(
manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager,
) {
Expand Down Expand Up @@ -243,10 +263,7 @@ class AppContainer(private val context: Context) {
// and doesn't throw. dbPath mirrors iOS's
// `ShieldedService.dbPath(for:)` naming
// (`shielded_tree_<networkName>.sqlite`), rooted in filesDir.
val shieldedDbPath = java.io.File(
context.filesDir,
"shielded_tree_${manager.network.networkName}.sqlite",
).absolutePath
val shieldedDbPath = shieldedDbPath(manager.network)
shieldedService.bind(
manager = manager,
walletId = wallet.walletId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
var accountLabel by remember { mutableStateOf("") }
var isSending by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Set when an accept's private reply may have gone through (its result
// could not be confirmed): the screen only offers to close, never to
// submit again, as the send screen does for an unconfirmed spend.
var unconfirmedNotice by remember { mutableStateOf<String?>(null) }
// Whether the wallet has a private request to the resolved recipient in
// flight, or paid and not yet read back (`privateRepliesPending`). While
// it does, Send and the collision accept are off and the screen offers a
// private sync, which settles it. Read from the wallet, so reopening this
// screen keeps it. A failed read keeps them off until a read succeeds.
var recipientPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) }
var isSyncingPrivate by remember { mutableStateOf(false) }
var pendingReadVersion by remember { mutableStateOf(0) }
var collisionRecipient by remember { mutableStateOf<ByteArray?>(null) }
var previewProfile by remember { mutableStateOf<DashPayProfile?>(null) }

Expand All @@ -107,7 +119,14 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
AddMode.IDENTITY_ID -> parsedIdentityId
}
val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true
val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending
val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending &&
unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex())
val recipientBlocked = resolvedRecipient != null &&
(
recipientPending.isRestoringPrivatePayments ||
recipientPending.isPending(resolvedRecipient.toHex()) ||
recipientPending.isUnavailable
)

// Scan-to-send: consume the QR string handed back by the scanner screen
// (the house pattern — observe the saved-state flow, then null it out).
Expand Down Expand Up @@ -168,6 +187,18 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
// Cache-only profile for the preview card (local read; most unknown
// identities won't have one).
val recipientHex = resolvedRecipient?.toHex()
LaunchedEffect(recipientHex, wallet, pendingReadVersion) {
val contactIds = listOfNotNull(resolvedRecipient)
recipientPending = recipientPending.recorded(
privateRepliesPending(wallet, idBytes, contactIds),
contactIds.map { it.toHex() },
).withReadiness(privateContactReadiness(wallet, idBytes))
}
// Binding the shielded account restores the wallet's private payments:
// read the readiness again then.
LaunchedEffect(container.shieldedService) {
container.shieldedService.isBound.collect { pendingReadVersion++ }
}
LaunchedEffect(recipientHex) {
val recipient = resolvedRecipient
val w = wallet
Expand Down Expand Up @@ -204,25 +235,62 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
kickDashPaySync(scope, m)
navController.popBackStack()
} catch (e: Exception) {
errorMessage = e.message ?: "Send failed"
when (val outcome = ContactRequestSubmissionOutcome.of(e)) {
// Nothing was sent, and the wallet may hold nothing for
// it: the screen shows it as pending and offers the
// private sync that settles it.
ContactRequestSubmissionOutcome.PrivateRequestPending ->
recipientPending = recipientPending.withRefusal(recipient.toHex())
// Nothing was sent: the screen shows the wallet restoring
// its private payments until its readiness says restored.
ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored ->
recipientPending = recipientPending.withRestoringRefusal()
ContactRequestSubmissionOutcome.MayHaveGoneThrough -> {
kickDashPaySync(scope, m)
unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE
}
is ContactRequestSubmissionOutcome.Failed -> errorMessage = outcome.message
}
} finally {
isSending = false
pendingReadVersion++
}
}
}

/**
* Read the private store, which settles a pending private request, then
* re-read the recipient's pending state: Send comes back once nothing is
* pending, for the user to try again.
*/
fun syncPrivateRequests() {
val w = wallet ?: return
val m = manager ?: return
isSyncingPrivate = true
scope.launch {
try {
syncPrivateContactRequests(w, m)?.let { errorMessage = it }
} finally {
isSyncingPrivate = false
pendingReadVersion++
}
}
}

fun acceptIncoming(recipient: ByteArray) {
val w = wallet ?: return
val m = manager ?: return
// A private reply to this contact may be pending in the wallet.
if (!recipientPending.allowsSubmission(recipient.toHex())) return
isSending = true
errorMessage = null
scope.launch {
try {
val ok = w.dashpay.acceptIncomingRequest(
val ok = acceptIncomingContactRequest(
wallet = w,
manager = m,
ourIdentityId = idBytes,
senderId = recipient,
signerHandle = m.signerHandle,
coreSignerHandle = m.mnemonicResolverHandle,
)
if (ok) {
kickDashPaySync(scope, m)
Expand All @@ -232,9 +300,23 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
"accept it from Requests."
}
} catch (e: Exception) {
errorMessage = "Accept failed: ${e.message ?: "unknown error"}"
when (val outcome = ContactRequestSubmissionOutcome.of(e)) {
ContactRequestSubmissionOutcome.MayHaveGoneThrough -> {
// Pending, not failed: the next sync settles it.
kickDashPaySync(scope, m)
unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE
}
// Nothing was sent: the screen offers the private sync.
ContactRequestSubmissionOutcome.PrivateRequestPending ->
recipientPending = recipientPending.withRefusal(recipient.toHex())
ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored ->
recipientPending = recipientPending.withRestoringRefusal()
is ContactRequestSubmissionOutcome.Failed ->
errorMessage = "Accept failed: ${outcome.message}"
}
} finally {
isSending = false
pendingReadVersion++
}
}
}
Expand Down Expand Up @@ -334,6 +416,27 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
enabled = canSend,
modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"),
) { attemptSend() }
if (recipientBlocked) {
Text(
ContactRequestSubmissionOutcome.rowMessage(
pending = recipientPending.isPending(recipient.toHex()),
restoring = recipientPending.isRestoringPrivatePayments,
unavailable = recipientPending.isUnavailable,
error = null,
).orEmpty(),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("dashpay.addContact.pending"),
)
// A private sync settles a pending request; it does not
// restore private payments, so it is not offered for that.
if (!recipientPending.isRestoringPrivatePayments) {
TextButton(
onClick = { syncPrivateRequests() },
enabled = !isSyncingPrivate,
modifier = Modifier.testTag("dashpay.addContact.syncPrivate"),
) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") }
}
}
}

if (errorMessage != null) {
Expand Down Expand Up @@ -366,6 +469,17 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) {
},
)
}

unconfirmedNotice?.let { notice ->
AlertDialog(
onDismissRequest = { navController.popBackStack() },
title = { Text("Submitted") },
text = { Text(notice) },
confirmButton = {
TextButton(onClick = { navController.popBackStack() }) { Text("Done") }
},
)
}
}

@Composable
Expand Down
Loading
Loading