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
442 changes: 442 additions & 0 deletions docs/dashpay/KOTLIN_INVITATIONS_SPEC.md

Large diffs are not rendered by default.

53 changes: 42 additions & 11 deletions docs/sdk/sdk-parity-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,10 @@
"reason": "Shared/Swift implementation and focused tests exist, but the manual plan still marks DP-12 through DP-19 bridge-only and lacks executed restart evidence."
},
"kotlin": {
"sdk": "unsupported",
"example_app": "unsupported",
"sdk": "supported",
"example_app": "partial",
"restart": "required",
"reason": "Android has no invitation callback, wrappers, screens, or DP-12 through DP-19 gates."
"reason": "Invitation callback (Room v8 + CAPABILITY_INVITATIONS), parse/create/claim/reclaim wrappers, and the four screens shipped; funded testnet e2e executed 2026-07-23 (create, claim, already-consumed classifier, reclaim-as-top-up, malformed reject). DP-14 two-wallet bootstrap and DP-18 reclaim-as-register remain manual, and executed restart evidence is lacking."
}
},
"verification": [
Expand All @@ -427,6 +427,45 @@
"file": "packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md",
"id": "DP-19",
"covers_restart": false
},
{
"host": "kotlin",
"kind": "unit",
"file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt",
"id": "invitationPersistRoundTripsEveryField",
"command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*PlatformWalletPersistenceHandlerTest*'",
"covers_restart": false
},
{
"host": "kotlin",
"kind": "unit",
"file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/InvitationReclaimLogicTest.kt",
"id": "consensusConsumedWithMarkerIsExplicitlyAmbiguousNeverReclaimed",
"command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*InvitationReclaimLogicTest*'",
"covers_restart": false
},
{
"host": "kotlin",
"kind": "device",
"file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt",
"id": "migrate8To9AddsInvitationsTable",
"command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest",
"covers_restart": false
},
{
"host": "kotlin",
"kind": "device",
"file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt",
"id": "persistenceBridgeDescriptorsAllResolve",
"command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest",
"covers_restart": false
},
{
"host": "kotlin",
"kind": "manual",
"file": "packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md",
"id": "DP-19",
"covers_restart": false
}
]
},
Expand Down Expand Up @@ -656,14 +695,6 @@
"id": "generic_resume_refuses_invitation_voucher_locks",
"command": "cargo test -p platform-wallet generic_resume_refuses_invitation_voucher_locks",
"covers_restart": false
},
{
"host": "kotlin",
"kind": "unit",
"file": "packages/rs-unified-sdk-jni/src/support.rs",
"id": "generic_asset_lock_recovery_rejects_invitation_authority",
"command": "cargo test -p rs-unified-sdk-jni --lib generic_asset_lock_recovery_rejects_invitation_authority",
"covers_restart": false
}
]
},
Expand Down
12 changes: 10 additions & 2 deletions packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ Shielded notes/balance/activity have **no read-side FFI** by design — Rust pus
| DP-09 | Publish encrypted on-chain `contactInfo` (private contact metadata) | Platform | Thorough | ✅ | | DIP-15 §10. `ContactDetailScreen` → edit **Alias** (`dashpay.detail.aliasEdit`) / **Note** (`dashpay.detail.noteEdit`) / **Hide contact** (`dashpay.detail.hideToggle`) → `setContactInfo` (`platform_wallet_set_dashpay_contact_info_with_signer`, ECB `encToUserId` + CBC `privateData`). Locally cached **and** published encrypted to Platform once the identity has **≥2 established contacts** → `ContactInfoPublishOutcome` (`Published` / `DeferredUntilTwoContacts` / `SkippedWatchOnly`), surfaced in the UI. Hide is reversible from `ui/dashpay/HiddenContactsScreen.kt` (`dashpay.openHidden`, `setContactInfo(displayHidden = false)` preserving alias/note). |
| DP-10 | Incoming-payment backfill rescan (restore-from-seed / pre-watch window) | Cross | Manual | ✅ | regression | DIP-15 §8.7 / §12.6 (on the DIP-16 SPV base). No UI trigger — automatic in DashPay sync: the Rust `reconcile_dashpay_rescan` lowers SPV `synced_height` to `min($coreHeightCreatedAt)` across new receival contacts so the filter manager backfills, driven through the Kotlin manager sync loop. Pass: a DashPay payment that landed on a contact's address **before** it was watched (restore-from-seed / second device / the offline-accept→pay window) appears after restore + SPV sync. Environment-limited (must construct the skew window); the regression pin for the §12.6 payment-loss gap. The identity-key breadcrumb backfill is deliberately not ported (no pre-breadcrumb Android installs), but the SPV rescan itself applies. |
| DP-11 | DashPay request → accept → payment, both endpoints on device | Platform | Thorough | ✅ | multiwallet | A's identity sends a contact request (`DP-01`) to B's; switch to wallet B's identity and accept (`DP-02`); then pay (`DP-03`). Full bidirectional loop entirely local. |
| DP-12 | Create invitation (DIP-13) | Cross | Common | ✅ | funding | DashPay tab → paperplane (`dashpay.openSentInvitations`) → **+** (`dashpay.invitations.create`) → `CreateInvitationSheet` → amount (default **0.03**, Rust-capped 0.003–0.05) + "send a contact request back to me" toggle (disabled without a DPNS username) → `Dashpay.createInvitation` → `platform_wallet_create_invitation`. Builds an **L1 InstantSend asset-lock voucher** at the DIP-13 `3'` path — needs SPV running + testnet funds. Returns the legacy `dashpay://invite?assetlocktx=…&pk=…&islock=…` link (QR + text-only share + sensitive/auto-cleared clipboard). `pk` is a one-time bearer key — never logged. The row is persisted via `onPersistInvitationUpsert` **before** the proof wait, so an IS-timeout create still lands as `Created` and stays reclaimable (verified 2026-07-23). Funded run 2026-07-23: voucher `f9ef7f5b…:0` created + listed. |
| DP-13 | Claim invitation (DIP-13) | Platform | Common | ✅ | | Toolbar redeem (`dashpay.claimInvitation`, enabled with a loaded wallet — no identity required) or a parked `dashpay://invite` deep link → `ClaimInvitationSheet` (`dashpay.invite.claim.*`) → paste/seed the link → `parseInvitation` preview (amount "—", not on the wire) → 6-key pre-persist → `claimInvitation` (claim-by-fetch: refetches the funding tx by txid, both byte orders, reconstructs the proof, registers a **new invitee identity** — no L1 Dash on this side). "Authorize signing" PIN gate applies. If the link carries `du`, the post-claim "Add \<username\>?" prompt resolves via DPNS and sends the contact request. Funded run 2026-07-23: identity `292ebab4…` registered with 2.82B credits. |
| DP-14 | Invite → claim two-wallet e2e with contact bootstrap | Cross | Thorough | ⬜ | multiwallet | Wallet A (funded, DPNS-named identity, toggle on) creates; wallet B (no funds) claims; invitee confirms the "Add \<username\>?" prompt → contact establishes on both ends (cf. `DP-11`). Requires an inviter identity **with a DPNS username** (the 2026-07-23 run had none, so the toggle was correctly disabled — pure-voucher path only). Manual, funded. |
| DP-15 | Reject malformed / reused / wrong-network invitation | Platform | Uncommon | ✅ | | Negative paths fail loudly with no side effects: malformed link → `structurallyValid=false` → "Invalid invitation link." with Claim disabled (verified 2026-07-23); reused link → deterministic consensus "already completely used"; wrong-network WIF caught at claim before the funding fetch. No expiry on the legacy wire — a link stays claimable until the inviter reclaims the voucher (`MIN_INVITATION_DUFFS` bounds only the amount, at create). |
| DP-16 | Sent-invitations list persists a created invitation | Platform | Common | ✅ | | Create (`DP-12`) → `InvitationEntity` row in Room keyed `outPointHex` (push-only via `onPersistInvitationUpsert`; no Rust rehydrate — Room is the UI source) → row in `dashpay.invitations.list` with amount + status badge. Upsert-in-place on re-emit preserves the client-written `statusRaw`/`reclaimInFlight` (unit-pinned). Verified 2026-07-23. |
| DP-17 | Reclaim an unclaimed invitation into an existing identity (top-up) | Platform | Common | ✅ | funding | Tap a `Created` row (`dashpay.invitations.reclaim`) → `ReclaimInvitationSheet` → target **Existing identity** (`dashpay.invite.reclaim.identityPicker`) → `reclaimInvitationAsTopUp` (`consumeInvitationVoucher = true` — one of exactly two authorized call sites) → voucher consumed as an IdentityTopUp; value returns as **credits**, never L1 Dash. Funded run 2026-07-23: IS-timeout voucher `732bf38c…:0` reclaimed, identity balance 49 818 637 700 → 52 736 112 114, row → `Reclaimed`. |
| DP-18 | Reclaim an unclaimed invitation by registering a new identity | Platform | Uncommon | ⬜ | funding | As `DP-17` but target **New identity** → `reclaimInvitationAsNewIdentity` (base **4-key** set — a reclaim sends no contact request) → new funded identity lands, row → `Reclaimed`. Wrapper + flag path seam-tested (`InvitationReclaimTest`); funded execution manual. |
| DP-19 | Reclaim vs claim race → deterministic already-consumed | Platform | Uncommon | ✅ | multiwallet | Claim a voucher (`DP-13`), then attempt to **Reclaim** it. Platform deterministically rejects the second consume (consensus 10504 "already completely used"); `InvitationReclaimLogic.classifyReclaimFailure` resolves it — no prior `reclaimInFlight` marker → neutral "This invitation was already claimed." (claimant never named), row → `Claimed`; with a prior marker → the explicitly-ambiguous copy, never an inferred `Reclaimed`. Full outcome matrix unit-pinned. Funded run 2026-07-23: live rejection classified correctly, row flipped, reclaim affordance gone. |

