feat(kotlin-sdk): DashPay invitations — create, claim, reclaim, persistence (DIP-13) - #4284
feat(kotlin-sdk): DashPay invitations — create, claim, reclaim, persistence (DIP-13)#4284shumkov wants to merge 10 commits into
Conversation
Research + five-lens review (feasibility, security, scope, adversarial, fact-check) folded; owner decisions recorded in §10 (reclaim JNI guard drop, dual deep-link filters with walletless parking, one PR / five slices, WAL synchronous=NORMAL residual accepted+documented). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bridge) Slice 1 of the invitations port (docs/dashpay/KOTLIN_INVITATIONS_SPEC.md): - InvitationEntity (field-exact port of PersistentInvitation.swift, no secret column) + InvitationDao; DashDatabase v7→v8 additive migration with exported 8.json; wallet-teardown pass. - Per-row bridge slots onPersistInvitationUpsert/-Removal + handler impl: staged into the round buffer, upsert preserves the client-written statusRaw/reclaimInFlight columns (Rust emits only Created). - JNI trampoline tramp_persist_invitations; on_persist_invitations_fn flipped from the fail-closed None to Some; the handler now attests CAPABILITY_INVITATIONS (0x02) in the same change that wires the path. - Voucher-key-reuse hardening: the address-pool persist no longer silently skips a missing IdentityInvitation account row — it creates the row (healing installs that predate invitation support) or fails the round so Rust aborts before broadcasting the funding tx. Red→green: invitationPoolEntryCreatesTheMissingAccountRow and invitationPoolEntryWithoutWalletFailsTheRound fail against the pre-fix silent skip (verified ✖) and pass with the fix (✔). - Descriptor lockstep is now tested, not eyeballed: a bridge-method table + nativeVerifyPersistenceBridgeDescriptors export resolve every (name, descriptor) pair up front; instrumented FfiSmokeTest pins it. JVM suite green (55/55 handler tests incl. 7 new); cargo check clean. Instrumented tiers (migration 7→8, descriptor smoke) run on the CI emulator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 2 of the invitations port: - Three JNI exports on DashpayNative (rs-unified-sdk-jni/src/dashpay.rs): parseInvitation (compact-JSON preview, malformed link => structurallyValid:false, never an exception), createInvitation (returns the bearer link; outpoint out-param discarded as on iOS), claimInvitation (IdentityRegistrationNativeResult via the resume convention, handle transferred only after result construction). Section-level rule: the URI is the bearer credential and is never interpolated into exception messages or logs. - Kotlin wrappers: Dashpay.parseInvitation/createInvitation (+ the InvitationPreview mirror of Swift's type with the gate-on-username-not-hasInviter contract), and IdentityRegistration.claimInvitation (6-key fresh-registration set, adopt-then-release managed handle, injectable native seam). - ManagedIdentityHandleGuard promoted to pub(crate) for reuse. Tests: claim wrapper seam (forwarding, handle freed on success and on validation failure, wrong-slot key set rejected before JNI) + InvitationPreview JSON contract. JVM suite green; cargo check + clippy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…laim wrappers Slice 3 of the invitations port: - The resume/top-up JNI bindings now forward consumeInvitationVoucher verbatim instead of hardcoding false; the interim JNI-level generic_asset_lock_recovery_allowed guard is removed (it existed only until reclaim shipped). Authorization is Rust core's authorized_invitation_reclaim: invitation-typed locks are refused on every generic path regardless of the flag and consumable only into a register/top-up target with the flag set — the same single-gate trust model shipped on iOS. - Two new outpoint-taking Kotlin wrappers, the ONLY true-passing call sites: IdentityCredits.reclaimInvitationAsTopUp (returns the new balance) and IdentityRegistration.reclaimInvitationAsNewIdentity (base 4-key set — a reclaim sends no contact request, matching iOS authKeyCount=4). Generic recovery wrappers and all existing call sites are unchanged (still false, still funding-type-gated). Tests: reclaim seam coverage (flag forwarded, raw outpoint, handle freed, wrong-slot key set rejected); the existing recovery tests keep the generic paths' false discipline pinned. JVM suite green; cargo check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…im, sent list
Slice 4 of the invitations port (mirrors Views/DashPay/*Invitation*.swift;
testTags reuse the iOS accessibility identifiers verbatim):
- InvitationsScreen (dashpay.invitations.{list,create,reclaim}): Room-Flow
driven sent-invitations list, filtered to loaded wallets, per-row
wallet-scoped reclaim, status badges; new DashPayInvitations route +
DashPayTabScreen toolbar entries (dashpay.openSentInvitations,
dashpay.claimInvitation — enabled without an active identity).
- CreateInvitationSheet (dashpay.invite.create.*): default 0.03 DASH,
contact-request-back toggle gated on a DPNS username, QR from an
in-memory bitmap, TEXT-only share, clipboard copy flagged sensitive on
API 33+ and compare-and-cleared after ~60s (Android has no local-only /
expiring clipboard; documented platform gap vs iOS).
- ClaimInvitationSheet (dashpay.invite.claim.*): paste or deep-link seed,
off-chain preview (amount '—'), claim wallet = active identity's wallet
else first loaded (fresh invitee works), 6-key set pre-persisted, post-
claim 'Add <username>?' prompt -> DPNS resolve -> contact request.
- ReclaimInvitationSheet (dashpay.invite.reclaim.*): top-up vs register
targets (register = base 4-key set), in-memory isReclaiming single-
flight gating submit AND dismissal, reclaimInFlight marker persisted
only immediately before the consume (write must succeed), terminal
status+marker saved atomically, failures resolved through the pure
InvitationReclaimLogic classifier (typed tombstone -> Reclaimed;
exact 'already completely used' phrase split by prior marker into
Claimed vs explicitly-ambiguous; 'is not tracked' + marker -> leave
state; stale-marker clearing rule) — iOS copy carried verbatim.
- Deep links: dashpay://invite + legacy invitations.dashpay.io/applink
intent-filters (unverified until #4096 serves assetlinks.json);
MainActivity parks the URI in AppUiState until a wallet exists —
deliberate deviation from iOS, which drops a walletless link (flagged
upstream); mid-claim second links stay parked (invitationClaimInFlight).
- All three network flows run in the application scope with
NonCancellable around marker->consume->status (dismissal-safe).
Tests: InvitationReclaimLogicTest ports the full classifier outcome
matrix incl. exact-phrase false-positive safety, marker-clear rule,
outpoint split, next-unused-index. :app:assembleDebug +
:app:testDebugUnitTest green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 5 bookkeeping: PARITY.md gains the four invitation view rows (InvitationsView / CreateInvitationSheet / ClaimInvitationSheet / ReclaimInvitationSheet — all ported, with the documented Android adaptations); KOTLIN_INVITATIONS_SPEC.md status notes slices 1-5 implemented with instrumented + funded-testnet QA as the remaining environment-bound gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ounts formatDuffs already appends the unit; funded-e2e screenshots showed '0.03 DASH DASH' on the sent-invitations row and reclaim sheet. Display copy only — no behavior delta, so no regression test (per the docs/typo carve-out). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- KotlinExampleApp TEST_PLAN.md gains DP-12..DP-19 (shared IDs with the iOS plan) with the 2026-07-23 funded-run evidence inline; DP-14 (two-wallet bootstrap — needs a DPNS-named inviter) and DP-18 (reclaim-as-register) stay open as manual funded gates. - sdk-parity-manifest: dashpay.invitations kotlin host unsupported → sdk=supported / example_app=partial, with kotlin verification entries (JVM unit, device migration + descriptor tests, manual DP-19); the retired JNI-guard test reference is replaced by the wrapper-seam pin. - PARITY_SUMMARY regenerated via check_sdk_parity_manifest --write-summary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Independent review (17 findings) triaged; the confirmed ones fixed: - Stale JNI vtable test still asserted on_persist_invitations_fn is None — cargo test -p rs-unified-sdk-jni --lib was red; now 37/37 green. - Deep links now navigate to the DashPay tab (MainScreen effect) instead of parking invisibly, and invite intents are consumed one-shot so an Activity recreation can't re-park a handled link. - Create and claim now bind to the DashPay tab's ACTIVE identity/wallet (route param + preferredWalletIdHex) instead of an arbitrary first row. - Clipboard auto-clear moved to the application scope (a composition scope died with the sheet, leaving the bearer link forever) and keyed by a per-copy label nonce so it can never wipe a newer clip. - Host-level sheet dismissal (scrim/back) is now gated while create or reclaim is busy — a mid-create dismissal could discard the only bearer link; a reopened reclaim could double-submit. - InvitationDao marker/status updates return the affected-row count and the reclaim marker write fails closed on zero rows (concurrent wallet deletion race). - Claim sheet gains the QR-scan path (routes through the shared scanner and the pending-invite parking mechanism); sent rows show the advisory expiry (with an expired state) alongside creation time. - Unused InvitationDao.observeByWallet dropped; manifest comment made timeless; reclaim/claim KDocs cite the Swift source path. Deferred with rationale (recorded in the spec §9): typed funded-failure recovery outpoint (needs a shared-Rust change; tracked-lock diagnostics cover recovery), driving the Rust pre-broadcast abort from a JVM test, and the test-naming nit (sibling-file convention wins). JVM suites + :app:assembleDebug green; cargo lib tests 37/37. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
⛔ Blockers found — Sonnet deferred (commit f474ab1) |
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
The JNI ownership, persistence callbacks, reclaim authorization forwarding, and fail-closed storage path are sound. Three blocking issues remain: the claim can submit a URI different from its displayed preview, QR scans pass the bearer credential through restorable saved state, and invitation registrations can reuse identity-key slots held by registrations already in flight; two additional security and timestamp-width improvements are also warranted.
Source: reviewers codex/general=gpt-5.6-sol(completed); codex/security-auditor=gpt-5.6-sol(completed); codex/ffi-engineer=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only).
Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— ffi-engineer (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 🟡 2 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt:107-113: Submit only the URI associated with the displayed preview
Changing `uriText` does not immediately invalidate `preview`; the replacement is parsed later by `LaunchedEffect`. During that interval, `canClaim` remains true based on the previous valid preview, but `claim()` snapshots the newly entered URI. The user can therefore approve one displayed inviter and irreversibly claim a different credential, and the post-claim contact prompt can also use metadata from the stale preview. Store the trimmed URI with each completed preview, require that pair to match before enabling Claim, and snapshot both values together when submission starts.
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt:120-124: Reserve identity HD slots across every registration flow
The next identity index is derived only from identities already committed to Room. It excludes slots held by the application-scoped `RegistrationCoordinator` in `PreparingKeys`, `InFlight`, or `Unconfirmed`, and the chosen index is not atomically reserved before keys are derived. A concurrent normal registration, claim, or reclaim-register can therefore use the same wallet/index and produce identical ECDSA public-key hashes for different identities. Platform's identity-create state validation treats those hashes as duplicates and applies a `PartiallyUseAssetLockAction` charging the unique-key penalty plus processing fees, so the invitation voucher can be partially consumed without creating the identity; retries continue selecting the absent Room slot. The same allocation occurs in `ReclaimInvitationSheet.kt:143-145`. Introduce an application-scoped per-wallet slot allocator shared by all registration paths and reserve the index before key derivation.
In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt:140-147: Keep scanned invitation URIs out of SavedStateHandle
The generic scanner writes its raw result into `SavedStateHandle`, and this invitation path consumes the plaintext bearer URI from that channel. Values in `SavedStateHandle` participate in saved-instance-state restoration, so a state snapshot taken after delivery and before the collector clears the key can retain the voucher WIF across activity or process recreation. This violates the feature's normative rule that invitation URIs remain in memory and are never persisted. Deliver invitation scan results through a transient, non-saveable one-shot registry or application-scoped event instead.
In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/CreateInvitationSheet.kt`:
- [SUGGESTION] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/CreateInvitationSheet.kt:203-214: Protect bearer invitation screens with FLAG_SECURE
The result screen renders a QR containing the plaintext voucher WIF without activating the app's existing `LocalSecureScreen` protection. Android can consequently expose the credential through screenshots, screen recordings, MediaProjection, or task snapshots, allowing anyone with the image to claim the voucher. Hold `FLAG_SECURE` while the created-link QR is visible and while `ClaimInvitationSheet` contains a nonblank invitation URI, then release it on disposal, following the seed and key-reveal screen pattern.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt:434: Carry u32 invitation timestamps without signed narrowing
The Kotlin-to-JNI path narrows Unix seconds to a signed 32-bit `Int`, while the shared C API intentionally accepts `u32`. At Unix second 2147483648, the default becomes negative and JNI rejects every invitation creation even though the C API remains valid through 2106. The persistence callback independently casts `expiry_unix` and `created_at_secs` from `u32` to `jint`, and `InvitationEntity` stores both as `Int`; the roughly 24-hour expiry begins overflowing shortly before the creation-time cutoff, producing negative values and incorrect display and ordering. Use Kotlin `Long` and JNI `jlong` throughout, validate the range `1..u32::MAX` before Rust casts, update the callback descriptor and descriptor-resolution test, and store both Room fields as `Long`.
| val canClaim = !isClaiming && preview.structurallyValid && claimWallet != null | ||
|
|
||
| fun claim() { | ||
| if (!canClaim || isClaiming) return | ||
| val wallet = claimWallet ?: return | ||
| val mgr = manager ?: return | ||
| val uri = uriText.trim() |
There was a problem hiding this comment.
🔴 Blocking: Submit only the URI associated with the displayed preview
Changing uriText does not immediately invalidate preview; the replacement is parsed later by LaunchedEffect. During that interval, canClaim remains true based on the previous valid preview, but claim() snapshots the newly entered URI. The user can therefore approve one displayed inviter and irreversibly claim a different credential, and the post-claim contact prompt can also use metadata from the stale preview. Store the trimmed URI with each completed preview, require that pair to match before enabling Claim, and snapshot both values together when submission starts.
source: ['codex']
| val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle | ||
| LaunchedEffect(savedStateHandle) { | ||
| savedStateHandle | ||
| ?.getStateFlow<String?>(org.dashfoundation.example.navigation.QrScanner.RESULT_KEY, null) | ||
| ?.collect { scanned -> | ||
| if (!scanned.isNullOrBlank()) { | ||
| savedStateHandle[org.dashfoundation.example.navigation.QrScanner.RESULT_KEY] = null | ||
| appUiState.pendingInviteUri.value = scanned |
There was a problem hiding this comment.
🔴 Blocking: Keep scanned invitation URIs out of SavedStateHandle
The generic scanner writes its raw result into SavedStateHandle, and this invitation path consumes the plaintext bearer URI from that channel. Values in SavedStateHandle participate in saved-instance-state restoration, so a state snapshot taken after delivery and before the collector clears the key can retain the voucher WIF across activity or process recreation. This violates the feature's normative rule that invitation URIs remain in memory and are never persisted. Deliver invitation scan results through a transient, non-saveable one-shot registry or application-scoped event instead.
source: ['codex']
| val identityIndex = InvitationReclaimLogic.nextUnusedIdentityIndex( | ||
| walletOwned | ||
| .filter { it.walletId?.contentEquals(wallet.walletId) == true } | ||
| .map { it.identityIndex }, | ||
| ) |
There was a problem hiding this comment.
🔴 Blocking: Reserve identity HD slots across every registration flow
The next identity index is derived only from identities already committed to Room. It excludes slots held by the application-scoped RegistrationCoordinator in PreparingKeys, InFlight, or Unconfirmed, and the chosen index is not atomically reserved before keys are derived. A concurrent normal registration, claim, or reclaim-register can therefore use the same wallet/index and produce identical ECDSA public-key hashes for different identities. Platform's identity-create state validation treats those hashes as duplicates and applies a PartiallyUseAssetLockAction charging the unique-key penalty plus processing fees, so the invitation voucher can be partially consumed without creating the identity; retries continue selecting the absent Room slot. The same allocation occurs in ReclaimInvitationSheet.kt:143-145. Introduce an application-scoped per-wallet slot allocator shared by all registration paths and reserve the index before key derivation.
source: ['codex']
| Text("Invitation ready", style = MaterialTheme.typography.titleLarge) | ||
| Text( | ||
| "Share this link with your friend. It funds their new identity — " + | ||
| "treat it like cash.", | ||
| style = MaterialTheme.typography.bodyMedium, | ||
| ) | ||
| remember(link) { generateQrBitmap(link) }?.let { qr -> | ||
| Image( | ||
| bitmap = qr.asImageBitmap(), | ||
| contentDescription = "Invitation QR", | ||
| modifier = Modifier.size(220.dp).align(Alignment.CenterHorizontally), | ||
| ) |
There was a problem hiding this comment.
🟡 Suggestion: Protect bearer invitation screens with FLAG_SECURE
The result screen renders a QR containing the plaintext voucher WIF without activating the app's existing LocalSecureScreen protection. Android can consequently expose the credential through screenshots, screen recordings, MediaProjection, or task snapshots, allowing anyone with the image to claim the voucher. Hold FLAG_SECURE while the created-link QR is visible and while ClaimInvitationSheet contains a nonblank invitation URI, then release it on disposal, following the seed and key-reveal screen pattern.
source: ['codex']
| inviterIdentityId: ByteArray?, | ||
| inviterUsername: String?, | ||
| coreSignerHandle: Long, | ||
| nowUnix: Int = (System.currentTimeMillis() / 1000L).toInt(), |
There was a problem hiding this comment.
🟡 Suggestion: Carry u32 invitation timestamps without signed narrowing
The Kotlin-to-JNI path narrows Unix seconds to a signed 32-bit Int, while the shared C API intentionally accepts u32. At Unix second 2147483648, the default becomes negative and JNI rejects every invitation creation even though the C API remains valid through 2106. The persistence callback independently casts expiry_unix and created_at_secs from u32 to jint, and InvitationEntity stores both as Int; the roughly 24-hour expiry begins overflowing shortly before the creation-time cutoff, producing negative values and incorrect display and ordering. Use Kotlin Long and JNI jlong throughout, validate the range 1..u32::MAX before Rust casts, update the callback descriptor and descriptor-resolution test, and store both Room fields as Long.
source: ['codex']
Issue being fixed or feature implemented
Ports the shipped iOS DashPay invitation feature (DIP-13 sub-feature 3', PR #4041) to the Kotlin SDK and KotlinExampleApp — the last DashPay gap left out of the K1–K3 migration. Android was deliberately fail-closed (
on_persist_invitations_fn: None), refusing invitation creation rather than risk the voucher-key-reuse defect class (55937e1).Spec + owner decisions + funded-e2e evidence:
docs/dashpay/KOTLIN_INVITATIONS_SPEC.md.What was done?
Zero shared-Rust changes — all invitation logic stays in
rs-platform-wallet; the port is bindings + persistence + UI:InvitationEntity(field-exact withPersistentInvitation.swift, no secret column) + DAO; Room v7→v8 additive migration; per-row bridge slots + JNI trampoline;CAPABILITY_INVITATIONSattested in the same commit that wires the path. Hardening: the address-pool persist no longer silently skips a missing IdentityInvitation account row — it creates it or fails the round so Rust aborts before broadcasting (red→green demonstrated); JNI↔Kotlin descriptor lockstep is pinned by an instrumented test resolving every bridge slot up front.parseInvitation/createInvitation/claimInvitationJNI exports + Kotlin wrappers (InvitationPreview, 6-key claim set); reclaim forwardsconsumeInvitationVoucherthrough the existing resume/top-up bindings (Rust core'sauthorized_invitation_reclaimremains the enforcement, matching iOS's trust model) with two new outpoint-taking reclaim wrappers — the onlytrue-passing call sites.InvitationsScreen/CreateInvitationSheet/ClaimInvitationSheet/ReclaimInvitationSheet, testTags = iOS accessibility identifiers verbatim; deep-link intent-filters with walletless parking; the verbatim reclaim-outcome classifier +reclaimInFlightmarker discipline; bearer-secret hygiene (no logging, text-only share, sensitive + auto-cleared clipboard).KotlinExampleApp/TEST_PLAN.md), parity manifest (kotlin: supported/partial) + regeneratedPARITY_SUMMARY.md.How Has This Been Tested?
InvitationPreviewcontract).cargo test -p rs-unified-sdk-jni --lib37/37; clippy clean.MIGRATION_7_8+ full v1→v8 chain, the descriptor-resolution gate, FFI smoke.Breaking Changes
None.
Checklist:
🤖 Generated with Claude Code