### 4.11 System / Protocol / Diagnostics — `Domain=System`

Expand Down Expand Up @@ -325,14 +333,14 @@ Membership of each feature category across **all** sections (primary section mem
- **Document** — `DOC-01..15`
- **Token** — `TOK-01..20`
- **Shielded** — `SH-01..17`
- **DashPay** — `DP-01..11`
- **DashPay** — `DP-01..19` (`DP-12..19` = invitation create, claim, persistence, reclaim; funded evidence 2026-07-23 in `docs/dashpay/KOTLIN_INVITATIONS_SPEC.md` §7)
- **System / Diagnostics** — `SYS-01..08`

### Tag index

Tags are cross-cutting modalities. A test may appear under multiple tags.

- **multiwallet** — `CORE-14..23`, `ID-14`, `ID-15`, `TOK-17`, `DPNS-08`, `DP-11`, `DOC-15`, `SH-14`, `SH-15`, `SH-16`, `SYS-08`
- **multiwallet** — `CORE-14..23`, `ID-14`, `ID-15`, `TOK-17`, `DPNS-08`, `DP-11`, `DP-14`, `DP-19`, `DOC-15`, `SH-14`, `SH-15`, `SH-16`, `SYS-08`
- **group** — `TOK-15`, `TOK-16`, `TOK-18`, `TOK-19`
- **contested** — `DPNS-05`, `DPNS-08`, `VOTE-01..06`
- **withdrawal** — `ID-10`, `ADDR-04`, `SH-08`, `SH-16`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,36 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- DIP-13 invitation links. Custom scheme = plain implicit-intent
match: any app may register it, and an "Always" choice for a
malicious app silently captures future links (documented,
Android-specific persistence risk; the economic bound is the
Rust-enforced amount cap + reclaim). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="dashpay" android:host="invite" />
</intent-filter>
<!-- Legacy AppsFlyer applink host (what production dashwallet-iOS
emits). Unverified: the domain serves no assetlinks.json, so
this participates in the app chooser rather than auto-opening;
add android:autoVerify="true" once the domain hosts one. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="invitations.dashpay.io"
android:path="/applink" />
</intent-filter>
</activity>
</application>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.dashfoundation.example

import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
Expand Down Expand Up @@ -41,6 +42,11 @@ class MainActivity : FragmentActivity() {
// route through it). Re-binding on recreation is intentional.
container.biometricGate.delegate = AuthPrompt(this)

// Cold-start deep link (`dashpay://invite` / legacy applink) — the
// analog of iOS `.onOpenURL`. Parked in AppUiState until the claim
// sheet can consume it (survives a walletless fresh install).
captureInviteIntent(intent, container)

setContent {
// Expose Compose testTags as uiautomator resource-ids so the
// TEST_PLAN's on-device flows are drivable via adb (the Android
Expand All @@ -62,6 +68,35 @@ class MainActivity : FragmentActivity() {
}
}

/** Warm-start deep link (launchMode=singleTop). */
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
captureInviteIntent(intent, (application as ExampleApplication).container)
}

/**
* Park an invitation link from a VIEW intent. Accepts the custom
* `dashpay://invite` scheme and the legacy AppsFlyer
* `https://invitations.dashpay.io/applink` host (both parsed leniently
* Rust-side). The URI is a bearer credential — never log it. The intent
* is consumed one-shot: its data is scrubbed after capture so an
* Activity recreation (rotation, process restore) can't re-park a link
* that was already claimed.
*/
private fun captureInviteIntent(intent: Intent?, container: AppContainer) {
if (intent?.action != Intent.ACTION_VIEW) return
val uri = intent.data ?: return
val isInviteScheme = uri.scheme == "dashpay" && uri.host == "invite"
val isLegacyApplink = uri.scheme == "https" &&
uri.host == "invitations.dashpay.io" && uri.path == "/applink"
if (isInviteScheme || isLegacyApplink) {
container.appUiState.pendingInviteUri.value = uri.toString()
}
intent.data = null
setIntent(intent)
}

private fun setSecureScreen(secure: Boolean) {
if (secure) {
window.setFlags(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import org.dashfoundation.example.ui.dashpay.DashPayProfileScreen
import org.dashfoundation.example.ui.dashpay.DashPayTabScreen
import org.dashfoundation.example.ui.dashpay.HiddenContactsScreen
import org.dashfoundation.example.ui.dashpay.IgnoredContactsScreen
import org.dashfoundation.example.ui.dashpay.InvitationsScreen
import org.dashfoundation.example.ui.identity.DpnsTestScreen
import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen
import org.dashfoundation.example.ui.identity.IdentityDetailScreen
Expand Down Expand Up @@ -445,6 +446,10 @@ fun AppNavHost(
HiddenContactsScreen(entry.toRoute<DashPayHidden>().ownerIdentityIdHex, navController)
}

composable<DashPayInvitations> { entry ->
InvitationsScreen(entry.toRoute<DashPayInvitations>().activeIdentityIdHex)
}

// ── Diagnostics graph ──────────────────────────────────────────

composable<AddressQueries> { AddressQueriesScreen(navController) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ import kotlinx.serialization.Serializable
/** Hidden established-contacts list (← `HiddenContactsView.swift`). */
@Serializable data class DashPayHidden(val ownerIdentityIdHex: String)

/**
* Sent-invitations list (← `InvitationsView.swift`); hosts create/reclaim.
* [activeIdentityIdHex] carries the DashPay tab's active identity so the
* create sheet funds from — and advertises — the identity the user actually
* selected, not an arbitrary first row; null when the tab had none.
*/
@Serializable data class DashPayInvitations(val activeIdentityIdHex: String?)

// ── Contracts graph ────────────────────────────────────────────────────

/** Fetch-a-contract screen (← `LocalDataContractsView.swift`). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,37 @@ package org.dashfoundation.example.state
import kotlinx.coroutines.flow.MutableStateFlow

/**
* UI-only flags — port of `AppUIState.swift` (which holds exactly one
* published property: whether the wallets tab shows the detailed sync
* banner).
* UI-only flags — port of `AppUIState.swift`: the wallets-tab sync-banner
* toggle plus the invitation deep-link plumbing
* (`pendingInviteURL` / `invitationClaimInFlight`).
*/
class AppUiState {
val showWalletsSyncDetails = MutableStateFlow(false)

/**
* A `dashpay://invite` (or legacy applink) URI captured from an incoming
* intent, parked until the claim sheet can consume it. Deliberately NOT
* cleared by the no-wallet guard (deviation from iOS, which drops the
* link walletless — flagged as an upstream bug): the fresh-install
* onboarding tap must survive until a wallet exists. Bearer secret —
* never logged.
*/
val pendingInviteUri = MutableStateFlow<String?>(null)

/**
* True while a claim is running; a second incoming invite link stays
* parked in [pendingInviteUri] until the current claim resolves
* (mirror of the iOS mid-claim deferral gate).
*/
val invitationClaimInFlight = MutableStateFlow(false)

/**
* One-shot in-memory sink for the next QR scan result. When set, the
* scanner delivers the raw string here INSTEAD of the navigation
* `SavedStateHandle` — bearer-credential scans (invitation links) must
* never enter saved-instance state, which Android snapshots to disk.
* The scanner clears it on delivery and on dispose (a cancelled scan
* must not leave a stale sink to hijack a later unrelated scan).
*/
var scanResultSink: ((String) -> Unit)? = null
}
Loading
Loading